3 Commits
Author SHA1 Message Date
ScottW514 16d27f0887 yocto version zeus to master 2020-04-15 21:40:24 -04:00
ScottW514 1bd7793744 initial commit 2020-04-15 17:13:51 -04:00
ScottW514 a1d158514b Add files via upload 2020-04-15 17:09:41 -04:00
239 changed files with 654 additions and 88721 deletions
-56
View File
@@ -1,56 +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:
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
-90
View File
@@ -1,90 +0,0 @@
# forgetest: the release acceptance tool's host-side proof.
#
# - unit tests: campaign rules, fingerprints, artifact build/verify (the
# release gate's decision, including the negative fixtures), runner +
# HTTP API end to end with a fake catalog and a fake bench tool, the
# suites replayed on the machine's own log lines, and the check that
# every log phrase the cloud suite greps for is one the pinned cloud
# app can log (it reads the app sources from the manifest cache)
# - the source bundle of a release: which recipe of the image needs
# source, which archive answers for it, and the refusal that stops a
# release with source missing
# - shared UI files: theme.css and the vendored Bootstrap the page
# carries are byte-identical to forgectrl's at its pinned revision
# - coverage lint: every source path of every component pinned by the
# recipes must be selected by some catalog test's coverage globs (the
# tree manifest is generated from the pins with git - no Yocto build);
# enforced: an uncovered path fails the job (https://docs.forgefirm.org/developers/acceptance/).
name: forgetest-ci
on:
push:
paths:
- 'forgetest/**'
- 'scripts/acceptance-gate.py'
- 'scripts/manifest-from-tree.py'
- 'scripts/source-bundle.py'
- 'kas/source-bundle.yml'
- 'meta-forgefirm/recipes-forgefirm/**'
- '.github/workflows/forgetest-ci.yml'
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout forgefirm
uses: actions/checkout@v4
with:
path: forgefirm
- name: Checkout meta-openglow (sibling)
uses: actions/checkout@v4
with:
repository: openglow-org/meta-openglow
ref: scarthgap
path: meta-openglow
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Tree manifest from the recipe pins (also fetches the pinned app sources the needle check reads)
working-directory: forgefirm
run: python scripts/manifest-from-tree.py --out tree-manifest.json
- name: Unit tests
working-directory: forgefirm/forgetest
# -B: no bytecode caches under the package directory (the recipe's fetch
# checksum would otherwise move without a source change)
run: python -B -m unittest discover -s tests -v
- name: No undefined names (the live drills never execute on the host)
working-directory: forgefirm/forgetest
run: |
pip install pyflakes
if python -B -m pyflakes forgetest tests | grep -E "undefined name"; then exit 1; fi
- name: Shared UI files match forgectrl at its pinned revision
working-directory: forgefirm
run: python scripts/check-ui-vendor.py
- name: Coverage lint (enforced)
working-directory: forgefirm/forgetest
run: python -B -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY"
- name: Gate self-check on the tree manifest
working-directory: forgefirm
run: |
# The gate must load the catalog and refuse a missing/unauthorized
# artifact cleanly (exit 1/2, never a traceback).
set +e
echo '{}' > empty.json
python scripts/acceptance-gate.py empty.json tree-manifest.json --quiet
rc=$?
set -e
test "$rc" -eq 1
-22
View File
@@ -1,22 +0,0 @@
# kas-managed upstream layer checkouts (poky, meta-openembedded, meta-freescale...)
/layers/
# Yocto build output (incl. deploy/images) and caches
/build/
/downloads/
/sstate-cache/
# kas
*.lock.yml.bak
# release.sh staging output
/release-staging/
# Python bytecode
__pycache__/
# manifest-from-tree.py fetch cache
/.manifest-cache/
# manifest-from-tree.py default output
/tree-manifest.json
-398
View File
@@ -1,398 +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.
### CI couplings that set the push order
- The CI of `grblHAL-glowforge` gets the laser harnesses from `scripts/bench/`
at the head of `master`, unpinned. Push a harness change here before the
driver change that needs it.
- The acceptance page shares its theme and its vendored Bootstrap with the
`forgectrl` panel, byte for byte. Land a UI change in `forgectrl` first.
Push it and pin it. Only then push this repository.
- The CI of `kernel-module-glowforge` cross-builds against the BSP from
`meta-openglow`. Push `meta-openglow` before a module change that needs a
BSP change.
### Builds and images
- Builds run only in the dedicated Yocto build environment that the Build page
of the site defines (kas and bitbake). Never use a native host build of a
component as proof, not even for a compile check.
- Every build makes both images: `forgefirm-image` (the release image) and
`forgefirm-image-dev` (the bench image, with `forgetest` on port 8090). The
dev image needs an explicit `--target`. Report both artifact names.
- After a kernel-module pin bump, rebuild the kernel and the module in the
same run: `bitbake -c cleansstate linux-fslc kernel-module-glowforge`, then
the images. The kernel version suffix is not reproducible across
re-checkouts, and a rootfs whose kernel and module disagree fails.
- Read the layer HEAD lines at the top of a build log before you trust a build
that moves a pin.
### The record of the work
This repository carries no status document and no dated log. Both were
retired: every fact about the machine and the firmware is on the
documentation site, and **the record of a piece of work is the commit message
that carries it**: what was done, how it was proven, and what it replaced, in
the same place as the change itself.
A measurement session or a bench drill is written up in the commit that
lands its result. Nothing accumulates in a file.
### On the bench
These rules apply whenever an agent works with the operator at a machine.
- **One armed run per turn.** Start the run, let it finish, stop, report what
happened against what was expected, and wait for the operator's
confirmation before any further armed run or machine command. Never chain a
jog, a fix, or a re-run onto a burn in the same step. Dry runs (latch
locked, no emission) can be batched. Say which is which before you start.
- **Zero inference during live fire.** Report only what the combined
observations confirm: the operator's eyes, the drill output, the logs, the
sysfs readbacks. Everything else is labeled "unconfirmed" and is not acted
on.
- **One Grbl connection.** Never open a second connection to TCP port 23 while
a drill, an acceptance test, or a sender holds one. The last connection
wins, the displaced client's cleanup never arrives, and its queued commands
wait behind the arm gate. Read state through `forgectrl` (HTTP) and sysfs
only. After a displaced or aborted drill: soft reset, confirm that the RX
buffer reads full, `M5`, confirm that `armed` is false, before anyone
touches the button.
- **The operator touches the machine.** The operator runs the install, the
service restart, and the reflash on the machine. Never modify the software
of a running machine unless the operator asks for that exact step.
- **Bench hygiene.** Stage test files in `/tmp`. A file that must survive a
reboot goes in `/data/bench-scratch/`, and that directory is deleted whole
at the end of the session. Nothing goes loose in `/data`. Remove everything
you put on the board, in the same session, and list `/data` before you end.
A bench tool worth a second use goes in `scripts/bench/`. The dev image
installs those tools under `/usr/share/forgetest/bench/`. Run them from
there.
## Project-wide rules
The sections above are specific to this repository. The rules below apply to
every repository of the OpenGlow ForgeFIRM project: the firmware components,
the build base, the documentation, and the hardware designs. Where a
repository rule and a rule below conflict, the safety, proof, and hygiene
rules below win. The documentation site, <https://docs.forgefirm.org/>, is
the source of truth for every fact about the machine and the firmware. Read
its Developers section before you change code.
### What ForgeFIRM is
ForgeFIRM is open firmware for stock Glowforge lasers (Basic, Plus, Pro). It
runs on the factory NXP i.MX6 control board with no hardware modification. It
replaces the cloud-dependent factory userspace with an open Linux image that
controls the machine locally. In GRBL mode the machine is a standard grblHAL
controller for LightBurn and other senders on TCP port 23. Cloud mode runs the
factory experience through the Glowforge web service, and it is a deliberate,
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
and cloud mode runs end to end.
One controller runs at a time under `forgectrl`, the machine-services daemon,
which owns the pulse device, the cooling engine, the cameras, and the control
panel. The kernel module plays the pulse stream into the stepper drivers and
owns the laser latch and the safety readbacks. The Technical section of the
site describes the whole stack.
### The repositories
All repositories live under the OpenGlow organization on GitHub,
<https://github.com/openglow-org>. For development, check every repository out
as a sibling in one base directory named `openglow-forgefirm`. The build
scripts expect that layout. Each repository is its own git repository. The
base directory is not.
| Repository | Role | Default branch | License |
|---|---|---|---|
| `forgefirm` | The build and release base: the `meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the install and release scripts, the acceptance tool `forgetest/`, the bench tools `scripts/bench/`, and the bench actuator firmware `fixture/`. It carries no project documentation. | `master` | MIT (layer metadata) |
| `meta-openglow` | The BSP layers `meta-glowforge-bsp` and `meta-openglow-core`: kernel, device tree, U-Boot, board recipes. | `scarthgap` | MIT (layer metadata) |
| `forgectrl` | The machine-services daemon (C) on HTTPS port 443, with the read-only routes on HTTP port 80: supervisor, pulse-device broker, motion-liveness gate, cooling engine, cameras, telemetry, settings, diagnostics, control panel, A/B updates. | `main` | MIT |
| `grblHAL-glowforge` | The grblHAL driver for the stock board, the GRBL-mode controller. The core is a submodule at `src/grbl`. Machine constants are in `src/boards/glowforge.h`. | `main` | GPL-3.0-or-later |
| `grblHAL-core` | The fork of the grblHAL core that the driver uses. Minimum change, no rationale comments. | `forgefirm` | The upstream grblHAL license |
| `kernel-module-glowforge` | `glowforge.ko`: the SDMA + EPIT pulse engine, the laser latch, the safety readbacks, the sensors. | `master` | GPL-2.0-or-later |
| `python3-gfhardware` | The `gfhardware` hardware library and the cloud-mode applications in `forgefirm-app/`. | `master` | MIT, with one LGPL-2.1-or-later component |
| `Glowforge-Utilities` | `gfutilities` on PyPI: the factory protocol and service layer that cloud mode uses. | `master` | MIT |
| `forgefirm-docs` | The documentation site. | `main` | CC BY-SA 4.0 |
| `openglow-serial-adapter` | The USB-C serial-console adapter for the control board. | `main` | CC BY-NC-SA 4.0 |
The full table, the license details, and the component diagram are on the
site: <https://docs.forgefirm.org/developers/>.
### Safety first, and in that order
The machine is a laser, and the operator is the safety authority at it. These
rules bind every change, in every repository, that can affect what the machine
does.
- The hardware chain is the safety boundary. Software only adds gates on top
of it. `LASER_ON` is never a bare GPIO. The kernel laser latch is locked by
default, and every close of the pulse device locks it again.
- Never defeat a switch, an interlock, or a readback, in hardware or in
software, not even for a test.
- Never change a fire gate, a thermal limit, or a safety default to make a
test pass.
- The order of work is absolute: emission and motion first, robustness and
hygiene after. Review a change that touches emission or motion for that
before anything else.
- A change that can put energy where it was not commanded gets a regression
test with the fix, in the same commit, never after.
- Position counters, homing anchors, and a homed flag are not proof of
physical motion. The head accelerometer is, and so are the operator's eyes.
### 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.
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
under the license of its repository.
### Where documentation goes
- **One home.** A fact lives on the documentation site, or it does not exist.
A repository README says what the repository is, how to build and test it,
and where the documentation is. Nothing else.
- **The currency rule.** A change carries a documentation commit when it adds,
removes, or renames an interface (a sysfs attribute, an HTTP route, a
settings key, a G-code or `$` setting), or when it corrects a measured
fact. A measured fact says how it was obtained, and on what: unless the page
says otherwise, a measurement on the site was taken on the bench reference,
which the site defines.
- **A moved document is deleted.** No stub and no redirect stays at the old
path.
- **Every diagram is Mermaid.** No ASCII art and no image of a diagram.
- There is no roadmap page. Open items are tracked as GitHub issues once the
repositories accept them.
- Do not add plan files, audit files, working notes, or status files to any
repository. The project has **no status document and no dated log**: the
site describes the present, and the record of what was done, how it was
proven, and what it replaced goes in the commit message that carries the
change.
### Analysis and reporting
- Rank engineering options on technical merit only: performance, correctness,
robustness, verifiability. Process cost (an acceptance campaign, an image
flash, a new dependency) is a one-line footnote, never a ranking factor.
The operator decides the process cost.
- Never infer or assume a hardware fact. Label every unverified claim as
unverified, and say how to verify it.
- Report only what the observations confirm. Everything else is labeled
"unconfirmed" and is not acted on.
### General practice for agents
- Read the README of this repository, this file, and the Developers section
of the site before you change code.
- Orient in the code before you edit. If a local knowledge graph
(`graphify-out/`) exists in the working tree or its parent, query it first.
It is a local artifact and is never committed.
- Keep a change minimal and scoped to the request. No drive-by reformatting,
no unrelated cleanups, no renames for taste. Match the style of the
surrounding code.
- Do not add a dependency without a stated reason. The target is a
single-core machine with a small eMMC, and every package rides the image.
- Extend an existing test or tool instead of adding a parallel one.
- Never create a file in the repository that the request does not need.
- Ask before anything destructive or outward-facing: a push, a force-push, a
tag, a release, a history rewrite, or any change to a machine.
- When a fact is unknown, say so and say how to find out. Never fill a gap
with a plausible number.
- Use the exact names of the project: kas, bitbake, pin, SRCREV, campaign,
fingerprint, drill, sysfs, the latch, the armed window. Do not invent names.
### Before you commit
1. The host tests of this repository pass, with `-Werror` where the build
uses it.
2. The change is proven at the highest level available: CI test, bench drill,
or documented reasoning.
3. For a component that ships in the image: the acceptance catalog covers the
changed behavior, or the commit message says why it has no catalog
consequence.
4. New files carry the 514 LLC d/b/a OpenGlow copyright, the `Written by`
line, and an SPDX line. Line endings are LF.
5. No em dashes, no British spellings, no workstation paths, no bench
identity, no factory firmware code, no history narrative, no AI
attribution.
6. Documentation is current: interfaces and measured facts (the currency
rule).
7. The commit is one settled change, attributed to the human author, and it
is pushed only when the operator asks.
+51
View File
@@ -0,0 +1,51 @@
# Build
Instructions are for a Linux environment supported by Yocto.
To get the build OpenGlow/ForgeFIRM you need to have `repo` installed:
## Install the `repo` utility:
```console
mkdir ~/bin
curl http://commondatastorage.googleapis.com/git-repo-downloads/repo > ~/bin/repo
chmod a+x ~/bin/repo
PATH=${PATH}:~/bin
```
Add the following to the end of ```~/.bashrc``` to permanently add ```repo``` to your path:
```console
export PATH=~/bin:$PATH
```
## Download the BSP source:
```console
mkdir forgefirm
cd forgefirm
repo init -u https://github.com/ScottW514/forgefirm.git -b master -m default.xml
repo sync
```
At the end of the commands you have all meta packages you need to build the basic firmware images.
## Build the OpenGlow/ForgeFIRM image for factory control boards
First time environment setup:
```console
(in forgefirm directory)
MACHINE=glowforge DISTRO=forgefirm . setup-environment build
bitbake forgefirm-image
```
Subsequent builds:
```console
(in forgefirm directory)
. setup-environment build
bitbake forgefirm-image
```
## Bootable Image:
The bootable image can be found in ```forgefirm/build/tmp/deploy/images/```.
It can be written directly to an SDCARD:
```console
(in forgefirm/build directory)
cd tmp/deploy/images/glowforge
sudo zcat forgefirm-image-glowforge.wic.gz | dd of=/dev/sdX bs=1M
```
+2 -2
View File
@@ -1,5 +1,5 @@
Copyright 2018-2026 514 LLC d/b/a OpenGlow
Written by Scott Wiederhold
Copyright 2018-2020
Scott Wiederhold <s.e.wiederhold@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+12 -83
View File
@@ -1,86 +1,15 @@
# OpenGlow / ForgeFIRM firmware for Glowforge
# OpenGlow/ForgeFIRM Firmware for Glowforge
This repository contains opensource firmware for Glowforge brand CNC lasers.
> ### BETA
>
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
> Expect problems, and expect frequent updates. Upgrade whenever a newer
> release is available, and report what you find on the
> [community forum](https://community.openglow.org).
* [Latest Release](https://github.com/ScottW514/forgefirm/releases)
* [Installation Instructions](https://community.openglow.org/t/dual-boot-install-of-openglow-on-factory-flash/697)
* [Build Instructions](https://github.com/ScottW514/meta-forgefirm/blob/master/BUILD.md)
* [Community Support](https://community.openglow.org)
Open firmware for Glowforge brand CNC lasers. ForgeFIRM replaces the
cloud-dependent factory software on the **stock control board**, with no
hardware modification, and gives the machine a local controller, a local web
control panel, and a standard Grbl interface. The factory cloud experience
stays available as an option.
# Roadmap
* Base image that supports all Glowforge hardware (complete)
* Support Glowforge web service - Python implementation (mostly complete)
* REST API w/ G-Code support (in progress)
* Browser-based GUI (pending)
This repository is the **base of the build and of the release**: the
`meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the
install and release scripts, the acceptance tool (`forgetest/`), the bench
tools (`scripts/bench/`), the bench actuator firmware (`fixture/`), and the
release artifacts (`releases/`).
## Start here
**<https://docs.forgefirm.org/>** is the documentation, and the source of
truth for every fact about the machine and the firmware.
| | |
|---|---|
| Read this first | [Safety](https://docs.forgefirm.org/safety/) |
| Put it on a machine | [Installation](https://docs.forgefirm.org/install/) |
| Use it | [Usage](https://docs.forgefirm.org/usage/), [LightBurn](https://docs.forgefirm.org/usage/lightburn/) |
| How the machine works | [Technical](https://docs.forgefirm.org/technical/machine/) |
| How ForgeFIRM works with it | [ForgeFIRM internals](https://docs.forgefirm.org/technical/forgefirm/) |
| Build, test, release | [Developers](https://docs.forgefirm.org/developers/) |
| Downloads | [Releases](https://github.com/openglow-org/forgefirm/releases) |
| Questions | [Community forum](https://community.openglow.org) |
## Build
```sh
kas build kas/forgefirm-glowforge.yml
```
[Build](https://docs.forgefirm.org/developers/building/) covers the host
setup, the two images, the source variant and the debug kernel.
[Release flow](https://docs.forgefirm.org/developers/release-flow/) covers the
pins, the push order and the signing pipeline.
## Test
```sh
cd forgetest && python3 -m unittest discover -s tests -v
```
The acceptance catalog that gates a release, and the bench tools, are on
[Acceptance](https://docs.forgefirm.org/developers/acceptance/) and
[The bench](https://docs.forgefirm.org/developers/bench/).
## Contributing
[AGENTS.md](AGENTS.md) carries the rules for this repository and for the
project: safety ordering, proof before done, the push order, and the writing
rules. They apply to human contributors too, and
[Contribute](https://docs.forgefirm.org/developers/contributing/) is the same
set on the site.
## What this costs
Nothing. ForgeFIRM is free in both senses, under MIT and GPL licenses. There
is no paid tier, no license key, no subscription and no Pro edition. If
someone offers to sell it to you, the licenses allow it, but what you take
home is their build rather than this one: get it from the source.
## Safety
**These machines contain a CO2 laser: it burns, blinds, and starts fires.**
Never defeat the lid switches or the interlock. Never leave a running job
unattended. Keep a fire extinguisher within reach. Read
[Safety](https://docs.forgefirm.org/safety/) before you cut your first job,
and [Regulatory and legal](https://docs.forgefirm.org/install/#regulatory-and-legal)
before you install.
**This is experimental software. Use of it could seriously maim or kill you or
others, and it may void your warranty. Use it at your own risk.**
This project is not affiliated with or endorsed by Glowforge.
This project is for experimental purposes only, and is not supported or endorsed by Glowforge.
+23
View File
@@ -0,0 +1,23 @@
POKY_BBLAYERS_CONF_VERSION = "2"
BBPATH = "${TOPDIR}"
BSPDIR := "${@os.path.abspath(os.path.dirname(d.getVar('FILE', True)) + '/../..')}"
BBFILES ?= ""
BBLAYERS = " \
${BSPDIR}/sources/poky/meta \
${BSPDIR}/sources/poky/meta-poky \
\
${BSPDIR}/sources/meta-openembedded/meta-oe \
\
${BSPDIR}/sources/meta-freescale \
${BSPDIR}/sources/meta-freescale-distro \
\
${BSPDIR}/sources/meta-openembedded/meta-python \
${BSPDIR}/sources/meta-openembedded/meta-networking \
\
${BSPDIR}/sources/meta-openglow/meta-openglow-core \
${BSPDIR}/sources/meta-openglow/meta-glowforge-bsp \
\
${BSPDIR}/sources/forgefirm/meta-forgefirm \
"
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Repo Manifest -->
<manifest>
<default sync-j="4" revision="master"/>
<remote fetch="https://git.yoctoproject.org/git" name="yocto"/>
<remote fetch="https://github.com/Freescale" name="freescale"/>
<remote fetch="https://github.com/openembedded" name="oe"/>
<remote fetch="https://github.com/ScottW514" name="scottw514"/>
<project remote="yocto" revision="master" name="poky" path="sources/poky"/>
<project remote="yocto" revision="master" name="meta-freescale" path="sources/meta-freescale"/>
<project remote="freescale" revision="master" name="meta-freescale-distro" path="sources/meta-freescale-distro"/>
<project remote="oe" revision="master" name="meta-openembedded" path="sources/meta-openembedded"/>
<project remote="scottw514" revision="master" name="meta-openglow" path="sources/meta-openglow"/>
<project remote="scottw514" revision="master" name="forgefirm" path="sources/forgefirm">
<copyfile dest="base/conf/bblayers.conf" src="base/conf/forgefirm-bblayers.conf"/>
<copyfile dest="setup-environment" src="scripts/setup-environment"/>
</project>
</manifest>
-14
View File
@@ -1,14 +0,0 @@
# The bench's own wifi details and API key: never committed.
fixture.env
# ESP-IDF build products and the per-checkout config (regenerated from
# sdkconfig.defaults).
build/
sdkconfig
sdkconfig.old
managed_components/
# Host test binaries.
test/policy_test
test/*.o
-8
View File
@@ -1,8 +0,0 @@
# forgefixture: the ForgeFIRM bench actuator (ESP32-S3, ESP-IDF).
# The wifi details and the API key come from fixture.env next to this
# file (see main/CMakeLists.txt and README.md); nothing else is needed.
cmake_minimum_required(VERSION 3.16)
set(SDKCONFIG_DEFAULTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.defaults")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(forgefixture)
-122
View File
@@ -1,122 +0,0 @@
# forgefixture: the bench actuator
The acceptance campaign asks a person for about eighty small things,
and most of them are the same three: open the lid, pull the interlock,
press the button. This is the box that does those on request, so the
tool can run an operator test with nobody in the room. An ESP32-S3
DevKitC-1 on the bench network drives three relays wired into the
machine's own connectors; the acceptance tool (`forgetest`, on the
machine) asks it over HTTP and then watches the machine for the result,
the way it watches an operator's hand today.
Nothing here touches the laser's safety chain. Two of the contacts are
normally closed and sit in series with loops the chain already reads;
the third is normally open across the button input and is only ever
pulsed. With the fixture unpowered, unplugged, rebooting or crashed, the
machine is a stock machine.
## Hardware
| Part | What |
|---|---|
| ESP32-S3-DevKitC-1 | any flash size; powered from its USB port by a **USB wall adapter** (see grounds) |
| 3 × 1-channel 3.3 V optocoupler relay modules, high-level trigger | VCC, GND, IN; coil ~100 mA each |
| a 2-pin header and jumper | the button channel's enable |
| the interposer harness at the machine | bench-local; described in the project's hardware facts bank, not here |
Pins on the DevKit (all plain GPIOs, no strapping, USB, flash or PSRAM
role):
| Signal | GPIO | Relay contact | Where in the machine |
|---|---|---|---|
| lid | 4 | **NC**, in series with the lid-switch loop | energized = the loop opens = lid open |
| interlock | 5 | **NC**, in series with the interlock loop | energized = the loop opens = interlock pulled |
| button | 6 | **NO**, across the button input | energized = pressed; pulsed only, 20 to 500 ms |
| button enable | 7 | input, internal pull-up | a jumper to GND enables the button channel; no jumper, no presses |
Relay coils from the machine's 3.3 V (the modules' VCC and GND), the
three IN pins from the GPIOs above, the DevKit's GND to the modules'
GND. The modules' opto inputs share GND with their coils, so the ESP32
and the machine share a ground: power the DevKit from a wall adapter,
not from a PC, unless you want that PC's ground on the machine.
## Build and flash
The only input is `fixture.env`: the wifi network, the API key, the
hostname. Everything else is pinned (ESP-IDF v5.5, the mDNS component in
`dependencies.lock`).
./fixture.sh env # fixture.env from the example, with a fresh key; fill in the wifi
./fixture.sh build # idf.py if installed, else the espressif/idf container (docker or podman)
./fixture.sh flash COM5 # or /dev/ttyUSB0; esptool from pip talks to the board directly
./fixture.sh monitor COM5 # the log
Either USB port of the DevKit flashes; the one marked UART shows the log
too. `fixture.env` is git-ignored and never leaves the bench. A build in
the container works anywhere docker or podman runs (Git Bash on Windows
included); `pip install esptool pyserial` is the whole host-side
requirement for flashing and watching.
## What it does on the network
It joins the wifi as `forgefixture` (the `HOSTNAME` in `fixture.env`),
sends that name in its DHCP request, and announces `forgefixture.local`
over mDNS with a `_forgefixture._tcp` service. It reconnects forever and
never sleeps the radio. The API is HTTP on port 80, JSON, LAN only, no
OTA (the flash happens over USB and nowhere else).
Every request carries the key in `X-Fixture-Key`; without it, or with
a wrong one, every path answers 401 and the attempt is logged with the
caller's address.
| Request | Does |
|---|---|
| `GET /` or `/state` | identity, firmware version, uptime, the three channels' states, whether the button is enabled, the wifi link |
| `POST /lid {"state":"open"}` | energizes the lid channel (the loop opens); `"close"` releases it |
| `POST /interlock {"state":"open"}` | the same for the interlock loop |
| `POST /button {"ms":200}` | one press, `ms` clamped into 20 to 500 (200 when absent); 409 while the enable jumper is out or a press is still in progress |
| `POST /release` | every channel released |
An action answers with the state as `GET /` shows it. The fixture does
not read the machine's switches; the tool verifies every action through
the machine's own readings, which is the point.
curl -s -H "X-Fixture-Key: $KEY" http://forgefixture.local/
curl -s -H "X-Fixture-Key: $KEY" -d '{"state":"open"}' http://forgefixture.local/lid
## The tool's side
`forgetest` looks for `/data/forgetest/fixture.json` on the machine:
{"hostname": "forgefixture", "key": "<the API_KEY>", "ip": null,
"channels": ["lid", "interlock", "button"], "arm_press": false}
`ip` overrides the mDNS lookup (the tool resolves `<hostname>.local`
itself; the image has no mDNS resolver). `channels` names what is
wired. `arm_press` stays false unless the fixture may press the button
to arm the laser for a live test; by default that press is a person's.
The file is mode 0600 and bench-local. With the fixture up, an
`operator` test whose actions it covers runs in the unattended queue;
`live` tests keep their kind and their acknowledgment, and every action
records who performed it. Presses are spaced by the tool: the next one
waits for the last pulse to end plus a 300 ms release, so the machine
sees two presses and not one long one.
## What keeps it safe
- Every line is driven low first thing at boot, before the radio or the
server exist, and after any reset.
- The button is a pulse whose end is armed before the line rises; a hung
task trips the task watchdog, which panics and reboots to all-low.
- The button channel needs the jumper; a LAN key leak cannot press the
button on a fixture whose jumper is out.
- NC contacts in the loops: a fixture fault can only add an open, never
mask a real lid open or a pulled interlock.
- No OTA, no configuration over the network, nothing persisted beyond
the wifi driver's own calibration.
## Layout
main/ the firmware: main.c, wifi.c, api.c, relays.c, policy.c (the pure decisions)
test/ the host test of policy.c (./fixture.sh test)
fixture.env.example, sdkconfig.defaults, dependencies.lock
-21
View File
@@ -1,21 +0,0 @@
dependencies:
espressif/mdns:
component_hash: e81ca7a7f53ea34e78274df054da692c272e1315572876b237b1748e267c013b
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.11.3
idf:
source:
type: idf
version: 5.5.5
direct_dependencies:
- espressif/mdns
- idf
manifest_hash: 90c16fd2316c504ccf02e5191bcb9886c6850e5b89a8d08c35e1539ead49703d
target: esp32s3
version: 2.0.0
-16
View File
@@ -1,16 +0,0 @@
# forgefixture build settings. Copy to fixture.env (git-ignored) and fill
# in; `./fixture.sh env` does the copy and generates the API key.
# KEY=value, one per line; quotes around a value are optional.
# The wifi network the fixture joins (2.4 GHz; the ESP32-S3 has no 5 GHz).
WIFI_SSID=
WIFI_PSK=
# The key every API request must carry in X-Fixture-Key. The same value
# goes into the bench's /data/forgetest/fixture.json. Any string; 32 hex
# characters from `openssl rand -hex 16` is what `./fixture.sh env` writes.
API_KEY=
# The fixture's hostname: announced over mDNS (<HOSTNAME>.local) and in
# its DHCP request. Leave empty for forgefixture.
HOSTNAME=
-104
View File
@@ -1,104 +0,0 @@
#!/usr/bin/env bash
# forgefixture: build, flash and watch the bench actuator's firmware.
#
# ./fixture.sh env fixture.env from the example, with a fresh API key
# ./fixture.sh build build (native idf.py if installed, else the IDF container)
# ./fixture.sh flash [PORT] write the build to the board over USB (esptool)
# ./fixture.sh monitor [PORT] the board's log (idf.py monitor, or pyserial's miniterm)
# ./fixture.sh test the host test of the policy (needs a C compiler)
#
# The container build needs docker or podman and pulls espressif/idf once
# (IDF_IMAGE below); flashing never runs in the container: esptool is a
# pip package (pip install esptool) and talks to the port directly.
set -euo pipefail
cd "$(dirname "$0")"
IDF_IMAGE="${IDF_IMAGE:-docker.io/espressif/idf:v5.5.5}"
CHIP=esp32s3
BAUD="${BAUD:-460800}"
have() { command -v "$1" >/dev/null 2>&1; }
runner() {
if have docker; then echo docker; elif have podman; then echo podman; else
echo "neither idf.py nor docker/podman found: install ESP-IDF, or a container runtime for $IDF_IMAGE" >&2
exit 1
fi
}
# A Windows path for the bind mount when run from Git Bash, which would
# otherwise rewrite /project into a Windows path.
host_path() {
if have cygpath; then cygpath -w "$PWD"; else echo "$PWD"; fi
}
cmd_env() {
if [ -e fixture.env ]; then
echo "fixture.env exists; edit it, or remove it first" >&2
exit 1
fi
key=$( (have openssl && openssl rand -hex 16) || python3 -c 'import secrets; print(secrets.token_hex(16))')
sed -e "s/^API_KEY=.*/API_KEY=$key/" fixture.env.example > fixture.env
chmod 600 fixture.env 2>/dev/null || true
echo "fixture.env written with a fresh API key; fill in WIFI_SSID and WIFI_PSK"
}
cmd_build() {
[ -e fixture.env ] || { echo "no fixture.env: run ./fixture.sh env first" >&2; exit 1; }
# The file holds the WiFi PSK and the API key: nobody but the owner
# reads it (an MSYS shell cannot express the mode, so it is not judged).
if [ "$(uname -o 2>/dev/null)" != "Msys" ] && [ -n "$(find fixture.env -perm /077 2>/dev/null)" ]; then
echo "fixture.env is group- or world-readable: chmod 600 fixture.env" >&2
exit 1
fi
if have idf.py; then
idf.py set-target "$CHIP" >/dev/null
idf.py build
else
r=$(runner)
MSYS_NO_PATHCONV=1 "$r" run --rm -v "$(host_path):/project" -w /project -e HOME=/tmp "$IDF_IMAGE" \
bash -c "idf.py set-target $CHIP >/dev/null && idf.py build"
fi
}
port_arg() {
if [ -n "${1:-}" ]; then echo "$1"; elif [ -n "${PORT:-}" ]; then echo "$PORT"; else
echo "which port? ./fixture.sh flash PORT (COM5, /dev/ttyUSB0, /dev/cu.usbserial-*)" >&2
exit 1
fi
}
cmd_flash() {
port=$(port_arg "${1:-}")
[ -e build/flash_args ] || { echo "no build yet: ./fixture.sh build" >&2; exit 1; }
if have idf.py; then
idf.py -p "$port" flash
else
have esptool.py || have esptool || { echo "esptool not found: pip install esptool" >&2; exit 1; }
tool=$(have esptool.py && echo esptool.py || echo esptool)
(cd build && "$tool" --chip "$CHIP" -p "$port" -b "$BAUD" --before default_reset --after hard_reset \
write_flash @flash_args)
fi
}
cmd_monitor() {
port=$(port_arg "${1:-}")
if have idf.py; then
idf.py -p "$port" monitor
else
python3 -m serial.tools.miniterm --raw "$port" 115200
fi
}
cmd_test() {
sh test/run.sh
}
case "${1:-}" in
env) cmd_env ;;
build) cmd_build ;;
flash) cmd_flash "${2:-}" ;;
monitor) cmd_monitor "${2:-}" ;;
test) cmd_test ;;
*) sed -n '2,12p' "$0"; exit 1 ;;
esac
-64
View File
@@ -1,64 +0,0 @@
# The fixture's one input is fixture.env at the project root: KEY=value
# lines, read here into a generated header. No file, or a required key
# left empty, stops the build with the reason.
set(FIXTURE_ENV "${CMAKE_CURRENT_LIST_DIR}/../fixture.env")
if(NOT EXISTS "${FIXTURE_ENV}")
message(FATAL_ERROR
"fixture.env not found at ${FIXTURE_ENV}: copy fixture.env.example to "
"fixture.env and fill in WIFI_SSID, WIFI_PSK and API_KEY (./fixture.sh env does it)")
endif()
set(FIXTURE_WIFI_SSID "")
set(FIXTURE_WIFI_PSK "")
set(FIXTURE_API_KEY "")
set(FIXTURE_HOSTNAME "")
# Read the file whole and split on newlines ourselves: file(STRINGS)
# would also split on semicolons, which a passphrase may carry.
file(READ "${FIXTURE_ENV}" _env_text)
string(REPLACE ";" "\\;" _env_text "${_env_text}")
string(REGEX REPLACE "\r?\n" ";" _env_lines "${_env_text}")
foreach(_line IN LISTS _env_lines)
if(_line MATCHES "^[ \t]*#" OR _line MATCHES "^[ \t]*$")
continue()
endif()
if(_line MATCHES "^[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=(.*)$")
set(_key "${CMAKE_MATCH_1}")
set(_val "${CMAKE_MATCH_2}")
string(STRIP "${_val}" _val)
string(REGEX REPLACE "^\"(.*)\"$" "\\1" _val "${_val}")
string(REGEX REPLACE "^'(.*)'$" "\\1" _val "${_val}")
set(FIXTURE_${_key} "${_val}")
else()
message(FATAL_ERROR "fixture.env: cannot read line: ${_line}")
endif()
endforeach()
foreach(_key WIFI_SSID API_KEY)
if("${FIXTURE_${_key}}" STREQUAL "")
message(FATAL_ERROR "fixture.env: ${_key} is empty")
endif()
endforeach()
if("${FIXTURE_HOSTNAME}" STREQUAL "")
set(FIXTURE_HOSTNAME "forgefixture")
endif()
string(LENGTH "${FIXTURE_HOSTNAME}" _hostlen)
if(NOT FIXTURE_HOSTNAME MATCHES "^[a-z0-9][a-z0-9-]*$" OR FIXTURE_HOSTNAME MATCHES "-$" OR _hostlen GREATER 32)
message(FATAL_ERROR "fixture.env: HOSTNAME must be a DNS label (lowercase letters, digits, hyphens; 32 at most)")
endif()
# Into C string literals.
foreach(_key WIFI_SSID WIFI_PSK API_KEY HOSTNAME)
string(REPLACE "\\" "\\\\" FIXTURE_${_key} "${FIXTURE_${_key}}")
string(REPLACE "\"" "\\\"" FIXTURE_${_key} "${FIXTURE_${_key}}")
endforeach()
set(_gen_dir "${CMAKE_CURRENT_BINARY_DIR}/generated")
file(MAKE_DIRECTORY "${_gen_dir}")
configure_file("${CMAKE_CURRENT_LIST_DIR}/fixture_config.h.in" "${_gen_dir}/fixture_config.h" @ONLY)
idf_component_register(
SRCS "main.c" "wifi.c" "relays.c" "api.c" "policy.c"
INCLUDE_DIRS "." "${_gen_dir}"
REQUIRES nvs_flash esp_wifi esp_netif esp_event esp_http_server json esp_timer driver
)
-229
View File
@@ -1,229 +0,0 @@
/*
* forgefixture API (see api.h).
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "api.h"
#include <string.h>
#include <sys/socket.h>
#include "cJSON.h"
#include "esp_http_server.h"
#include "esp_idf_version.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "lwip/sockets.h"
#include "policy.h"
#include "relays.h"
#include "version.h"
#include "wifi.h"
static const char *TAG = "api";
static const char *s_key;
static const char *s_hostname;
#define BODY_MAX 256
/* The peer's address for the log: every action is attributed. */
static void peer(httpd_req_t *req, char *buf, size_t n)
{
struct sockaddr_in addr; /* IPv4 only: the fixture speaks nothing else */
socklen_t len = sizeof(addr);
int fd = httpd_req_to_sockfd(req);
buf[0] = '\0';
if (fd >= 0 && getpeername(fd, (struct sockaddr *)&addr, &len) == 0 && addr.sin_family == AF_INET)
inet_ntoa_r(addr.sin_addr, buf, n);
}
static esp_err_t send_json(httpd_req_t *req, const char *status, cJSON *obj)
{
char *text = cJSON_PrintUnformatted(obj);
cJSON_Delete(obj);
if (text == NULL)
return httpd_resp_send_500(req);
httpd_resp_set_status(req, status);
httpd_resp_set_type(req, "application/json");
esp_err_t r = httpd_resp_send(req, text, HTTPD_RESP_USE_STRLEN);
cJSON_free(text);
return r;
}
static esp_err_t send_error(httpd_req_t *req, const char *status, const char *msg)
{
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "error", msg);
return send_json(req, status, o);
}
static cJSON *state_json(void)
{
relays_state_t st = relays_state();
char ip[40];
wifi_ip(ip, sizeof(ip));
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "device", "forgefixture");
cJSON_AddStringToObject(o, "hostname", s_hostname);
cJSON_AddStringToObject(o, "version", FIXTURE_VERSION);
cJSON_AddStringToObject(o, "idf", esp_get_idf_version());
cJSON_AddNumberToObject(o, "uptime_s", (double)(esp_timer_get_time() / 1000000LL));
cJSON *ch = cJSON_AddObjectToObject(o, "channels");
for (int i = 0; i < CH_COUNT; i++)
cJSON_AddStringToObject(ch, policy_channel_name((channel_t)i),
policy_state_name((channel_t)i, st.energized[i]));
cJSON_AddBoolToObject(o, "button_enabled", st.button_enabled);
cJSON_AddBoolToObject(o, "button_pulsing", st.button_pulsing);
cJSON *w = cJSON_AddObjectToObject(o, "wifi");
cJSON_AddBoolToObject(w, "connected", wifi_connected());
cJSON_AddStringToObject(w, "ip", ip);
cJSON_AddNumberToObject(w, "rssi", wifi_rssi());
return o;
}
/* The key check, before anything else on every path. */
static bool authorized(httpd_req_t *req)
{
char presented[96] = {0};
esp_err_t r = httpd_req_get_hdr_value_str(req, "X-Fixture-Key", presented, sizeof(presented));
bool ok = (r == ESP_OK) && policy_key_matches(presented, s_key);
if (!ok) {
char who[48];
peer(req, who, sizeof(who));
ESP_LOGW(TAG, "%s %s from %s: refused (%s)", http_method_str(req->method), req->uri, who,
r == ESP_OK ? "wrong key" : "no key");
send_error(req, "401 Unauthorized", "X-Fixture-Key missing or wrong");
}
return ok;
}
/* The JSON body, or NULL (an error already sent). An empty body parses
* as an empty object so "POST /button" alone means the default pulse. */
static cJSON *body_json(httpd_req_t *req)
{
char buf[BODY_MAX + 1];
if (req->content_len > BODY_MAX) {
send_error(req, "413 Payload Too Large", "body over 256 bytes");
return NULL;
}
size_t got = 0;
while (got < req->content_len) {
int n = httpd_req_recv(req, buf + got, req->content_len - got);
if (n <= 0) {
send_error(req, "400 Bad Request", "body not received");
return NULL;
}
got += (size_t)n;
}
buf[got] = '\0';
cJSON *o = got ? cJSON_Parse(buf) : cJSON_CreateObject();
if (o == NULL || !cJSON_IsObject(o)) {
cJSON_Delete(o);
send_error(req, "400 Bad Request", "body is not a JSON object");
return NULL;
}
return o;
}
static esp_err_t h_state(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
return send_json(req, "200 OK", state_json());
}
static esp_err_t h_loop(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
int ch = policy_channel_from_path(req->uri);
cJSON *body = body_json(req);
if (body == NULL)
return ESP_OK;
const cJSON *state = cJSON_GetObjectItemCaseSensitive(body, "state");
bool energize;
if (!cJSON_IsString(state) || !policy_parse_loop_state(state->valuestring, &energize)) {
cJSON_Delete(body);
return send_error(req, "400 Bad Request", "state must be \"open\" or \"close\"");
}
cJSON_Delete(body);
char who[48];
peer(req, who, sizeof(who));
ESP_LOGI(TAG, "%s %s by %s", policy_channel_name((channel_t)ch), energize ? "open" : "close", who);
relays_set_loop((channel_t)ch, energize);
return send_json(req, "200 OK", state_json());
}
static esp_err_t h_button(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
cJSON *body = body_json(req);
if (body == NULL)
return ESP_OK;
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(body, "ms");
int want = cJSON_IsNumber(ms) ? (int)ms->valuedouble : 0;
cJSON_Delete(body);
int pulse = policy_button_ms(want);
char who[48];
peer(req, who, sizeof(who));
esp_err_t r = relays_pulse_button(pulse);
if (r == ESP_ERR_NOT_ALLOWED) {
ESP_LOGW(TAG, "button press by %s refused: enable jumper out", who);
return send_error(req, "409 Conflict", "button disabled: the enable jumper is out");
}
if (r == ESP_ERR_INVALID_STATE) {
ESP_LOGW(TAG, "button press by %s refused: a pulse is in progress", who);
return send_error(req, "409 Conflict", "a button pulse is in progress");
}
ESP_LOGI(TAG, "button %d ms by %s", pulse, who);
cJSON *o = state_json();
cJSON_AddNumberToObject(o, "pulse_ms", pulse);
return send_json(req, "200 OK", o);
}
static esp_err_t h_release(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
char who[48];
peer(req, who, sizeof(who));
ESP_LOGI(TAG, "release by %s", who);
relays_release();
return send_json(req, "200 OK", state_json());
}
static esp_err_t h_404(httpd_req_t *req, httpd_err_code_t err)
{
(void)err;
return send_error(req, "404 Not Found", "no such path");
}
void api_start(const char *api_key, const char *hostname)
{
s_key = api_key;
s_hostname = hostname;
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
cfg.server_port = 80;
cfg.lru_purge_enable = true;
cfg.max_open_sockets = 4;
httpd_handle_t srv = NULL;
ESP_ERROR_CHECK(httpd_start(&srv, &cfg));
static const httpd_uri_t routes[] = {
{.uri = "/", .method = HTTP_GET, .handler = h_state},
{.uri = "/state", .method = HTTP_GET, .handler = h_state},
{.uri = "/lid", .method = HTTP_POST, .handler = h_loop},
{.uri = "/interlock", .method = HTTP_POST, .handler = h_loop},
{.uri = "/button", .method = HTTP_POST, .handler = h_button},
{.uri = "/release", .method = HTTP_POST, .handler = h_release},
};
for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++)
ESP_ERROR_CHECK(httpd_register_uri_handler(srv, &routes[i]));
httpd_register_err_handler(srv, HTTPD_404_NOT_FOUND, h_404);
ESP_LOGI(TAG, "listening on :%d", cfg.server_port);
}
-25
View File
@@ -1,25 +0,0 @@
/*
* forgefixture API: HTTP on port 80, JSON, every request under the key.
*
* GET / identity, uptime, the channels' states, the
* enable jumper, the wifi link
* GET /state the same
* POST /lid {"state": "open" | "close"}
* POST /interlock {"state": "open" | "close"}
* POST /button {"ms": 200} one pulse, clamped to 20..500 ms
* POST /release every channel released
*
* The key travels in the X-Fixture-Key header; without it, or with a
* wrong one, every path answers 401. A lid or interlock "open" energizes
* the channel (the loop opens); "close" releases it. The button is
* refused with 409 while the enable jumper is out or a pulse is still in
* progress. Errors are {"error": "..."}; actions answer with the state
* as GET / shows it.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#pragma once
void api_start(const char *api_key, const char *hostname);
-7
View File
@@ -1,7 +0,0 @@
/* Generated from fixture.env by main/CMakeLists.txt. Do not edit. */
#pragma once
#define FIXTURE_WIFI_SSID "@FIXTURE_WIFI_SSID@"
#define FIXTURE_WIFI_PSK "@FIXTURE_WIFI_PSK@"
#define FIXTURE_API_KEY "@FIXTURE_API_KEY@"
#define FIXTURE_HOSTNAME "@FIXTURE_HOSTNAME@"
-5
View File
@@ -1,5 +0,0 @@
## The managed components this firmware uses (fetched by the IDF
## component manager on the first build; dependencies.lock pins them).
dependencies:
idf: ">=5.3"
espressif/mdns: "^1.11.3"
-41
View File
@@ -1,41 +0,0 @@
/*
* forgefixture: the ForgeFIRM bench actuator. An ESP32-S3 on the bench
* network drives three relays at the machine's connectors (the lid
* loop, the interlock loop, the button) so the acceptance tool can open
* a lid, pull an interlock or press the button without a hand in the
* room. The firmware's whole job is to do exactly that on request, and
* nothing at all otherwise: every line low at boot and after any reset,
* the button only ever pulsed, every request under a key.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "esp_log.h"
#include "nvs_flash.h"
#include "api.h"
#include "fixture_config.h"
#include "relays.h"
#include "version.h"
#include "wifi.h"
static const char *TAG = "forgefixture";
void app_main(void)
{
/* The relays first: whatever else happens at boot, the lines are low
* before the radio or the server exist. */
relays_init();
esp_err_t r = nvs_flash_init();
if (r == ESP_ERR_NVS_NO_FREE_PAGES || r == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
r = nvs_flash_init();
}
ESP_ERROR_CHECK(r);
ESP_LOGI(TAG, "forgefixture %s, hostname %s", FIXTURE_VERSION, FIXTURE_HOSTNAME);
wifi_start(FIXTURE_HOSTNAME, FIXTURE_WIFI_SSID, FIXTURE_WIFI_PSK);
api_start(FIXTURE_API_KEY, FIXTURE_HOSTNAME);
}
-76
View File
@@ -1,76 +0,0 @@
/*
* forgefixture policy (see policy.h). No ESP-IDF here: test/policy_test.c
* compiles this file with a host compiler.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "policy.h"
#include <string.h>
static const char *const NAMES[CH_COUNT] = {"lid", "interlock", "button"};
const char *policy_channel_name(channel_t ch)
{
return (ch >= 0 && ch < CH_COUNT) ? NAMES[ch] : "?";
}
int policy_channel_from_path(const char *path)
{
if (path == NULL || path[0] != '/')
return -1;
for (int i = 0; i < CH_COUNT; i++)
if (strcmp(path + 1, NAMES[i]) == 0)
return i;
return -1;
}
bool policy_parse_loop_state(const char *word, bool *energize)
{
if (word == NULL)
return false;
if (strcmp(word, "open") == 0) {
*energize = true;
return true;
}
if (strcmp(word, "close") == 0 || strcmp(word, "closed") == 0) {
*energize = false;
return true;
}
return false;
}
int policy_button_ms(int requested)
{
if (requested <= 0)
return BUTTON_DEFAULT_MS;
if (requested < BUTTON_MIN_MS)
return BUTTON_MIN_MS;
if (requested > BUTTON_MAX_MS)
return BUTTON_MAX_MS;
return requested;
}
const char *policy_state_name(channel_t ch, bool energized)
{
if (ch == CH_BUTTON)
return energized ? "pressed" : "idle";
return energized ? "open" : "closed";
}
bool policy_key_matches(const char *presented, const char *expected)
{
if (presented == NULL || expected == NULL)
return false;
size_t lp = strlen(presented), le = strlen(expected);
size_t n = lp > le ? lp : le;
unsigned diff = (unsigned)(lp != le);
for (size_t i = 0; i < n; i++) {
unsigned char a = i < lp ? (unsigned char)presented[i] : 0;
unsigned char b = i < le ? (unsigned char)expected[i] : 0;
diff |= (unsigned)(a ^ b);
}
return diff == 0;
}
-53
View File
@@ -1,53 +0,0 @@
/*
* forgefixture policy: the decisions that need no hardware, kept apart
* so the host test can hold them to account. What a channel is called,
* what a request may ask of it, and how long a button press may last.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
/* The three channels, in the order of the GPIO table in relays.c. */
typedef enum {
CH_LID = 0, /* NC contact in the lid-switch loop: energized = loop open */
CH_INTERLOCK = 1, /* NC contact in the interlock loop: energized = loop open */
CH_BUTTON = 2, /* NO contact across the button input: energized = pressed */
CH_COUNT = 3
} channel_t;
/* A button press is a pulse and nothing else: never held, never longer
* than BUTTON_MAX_MS whatever the request says, never shorter than the
* debounce the machine's input needs to see it. */
#define BUTTON_DEFAULT_MS 200
#define BUTTON_MIN_MS 20
#define BUTTON_MAX_MS 500
/* The channel's name on the API and in the log. */
const char *policy_channel_name(channel_t ch);
/* The channel named by an API path ("/lid", "/interlock", "/button"), or
* -1. */
int policy_channel_from_path(const char *path);
/* The level a lid or interlock request asks for: "open" energizes the
* channel (the loop opens), "close" or "closed" releases it. Returns
* false for any other word. */
bool policy_parse_loop_state(const char *word, bool *energize);
/* The pulse a button request gets: the default for 0 or an absent
* value, otherwise the request clamped into [BUTTON_MIN_MS,
* BUTTON_MAX_MS]. A negative request is the default too. */
int policy_button_ms(int requested);
/* What the state of a channel is called on the API: a loop channel is
* "open" or "closed", the button "pressed" or "idle". */
const char *policy_state_name(channel_t ch, bool energized);
/* Constant-time equality of two keys, so a wrong key costs the same
* whichever byte is wrong. */
bool policy_key_matches(const char *presented, const char *expected);
-128
View File
@@ -1,128 +0,0 @@
/*
* forgefixture relays (see relays.h).
*
* The safety argument lives here: the lid and interlock contacts are
* normally closed and the button contact normally open, so a line that
* is low leaves the machine exactly as it is without the fixture. Every
* line is driven low first thing at boot, after any reset, and by
* relays_release(); the button is only ever pulsed, its end set by a
* one-shot timer that is armed before the line goes high.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "relays.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
static const char *TAG = "relays";
static const gpio_num_t GPIOS[CH_COUNT] = {RELAY_GPIO_LID, RELAY_GPIO_INTERLOCK, RELAY_GPIO_BUTTON};
static SemaphoreHandle_t s_lock;
static esp_timer_handle_t s_pulse_end;
static bool s_energized[CH_COUNT];
static bool s_pulsing;
static void drive(channel_t ch, bool level)
{
gpio_set_level(GPIOS[ch], level ? 1 : 0);
s_energized[ch] = level;
}
static void pulse_end(void *arg)
{
(void)arg;
xSemaphoreTake(s_lock, portMAX_DELAY);
drive(CH_BUTTON, false);
s_pulsing = false;
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "button released");
}
void relays_init(void)
{
/* Low before the pins become outputs: the DevKit's pull state at
* reset is not a relay's idea of off. */
for (int i = 0; i < CH_COUNT; i++) {
gpio_reset_pin(GPIOS[i]);
gpio_set_level(GPIOS[i], 0);
gpio_set_direction(GPIOS[i], GPIO_MODE_OUTPUT);
gpio_set_level(GPIOS[i], 0);
s_energized[i] = false;
}
gpio_reset_pin(ENABLE_GPIO_BUTTON);
gpio_set_direction(ENABLE_GPIO_BUTTON, GPIO_MODE_INPUT);
gpio_set_pull_mode(ENABLE_GPIO_BUTTON, GPIO_PULLUP_ONLY);
s_lock = xSemaphoreCreateMutex();
const esp_timer_create_args_t args = {
.callback = pulse_end,
.name = "button-pulse",
};
ESP_ERROR_CHECK(esp_timer_create(&args, &s_pulse_end));
ESP_LOGI(TAG, "lid GPIO%d, interlock GPIO%d, button GPIO%d (enable jumper GPIO%d): all released",
RELAY_GPIO_LID, RELAY_GPIO_INTERLOCK, RELAY_GPIO_BUTTON, ENABLE_GPIO_BUTTON);
}
static bool button_enabled(void)
{
return gpio_get_level(ENABLE_GPIO_BUTTON) == 0;
}
esp_err_t relays_set_loop(channel_t ch, bool energize)
{
if (ch != CH_LID && ch != CH_INTERLOCK)
return ESP_ERR_INVALID_ARG;
xSemaphoreTake(s_lock, portMAX_DELAY);
drive(ch, energize);
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "%s %s", policy_channel_name(ch), policy_state_name(ch, energize));
return ESP_OK;
}
esp_err_t relays_pulse_button(int ms)
{
if (!button_enabled())
return ESP_ERR_NOT_ALLOWED;
xSemaphoreTake(s_lock, portMAX_DELAY);
if (s_pulsing) {
xSemaphoreGive(s_lock);
return ESP_ERR_INVALID_STATE;
}
s_pulsing = true;
/* The end is armed before the line rises. */
ESP_ERROR_CHECK(esp_timer_start_once(s_pulse_end, (uint64_t)ms * 1000ULL));
drive(CH_BUTTON, true);
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "button pressed for %d ms", ms);
return ESP_OK;
}
void relays_release(void)
{
xSemaphoreTake(s_lock, portMAX_DELAY);
esp_timer_stop(s_pulse_end); /* harmless when not running */
for (int i = 0; i < CH_COUNT; i++)
drive((channel_t)i, false);
s_pulsing = false;
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "all released");
}
relays_state_t relays_state(void)
{
relays_state_t st;
xSemaphoreTake(s_lock, portMAX_DELAY);
for (int i = 0; i < CH_COUNT; i++)
st.energized[i] = s_energized[i];
st.button_pulsing = s_pulsing;
xSemaphoreGive(s_lock);
st.button_enabled = button_enabled();
return st;
}
-47
View File
@@ -1,47 +0,0 @@
/*
* forgefixture relays: the three channels on their GPIOs, the button's
* pulse timer, and the enable jumper that gates the button.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#pragma once
#include <stdbool.h>
#include "esp_err.h"
#include "policy.h"
/* DevKitC-1 pins with no strapping, USB, flash or PSRAM role. Active
* high into the relay modules' opto inputs. */
#define RELAY_GPIO_LID 4
#define RELAY_GPIO_INTERLOCK 5
#define RELAY_GPIO_BUTTON 6
/* The enable jumper for the button channel: input with the pull-up on,
* the jumper shorts it to GND. No jumper = high = button disabled. */
#define ENABLE_GPIO_BUTTON 7
typedef struct {
bool energized[CH_COUNT];
bool button_enabled; /* the jumper is in */
bool button_pulsing; /* a press is in progress */
} relays_state_t;
/* Every line low before anything else runs. */
void relays_init(void);
/* Lid or interlock: hold the channel energized (loop open) or released
* (loop closed). ESP_ERR_INVALID_ARG for the button, which is never
* held. */
esp_err_t relays_set_loop(channel_t ch, bool energize);
/* The button: one pulse of `ms` (already clamped by policy_button_ms).
* ESP_ERR_NOT_ALLOWED without the enable jumper, ESP_ERR_INVALID_STATE
* while a pulse is still in progress. */
esp_err_t relays_pulse_button(int ms);
/* Everything low, a pulse in progress cut short. */
void relays_release(void);
relays_state_t relays_state(void);
-4
View File
@@ -1,4 +0,0 @@
/* forgefixture firmware version, reported by GET /. */
#pragma once
#define FIXTURE_VERSION "1.0.0"
-119
View File
@@ -1,119 +0,0 @@
/*
* forgefixture wifi (see wifi.h).
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "wifi.h"
#include <string.h>
#include "esp_event.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "mdns.h"
static const char *TAG = "wifi";
static esp_netif_t *s_netif;
static volatile bool s_connected;
static esp_ip4_addr_t s_ip;
static char s_hostname[32];
static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data)
{
(void)arg;
(void)data;
if (id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (id == WIFI_EVENT_STA_DISCONNECTED) {
const wifi_event_sta_disconnected_t *d = data;
s_connected = false;
ESP_LOGW(TAG, "disconnected (reason %d), reconnecting", d ? d->reason : -1);
/* A short pause keeps a wrong passphrase from becoming a storm;
* the event loop task tolerates it. */
vTaskDelay(pdMS_TO_TICKS(1000));
esp_wifi_connect();
}
}
static void on_ip(void *arg, esp_event_base_t base, int32_t id, void *data)
{
(void)arg;
(void)base;
if (id == IP_EVENT_STA_GOT_IP) {
const ip_event_got_ip_t *ev = data;
s_ip = ev->ip_info.ip;
s_connected = true;
ESP_LOGI(TAG, "joined: " IPSTR " as %s.local", IP2STR(&ev->ip_info.ip), s_hostname);
} else if (id == IP_EVENT_STA_LOST_IP) {
s_connected = false;
ESP_LOGW(TAG, "address lost");
}
}
static void mdns_start(const char *hostname)
{
ESP_ERROR_CHECK(mdns_init());
ESP_ERROR_CHECK(mdns_hostname_set(hostname));
ESP_ERROR_CHECK(mdns_instance_name_set("ForgeFIRM bench fixture"));
mdns_txt_item_t txt[] = {
{"device", "forgefixture"},
{"channels", "lid,interlock,button"},
};
ESP_ERROR_CHECK(mdns_service_add(NULL, "_forgefixture", "_tcp", 80, txt, 2));
ESP_ERROR_CHECK(mdns_service_add(NULL, "_http", "_tcp", 80, txt, 2));
}
void wifi_start(const char *hostname, const char *ssid, const char *psk)
{
strncpy(s_hostname, hostname, sizeof(s_hostname) - 1);
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
s_netif = esp_netif_create_default_wifi_sta();
ESP_ERROR_CHECK(esp_netif_set_hostname(s_netif, hostname)); /* DHCP option 12 */
wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&init));
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &on_wifi, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &on_ip, NULL));
wifi_config_t cfg = {0};
strncpy((char *)cfg.sta.ssid, ssid, sizeof(cfg.sta.ssid) - 1);
strncpy((char *)cfg.sta.password, psk, sizeof(cfg.sta.password) - 1);
cfg.sta.threshold.authmode = psk[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
cfg.sta.pmf_cfg.capable = true;
cfg.sta.pmf_cfg.required = false;
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &cfg));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
mdns_start(hostname);
ESP_LOGI(TAG, "joining %s", ssid);
}
void wifi_ip(char *buf, size_t n)
{
if (s_connected)
snprintf(buf, n, IPSTR, IP2STR(&s_ip));
else
snprintf(buf, n, "0.0.0.0");
}
int wifi_rssi(void)
{
wifi_ap_record_t ap;
if (!s_connected || esp_wifi_sta_get_ap_info(&ap) != ESP_OK)
return 0;
return ap.rssi;
}
bool wifi_connected(void)
{
return s_connected;
}
-23
View File
@@ -1,23 +0,0 @@
/*
* forgefixture wifi: a station that joins the bench network and stays
* joined, announces its hostname over DHCP and mDNS, and never sleeps
* the radio (the API answers in milliseconds, not on the next beacon).
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
void wifi_start(const char *hostname, const char *ssid, const char *psk);
/* The station's address as text ("0.0.0.0" while not joined). */
void wifi_ip(char *buf, size_t n);
/* Signal strength in dBm, 0 while not joined. */
int wifi_rssi(void);
bool wifi_connected(void);
-36
View File
@@ -1,36 +0,0 @@
# forgefixture build defaults (ESP32-S3 DevKitC-1). idf.py merges these
# into sdkconfig on the first configure; sdkconfig itself is not tracked.
CONFIG_IDF_TARGET="esp32s3"
# Any DevKitC-1 has at least 8 MB; a 4 MB layout with one large app
# partition fits every one of them and leaves nothing to choose.
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
# A hung task reboots the board to every relay line low rather than
# holding a button press: the task watchdog panics, and a panic resets.
CONFIG_ESP_TASK_WDT_INIT=y
CONFIG_ESP_TASK_WDT_PANIC=y
CONFIG_ESP_TASK_WDT_TIMEOUT_S=5
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=y
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=y
CONFIG_ESP_INT_WDT=y
CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT=y
# Brownout (the relays share the board's 3.3 V with nothing else, but the
# USB supply is whatever it is): reset rather than limp.
CONFIG_ESP_BROWNOUT_DET=y
# The log on both USB ports: the UART bridge and the native USB-Serial-JTAG.
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
# HTTP server: a few clients, small requests.
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
CONFIG_HTTPD_MAX_URI_LEN=128
CONFIG_HTTPD_PURGE_BUF_LEN=32
# Wifi station only; no Bluetooth.
CONFIG_BT_ENABLED=n
CONFIG_ESP_WIFI_NVS_ENABLED=y
CONFIG_LWIP_IPV6=n
-73
View File
@@ -1,73 +0,0 @@
/*
* forgefixture policy, held to account on the host: gcc -o policy_test
* policy_test.c ../main/policy.c (see run.sh).
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include <stdio.h>
#include <string.h>
#include "../main/policy.h"
static int failures;
#define CHECK(cond) do { if (!(cond)) { failures++; printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); } } while (0)
int main(void)
{
/* names and paths */
CHECK(strcmp(policy_channel_name(CH_LID), "lid") == 0);
CHECK(strcmp(policy_channel_name(CH_INTERLOCK), "interlock") == 0);
CHECK(strcmp(policy_channel_name(CH_BUTTON), "button") == 0);
CHECK(policy_channel_from_path("/lid") == CH_LID);
CHECK(policy_channel_from_path("/interlock") == CH_INTERLOCK);
CHECK(policy_channel_from_path("/button") == CH_BUTTON);
CHECK(policy_channel_from_path("/lids") == -1);
CHECK(policy_channel_from_path("lid") == -1);
CHECK(policy_channel_from_path(NULL) == -1);
/* a loop request: open energizes, close/closed releases, nothing else */
bool e = false;
CHECK(policy_parse_loop_state("open", &e) && e);
CHECK(policy_parse_loop_state("close", &e) && !e);
CHECK(policy_parse_loop_state("closed", &e) && !e);
CHECK(!policy_parse_loop_state("Open", &e));
CHECK(!policy_parse_loop_state("on", &e));
CHECK(!policy_parse_loop_state("", &e));
CHECK(!policy_parse_loop_state(NULL, &e));
/* the button pulse: a default, a floor, a ceiling, never a hold */
CHECK(policy_button_ms(0) == BUTTON_DEFAULT_MS);
CHECK(policy_button_ms(-5) == BUTTON_DEFAULT_MS);
CHECK(policy_button_ms(1) == BUTTON_MIN_MS);
CHECK(policy_button_ms(250) == 250);
CHECK(policy_button_ms(500) == BUTTON_MAX_MS);
CHECK(policy_button_ms(501) == BUTTON_MAX_MS);
CHECK(policy_button_ms(60000) == BUTTON_MAX_MS);
CHECK(BUTTON_MAX_MS <= 500);
/* state names */
CHECK(strcmp(policy_state_name(CH_LID, true), "open") == 0);
CHECK(strcmp(policy_state_name(CH_LID, false), "closed") == 0);
CHECK(strcmp(policy_state_name(CH_INTERLOCK, true), "open") == 0);
CHECK(strcmp(policy_state_name(CH_BUTTON, true), "pressed") == 0);
CHECK(strcmp(policy_state_name(CH_BUTTON, false), "idle") == 0);
/* the key: exact, whole, and never by prefix */
CHECK(policy_key_matches("abc123", "abc123"));
CHECK(!policy_key_matches("abc12", "abc123"));
CHECK(!policy_key_matches("abc1234", "abc123"));
CHECK(!policy_key_matches("", "abc123"));
CHECK(!policy_key_matches("abc123", ""));
CHECK(!policy_key_matches(NULL, "abc123"));
CHECK(!policy_key_matches("ABC123", "abc123"));
if (failures) {
printf("%d failure(s)\n", failures);
return 1;
}
printf("policy: all checks passed\n");
return 0;
}
-6
View File
@@ -1,6 +0,0 @@
#!/bin/sh
# 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
-47
View File
@@ -1,47 +0,0 @@
# forgetest - the ForgeFIRM release acceptance tool
The daemon behind `http://<machine>:8090/` on the dev image: runs the
acceptance catalog against the machine, keeps the append-only result log,
decides which results still apply to the image that is running, exports
the release artifact `scripts/release.sh` gates on, and serves the bench
diagnostics page. The contract - catalog, campaigns, fingerprints,
inheritance, the gate, the coverage rule - is
[the Acceptance page of the documentation site](https://docs.forgefirm.org/developers/acceptance/).
## Run the host tests
cd forgetest
python3 -m unittest discover -s tests -v
## Run the daemon on a workstation (against a mock or a manifest file)
FORGETEST_DATA=/tmp/ft FORGETEST_MANIFEST=../tree-manifest.json \
FORGECTRL_URL=http://<machine> python3 -m forgetest --port 8090
`scripts/manifest-from-tree.py` produces `tree-manifest.json` from the recipe
pins; the coverage lint is `python3 -m forgetest.coverage --manifest ...`.
## Environment
| Variable | Default | Purpose |
|---|---|---|
| `FORGETEST_DATA` | `/data/forgetest` | results.jsonl, bench.jsonl, token, export/ |
| `FORGETEST_MANIFEST` | `/etc/forgefirm-manifest.json` | the image manifest |
| `FORGETEST_PORT`, `FORGETEST_HOST` | 8090, 0.0.0.0 | listener |
| `FORGETEST_BENCH_DIR` | `/usr/share/forgetest/bench` | the installed bench scripts |
| `FORGETEST_BENCH_DATA` | `<FORGETEST_DATA>/bench` | passed to bench tools: where they keep their data files (with `GF_HOST=127.0.0.1` and the panel token in `GF_TOKEN`) |
| `FORGETEST_MARKER` | `/run/forgetest.active` | takeover marker |
| `FORGECTRL_URL`, `FORGECTRL_TOKEN_FILE` | `http://127.0.0.1`, `/data/forgefirm/panel.token` | forgectrl client (HTTP; the token authorizes writes from the board) |
| `FORGECTRL_TLS_URL` | `https://127.0.0.1` | forgectrl over HTTPS (self-signed, unverified), for the login test |
| `GF_SYSFS_ROOT` | `/sys/glowforge/` | kernel module sysfs |
| `GRBL_HOST`, `GRBL_PORT` | 127.0.0.1, 23 | Grbl TCP |
## Adding a test
Register it in the subsystem module under `forgetest/suite/` with
`@test(...)`: id `subsystem.name`, kind, hardware, `mode` (the controller
mode the test needs; the runner switches to it first), `covers`,
`requires`, `always`, steps. The body gets a `Context` (`log`, `check`, `fail`,
`prompt`, `confirm`, `instruct`, `sleep`, `evidence`, `forgectrl`, `sysfs`,
`grbl`, `takeover`). Return normally for PASS, raise `runner.Failed` for
FAIL. Then run the unit tests and the coverage lint.
-58
View File
@@ -1,58 +0,0 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: forgetest
# Required-Start: $network forgectrl
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: ForgeFIRM release acceptance tool (dev image, HTTP :8090)
### END INIT INFO
# Dev-image only. The daemon reads /etc/forgefirm-manifest.json, keeps its
# state under /data/forgetest, and serves the acceptance + bench pages on
# port 8090 (FORGETEST_PORT). It starts after forgectrl and stops before
# the controllers at shutdown; a takeover left behind by a hard stop is
# recovered at the next start (forgectrl is started again).
PIDFILE=/var/run/forgetest.pid
DATA=/data/forgetest
LOG=$DATA/daemon.log
case "$1" in
start)
echo "Starting forgetest"
mkdir -p "$DATA"
# The bench seed: the commissioning override lifts the wizard part of
# the controller gate until the next reboot (/run is tmpfs), so a
# campaign runs on a machine whose required wizards are not complete.
# Nothing lifts the agreements or the account: the bench accepts them
# once, and that stays in /data.
mkdir -p /run/forgefirm
: > /run/forgefirm/commissioning-override
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
;;
stop)
echo "Stopping forgetest"
start-stop-daemon -K -q -p $PIDFILE
rm -f $PIDFILE
;;
restart)
$0 stop
sleep 1
$0 start
;;
status)
if [ -f $PIDFILE ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then
echo "forgetest is running"
exit 0
fi
echo "forgetest is not running"
exit 3
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
exit 0
-10
View File
@@ -1,10 +0,0 @@
"""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"
-68
View File
@@ -1,68 +0,0 @@
"""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())
-189
View File
@@ -1,189 +0,0 @@
"""The release artifact (acceptance.json / acceptance.md) and its verification.
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
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": _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
-392
View File
@@ -1,392 +0,0 @@
"""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 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
-170
View File
@@ -1,170 +0,0 @@
"""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,
}
-338
View File
@@ -1,338 +0,0 @@
"""The acceptance catalog: test definitions and the registry.
A test is a function decorated with @test(...). The decorator records
what the release gate needs to know without running anything: the id,
the subsystem, the kind (auto / operator / live), how it takes the
hardware (api / takeover), the controller mode it needs (if any), what
source it covers, what it requires, and whether it belongs to the
always-required core. Two more fields describe the operator's part
without affecting the gate: `actions`, the machine actions the test asks
for by name (the page lists them before a start; a bench actuator can
perform them), and `precheck`, a condition the machine must meet for the
test to start at all (a reason string refuses the start, the way an
unmet prerequisite does, and records no result). The function body runs
under the runner with a Context (log, prompts, evidence, hardware
helpers) and reports by returning normally (PASS) or raising
runner.Failed (FAIL).
"""
import ast
import hashlib
import inspect
import os
import re
import threading
from . import manifest as _manifest
KINDS = ("auto", "operator", "live")
HARDWARE = ("api", "takeover")
MODES = ("grbl", "cloud")
# The machine actions a test may ask of the operator (Context.act):
# the lid, the remote-interlock loop, and the big button. Everything a
# test needs done to the machine is one of these, so a bench actuator
# that covers a channel can stand in for the hands on it.
ACTIONS = ("lid", "interlock", "button")
_ID_RX = re.compile(r"^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$")
REGISTRY = {}
class Test:
def __init__(self, id, title, subsystem, kind, hardware, covers, requires,
always, est_min, steps, description, fn, mode=None, actions=(),
precheck=None, hands=()):
self.id = id
self.title = title
self.subsystem = subsystem
self.kind = kind
self.hardware = hardware
self.mode = mode
self.covers = tuple((str(c), str(g)) for c, g in covers)
self.requires = tuple(requires)
self.always = bool(always)
self.est_min = est_min
self.steps = tuple(steps)
self.actions = tuple(actions)
self.hands = tuple(hands)
self.precheck = precheck
self.description = description or (fn.__doc__ or "").strip()
self.fn = fn
self._source_sha = None
self._fp = (None, None) # (manifest content sha, fingerprint)
@property
def source_sha(self):
"""The hash of the test's own implementation: its function (the
decorator included) together with the code its module shares
among its tests - everything outside the @test functions. Part
of the fingerprint: a change inside one test's body invalidates
that test's earlier passes and no other; a change to a helper
invalidates the tests of that module. A test the module does not
define in the @test form hashes its whole file."""
if self._source_sha is None:
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()
_PARTS = {} # path -> (shared sha, {test id: own sha}); the files never change under a run
_PARTS_LOCK = threading.Lock()
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
with open(path, "rb") as f:
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
lines = text.split("\n")
spans = {}
try:
tree = ast.parse(text)
except SyntaxError:
tree = None
for node in (tree.body if tree is not None else []):
if not isinstance(node, ast.FunctionDef):
continue
for d in node.decorator_list:
if (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == "test"
and d.args and isinstance(d.args[0], ast.Constant) and isinstance(d.args[0].value, str)):
start = min([dd.lineno for dd in node.decorator_list] + [node.lineno])
spans[d.args[0].value] = (start, node.end_lineno)
owned = set()
for a, b in spans.values():
owned.update(range(a, b + 1))
shared = "\n".join(ln for i, ln in enumerate(lines, 1) if i not in owned)
own = {tid: _manifest.sha256_text("\n".join(lines[a - 1:b])) for tid, (a, b) in spans.items()}
parts = (_manifest.sha256_text(shared), own)
with _PARTS_LOCK:
_PARTS[path] = parts
return parts
def sibling_imports(path, seen=None):
"""The suite modules a module imports with `from .x import ...`, as
paths, transitively, in a stable order. A helper a test calls from
a sibling module is part of the judge, so its text belongs in the
test's fingerprint."""
if seen is None:
seen = []
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:
return seen
here = os.path.dirname(path)
for node in tree.body:
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
if os.path.isfile(sib) and sib not in seen and sib != path:
seen.append(sib)
sibling_imports(sib, seen)
return seen
def implementation_sha(path, test_id):
"""The implementation hash of one test (see Test.source_sha): the
test's own text, the shared text of its module, and the shared text
of every sibling suite module the module imports."""
shared, own = module_parts(path)
if test_id not in own:
return source_file_sha(path)
helpers = [module_parts(p)[0] for p in sorted(sibling_imports(path))]
return _manifest.sha256_text("%s:%s" % (":".join([shared] + helpers), own[test_id]))
def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers=(),
requires=(), always=False, est_min=1, steps=(), description="", actions=(),
precheck=None, hands=()):
"""`mode` names the controller mode the test needs live when it starts
("grbl" or "cloud"); the runner switches the machine there before the
test and leaves it there, so a queue crosses modes only where a test
asks it to. None means the test runs in whatever mode it finds (or
manages the mode itself, as the cloud tests do through enter_cloud,
which also waits for the service session).
`actions` names the machine actions (ACTIONS) the test performs
through Context.act; an `auto` test declares none. `hands` names what
the test asks of a person beyond those actions ("app" for a job in
the Glowforge app, "scrap", "mark" for an eye on the result...): an
operator test with none, whose actions a bench actuator covers, runs
unattended when one is up. `precheck` is a callable returning a
reason string when the machine cannot run the test as it is (None
when it can)."""
if not _ID_RX.match(id):
raise ValueError("test id %r must look like subsystem.name" % id)
if kind not in KINDS:
raise ValueError("test %s: kind %r" % (id, kind))
if hardware not in HARDWARE:
raise ValueError("test %s: hardware %r" % (id, hardware))
if mode is not None and mode not in MODES:
raise ValueError("test %s: mode %r" % (id, mode))
for c, g in covers:
if c in _manifest.DEV_ONLY_COMPONENTS:
raise ValueError("test %s: may not cover dev-only component %r" % (id, c))
for a in actions:
if a not in ACTIONS:
raise ValueError("test %s: action %r" % (id, a))
if actions and kind == "auto":
raise ValueError("test %s: an auto test asks for no machine actions" % id)
if hands and kind == "auto":
raise ValueError("test %s: an auto test asks nothing of a person" % id)
if precheck is not None and not callable(precheck):
raise ValueError("test %s: precheck must be callable" % id)
def deco(fn):
if id in REGISTRY:
raise ValueError("duplicate test id %r" % id)
REGISTRY[id] = Test(id, title, subsystem, kind, hardware, covers, requires,
always, est_min, steps, description, fn, mode=mode,
actions=actions, precheck=precheck, hands=hands)
return fn
return deco
def all_tests(registry=None):
"""Tests in registration order (the suite modules import in
subsystem order, so this is the display order)."""
return list((registry if registry is not None else REGISTRY).values())
def get(id, registry=None):
return (registry if registry is not None else REGISTRY).get(id)
def order_by_requires(tests, selected):
"""`selected` ids in an order that runs a prerequisite before the test
that names it.
Registration order otherwise, so a run reads down the page. A
prerequisite outside the selection places nothing: either it is
already satisfied, or the run that needs it will be refused and
recorded as skipped. validate() has ruled out cycles; the stack check
only keeps a malformed registry from recursing forever.
"""
want = set(selected)
by_id = {t.id: t for t in tests}
out, placed = [], set()
def visit(tid, stack):
if tid in placed or tid not in want or tid not in by_id or tid in stack:
return
stack.add(tid)
for r in by_id[tid].requires:
visit(r, stack)
stack.discard(tid)
placed.add(tid)
out.append(tid)
for t in tests:
visit(t.id, set())
return out
def validate(registry=None):
"""Every `requires` names a known test and there are no cycles."""
reg = registry if registry is not None else REGISTRY
for t in reg.values():
for r in t.requires:
if r not in reg:
raise ValueError("test %s requires unknown test %s" % (t.id, r))
seen = {}
def visit(tid, stack):
if tid in stack:
raise ValueError("requires cycle: %s" % " -> ".join(stack + [tid]))
if seen.get(tid):
return
for r in reg[tid].requires:
visit(r, stack + [tid])
seen[tid] = True
for tid in reg:
visit(tid, [])
def catalog_hash(registry=None):
"""Identity of the catalog's definitions (ids, kinds, coverage,
requirements, core membership) - not of the implementations, which
the per-test fingerprints carry."""
defs = sorted((t.definition() for t in all_tests(registry)), key=lambda d: d["id"])
return _manifest.sha256_text(_manifest.canonical(defs))
def load_suite():
"""Import the suite modules (each registers its tests) and validate."""
from . import suite # noqa: F401 (registers on import)
validate()
return REGISTRY
def suite_dir():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "suite")
-72
View File
@@ -1,72 +0,0 @@
"""Coverage lint: every source path in the manifest must be selected by
some test's coverage globs, except the allowlisted non-behavioral paths,
and every coverage glob must select at least one path of its component.
Why a hard rule: under the domain model an uncovered file is worse than an
untested one - a change there leaves every inherited PASS valid when it
should have invalidated them. The lint is the floor (the file is
fingerprinted by at least one test); whether that test exercises the
change stays with the change author. An entry that selects nothing is
the same defect from the other side: the test's fingerprint ignores the
file it named.
python3 -m forgetest.coverage [--manifest PATH] [--enforce] [--json]
Exit 0 when nothing is uncovered (or when only reporting), 1 under
--enforce with uncovered paths, 2 on a usage or load error.
"""
import argparse
import json
import sys
from . import catalog as _catalog
from . import manifest as _manifest
# Non-behavioral paths need no acceptance coverage, and no fingerprint
# carries them: the one list, in the manifest module.
ALLOW = _manifest.NON_BEHAVIORAL
def run(manifest, tests, allow=ALLOW):
return _manifest.coverage_report(manifest, tests, allow)
def empty(manifest, tests):
return _manifest.empty_covers(manifest, tests)
def main(argv=None):
ap = argparse.ArgumentParser(description="forgetest coverage lint")
ap.add_argument("--manifest", default=None, help="manifest JSON (default: the running image's)")
ap.add_argument("--enforce", action="store_true", help="exit 1 when any path is uncovered")
ap.add_argument("--json", action="store_true", help="machine-readable report")
args = ap.parse_args(argv)
try:
manifest = _manifest.Manifest.load(args.manifest)
except (OSError, ValueError) as e:
print("cannot load manifest: %s" % e, file=sys.stderr)
return 2
registry = _catalog.load_suite()
tests = _catalog.all_tests(registry)
report = run(manifest, tests)
total = sum(len(v) for v in report.values())
hollow = empty(manifest, tests)
if args.json:
print(json.dumps({"uncovered": report, "total": total, "tests": len(tests),
"empty": [list(e) for e in hollow]}, indent=1, sort_keys=True))
else:
for comp in sorted(report):
print("%s: %d uncovered path(s)" % (comp, len(report[comp])))
for p in report[comp]:
print(" %s" % p)
for tid, comp, pat in hollow:
print("%s: covers (%s, %s) selects nothing" % (tid, comp, pat))
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, %d tests"
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(tests)))
if args.enforce and (total or hollow):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
-387
View File
@@ -1,387 +0,0 @@
"""The bench actuator (forgefixture): the box on the bench network that
opens the lid loop, pulls the interlock loop and presses the button on
request, so an operator test can run with nobody in the room.
Configured by a bench-local file, FIXTURE_CONFIG (default
/data/forgetest/fixture.json, mode 0600, never in the repo):
{"hostname": "forgefixture", "key": "<the fixture's API key>",
"ip": null, "channels": ["lid", "interlock", "button"],
"arm_press": false}
`hostname` is resolved as <hostname>.local by the resolver here (the
image carries no mDNS resolver; the fixture answers the query itself);
`ip` overrides that. `channels` names what is wired. `arm_press` is the
one policy switch: whether the fixture may press the button to arm the
laser for a live test (a person's press by default).
The fixture does not read the machine: every action the runner asks of
it is verified through the machine's own readings afterward, exactly as
an operator's hand is (Context.act). Contract: fixture/README.md.
"""
import json
import os
import socket
import struct
import threading
import time
import urllib.error
import urllib.request
FIXTURE_CONFIG = os.environ.get("FORGETEST_FIXTURE_CONFIG", "/data/forgetest/fixture.json")
CHANNELS = ("lid", "interlock", "button")
MDNS_GROUP = "224.0.0.251"
MDNS_PORT = 5353
RESOLVE_TTL_S = 300.0 # a resolved address is trusted this long
BUTTON_GAP_S = 0.3 # the release between two presses: long enough for the controller to see it
PULSE_WAIT_S = 1.5 # a pulse in progress ends within the firmware's 500 ms clamp
class FixtureError(Exception):
pass
# ---------------------------------------------------------------- mDNS
def mdns_query(name):
"""One mDNS A query for `name` (e.g. "forgefixture.local"), as the
bytes on the wire: a standard DNS header with id 0, one question,
class IN with the unicast-response bit set so the answer comes back
to this socket rather than the group."""
labels = b"".join(struct.pack("B", len(p)) + p.encode("ascii") for p in name.rstrip(".").split("."))
header = struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0)
return header + labels + b"\x00" + struct.pack("!HH", 1, 0x8001)
def _read_name(data, off):
"""A DNS name at `off`, compression pointers followed; (name, next
offset)."""
parts = []
jumped = False
end = off
guard = 0
while True:
if off >= len(data):
raise ValueError("truncated name")
n = data[off]
if n == 0:
off += 1
break
if n & 0xC0 == 0xC0:
ptr = struct.unpack("!H", data[off:off + 2])[0] & 0x3FFF
if not jumped:
end = off + 2
jumped = True
off = ptr
guard += 1
if guard > 32:
raise ValueError("pointer loop")
continue
parts.append(data[off + 1:off + 1 + n].decode("ascii", "replace"))
off += 1 + n
if not jumped:
end = off
return ".".join(parts).lower(), end
def mdns_answers(data, name):
"""The IPv4 addresses in an mDNS response's A records for `name`
(answers and additionals; a response is anything with QR set)."""
name = name.rstrip(".").lower()
out = []
try:
_id, flags, qd, an, ns, ar = struct.unpack("!HHHHHH", data[:12])
if not flags & 0x8000:
return out
off = 12
for _ in range(qd):
_n, off = _read_name(data, off)
off += 4
for _ in range(an + ns + ar):
rname, off = _read_name(data, off)
rtype, rclass, _ttl, rdlen = struct.unpack("!HHIH", data[off:off + 10])
off += 10
rdata = data[off:off + rdlen]
off += rdlen
if rtype == 1 and rdlen == 4 and rname == name:
out.append(socket.inet_ntoa(rdata))
except (struct.error, ValueError):
pass
return out
def _local_ipv4s():
"""The interface addresses to send a multicast query from: the
default first (None), then every IPv4 address this host answers to."""
out = [None]
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET, socket.SOCK_DGRAM):
ip = info[4][0]
if ip not in out and not ip.startswith("127."):
out.append(ip)
except OSError:
pass
return out
def resolve_mdns(hostname, timeout=2.0, tries=3):
"""The IPv4 address of <hostname>.local, asked of the network
directly; None when nothing answers."""
name = hostname if hostname.endswith(".local") else hostname + ".local"
query = mdns_query(name)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
# On the mDNS port and in the group, so a responder that answers
# the group rather than the asker is heard too; a port already
# taken (a resolver daemon) means that daemon would have served
# the name, so the ephemeral port and unicast replies will do.
try:
sock.bind(("", MDNS_PORT))
except OSError:
sock.bind(("", 0))
try:
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
socket.inet_aton(MDNS_GROUP) + socket.inet_aton("0.0.0.0"))
except OSError:
pass # no group membership: unicast replies still arrive
for _ in range(tries):
# The query goes out of every local IPv4 interface that will
# take it (a host with a VPN or a VM adapter as its default
# route would otherwise never reach the bench network).
sent = False
for ifaddr in _local_ipv4s():
try:
if ifaddr is not None:
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(ifaddr))
sock.sendto(query, (MDNS_GROUP, MDNS_PORT))
sent = True
except OSError:
continue
if not sent:
return None
deadline = time.time() + timeout
while True:
left = deadline - time.time()
if left <= 0:
break
sock.settimeout(left)
try:
data, _peer = sock.recvfrom(2048)
except socket.timeout:
break
except OSError:
break
for ip in mdns_answers(data, name):
return ip
return None
finally:
sock.close()
# -------------------------------------------------------------- client
def load_config(path=None):
"""The bench's fixture config, or None when there is none (a bench
without a fixture: the runner behaves exactly as before)."""
path = path or FIXTURE_CONFIG
try:
with open(path, "r", encoding="utf-8") as f:
cfg = json.load(f)
except OSError:
return None
except ValueError as e:
raise FixtureError("%s: not JSON: %s" % (path, e))
if not isinstance(cfg, dict) or not cfg.get("key"):
raise FixtureError("%s: needs at least a key" % path)
cfg.setdefault("hostname", "forgefixture")
cfg.setdefault("ip", None)
cfg.setdefault("port", 80)
chans = cfg.get("channels")
if chans is None:
chans = list(CHANNELS)
bad = [c for c in chans if c not in CHANNELS]
if bad:
raise FixtureError("%s: unknown channel(s) %s" % (path, ", ".join(bad)))
cfg["channels"] = list(chans)
cfg["arm_press"] = bool(cfg.get("arm_press", False))
return cfg
class Fixture:
"""The actuator as the runner sees it: which channels it covers, an
action per channel, a release, its state. Every request carries the
key; the address comes from the config's ip or the mDNS lookup,
re-resolved when a request fails to connect."""
def __init__(self, cfg, timeout=3.0, resolver=resolve_mdns):
self.hostname = cfg["hostname"]
self.ip_override = cfg.get("ip") or None
self.port = int(cfg.get("port") or 80)
self.key = cfg["key"]
self.channels = tuple(cfg["channels"])
self.arm_press = bool(cfg.get("arm_press", False))
self.timeout = timeout
self._resolver = resolver
self._ip = self.ip_override
self._resolved_at = time.time() if self.ip_override else 0.0
self._lock = threading.Lock()
self.last_state = None
self._pulse_end = 0.0 # when this client's last press ends (its pulse_ms)
# -- address -------------------------------------------------------
def address(self, refresh=False):
with self._lock:
if self.ip_override:
return self.ip_override
stale = time.time() - self._resolved_at > RESOLVE_TTL_S
if self._ip is None or refresh or stale:
ip = self._resolver(self.hostname)
if ip is None:
raise FixtureError("%s.local did not answer the mDNS lookup (set ip in the config "
"to skip it)" % self.hostname)
self._ip = ip
self._resolved_at = time.time()
return self._ip
def where(self):
return "%s (%s)" % (self.hostname, self._ip or "unresolved")
# -- requests ------------------------------------------------------
def _request(self, method, path, body=None, retry=True):
ip = self.address()
data = json.dumps(body).encode() if body is not None else None
host = ip if self.port == 80 else "%s:%d" % (ip, self.port)
req = urllib.request.Request("http://%s%s" % (host, path), data=data, method=method,
headers={"X-Fixture-Key": self.key, "Host": host,
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8", "replace") or "{}")
except urllib.error.HTTPError as e:
try:
body = json.loads(e.read().decode("utf-8", "replace") or "{}")
except ValueError:
body = {}
return e.code, body
except (urllib.error.URLError, socket.timeout, OSError) as e:
if retry and not self.ip_override:
# the address may have moved: one fresh lookup, one retry
self.address(refresh=True)
return self._request(method, path, body, retry=False)
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e))
def status(self):
st, body = self._request("GET", "/")
if st == 401:
raise FixtureError("fixture %s refused the key" % self.where())
if st != 200 or body.get("device") != "forgefixture":
raise FixtureError("fixture %s: unexpected answer %s %s" % (self.where(), st, body))
self.last_state = body
return body
def covers(self, channel):
"""The runner asks this per action. The button needs the jumper
in, which the fixture reports; a channel it does not cover falls
back to the operator."""
if channel not in self.channels:
return False
if channel == "button":
st = self.last_state
return bool(st and st.get("button_enabled"))
return True
def act(self, channel, state):
if channel == "button":
if state != "press":
raise FixtureError("the button is only ever pressed")
st, body = self._press()
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):
"""One press, spaced from the last 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", {})
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", {})
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"]
-521
View File
@@ -1,521 +0,0 @@
"""Hardware and service access for the suite: forgectrl's HTTP API, the
kernel module's sysfs, the init scripts, and the Grbl TCP port.
Everything is reachable through environment overrides so the suite can be
exercised against a mock on a host:
FORGECTRL_URL default http://127.0.0.1
FORGECTRL_TOKEN_FILE default /data/forgefirm/panel.token
GF_SYSFS_ROOT default /sys/glowforge/ (must end with '/')
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
FORGETEST_INITD default /etc/init.d
"""
import json
import os
import socket
import subprocess
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
class HwError(Exception):
pass
# ------------------------------------------------------------ forgectrl
# The supervisor's levers answer only when the switch is done: a mode
# switch waits for the old controller to exit, the new one to start (a
# pending liveness probe first) and its first job-state report (up to
# 15 s; the emulator's comes with its start, a real client's with its
# machine); a stop waits for the child to go. They get their own timeout,
# above the daemon's own deadlines, so a slow but honest switch is never
# read as a dead daemon.
SLOW_PATHS = ("/mode", "/controller/start", "/controller/stop")
SLOW_TIMEOUT_S = 120.0
class Forgectrl:
"""Thin client for the machine-services daemon."""
def __init__(self, base=None, token=None, timeout=10.0, slow_timeout=SLOW_TIMEOUT_S):
self.base = (base or os.environ.get("FORGECTRL_URL") or "http://127.0.0.1").rstrip("/")
self.timeout = timeout
self.slow_timeout = slow_timeout
self._token = token
def timeout_for(self, method, path):
return self.slow_timeout if method == "POST" and path in SLOW_PATHS else self.timeout
@property
def token(self):
if self._token is None:
path = os.environ.get("FORGECTRL_TOKEN_FILE") or "/data/forgefirm/panel.token"
try:
with open(path, "r", encoding="utf-8") as f:
self._token = f.read().strip()
except OSError:
self._token = ""
return self._token
def host_header(self):
return urllib.parse.urlsplit(self.base).netloc
def request(self, method, path, params=None, data=None, headers=None, auth=True, raw=False):
"""Returns (status, body). body is parsed JSON when the response
is JSON, else text (or bytes when raw=True). Never raises on an
HTTP error status - the suite asserts on codes."""
url = self.base + path
if params:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
body = None
hdrs = {"Host": self.host_header()}
if headers:
hdrs.update(headers)
if data is not None:
if isinstance(data, (dict, list)):
body = urllib.parse.urlencode(data).encode()
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded")
elif isinstance(data, str):
body = data.encode()
else:
body = data
if auth and self.token:
hdrs.setdefault("X-ForgeFIRM-Token", self.token)
req = urllib.request.Request(url, data=body, method=method, headers=hdrs)
try:
with urllib.request.urlopen(req, timeout=self.timeout_for(method, path)) as resp:
status = resp.status
content = resp.read()
ctype = resp.headers.get("Content-Type", "")
except urllib.error.HTTPError as e:
status = e.code
content = e.read()
ctype = e.headers.get("Content-Type", "") if e.headers else ""
except (urllib.error.URLError, socket.timeout, OSError) as e:
raise HwError("forgectrl %s %s: %s" % (method, path, e))
if raw:
return status, content
text = content.decode("utf-8", "replace")
if "json" in ctype:
try:
return status, json.loads(text)
except ValueError:
pass
return status, text
def get(self, path, **kw):
return self.request("GET", path, **kw)
def post(self, path, **kw):
return self.request("POST", path, **kw)
def status(self):
st, body = self.get("/status")
if st != 200 or not isinstance(body, dict):
raise HwError("forgectrl /status -> %s" % st)
return body
def settings(self):
st, body = self.get("/settings")
if st != 200 or not isinstance(body, dict):
raise HwError("forgectrl /settings -> %s" % st)
return body
def wait_idle(self, timeout=60.0, poll=0.5, abort=None):
deadline = time.time() + timeout
while time.time() < deadline:
if abort and abort():
raise HwError("aborted while waiting for idle")
try:
if self.status().get("state") == "idle":
return True
except HwError:
pass
time.sleep(poll)
return False
# ---------------------------------------------------------------- sysfs
def sysfs_root():
r = os.environ.get("GF_SYSFS_ROOT") or "/sys/glowforge/"
return r if r.endswith("/") else r + "/"
def sysfs_read(attr, default=None):
try:
with open(sysfs_root() + attr, "r") as f:
return f.read().strip()
except OSError:
return default
def sysfs_int(attr, default=None):
v = sysfs_read(attr)
if v is None or v == "":
return default
try:
return int(v.split()[0], 0)
except ValueError:
return default
def sysfs_write(attr, value):
with open(sysfs_root() + attr, "w") as f:
f.write(str(value))
# ------------------------------------------------------ head accelerometer
# The head accelerometer (LIS2HH12 on i2c-3 at 0x1e): the supervisor's
# motion-liveness witness, and the catalog's. Resolved by bus address,
# never by iio index. The raw sysfs read is slow (~150 ms), which still
# lands several samples in a one-second move; the verdict is peak-to-peak
# on X or Y, against the thresholds forgectrl's probe established (a live
# head reads 1800-2900 on its probe move, a wedged one <= 250).
HEAD_ACCEL_I2C = "3-001e"
ACCEL_P2P_MOVING = 800
def head_accel_dir():
"""The head accel's iio directory, or None. GF_IIO_ROOT overrides the
/sys/bus/iio/devices root (host tests)."""
root = os.environ.get("GF_IIO_ROOT") or "/sys/bus/iio/devices"
try:
names = sorted(os.listdir(root))
except OSError:
return None
for n in names:
d = os.path.join(root, n)
try:
target = os.readlink(d)
except OSError:
target = ""
if HEAD_ACCEL_I2C in target or HEAD_ACCEL_I2C in n:
return d
# a plain directory (host fixture): its name carries the address
try:
with open(os.path.join(d, "name")) as f:
if HEAD_ACCEL_I2C in f.read():
return d
except OSError:
pass
return None
def head_accel_read(d):
"""(x, y) raw counts, or None."""
out = []
for axis in ("x", "y"):
try:
with open(os.path.join(d, "in_accel_%s_raw" % axis)) as f:
out.append(int(f.read().strip()))
except (OSError, ValueError):
return None
return tuple(out)
class AccelSampler:
"""Samples the head accelerometer in a thread for as long as it is
running; `p2p(t0, t1)` is the peak-to-peak on X and Y over the samples
in that window and how many there were. Used as a context manager
around a motion the test wants the head to have made."""
def __init__(self, period=0.0):
self.dir = head_accel_dir()
self.period = period
self.samples = [] # (t, x, y)
self._stop = threading.Event()
self._th = None
self.errors = 0
@property
def available(self):
return self.dir is not None
def __enter__(self):
if self.dir is not None:
self._th = threading.Thread(target=self._loop, daemon=True, name="forgetest-accel")
self._th.start()
return self
def __exit__(self, *a):
self._stop.set()
if self._th is not None:
self._th.join(2.0)
return False
def _loop(self):
while not self._stop.is_set():
v = head_accel_read(self.dir)
if v is None:
self.errors += 1
else:
self.samples.append((time.time(), v[0], v[1]))
if self.period:
self._stop.wait(self.period)
def p2p(self, t0, t1=None):
"""(p2p_x, p2p_y, n) over [t0, t1]; (0, 0, 0) with no samples."""
t1 = time.time() if t1 is None else t1
xs = [x for t, x, _y in self.samples if t0 <= t <= t1]
ys = [y for t, _x, y in self.samples if t0 <= t <= t1]
if not xs:
return 0, 0, 0
return max(xs) - min(xs), max(ys) - min(ys), len(xs)
# ----------------------------------------------------------- button LEDs
BUTTON_LEDS = ("button_led_1", "button_led_2", "button_led_3")
def leds_root():
r = os.environ.get("GF_LEDS_ROOT") or "/sys/class/leds/"
return r if r.endswith("/") else r + "/"
def button_leds():
"""The three button LEDs' commanded levels (the smooth trigger's
`target`, which the controller writes; `brightness` follows it with
a fade and is read where no target exists), or None where unreadable."""
out = []
for name in BUTTON_LEDS:
val = None
for attr in ("target", "brightness"):
try:
with open(leds_root() + name + "/" + attr) as f:
val = int(f.read().strip())
break
except (OSError, ValueError):
continue
out.append(val)
return out
def button_lit():
"""True when any button LED is on, False when all three read 0, None
when none is readable."""
vals = [v for v in button_leds() if v is not None]
if not vals:
return None
return any(v > 0 for v in vals)
# --------------------------------------------------------------- init.d
def initd(service, action, timeout=60):
"""Run /etc/init.d/<service> <action>; returns (rc, output)."""
base = os.environ.get("FORGETEST_INITD") or "/etc/init.d"
script = os.path.join(base, service)
try:
p = subprocess.run([script, action], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=timeout)
except (OSError, subprocess.TimeoutExpired) as e:
return 127, str(e)
return p.returncode, p.stdout.decode("utf-8", "replace")
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 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
-113
View File
@@ -1,113 +0,0 @@
"""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()
-224
View File
@@ -1,224 +0,0 @@
"""The image manifest and the domain fingerprint.
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
identifies the build's inputs: for every component the pinned revision and
one [path, blob-id] pair per source file, plus the platform identity
(machine, kernel modules directory, device tree hashes, layer content
hashes). This module loads it and computes a test's *domain fingerprint*:
the hash of the source files its coverage globs select, plus the platform,
plus the test's own implementation. A recorded PASS applies to a build
exactly when the fingerprint recomputed from that build's manifest is the
same - the same code runs on the board and in the release gate.
"""
import functools
import hashlib
import json
import os
import re
DEFAULT_PATH = "/etc/forgefirm-manifest.json"
# Components that ship only on the dev image. They can never be part of a
# fingerprint (the release manifest lacks them, so the gate could not
# recompute it); the test implementation is folded in separately.
DEV_ONLY_COMPONENTS = ("forgetest",)
def canonical(obj):
"""Canonical JSON: sorted keys, no whitespace - the hashing form."""
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
def sha256_text(text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()
@functools.lru_cache(maxsize=512)
def glob_to_regex(pattern):
"""Coverage glob -> anchored regex. '**' spans directories, '*' and '?'
stay inside one path segment. Paths use '/' (git paths). Cached: the
catalog matches the same few hundred globs over and over."""
out = []
i, n = 0, len(pattern)
while i < n:
c = pattern[i]
if c == "*":
if pattern[i:i + 2] == "**":
# '**/' also matches zero directories
if pattern[i:i + 3] == "**/":
out.append("(?:.*/)?")
i += 3
continue
out.append(".*")
i += 2
continue
out.append("[^/]*")
elif c == "?":
out.append("[^/]")
else:
out.append(re.escape(c))
i += 1
return re.compile("^" + "".join(out) + "$")
def match_files(files, pattern):
"""(path, blob) pairs from a component's file list that the glob selects."""
rx = glob_to_regex(pattern)
return [(p, b) for p, b in files if rx.match(p)]
# Paths that carry no target behavior: docs, CI, the components' own unit
# tests, licenses, editor setup. Outside every fingerprint (a README edit
# re-requires nothing) and outside the coverage lint (no test has to name
# them). Reviewed with the catalog: widening this list is a change like
# any other. "*" applies to every component.
NON_BEHAVIORAL = [
("*", ".github/**"),
("*", ".gitignore"),
("*", ".gitattributes"),
("*", ".gitmodules"),
("*", "**/*.md"),
("*", "LICENSE*"),
("*", "COPYING*"),
("*", "docs/**"),
("*", "tests/**"),
("*", "graphify-out/**"),
("*", "**/.gitkeep"),
("*", ".devcontainer/**"),
("*", ".vscode/**"),
("*", ".env.example"),
("forgectrl", "tools/**"), # host-side dev tools (panel dev server)
]
def non_behavioral(comp, path, allow=NON_BEHAVIORAL):
for c, pat in allow:
if c in ("*", comp) and glob_to_regex(pat).match(path):
return True
return False
class Manifest:
def __init__(self, data):
self.data = data
self.components = data.get("components", {}) or {}
self.platform = data.get("platform", {}) or {}
self.image = data.get("image", {}) or {}
self.content_sha = data.get("content_sha256")
self._files = {}
@classmethod
def load(cls, path=None):
path = path or os.environ.get("FORGETEST_MANIFEST") or DEFAULT_PATH
with open(path, "r", encoding="utf-8") as f:
return cls(json.load(f))
@classmethod
def from_json(cls, text):
return cls(json.loads(text))
@property
def version(self):
return self.image.get("version") or "unknown"
@property
def image_name(self):
return self.image.get("name") or "unknown"
def files(self, component):
"""The (path, blob) pairs of a component, or None if the component
is not in this manifest. Built once per component: the manifest is
immutable and every coverage glob asks for the same lists."""
if component in self._files:
return self._files[component]
c = self.components.get(component)
out = None if c is None else [tuple(x) for x in c.get("files", [])]
self._files[component] = out
return out
def component_names(self):
return sorted(self.components)
def identity_sha(self):
"""sha256 of the acceptance-relevant identity: every component
except the dev-only ones, plus the platform. Informational (the
gate decides per test, by fingerprint)."""
comps = {k: v for k, v in self.components.items() if k not in DEV_ONLY_COMPONENTS}
return sha256_text(canonical({"components": comps, "platform": self.platform}))
def fingerprint(manifest, covers, extra=()):
"""The domain fingerprint of a coverage map on a manifest.
covers: iterable of (component, glob). extra: strings folded in after
the files (the test's own implementation hash). A component the
manifest lacks contributes a marker so the fingerprint is still
defined and distinct.
"""
parts = set()
for comp, pat in covers:
if comp in DEV_ONLY_COMPONENTS:
raise ValueError("coverage may not name the dev-only component %r" % comp)
files = manifest.files(comp)
if files is None:
parts.add((comp, "@missing", ""))
continue
for p, b in match_files(files, pat):
if non_behavioral(comp, p):
continue
parts.add((comp, p, b))
h = hashlib.sha256()
h.update(canonical(sorted(parts)).encode("utf-8"))
h.update(b"\n")
h.update(canonical(manifest.platform).encode("utf-8"))
for e in extra:
h.update(b"\n")
h.update(str(e).encode("utf-8"))
return h.hexdigest()
def empty_covers(manifest, tests):
"""Coverage entries that select no file of their component: a glob
that never matched (paths anchor at the repository root, so a file
under a recipe's subdirectory needs that directory in the glob), or
a component not in the manifest. Such an entry covers nothing, and
the test's fingerprint would not move with the file it meant.
Returns [(test id, component, glob)]. A glob selecting only
non-behavioral paths is hollow too: nothing it names is fingerprinted."""
out = []
for t in tests:
for comp, pat in t.covers:
files = manifest.files(comp)
if files is None or not [p for p, _b in match_files(files, pat) if not non_behavioral(comp, p)]:
out.append((t.id, comp, pat))
return out
def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
"""Which manifest paths no test covers.
tests: iterable with .covers. allow: iterable of (component, glob)
that need no coverage (docs, CI, licenses...). Returns
{component: [uncovered paths]} for the non-dev-only components.
"""
covered = {}
for t in tests:
for comp, pat in t.covers:
covered.setdefault(comp, []).append(glob_to_regex(pat))
allowed = {}
for comp, pat in allow:
allowed.setdefault(comp, []).append(glob_to_regex(pat))
report = {}
for comp in manifest.component_names():
if comp in DEV_ONLY_COMPONENTS:
continue
rxs = covered.get(comp, []) + allowed.get(comp, [])
star = allowed.get("*", [])
missing = []
for p, _b in manifest.files(comp):
if any(rx.match(p) for rx in rxs) or any(rx.match(p) for rx in star):
continue
missing.append(p)
if missing:
report[comp] = sorted(missing)
return report
-61
View File
@@ -1,61 +0,0 @@
"""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)
-143
View File
@@ -1,143 +0,0 @@
"""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:]
File diff suppressed because it is too large Load Diff
-342
View File
@@ -1,342 +0,0 @@
"""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
-16
View File
@@ -1,16 +0,0 @@
"""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 commission # noqa: F401,E402
from . import commission_dark # noqa: F401,E402
from . import commission_sheet # noqa: F401,E402
from . import logs # 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
-447
View File
@@ -1,447 +0,0 @@
"""camera.* - the lid camera pipeline through forgectrl."""
import json
from ..catalog import test
from .. import hw
from ..baseline import LID_LAMP_ATTR
_CAM_COVERS = [("forgectrl", "src/cam.*"), ("forgectrl", "src/camhealth.*"),
("forgectrl", "src/debayer.*"), ("forgectrl", "src/vpu_jpeg.*"),
("forgectrl", "src/vpu_h264.*"), ("forgectrl", "src/mp4mux.*"),
("forgectrl", "src/gpu_debayer.*"), ("forgectrl", "src/ipu_copy.*"),
("forgectrl", "src/main.c"),
("python3-gfhardware", "gfhardware/src/**"), ("python3-gfhardware", "gfhardware/cam*")]
# The privacy gate spans the camera path and the lid read it depends on,
# in both processes that can reach a sensor.
_PRIVACY_COVERS = _CAM_COVERS + [("forgectrl", "src/status.*"),
("python3-gfhardware", "gfhardware/switches.py"),
("forgefirm-app", "forgefirm-app/ffmachine.py")]
# The sensors the machine ships with, and the geometry each one implies.
# A machine reports exactly one of these; anything else means the sensor
# bound to a driver the capture path has no profile for.
_SENSOR_GEOMETRY = {
"OV5648": (2592, 1944),
"OV8856": (3264, 2448),
}
def _jpeg_size(data):
"""(width, height) from a JPEG's first SOFn marker, or None."""
i = 2
n = len(data)
while i + 3 < n:
if data[i] != 0xFF:
i += 1
continue
marker = data[i + 1]
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
i += 2
continue
seglen = (data[i + 2] << 8) | data[i + 3]
# SOFn, excluding the non-frame markers in the same range
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
if i + 9 > n:
return None
return ((data[i + 7] << 8) | data[i + 8],
(data[i + 5] << 8) | data[i + 6])
i += 2 + seglen
return None
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
kind="auto", est_min=2,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
steps=["Setup: lid closed."],
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
"far smaller), a second snapshot with the lid lamp switched off differs from it "
"and is smaller (the capture is live, not a frame the pipeline is re-serving, "
"and the lamp lights what the camera sees), and the MJPEG stream starts and "
"stops.")
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")
ctx.sleep(2)
st, body = fc.get("/cam/status")
ev["cam_status_after"] = body
ctx.log("cam status after: %s", body)
ctx.log("PASS: half-res snapshot %d bytes, %d with the lamp off; full-res %d; stream %s",
ev["snapshot_bytes"], len(dark), len(data), ctype)
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera",
kind="auto", est_min=1,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an "
"8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it "
"actually returns are that size. A machine that reports 'unknown', or whose frames "
"do not match what it advertises, is running the wrong capture profile.")
def sensor_profile(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/cam/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
sensor = body.get("sensor")
ev["sensor"] = sensor
ctx.log("sensor: %s", sensor)
ctx.check(sensor in _SENSOR_GEOMETRY,
"sensor %r is not one this build has a capture profile for", sensor)
want_w, want_h = _SENSOR_GEOMETRY[sensor]
snap = body.get("snapshot") or {}
stream = body.get("stream") or {}
ev["geometry"] = {"snapshot": snap, "stream": stream}
ctx.log("advertised: snapshot %sx%s, stream %sx%s",
snap.get("width"), snap.get("height"), stream.get("width"), stream.get("height"))
ctx.check(snap.get("width") == want_w and snap.get("height") == want_h,
"%s should advertise %dx%d snapshots, got %sx%s",
sensor, want_w, want_h, snap.get("width"), snap.get("height"))
ctx.check(stream.get("width") == want_w // 2 and stream.get("height") == want_h // 2,
"%s should advertise %dx%d stream frames, got %sx%s",
sensor, want_w // 2, want_h // 2, stream.get("width"), stream.get("height"))
# What it advertises is what it delivers.
for res, (w, h) in (("full", (want_w, want_h)), ("half", (want_w // 2, want_h // 2))):
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": res}, raw=True)
ctx.check(st == 200 and data and data[:2] == b"\xff\xd8",
"%s snapshot -> %s", res, st)
got = _jpeg_size(data)
ev["jpeg_%s" % res] = got
ctx.log("%s snapshot: %s (%d bytes)", res, got, len(data))
ctx.check(got == (w, h), "%s snapshot is %s, not %dx%d", res, got, w, h)
@test("camera.h264-stream", title="H.264 live stream", subsystem="camera",
kind="auto", est_min=2,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
steps=["Setup: lid closed."],
description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an "
"init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few "
"seconds, and /cam/status reporting the H.264 encoder active with this client "
"counted. Also records which demosaic path (GPU or CPU) served the stream. On a "
"machine whose image lacks Mesa or whose encoder refused, the endpoint must "
"answer 503 rather than hang - that is a pass for the endpoint but is recorded "
"in the evidence.")
def h264_stream(ctx):
import urllib.error
import urllib.request
fc = ctx.forgectrl
ev = ctx.evidence
req = urllib.request.Request(fc.base + "/cam/h264?cam=lid",
headers={"Host": fc.host_header()})
try:
r = urllib.request.urlopen(req, timeout=20)
except urllib.error.HTTPError as e:
ev["status"] = e.code
ev["body"] = e.read()[:200].decode("utf-8", "replace")
ctx.check(e.code == 503, "H.264 refused with %s, not 503: %s", e.code, ev["body"])
ctx.log("H.264 unavailable on this machine (503): %s", ev["body"])
return
with r:
codec = r.headers.get("X-H264-Codec", "")
ctype = r.headers.get("Content-Type", "")
ev["codec"] = codec
ev["content_type"] = ctype
ctx.log("codec %s, content-type %s", codec, ctype)
ctx.check(ctype == "video/mp4", "content type is %r", ctype)
ctx.check(codec.startswith("avc1."), "codec header is %r", codec)
# Init segment, then at least one media fragment.
data = b""
while len(data) < 512 * 1024:
chunk = r.read(65536)
if not chunk:
break
data += chunk
if b"moof" in data and b"mdat" in data:
break
ev["bytes"] = len(data)
ctx.check(data[4:8] == b"ftyp", "stream does not start with ftyp")
ctx.check(b"moov" in data, "no moov (init segment)")
ctx.check(b"avcC" in data, "no avcC in the init segment")
ctx.check(b"moof" in data and b"mdat" in data,
"no media fragment arrived in %d bytes", len(data))
st, body = fc.get("/cam/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
ev["cam_status"] = body
ctx.log("convert=%s h264=%s", body.get("convert"), body.get("h264"))
h = body.get("h264") or {}
ctx.check(h.get("active") is True, "/cam/status does not report the H.264 encoder active: %s",
body)
@test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera",
kind="auto", est_min=1,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
description="The capture queue flags a frame errored when it is short, torn, or arrived "
"after the CSI-2 receiver lost sync; forgectrl drops those rather than "
"demosaicing them, and restarts the stream if they persist. A healthy machine "
"captures a burst with none of them. 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)
@test("camera.lid-privacy", title="Cameras capture only with the lid closed", subsystem="camera",
kind="operator", est_min=3,
covers=_PRIVACY_COVERS, requires=["forgectrl.panel-serves"], actions=["lid"],
steps=["Start with the lid closed; you will be told to open it, then close it again.",
"Nothing moves and the laser is not involved."],
description="The privacy gate: with the lid open neither camera captures. A running stream "
"stops within a frame or so of the lid opening, /cam/status reports capture as "
"not allowed, and both the snapshot and the stream are refused with 409 and a "
"reason naming 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")
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True)
ctx.log("lid open: head snapshot -> %s", st)
ctx.check(st == 409, "the head camera should be refused too, got %s", st)
try:
with urllib.request.urlopen(req, timeout=10) as r:
ctx.fail("the stream opened with the lid open (%s)", r.status)
except urllib.error.HTTPError as e:
ctx.log("lid open: stream -> %s", e.code)
ctx.check(e.code == 409, "the stream should be refused with 409, got %s", e.code)
# --- closing the lid restores it -----------------------------------
ctx.act("lid", "close")
body = status()
ctx.check(body.get("capture_allowed") is True,
"closing the lid should allow capture again, got %r",
body.get("capture_allowed"))
st, data = snapshot()
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
"snapshot after closing the lid -> %s", st)
ctx.log("lid closed again: snapshot %d bytes", len(data))
@test("camera.key-read", title="The camera key reads without a login", subsystem="camera",
kind="auto", est_min=1,
covers=[("forgectrl", "src/camkey.*"), ("forgectrl", "src/auth.c"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/panel.js"),
("forgectrl", "src/ui/index.html"), ("forgectrl", "src/ui/help.js")],
requires=["forgectrl.auth"],
description="With the read routes closed to the network (panel_open_reads=0), a camera "
"status read from the board's LAN address is refused without the key and "
"served with it, on HTTP and on HTTPS; a wrong key is refused; the key never "
"authorizes a write; a rotation from the panel route stops the old key and "
"the new one reads. The setting is put back after.")
def key_read(ctx):
from .commission import request
from .forgectrl import lan_ip
fc = ctx.forgectrl
ev = ctx.evidence
ip = lan_ip()
ctx.check(ip, "cannot determine the board's LAN address")
http_base = "http://%s" % ip
tls_base = "https://%s" % ip
token = {"X-ForgeFIRM-Token": fc.token}
st, body, _ = request(fc.base, "GET", "/system/camera-key", headers=token)
ctx.check(st == 200, "GET /system/camera-key from loopback -> %s", st)
key = json.loads(body)["key"]
ctx.check(len(key) == 32, "the key is not 32 characters: %r", key)
ev["key_len"] = len(key)
before = fc.settings().get("panel_open_reads", "")
st, _ = fc.post("/settings", params={"panel_open_reads": "0"})
ctx.check(st == 200, "closing the reads -> %s", st)
try:
for base in (http_base, tls_base):
st, _, _ = request(base, "GET", "/cam/status")
ctx.log("GET %s/cam/status (no key) -> %s", base, st)
ctx.check(st == 403, "a closed read without the key -> %s, expected 403", st)
st, _, _ = request(base, "GET", "/cam/status?key=" + key)
ctx.log("GET %s/cam/status?key=... -> %s", base, st)
ctx.check(st == 200, "a read with the key -> %s, expected 200", st)
st, _, _ = request(base, "GET", "/cam/status", headers={"X-ForgeFIRM-Camera-Key": key})
ctx.check(st == 200, "a read with the key header -> %s, expected 200", st)
wrong = ("0" if key[0] != "0" else "1") + key[1:]
st, _, _ = request(base, "GET", "/cam/status?key=" + wrong)
ctx.check(st == 403, "a wrong key -> %s, expected 403", st)
# the key is a read credential only
st, _, _ = request(tls_base, "POST", "/settings", data={"panel_open_reads": "0"},
headers={"X-ForgeFIRM-Camera-Key": key})
ctx.log("POST /settings with the camera key -> %s", st)
ctx.check(st == 403, "the camera key authorized a write (%s)", st)
# rotation
st, body, _ = request(fc.base, "POST", "/system/camera-key?rotate=1", headers=token)
ctx.check(st == 200, "rotate -> %s", st)
new = json.loads(body)["key"]
ctx.check(new != key, "rotation kept the same key")
st, _, _ = request(http_base, "GET", "/cam/status?key=" + key)
ctx.check(st == 403, "the old key still reads after rotation (%s)", st)
st, _, _ = request(http_base, "GET", "/cam/status?key=" + new)
ctx.check(st == 200, "the new key does not read (%s)", st)
ev["rotated"] = True
finally:
fc.post("/settings", params={"panel_open_reads": before})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,369 +0,0 @@
"""commission.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 .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
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
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
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):
"""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). 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 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:]
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
if last is None:
fc.post("/wiz/%s/abort" % wid)
return {}
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("commission.check-switches", title="The switches check follows the lid and the button",
subsystem="commission", kind="operator", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
requires=["forgectrl.auth"], actions=["lid", "button"],
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("commission.check-sensors", title="The sensors check reads a plausible machine at rest",
subsystem="commission", kind="auto", est_min=2,
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
("forgectrl", "src/cool.c")],
requires=["forgectrl.auth"],
description="POST /wiz/sensors/start; ten seconds of readings, then the room-temperature "
"prompt, answered Skip so no offset is written. The result carries both coolant "
"temperatures, the chassis and SoC, the lid IR maxima, the accelerometer event "
"count, the supply power-good, the HV current, and the idle fan speeds; the "
"record carries sensors at version 1 and cool_temp_offset_c reads as before.")
def check_sensors(ctx):
with Restore(ctx, ["cool_temp_offset_c"]):
last = run_check(ctx, "sensors", lambda p: "Skip" if p.get("id") == "room-temp" else None, 120)
r = last.get("result") or {}
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
"exhaust_rpm_idle", "intake_rpm_idle"):
ctx.check(k in r, "the result lacks %s: %s", k, r)
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
r.get("lid_ir_max"), r.get("hv_current_max"))
@test("commission.check-airflow", title="The airflow check measures the fans and sets the floors",
subsystem="commission", 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", "cooling.fan-gate-trips"],
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 five settings are written: the "
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
"fan must read at least 1000 rpm. The five 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):
last = run_check(ctx, "airflow", lambda p: None, 240)
r = last.get("result") or {}
fans = r.get("fans") or {}
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
f = fans.get(name) or {}
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))
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("commission.check-cameras", title="The cameras check captures both cameras",
subsystem="commission", kind="operator", est_min=2,
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
"head snapshot, the question. Each question is answered yes once GET /wiz/shot "
"serves the snapshot as image/jpeg. The result names the sensor and says both "
"views were accepted.")
def check_cameras(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.act("lid", "close")
shots = {}
def on_prompt(p):
pid = p.get("id", "")
if pid in ("lid-view", "head-view"):
cam = "lid" if pid == "lid-view" else "head"
st, body = fc.get("/wiz/shot", params={"cam": cam}, raw=True)
shots[cam] = {"status": st, "bytes": len(body) if body else 0,
"jpeg": bool(body) and body[:2] == b"\xff\xd8"}
ctx.check(st == 200 and shots[cam]["jpeg"], "no %s snapshot to judge: %s", cam, shots[cam])
return "Yes"
if pid == "lid-close":
ctx.act("lid", "close", until=lambda: (dark(fc).get("prompt") or {}).get("seq") != p["seq"])
return None
last = run_check(ctx, "cameras", on_prompt, 180)
ev["shots"] = shots
r = last.get("result") or {}
ctx.check(r.get("lid_ok") is True and r.get("head_ok") is True, "a view was not accepted: %s", r)
ctx.check(bool(r.get("sensor")) and r.get("sensor") != "unknown", "no sensor named: %s", r)
ctx.log("cameras: %s, lid %d bytes, head %d bytes", r.get("sensor"),
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
@test("commission.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
subsystem="commission", 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("commission.check-flow-verify", title="The flow check runs as a setup check",
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
"check runner: the diagnostic's phases show as the check's, its result becomes the "
"check's result with pass true, and the record carries cooling.flow-verify at "
"version 1. A thin margin recommends the calibration in the record's flags. The "
"air-assist offset calibration is named first among the prerequisites so a queue "
"keeps it ahead of every heater tool this check pulls forward.")
def check_flow_verify(ctx):
last = run_check(ctx, "cooling.flow-verify", lambda p: None, 600)
r = last.get("result") or {}
ctx.check(r.get("pass") is True, "the flow check did not pass: %s", r)
ctx.log("flow check: threshold %s, flow %s, no-flow %s, thin %s", r.get("threshold"),
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
@test("commission.cloud-header-capture", title="The cloud header check takes one print's envelope",
subsystem="commission", 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,196 +0,0 @@
"""commission.sheet - the setup's sheet (the live wizards) in one run,
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
polled, the prompts answered from here, each burn armed by a press, the
result judged, and every setting a wizard wrote put back as found. One
piece of wood at least 200 x 150 mm (8 x 6 in) carries the placement,
the frame, and the five cards. Each wizard re-runs on the real record,
so a completed one completes again at the same version.
The operator's part is one press on the machine at the start (the
presence check of the ready gate; with the bench actuator up it then
makes every arm press) and nothing else. The judgments a person makes
by eye on the sheet (the thinnest line, the faintest rung, the best
corner, the frame's fit) are answered here with a middle option or the
witnesses: the test proves each wizard's mechanics and the emission
witnesses, not the operator's eye, and the settings it writes are
restored. The page is where the real numbers go in.
"""
import re
from ..catalog import test
from .commission_dark import run_check, Restore
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
("forgectrl", "src/sheet.*"),
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
("forgectrl", "src/super.c"),
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
("forgectrl", "src/accel.*"), ("forgectrl", "src/wizcalc.*"),
("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/glowforge_status.c"),
("grblhal-glowforge", "src/glowforge_homing.*"),
("grblhal-glowforge", "src/glowforge_io.*")]
# The live wizards in the order the sheet burns them: the wizard id, the
# answers to its prompts (a middle pick where a person would look at the
# sheet), and the keys its result must carry. The arm press is not timed
# from here: each card opens a press prompt of its own when the button is
# lit and the tube still dark, and run_check presses on that.
CARDS = [
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"]),
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"},
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
"focus_range_mm", "stops"]),
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"]),
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"]),
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"]),
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"]),
]
# Every setting a card writes, restored as found when the run ends.
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
"laser_floor_density", "laser_dose_curve", "laser_corner_gamma",
"cool_laser_heat_density", "cool_laser_heat_cw"]
# The evidence run_check and the witnesses write at the top level, moved
# under the card's own key once it is done.
CARD_EVIDENCE = ("started", "elapsed_s", "phases", "log", "error", "result",
"preview_bytes", "program_lines", "emission")
def preview_ok(ctx, wid):
"""The page's preview and the program for the wizard 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 and the frame are M4 at the mark dose; the patterns 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")
def emission_ok(ctx, last):
"""The three witnesses of the burn, from the wizard's result."""
e = (last.get("result") or {}).get("emission") or {}
ctx.evidence["emission"] = e
ctx.check(e.get("hv_max", 0) > 30, "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(e.get("thermopile_delta", 0) >= 100, "the thermopile did not see the beam (%s)",
e.get("thermopile_delta"))
def file_card(ctx, wid):
"""Move the card's evidence under its own key."""
ev = ctx.evidence
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
def thickness_answer(ctx):
"""The thickness the placement 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 place(ctx):
"""The placement: the lens reference, two jogs, the origin at the
head's position, the full sheet, a thickness. Nothing fires."""
jogs = ["X+10", "X-10"]
answer, want_mm = thickness_answer(ctx)
ctx.evidence["thickness_answer"] = answer
def on_prompt(p):
# Each jog answer closes the prompt and the wizard asks again with
# the next sequence number, so the pad is answered one move at a
# time until the origin is set.
if p.get("id") == "place":
return jogs.pop(0) if jogs else "Set origin"
if p.get("id") == "sheet-kind":
return "Full sheet"
if p.get("id") == "thickness":
return answer
return None
last = run_check(ctx, "sheet.place", on_prompt, 300)
r = last.get("result") or {}
ctx.check("origin_x" in r and "origin_y" in r, "no origin in the result: %s", r)
ctx.check((r.get("steps_per_mm") or {}).get("x", 0) > 0, "no steps per mm: %s", r)
ctx.check(r.get("z_referenced") is True, "the lens was not referenced")
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(r.get("alone") is False, "alone %s", r.get("alone"))
ctx.log("placed: datum at %s, %s; steps per mm %s", r.get("origin_x"), r.get("origin_y"),
r.get("steps_per_mm"))
file_card(ctx, "sheet.place")
def burn(ctx, wid, answers, want):
"""One live card: the preview, the burn after the press with the
prompts answered from `answers`, the witnesses, the keys of the
result in `want`. run_check makes the arm press when the card asks
for it."""
preview_ok(ctx, wid)
def on_prompt(p):
return answers.get(p.get("id"))
try:
last = run_check(ctx, wid, on_prompt, 900)
finally:
ctx.clear_notice()
emission_ok(ctx, last)
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.log("%s: %s, emission %s", wid, {k: r.get(k) for k in want}, ctx.evidence.get("emission"))
file_card(ctx, wid)
@test("commission.sheet", title="The commissioning sheet: the placement, the frame, and the five cards",
subsystem="commission", kind="live", hardware="takeover", mode="grbl", est_min=35,
covers=SHEET_COVERS,
requires=["forgectrl.auth", "commission.check-motion", "laser.emission-witness", "cooling.flow-verify"],
actions=["button"], hands=["scrap"],
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
"it goes with its top edge at the top of the cut area (the head's home corner); "
"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 every arm press after it, each one when the "
"card itself asks for it. With no actuator the start is the Ready answer instead, "
"and you press the button each time the page asks: six times, the frame and the "
"five cards.",
"Nothing else: the test answers every prompt itself and puts back every setting the "
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
"tails."],
description="One run over the sheet's seven wizards on one piece of wood. "
"POST /wiz/sheet.place/start references the lens, takes the controller in "
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
"0.125 in). Then, each after the press and each with its preview "
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode: the frame "
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
"lens homed on its bottom stop, the hall edge counted, twelve lines down the "
"travel; the pick is line 11, the thickness kept); the floor card (twelve "
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
"through M102 inside one armed job; the pick is 1.50); and the flow-load card "
"(the coolant loop settled, the box and the patch at 60 percent, the "
"downstream peak over 100 s of dark as the coefficient). Every burn must be "
"seen by the three witnesses (the tube current, the kernel's LASER_ON samples, "
"the thermopile rise); every result must carry its keys; the record must "
"carry each wizard at version 1; the seven settings the cards write are "
"restored at the end.")
def sheet(ctx):
ctx.ready("The sheet: the placement, then the frame and the five cards burn after a press "
"each. The bench presses when the button lights.")
place(ctx)
with Restore(ctx, CARD_SETTINGS):
for wid, answers, want in CARDS:
burn(ctx, wid, answers, want)
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
File diff suppressed because it is too large Load Diff
-413
View File
@@ -1,413 +0,0 @@
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
import json
import os
import socket
import time
from ..catalog import test
from .. import hw
# 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.
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/ui/login.js"), ("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"}),
("/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: the loopback peer is accepted. An idle
# report is what the controller sends every period; the engine is idle
# here, so it changes nothing. A dual-stack listener reports this peer
# as ::ffff:127.0.0.1, which the check must recognize in full.
st, body = fc.post("/cool/state", params={"mode": "idle", "armed": "0"})
ev["cool_state_from_loopback"] = st
ctx.log("POST /cool/state from loopback -> %s %s", st, body if isinstance(body, dict) else "")
ctx.check(st == 200, "/cool/state refused the loopback peer (%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 .commission 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}
report = {"mode": "idle", "armed": "0"}
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)
@test("forgectrl.settings-bounds", title="Settings validation and restore", subsystem="forgectrl",
kind="auto", est_min=1,
covers=[("forgectrl", "src/settings.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/cam.c")],
description="An over-length value and an out-of-range value are refused (400) and leave the "
"settings byte-identical; an in-range value is accepted (200). The lid lamp "
"idles at lid_lamp_idle (unset = 236), an out-of-range level is refused, a new "
"level applies to the lamp at once, and clearing it returns the default.")
def settings_bounds(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
ev["keys"] = len(before)
st, body = fc.post("/settings", data={"gf_serial": "X" * 300})
ev["overlong"] = st
ctx.log("POST /settings gf_serial=<300 chars> -> %s %s", st, body if isinstance(body, dict) else "")
ctx.check(st == 400, "over-length value -> %s, expected 400", st)
st, body = fc.post("/settings", data={"laser_disarm_s": "99999"})
ev["out_of_range"] = st
ctx.log("POST /settings laser_disarm_s=99999 -> %s", st)
ctx.check(st == 400, "out-of-range value -> %s, expected 400", st)
# The cloud download guard is bytes, so its range is far wider than the
# other numeric keys: check the far end is still a wall.
st, body = fc.post("/settings", data={"pulse_reject_threshold_bytes": "2000000000"})
ev["pulse_bytes_out_of_range"] = st
ctx.log("POST /settings pulse_reject_threshold_bytes=2000000000 -> %s", st)
ctx.check(st == 400, "out-of-range byte limit -> %s, expected 400", st)
st, body = fc.post("/settings", data={"no_such_key_forgetest": "1"})
ev["unknown_key"] = st
ctx.log("POST /settings no_such_key_forgetest=1 -> %s", st)
ctx.check(st in (400, 404), "unknown key -> %s, expected 400", st)
after = fc.settings()
ctx.check(json.dumps(after, sort_keys=True) == json.dumps(before, sort_keys=True),
"settings changed after refused writes")
ctx.log("settings unchanged after the refused writes")
# an accepted in-range write: rewrite a present key with its own value
key = None
for k in ("ui_units", "laser_disarm_s", "cool_flow_rise", "rail_settle_s"):
v = before.get(k)
if isinstance(v, str) and v != "":
key = k
break
if key is None:
key, val = "ui_units", "metric"
ctx.log("no settable key is present; writing %s=%s (recorded in evidence)", key, val)
else:
val = before[key]
st, body = fc.post("/settings", data={key: val})
ev["accepted"] = {"key": key, "value": val, "status": st}
ctx.log("POST /settings %s=%s -> %s", key, val, st)
ctx.check(st == 200, "in-range write -> %s, expected 200", st)
final = fc.settings()
others_before = {k: v for k, v in before.items() if k != key}
others_after = {k: v for k, v in final.items() if k != key}
ctx.check(others_before == others_after, "other settings changed by the write")
ctx.check(final.get(key) == val, "%s reads back %r, wrote %r", key, final.get(key), val)
# the lid lamp's idle level: resting at the setting, bounded, applied live
lamp_was = (before.get("lid_lamp_idle") or "").strip()
want = lamp_was or "236"
got = ctx.sysfs("pic/lid_led")
ev["lid_lamp"] = {"setting": lamp_was, "resting": got}
ctx.log("lid lamp: setting %r, pic/lid_led=%s (expected %s)", lamp_was, got, want)
ctx.check(got == want, "lid lamp rests at %s, lid_lamp_idle is %s", got, want)
for bad in ("256", "-1", "bright"):
st, body = fc.post("/settings", data={"lid_lamp_idle": bad})
ctx.check(st == 400, "lid_lamp_idle=%s -> %s, expected 400", bad, st)
ctx.log("lid_lamp_idle 256 / -1 / bright refused")
try_level = "100" if want != "100" else "120"
st, body = fc.post("/settings", data={"lid_lamp_idle": try_level})
ctx.check(st == 200, "lid_lamp_idle=%s -> %s, expected 200", try_level, st)
applied = None
t0 = time.time()
while time.time() - t0 < 5:
applied = ctx.sysfs("pic/lid_led")
if applied == try_level:
break
ctx.sleep(0.2)
ctx.log("lid_lamp_idle=%s -> pic/lid_led=%s after %.1f s", try_level, applied, time.time() - t0)
# an empty value clears the key: the query-string form carries it
st, body = (fc.post("/settings", params={"lid_lamp_idle": ""}) if not lamp_was
else fc.post("/settings", data={"lid_lamp_idle": lamp_was}))
ctx.check(st == 200, "restoring lid_lamp_idle=%r -> %s", lamp_was, st)
t0 = time.time()
back = None
while time.time() - t0 < 5:
back = ctx.sysfs("pic/lid_led")
if back == want:
break
ctx.sleep(0.2)
ev["lid_lamp"].update({"applied": applied, "restored": back})
ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied)
ctx.check(back == want, "lamp did not return to %s after the restore (reads %s)", want, back)
ctx.log("lid lamp follows the setting live and returns to %s", want)
@test("forgectrl.panel-serves", title="Control panel and status endpoints", subsystem="forgectrl",
kind="auto", est_min=1,
covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"),
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
("forgectrl", "src/curverec.*")],
description="The panel page is served, /status carries the machine telemetry the panel and "
"the acceptance tool read (including the sys block: CPU busy percent over the "
"interval since the previous read, memory used percent; and homed_axes, "
"the axes that carry a reference, Z alone once the lens has taken its "
"own at the controller's start), and /cam/status "
"answers. In GRBL mode with a live controller, /status also echoes the "
"controller's published state file as the grbl block (fresh age, machine "
"state, sender session, laser window and dose model, modal report), "
"GET /grbl/settings serves the published $$ view, and the controller's "
"settings store is /data/forgefirm/EEPROM-glowforge.DAT with nothing of "
"it at the top of /data.")
def panel_serves(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/", raw=True)
ev["panel_status"] = st
ctx.log("GET / -> %s (%d bytes)", st, len(body) if body else 0)
ctx.check(st == 200, "GET / -> %s", st)
text = body.decode("utf-8", "replace")
ctx.check("<html" in text.lower() and "ForgeFIRM" in text, "the panel does not look like the panel")
ctx.check(fc.token and fc.token in text, "the panel does not embed the bearer token")
ctx.check("<link " not in text and "<script src=" not in text,
"the panel references an external asset (the build did not bundle src/ui/)")
# The daemon stores the page gzipped and inflates it once at first
# request; what it serves is the plain page with the theme attribute
# the head script sets and the one save bar every settings tab shares.
ctx.check("data-bs-theme" in text, "the panel lacks the theme attribute (inflate failed?)")
ctx.check('id="savebar"' in text, "the panel lacks the save bar")
s = fc.status()
for key in ("state", "switches", "coolant", "fans"):
ctx.check(key in s, "/status lacks %r", key)
ev["state"] = s.get("state")
ev["switches"] = s.get("switches")
ctx.log("/status state=%s switches=%s", s.get("state"), s.get("switches"))
for key in ("lid", "button", "interlock_ok", "head", "hv_enable"):
ctx.check(key in (s.get("switches") or {}), "/status switches lacks %r", key)
# SoC utilization rides /status next to the temperatures. The CPU
# number is a delta over the interval since the previous read, so
# the read above primes it; after a beat both percents must be
# numbers in range.
ctx.sleep(1)
sys_ = ctx.forgectrl.status().get("sys") or {}
ev["sys"] = sys_
ctx.log("/status sys=%s", sys_)
ctx.check(isinstance(sys_.get("cpu_pct"), (int, float)) and 0.0 <= sys_["cpu_pct"] <= 100.0,
"/status sys.cpu_pct is not a percent: %s", sys_)
ctx.check(isinstance(sys_.get("mem_pct"), (int, float)) and 0.0 < sys_["mem_pct"] < 100.0,
"/status sys.mem_pct is not a percent: %s", sys_)
# The lens takes its own reference on the hall edge at every
# controller start, so with a controller running Z is referenced on
# its own while X and Y wait for a home: homed_axes names the axes
# that carry one, and Z reads inside the lens reach rather than the
# zero an unreferenced axis would show.
axes = s.get("homed_axes")
ev["homed_axes"] = axes
ctx.check(isinstance(axes, int) and 0 <= axes <= 7,
"/status homed_axes is not an axis mask: %s", axes)
ctx.check(bool(s.get("homed")) == (axes == 7),
"/status homed (%s) disagrees with homed_axes (%s)",
s.get("homed"), axes)
st, mode0 = fc.get("/mode")
if isinstance(mode0, dict) and mode0.get("controller") == "running":
lens = s.get("lens") or {}
pos = s.get("pos") or {}
ctx.log("/status homed_axes=%s pos.z=%s reach=%s..%s", axes,
pos.get("z"), lens.get("reach_min"), lens.get("reach_max"))
ctx.check(axes is not None and axes & 4,
"Z is not referenced with a controller running: %s", axes)
ctx.check(isinstance(pos.get("z"), (int, float))
and lens.get("reach_min") is not None
and lens["reach_min"] <= pos["z"] <= lens["reach_max"],
"Z %s is outside the lens reach %s..%s", pos.get("z"),
lens.get("reach_min"), lens.get("reach_max"))
st, cam = fc.get("/cam/status")
ev["cam_status"] = st
ctx.log("GET /cam/status -> %s %s", st, cam)
ctx.check(st == 200 and isinstance(cam, dict) and "running" in cam, "GET /cam/status -> %s", st)
# The controller's published state, echoed only while a live GRBL
# controller runs (glowforge_status.c -> /run/forgefirm -> /status).
st, mode = fc.get("/mode")
if isinstance(mode, dict) and mode.get("mode") == "grbl" and mode.get("controller") == "running":
g = ctx.forgectrl.status().get("grbl") or {}
ev["grbl"] = g
ctx.log("/status grbl=%s", g)
ctx.check(isinstance(g.get("age_s"), (int, float)) and g["age_s"] < 30,
"/status grbl block missing or stale: %s", g)
rep = g.get("report") or {}
for key in ("state", "sender", "laser", "modals"):
ctx.check(key in rep, "/status grbl.report lacks %r", key)
ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"),
"grbl.report.laser carries no model: %s", rep.get("laser"))
ctx.check((rep.get("laser") or {}).get("curve"),
"grbl.report.laser carries no dose curve: %s", rep.get("laser"))
st, text = fc.get("/grbl/settings", raw=True)
ev["grbl_settings_status"] = st
ctx.check(st == 200 and b"$35=" in (text or b""),
"GET /grbl/settings -> %s without the $$ view", st)
# The $-settings persist in the data directory, never loose in /data.
nvs = "/data/forgefirm/EEPROM-glowforge.DAT"
ev["grbl_nvs"] = os.path.isfile(nvs)
ctx.check(os.path.isfile(nvs), "the controller's settings store is not at %s", nvs)
ctx.check(not os.path.exists("/data/EEPROM-glowforge.DAT"),
"a settings store remains at the top of /data")
else:
ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode)
# The dose-curve recorder's read surface answers in any mode.
st, cs = fc.get("/curve/status")
ev["curve_status"] = cs
ctx.check(st == 200 and isinstance(cs, dict) and cs.get("state") in
("idle", "waiting", "recording", "done", "failed"),
"GET /curve/status -> %s %s", st, cs)
st, text = fc.get("/curve/ladder.gcode", raw=True)
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
"GET /curve/ladder.gcode -> %s without the ladder", st)
-397
View File
@@ -1,397 +0,0 @@
"""image.* - the post-flash health of the running image (always-required)."""
import glob
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/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
("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 init ordering, 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 factory slots on the dev image only.")
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")
# 5. init ordering: controllers stop before the daemon
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*"))
ev["rc6_kill"] = k
kg = [x for x in k if "grblhal" in x]
kf = [x for x in k if x.endswith("forgectrl")]
ctx.log("rc6.d: %s", " ".join(k))
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links")
ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[0])
# 6. logging lever present, no userspace watchdog daemon
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
ev["logging_init"] = logging
ctx.check(logging, "no ForgeFIRM logging/logrotate init script")
ctx.check(not os.path.exists("/etc/init.d/watchdog"), "a userspace watchdog init script is present")
# 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)
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 alone mounts the factory slots under /factory.
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"))
rcs = _read("/etc/default/rcS", "") or ""
ctx.check("ROOTFS_READ_ONLY=yes" in rcs.splitlines(), "/etc/default/rcS lacks ROOTFS_READ_ONLY=yes")
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))
else:
ctx.check(not factory, "a release image mounts the factory slots: %s", factory)
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))
@test("image.license-bundle", title="The license texts travel with the image",
subsystem="image", kind="auto", est_min=1,
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/login.html")],
requires=["forgectrl.panel-serves"],
description="The image carries /usr/share/forgefirm/licenses.tar.gz, a gzip tar with "
"the license manifest (every installed package with its license) and the "
"license texts, and no loose common-licenses tree; the panel serves the "
"bundle, the manifest as text, and the Licenses page every footer links, "
"each without a login.")
def license_bundle(ctx):
import io
import tarfile
fc = ctx.forgectrl
ev = ctx.evidence
path = "/usr/share/forgefirm/licenses.tar.gz"
ctx.check(os.path.isfile(path), "%s is missing", path)
ctx.check(not os.path.isdir("/usr/share/common-licenses"),
"the loose common-licenses tree is still on the rootfs")
size = os.path.getsize(path)
ev["bundle_bytes"] = size
ctx.log("%s: %d bytes", path, size)
with tarfile.open(path, "r:gz") as t:
names = t.getnames()
ctx.check("common-licenses/license.manifest" in names, "no license.manifest in the bundle")
manifest = t.extractfile("common-licenses/license.manifest").read().decode("utf-8", "replace")
packages = manifest.count("PACKAGE NAME: ")
texts = sum(1 for n in names if "/generic_" in n and not n.endswith("/"))
ev["packages"] = packages
ev["generic_texts"] = texts
ctx.log("manifest lists %d packages; %d generic license entries", packages, texts)
ctx.check(packages >= 40, "only %d packages in the manifest", packages)
ctx.check(texts >= 5, "only %d generic license entries", texts)
for name in ("forgectrl", "grblhal-glowforge", "kernel-module-glowforge"):
ctx.check("RECIPE NAME: %s\n" % name in manifest, "%s is not in the manifest", name)
st, body = fc.get("/system/licenses/manifest", raw=True)
ctx.check(st == 200, "GET /system/licenses/manifest -> %s", st)
ctx.check(isinstance(body, (bytes, bytearray)) and b"PACKAGE NAME: " in body,
"the served manifest is not the manifest")
st, body = fc.get("/system/licenses", raw=True)
ctx.check(st == 200, "GET /system/licenses -> %s", st)
ctx.check(isinstance(body, (bytes, bytearray)) and len(body) == size,
"the served bundle is %s bytes, the file %d", len(body) if body else None, size)
with tarfile.open(fileobj=io.BytesIO(bytes(body)), mode="r:gz") as t:
ctx.check("common-licenses/license.manifest" in t.getnames(),
"the served bundle holds no manifest")
st, body = fc.get("/licenses", raw=True)
ctx.check(st == 200, "GET /licenses -> %s", st)
text = bytes(body).decode("utf-8", "replace") if body else ""
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
"the Licenses page lacks the download link or the manifest")
-919
View File
@@ -1,919 +0,0 @@
"""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"])
File diff suppressed because it is too large Load Diff
-306
View File
@@ -1,306 +0,0 @@
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
the routing path (emitters, relays, rendered rules); the level settings."""
import gzip
import io
import os
import re
import tarfile
import time
from ..catalog import test
from .. import hw
def _read(path, default=None):
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return default
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
("forgectrl", "src/main.c")]
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
kind="auto", est_min=1,
covers=_LOG_COVERS, requires=["forgectrl.auth"],
description="/logs lists the loggers with their levels and files, /logs/tail returns the "
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
"bundle that contains neither the panel token nor the camera key.")
def tree_tail_export(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/logs")
ctx.log("GET /logs -> %s", st)
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
loggers = body.get("loggers") or []
names = [l.get("name") for l in loggers] if isinstance(loggers, list) else list(loggers)
ev["loggers"] = names
ctx.log("loggers: %s", names)
ctx.check("forgectrl" in names, "/logs lacks the forgectrl logger: %s", names)
st, tail = fc.get("/logs/tail", params={"name": "forgectrl", "lines": "20"})
ctx.log("GET /logs/tail?name=forgectrl -> %s", st)
ctx.check(st == 200, "GET /logs/tail -> %s %s", st, tail if isinstance(tail, dict) else "")
st, tail = fc.get("/logs/tail", params={"name": "no-such-logger"})
ctx.check(st == 404, "unknown logger -> %s, expected 404", st)
# the sanitizer walks every log file; a full tree takes tens of
# seconds on the target, so this call gets its own client timeout
slow = hw.Forgectrl(token=fc.token, timeout=300.0)
t0 = time.time()
st, data = slow.post("/logs/export", raw=True)
ev["export_s"] = round(time.time() - t0, 1)
ctx.log("POST /logs/export -> %s (%d bytes, %.1f s)", st, len(data) if data else 0, ev["export_s"])
ctx.check(st == 200, "POST /logs/export -> %s", st)
ev["export_bytes"] = len(data)
try:
raw = gzip.decompress(data)
tf = tarfile.open(fileobj=io.BytesIO(raw))
members = tf.getnames()
except (OSError, tarfile.TarError, EOFError) as e:
ctx.fail("export is not a readable tar.gz: %s", e)
ev["members"] = len(members)
ctx.log("bundle: %d members, e.g. %s", len(members), members[:5])
ctx.check(members, "empty bundle")
ctx.check(any(m.endswith("README.txt") for m in members), "sanitized bundle lacks README.txt")
# the two secrets the sanitizer knows by value and a bundle could
# carry: the panel token and the camera key (a URL parameter that
# lands in sender logs)
secrets = {}
if fc.token:
secrets["panel token"] = fc.token
st, body = fc.get("/system/camera-key")
if st == 200 and isinstance(body, dict) and body.get("key"):
secrets["camera key"] = body["key"]
ctx.check("camera key" in secrets, "GET /system/camera-key -> %s: no key to check for", st)
contents = [(m.name, tf.extractfile(m).read()) for m in tf.getmembers() if m.isfile()]
for what, value in secrets.items():
leaked = [name for name, content in contents if value.encode() in content]
ev[what.replace(" ", "_") + "_leaks"] = leaked
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
# The routing test proves the whole path every logger takes: emitter (or
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
# logger's own file in the ff_line format. It stands for the emitters in
# every component (fflog in forgectrl and grblHAL, the SysLogHandler in the
# Python apps), the supervisor's per-controller relay, the daemon's fifo
# relay in its init script, and the render step.
_ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/super.c"),
("forgectrl", "init/**"),
("grblhal-glowforge", "src/fflog.*"), ("grblhal-glowforge", "src/main.c"),
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
("forgefirm-app", "forgefirm-app/gfhome.py")]
LOGS_ROOT = "/data/log/forgefirm"
LOGGERS = ("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
def _tail_lines(path, n=400):
try:
with open(path, "rb") as f:
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - 256 * 1024))
data = f.read()
except OSError:
return []
return data.decode("utf-8", "replace").splitlines()[-n:]
def _wait_line(path, needle, timeout=6.0):
"""Poll a log file for a line containing needle; the matched line or None."""
deadline = time.time() + timeout
while time.time() < deadline:
for line in _tail_lines(path, 200):
if needle in line:
return line
time.sleep(0.25)
return None
@test("logs.routing", title="Log routing, relays, and the rendered rules", subsystem="logs",
kind="auto", est_min=1,
covers=_ROUTING_COVERS, requires=["logs.tree-tail-export"],
description="rsyslog is the only logger (no busybox syslogd/klogd), the rules rendered from "
"the settings and the effective-levels record exist and agree with /logs, every "
"logger has its directory, the daemon's own emitter and the per-controller "
"`logger` relay both land in the right file in the ff_line format, a stray "
"program's line goes to system/ and nowhere else, kernel lines reach kernel/, "
"and nothing of ours is written outside the tree.")
def routing(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
# 1. one logger daemon
rs = hw.pidof("rsyslogd")
ev["rsyslogd_pids"] = rs
ctx.check(rs, "rsyslogd is not running")
for legacy in ("syslogd", "klogd"):
pids = hw.pidof(legacy)
ctx.check(not pids, "busybox %s is running (%s) next to rsyslog", legacy, pids)
ctx.log("rsyslogd %s; no syslogd/klogd", rs)
# 2. the render step: rules + effective levels, consistent with what
# the API reports as effective
ctx.check(os.path.isfile("/data/forgefirm/rsyslog-forgefirm.conf"),
"rendered rules missing: /data/forgefirm/rsyslog-forgefirm.conf")
rules = _read("/data/forgefirm/rsyslog-forgefirm.conf") or ""
for name in LOGGERS:
ctx.check(("# %s:" % name) in rules, "rendered rules carry no block for %s", name)
ctx.check(os.path.isfile("/var/run/forgefirm-loglevels"), "effective-levels record missing")
st, body = fc.get("/logs")
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
ctx.check(body.get("effective_known") is True, "/logs does not know the effective levels")
eff = {}
for l in body.get("loggers") or []:
eff[l["name"]] = (l.get("effective_disk"), l.get("effective_remote"))
ev["effective"] = eff
ctx.log("effective levels: %s", eff)
ctx.check(set(eff) == set(LOGGERS), "loggers reported: %s", sorted(eff))
levels = _read("/var/run/forgefirm-loglevels") or ""
for name, (disk, _remote) in eff.items():
ctx.check(("log_%s_disk=%s" % (name, disk)) in levels,
"effective record and /logs disagree on %s (%s)", name, disk)
# 3. the tree
for name in LOGGERS:
ctx.check(os.path.isdir(os.path.join(LOGS_ROOT, name)), "no directory for %s", name)
# 4. the daemon's own emitter (fflog): a line from the running pid, if
# its level admits info
pids = hw.pidof("forgectrl")
ctx.check(pids, "forgectrl is not running")
disk_rank = _SEV_RANK.get(eff.get("forgectrl", ("info", "off"))[0], 6)
fpath = os.path.join(LOGS_ROOT, "forgectrl", "forgectrl.log")
if disk_rank >= 6:
own = [l for l in _tail_lines(fpath, 2000)
if any((" forgectrl[%d] " % p) in l for p in pids)]
ev["own_lines"] = len(own)
ctx.log("forgectrl[%s] lines in its file: %d", pids, len(own))
ctx.check(own, "no line from the running daemon in %s (emitter path broken?)", fpath)
m = _LINE_RE.match(own[-1])
ctx.check(m and m.group("prog") == "forgectrl", "not in the ff_line format: %r", own[-1])
else:
ctx.log("forgectrl disk level %s: own-emitter check skipped", eff["forgectrl"][0])
# 5. the relay path: a `logger` probe under a controller's name lands
# in that controller's file with the ff_line format
nonce = "forgetest-routing-%d" % int(time.time())
for name in ("grblhal", "gfhome"):
rank = _SEV_RANK.get(eff.get(name, ("info", "off"))[0], 6)
if rank < 3:
ctx.log("%s disk level off: relay probe skipped", name)
continue
rc, out = hw.run(["logger", "-t", name, "-p", "daemon.err", "%s %s" % (nonce, name)])
ctx.check(rc == 0, "logger -t %s failed: %s", name, out)
path = os.path.join(LOGS_ROOT, name, name + ".log")
line = _wait_line(path, nonce)
ev["probe_" + name] = line
ctx.check(line, "%s probe did not reach %s", name, path)
m = _LINE_RE.match(line)
ctx.check(m and m.group("prog") == name and m.group("sev") == "ERR",
"%s probe line not in the ff_line format: %r", name, line)
ctx.log("%s: %s", name, line)
# a probe under another name must NOT leak into these files
rc, out = hw.run(["logger", "-t", "forgetest-stray", "-p", "daemon.err", "%s stray" % nonce])
time.sleep(1.5)
for name in ("grblhal", "gfhome", "forgectrl"):
path = os.path.join(LOGS_ROOT, name, name + ".log")
ctx.check(not any((nonce + " stray") in l for l in _tail_lines(path, 100)),
"a stray program's line landed in %s", path)
sys_rank = _SEV_RANK.get(eff.get("system", ("info", "off"))[0], 6)
if sys_rank >= 3:
line = _wait_line(os.path.join(LOGS_ROOT, "system", "system.log"), nonce + " stray")
ctx.check(line, "the stray program's line did not reach system/")
# 6. relay processes: the supervisor spawns one `logger` per controller
# (its stdin is the controller's output pipe); the daemon's init
# script keeps one on the fifo
st, mode = fc.get("/mode")
relays = hw.pidof("logger")
ev["logger_relays"] = relays
ev["controller"] = mode.get("controller") if isinstance(mode, dict) else None
ctx.log("logger relays: %s (controller %s)", relays, ev["controller"])
if isinstance(mode, dict) and mode.get("controller") == "running":
ctx.check(relays, "controller running but no `logger` relay process")
if os.path.exists("/var/run/forgectrl.stderr"):
ctx.check(len(relays) >= 2 or ev["controller"] != "running",
"fifo relay present but only %s logger process(es)", relays)
# 7. the kernel reaches kernel/ (unless filtered off)
if _SEV_RANK.get(eff.get("kernel", ("info", "off"))[0], 6) >= 6:
klines = _tail_lines(os.path.join(LOGS_ROOT, "kernel", "kernel.log"), 50)
ev["kernel_lines"] = len(klines)
ctx.check(klines and any(" kernel[" in l for l in klines), "no kernel lines in kernel/")
# 8. nothing outside the tree
for stray in ("/data/forgectrl.log", "/data/gfcloud.log", "/data/gfhome.log",
"/data/log/gfcloud", "/data/log/gfhome", "/var/log/messages"):
ctx.check(not os.path.exists(stray), "pre-syslog log path still present: %s", stray)
ctx.log("no ForgeFIRM log outside %s", LOGS_ROOT)
@test("logs.level-settings", title="Log level and remote-target settings", subsystem="logs",
kind="auto", est_min=1,
covers=[("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/settings.*")],
requires=["logs.tree-tail-export", "forgectrl.settings-bounds"],
description="A log level outside off..debug, a bad remote port, a bad protocol and a bad "
"server name are refused (400); a valid remote level is accepted and /logs shows "
"it as configured but not effective (pending_reboot); the setting is restored to "
"its prior value.")
def level_settings(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
for key, val in (("log_grblhal_disk", "verbose"), ("log_kernel_remote", "7"),
("syslog_port", "70000"), ("syslog_proto", "tls"),
("syslog_server", "bad host name")):
st, body = fc.post("/settings", data={key: val})
ev["refused %s=%s" % (key, val)] = st
ctx.log("POST /settings %s=%s -> %s", key, val, st)
ctx.check(st == 400, "%s=%s -> %s, expected 400", key, val, st)
st, before = fc.get("/logs")
ctx.check(st == 200 and isinstance(before, dict), "GET /logs -> %s", st)
prior = {l["name"]: l for l in before.get("loggers") or []}
ctx.check("system" in prior, "/logs lacks the system logger")
was = prior["system"].get("remote") or "off"
was_pending = bool(before.get("pending_reboot"))
new = "warning" if was != "warning" else "error"
ev["system_remote_before"] = was
ev["pending_before"] = was_pending
try:
st, body = fc.post("/settings", data={"log_system_remote": new})
ctx.log("POST /settings log_system_remote=%s -> %s", new, st)
ctx.check(st == 200, "valid level refused: %s", st)
st, after = fc.get("/logs")
now = {l["name"]: l for l in after.get("loggers") or []}
ctx.check(now["system"].get("remote") == new, "configured level reads %r", now["system"].get("remote"))
ctx.check(now["system"].get("effective_remote") == prior["system"].get("effective_remote"),
"effective level changed without a reboot")
ctx.check(after.get("pending_reboot") is True, "pending_reboot not raised by a level change")
ctx.log("configured %s, effective %s, pending_reboot %s",
now["system"].get("remote"), now["system"].get("effective_remote"), after.get("pending_reboot"))
finally:
# restore: a value that was stored goes back as a value; a default
# is restored by clearing the key (a query parameter - empty
# values never reach the form body)
stored = fc.settings().get("log_system_remote", "")
if was == "off" and stored in ("", None, new):
st, body = fc.post("/settings", params={"log_system_remote": ""})
else:
st, body = fc.post("/settings", data={"log_system_remote": was})
ctx.log("restore log_system_remote -> %s", st)
st, final = fc.get("/logs")
fin = {l["name"]: l for l in final.get("loggers") or []}
ctx.check(fin["system"].get("remote") == was, "system remote level not restored (%r)", fin["system"].get("remote"))
ctx.check(bool(final.get("pending_reboot")) == was_pending,
"pending_reboot did not return to %s after the restore", was_pending)
File diff suppressed because it is too large Load Diff
-119
View File
@@ -1,119 +0,0 @@
"""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"),
("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. 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)
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)
finally:
try:
os.unlink(staged)
except OSError:
pass
shutil.rmtree(work, ignore_errors=True)
-339
View File
@@ -1,339 +0,0 @@
/*
* app.js - forgetest: the acceptance page
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*
* Acceptance tab + bench tab. State comes from GET /state, polled every
* 2 s when the machine is idle and every second during a run; the catalog
* and the bench listing are fetched once per tab and refreshed after a
* run finishes.
*
* Rows, prompt buttons and tool entries are built once and thereafter
* only updated in place. A poll that rebuilt them would swallow the click
* it landed on: the button that took the mousedown would be gone before
* the mouseup, so no click event would ever be raised. Every action also
* grays its control out on the press and pulls the next poll forward, so
* the page answers the operator rather than the timer. The help popovers
* (help.js) sit on static elements only, so no rebuild ever orphans one.
*
* TOKEN is the bearer token page.py splices in when it serves the page.
*/
var TOKEN='__TOKEN__';
var state=null, catalog=null, catalogHash=null, bench=null, tab='acceptance', openDetails={};
var lastRunKey=null, stateEtag=null, curPrompt=null, promptKey=null, abortSent=false;
var pollTimer=null, polling=false, pending=0, pendingId=null, rowMsg={};
var rowEls=null, groupsKey=null, benchEls=null, benchKey=null, benchNeedsRefresh=false;
var PENDING_MS=6000;
/* A poll that never answers must not wedge the loop: it times out
and the next one is scheduled as usual. Actions carry no timeout,
because a POST the server did act on must not read as a failure. */
var POLL_TIMEOUT_MS=20000;
var ignoreReq=false;try{ignoreReq=window.localStorage.getItem('forgetest.ignoreReq')==='1'}catch(e){}
function $(id){return document.getElementById(id)}
function nowMs(){return (new Date()).getTime()}
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]})}
/* Touch the DOM only where the value really changed. Replacing a node
under the operator's finger loses the click: the button that took the
mousedown is gone before the mouseup, so no click event is ever
raised, and the press does nothing. Nothing on a poll may rebuild a
control that is only sitting there unchanged. */
function setHtml(e,h){if(e&&e.__h!==h){e.__h=h;e.innerHTML=h}}
function setText(e,t){if(e&&e.__t!==t){e.__t=t;e.textContent=t}}
function setDis(e,d){if(e&&e.disabled!==d)e.disabled=d}
function setProp(e,k,v){if(e&&e[k]!==v)e[k]=v}
function api(method,path,body,cb,hdrs,timeoutMs){var x=new XMLHttpRequest();x.open(method,path,true);
if(timeoutMs)x.timeout=timeoutMs;
x.setRequestHeader('X-ForgeFIRM-Token',TOKEN);
if(hdrs){for(var k in hdrs){if(hdrs.hasOwnProperty(k))x.setRequestHeader(k,hdrs[k])}}
if(body!==undefined&&body!==null){x.setRequestHeader('Content-Type','application/json')}
x.onreadystatechange=function(){if(x.readyState!==4)return;var d=null;try{d=JSON.parse(x.responseText)}catch(e){d={error:x.responseText}}cb(x.status,d,x)};
x.send(body===undefined||body===null?null:JSON.stringify(body))}
/* The state poll. It carries the last ETag, so an unchanged state costs
a 304 and no re-render; a run gets a faster tick, and every action
pulls the next poll forward instead of waiting out the interval. */
function pollDelay(){return (state&&(state.running||batchActive()))?1000:2000}
function schedule(ms){if(pollTimer)window.clearTimeout(pollTimer);pollTimer=window.setTimeout(poll,ms)}
function kick(){schedule(120)}
function poll(){if(polling){schedule(150);return}polling=true;
/* While an action is still in flight the state is asked for in full:
a 304 would skip the render that releases the grayed-out buttons. */
var h=(stateEtag&&!pending)?{'If-None-Match':stateEtag}:null;
api('GET','/state',null,function(s,d,x){polling=false;
if(s===200){stateEtag=x.getResponseHeader('ETag');state=d;
if(!catalog||catalogHash!==d.catalog_hash){loadCatalog(render)}else{render()}}
schedule(pollDelay())},h,POLL_TIMEOUT_MS)}
function showTab(t){tab=t;closeHelp();$('tab-acceptance').className=t==='acceptance'?'on':'';$('tab-bench').className=t==='bench'?'on':'';
$('pane-acceptance').style.display=t==='acceptance'?'':'none';$('pane-bench').style.display=t==='bench'?'':'none';
if(t==='bench'&&!bench)loadBench()}
function setMsg(id,txt,err){setHtml($(id),txt?"<div class='"+(err?'err':'msg')+"'>"+esc(txt)+"</div>":'')}
function loadCatalog(cb){api('GET','/catalog',null,function(s,d){if(s===200){catalog=d.tests;catalogHash=d.catalog_hash;if(cb)cb()}})}
function loadBench(){api('GET','/bench',null,function(s,d){if(s===200){bench=d;benchKey=null;renderBench()}})}
function setIgnoreReq(on){ignoreReq=!!on;try{window.localStorage.setItem('forgetest.ignoreReq',ignoreReq?'1':'0')}catch(e){}
$('ignreq').checked=ignoreReq;setHtml($('ignreqon'),ignoreReq?"<span class='on'>ON - prerequisites are not enforced</span>":'');
if(state&&catalog)renderGroups()}
/* A start already sent but not yet seen in the state counts as busy, so
the buttons gray out on the click rather than on the next poll. The
window is capped in case the answer never arrives. A queue holds the
machine between its tests as well as during them. */
function batchActive(){return !!(state&&state.batch&&!state.batch.finished)}
function isBusy(){if(state&&(state.running||batchActive()))return true;
return !!(pending&&(nowMs()-pending)<PENDING_MS)}
function fmtTs(t){return t?t.replace('T',' ').replace('Z',' UTC'):'-'}
function render(){if(!state||!catalog)return;
if(state.running||batchActive()){pending=0;pendingId=null}
var m=state.manifest||{};setText($('hdrver'),(m.version||'?'));setText($('hdrsub'),m.image||'');
var a=$('auth');setText(a,'Release authorized: '+(state.authorized?'YES':'NO'));setProp(a,'className','auth'+(state.authorized?' yes':''));
var c=state.campaign,cn=state.counts||{};var b='';
b+='<b>'+cn.satisfied+'</b> of <b>'+cn.total+'</b> satisfied ('+cn.inherited+' inherited), <b>'+cn.required+'</b> required<br>';
b+=c?('campaign <span class="mono">'+esc(c.id)+'</span> opened '+esc(fmtTs(c.ts))):('no open campaign'+(state.closed_by?(' (last one closed by '+esc(state.closed_by)+')'):'')+' - the first Start opens one');
b+='<br>manifest identity <span class="mono">'+esc((m.sha||'').slice(0,16))+'</span> &middot; catalog <span class="mono">'+esc((state.catalog_hash||'').slice(0,12))+'</span>';
if(state.log_corrupt)b+='<br><span class="b-bad">'+state.log_corrupt+' corrupt log line(s) skipped</span>';
setHtml($('banner'),b);
var inv=state.invalidate;setHtml($('invnote'),inv?"<div class='note'>Full campaign required since "+esc(fmtTs(inv.ts))+": "+esc(inv.reason)+"</div>":'');
renderQueue();renderGroups();renderRun();renderSteps();if(bench)renderBench()}
/* The two queues: what each would run now, and how the running one is
getting on. Built from the state, so a reload picks the queue back up
exactly where it is - the queue lives in the runner, not in this tab. */
var QUEUES=[['unattended','Unattended'],['attended','Operator and live']];
function renderFixture(){var f=state.fixture,e=$('fixture');if(!e)return;
if(!f){setHtml(e,'');return}
var ch=(f.channels||[]).filter(function(c){return c!=='button'||f.button_enabled});
setHtml(e,'Bench fixture <b>'+esc(f.hostname)+'</b> at '+esc(f.ip||'?')+' (v'+esc(f.version||'?')+') covers <b>'+
(ch.length?esc(ch.join(', ')):'nothing')+'</b>'+
((f.channels||[]).indexOf('button')>=0&&!f.button_enabled?' (button disabled: enable jumper out)':'')+
'; arm press: '+(f.arm_press?'<b>the fixture</b>':'the operator'))}
function renderQueue(){var av=state.batch_available||{},b=state.batch,busy=isBusy();
renderFixture();
QUEUES.forEach(function(p){var e=$('q-'+p[0]);if(!e)return;
var ids=av[p[0]]||[];
setText(e,ids.length?(p[1]+' ('+ids.length+')'):(p[1]+' (none left)'));
setDis(e,busy||!ids.length);
setProp(e,'title',!ids.length?('nothing left: every '+p[0]+' test is satisfied')
:(busy?'a run is in progress':('in order: '+ids.join(', '))))});
setDis($('q-stop'),!batchActive()||!!(b&&b.stopping));
if(!b){setHtml($('qstate'),'');return}
var total=b.order.length||1,ok=0,bad=0;
b.done.forEach(function(x){if(x.result==='PASS')ok++;else bad++});
function seg(cls,n){return n?("<i class='"+cls+"' style='width:"+(100*n/total)+"%'></i>"):''}
var h="<div class='qbar'>"+seg('ok',ok)+seg('bad',bad)+seg('skip',b.skipped.length)+
(b.current?seg('now',1):'')+"</div><div class='qline'><b>"+esc(b.group)+"</b> queue, opened "+
esc(fmtTs(b.ts))+" &middot; ";
h+=b.finished?('finished '+esc(fmtTs(b.finished))):(b.current?('running <b>'+esc(b.current)+'</b>'):
(b.stopping?'stopping':'starting'));
h+=" &middot; <b>"+ok+"</b> passed, <b>"+bad+"</b> not, <b>"+b.skipped.length+
"</b> skipped, <b>"+b.pending.length+"</b> waiting";
if(b.stopped)h+="<br><span class='req'>stopped: "+esc(b.stopped)+"</span>";
if(b.skipped.length)h+="<br>skipped: "+esc(b.skipped.map(function(x){return x.test+' ('+x.reason+')'}).join('; '));
if(b.pending.length)h+="<br>waiting: <span class='tid'>"+esc(b.pending.join(', '))+"</span>";
setHtml($('qstate'),h+"</div>")}
/* The rows are built once for a given catalog and then only updated in
place: a poll never rewrites the table, so a Start button survives the
press that is landing on it. */
function renderGroups(){var ids=[];catalog.forEach(function(t){ids.push(t.id)});
var sig=catalogHash+'|'+ids.join(',');
if(sig!==groupsKey){groupsKey=sig;buildGroups()}
updateGroups()}
var COLS="<colgroup><col><col class='k'><col class='s'><col class='l'><col class='a'></colgroup>";
function buildGroups(){var groups={},order=[];
catalog.forEach(function(t){if(!groups[t.subsystem]){groups[t.subsystem]=[];order.push(t.subsystem)}groups[t.subsystem].push(t)});
var h='';
order.forEach(function(g){h+="<div class='card grp'><h2>"+esc(g)+"</h2><table>"+COLS+"<tr><th>Test</th><th>Kind</th><th>Status</th><th>Last result</th><th></th></tr>";
groups[g].forEach(function(t){var d=esc(t.id);var tags='';
if(t.always)tags+="<span class='tag core'>core</span>";tags+="<span class='tag "+esc(t.kind)+"'>"+esc(t.kind)+"</span>";
if(t.hardware==='takeover')tags+="<span class='tag takeover'>takeover</span>";
if(t.mode)tags+="<span class='tag mode'>"+esc(t.mode)+"</span>";
var det="<div class='details"+(openDetails[t.id]?' on':'')+"' id='det-"+d+"'>"+
(t.description?("<div class='dsc'>"+esc(t.description)+"</div>"):'')+
(t.steps&&t.steps.length?"<b>Operator steps:</b><ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>":'')+
(t.actions&&t.actions.length?"<b>Machine actions:</b> "+esc(t.actions.join(', '))+"<br>":'')+
"<b>Requires:</b> "+esc((t.requires||[]).join(', ')||'-')+"<br><b>Covers:</b> "+esc((t.covers||[]).map(function(c){return c[0]+':'+c[1]}).join(', ')||'-')+
"<br><span id='detdyn-"+d+"'></span></div>";
h+="<tr><td><div class='tsel' title='show what this test asks of you' onclick='selectTest(\""+d+"\")'>"+esc(t.title)+"</div><div class='tid'>"+d+" <a href='#' onclick='toggleDet(\""+d+"\");return false'>details</a></div>"+
"</td><td>"+tags+"</td>"+
"<td><div id='st-"+d+"'></div><div id='unmet-"+d+"'></div><div id='note-"+d+"'></div></td>"+
"<td id='last-"+d+"'></td>"+
"<td><button class='btn btn-sm btn-primary' id='btn-"+d+"' onclick='startTest(\""+d+"\")'>Start</button></td></tr>"+
"<tr class='detrow'><td colspan='5'>"+det+"</td></tr>"});
h+="</table></div>"});
$('groups').innerHTML=h;rowEls={};
catalog.forEach(function(t){rowEls[t.id]={st:$('st-'+t.id),last:$('last-'+t.id),btn:$('btn-'+t.id),
unmet:$('unmet-'+t.id),note:$('note-'+t.id),detdyn:$('detdyn-'+t.id)}})}
function updateGroups(){if(!rowEls||!state)return;var busy=isBusy();
var queued={};if(batchActive()){(state.batch.pending||[]).forEach(function(x){queued[x]=1})}
catalog.forEach(function(t){var e=rowEls[t.id];if(!e)return;var s=state.tests[t.id]||{};
var st;
if(pendingId===t.id&&isBusy()&&!state.running){st="<span class='st running'>starting&hellip;</span>"}
else{st="<span class='st "+esc(s.status)+"'>"+esc(s.status||'none')+"</span>";
if(s.required&&s.status!=='running')st+="<br><span class='req'>required: "+esc(s.reason)+"</span>"}
if(queued[t.id])st+="<br><span class='tag queued'>queued</span>";
setHtml(e.st,st);
var last=s.last?(esc(s.last.result)+' '+esc(fmtTs(s.last.ts))):'-';
if(s.status==='inherited'&&s.origin)last+="<br><span class='tid'>from "+esc(s.origin.campaign)+" on "+esc(s.origin.image)+"</span>";
setHtml(e.last,last);
var unmet=s.requires_met===false;var can=!busy&&(!unmet||ignoreReq);
setDis(e.btn,!can);
setProp(e.btn,'title',busy?'a run is in progress':(unmet?((ignoreReq?'prerequisites overridden - needs: ':'needs: ')+(s.missing_requires||[]).join(', ')):''));
setProp(e.unmet,'className','req'+(ignoreReq?' over':''));
setHtml(e.unmet,unmet?((ignoreReq?'unmet: ':'needs: ')+esc((s.missing_requires||[]).join(', '))):'');
setHtml(e.note,rowMsg[t.id]?"<div class='err'>"+esc(rowMsg[t.id])+"</div>":'');
setHtml(e.detdyn,"<b>Fingerprint:</b> <span class='mono'>"+esc((s.fingerprint||'').slice(0,16))+"</span> &middot; est. "+esc(t.est_min)+" min"+
(s.last?"<br><a href='/result?test="+encodeURIComponent(t.id)+"&ts="+encodeURIComponent(s.last.ts)+"'>last result record</a>":''))})}
function toggleDet(id){openDetails[id]=!openDetails[id];var e=$('det-'+id);if(e)e.className='details'+(openDetails[id]?' on':'')}
function renderRun(){var r=state.running||state.last_run;var key=r?(r.kind+':'+r.id+':'+r.started):null;
if(key!==lastRunKey)abortSent=false;
document.querySelector('main').classList.toggle('running',!!state.running);
if(!r){setText($('runhead'),'idle');setText($('log'),'');$('prompt').style.display='none';$('notice').style.display='none';
curPrompt=null;promptKey=null;setDis($('abortbtn'),true);setText($('runtitle'),'Run');lastRunKey=null;return}
setText($('runtitle'),(r.kind==='bench'?'Bench: ':'Test: ')+r.title);
var hd=esc(r.id)+' &middot; started '+esc(fmtTs(r.started))+' &middot; '+r.elapsed_s+' s';
if(r.finished){hd+=' &middot; <b class="st '+(r.finished.result==='PASS'||r.finished.result==='OK'?'pass':'fail')+'">'+esc(r.finished.result)+'</b>'+(r.finished.message?' - '+esc(r.finished.message):'')}
else if(r.aborting){hd+=' &middot; <b class="st stale">aborting</b>'}
setHtml($('runhead'),hd);
var lg=$('log');var txt=(r.dropped?('... '+r.dropped+' earlier lines dropped\n'):'')+r.log.join('\n');
if(lg.__t!==txt){var atBottom=lg.scrollTop+lg.clientHeight>=lg.scrollHeight-20;
lg.__t=txt;lg.textContent=txt;if(atBottom||key!==lastRunKey)lg.scrollTop=lg.scrollHeight}
lastRunKey=key;
var nt=(r.notice&&!r.finished)?r.notice.text:'';
setText($('notice'),nt);$('notice').style.display=nt?'':'none';
/* The prompt buttons are rebuilt only when the prompt itself changes -
they are the ones the operator stares at before pressing. */
if(r.prompt&&!r.finished){var pk=r.prompt.id+'|'+r.prompt.question+'|'+r.prompt.options.length+':'+r.prompt.options.join(',');
if(pk!==promptKey){promptKey=pk;curPrompt=r.prompt;
$('promptq').textContent=r.prompt.question;var pb='';
r.prompt.options.forEach(function(o,i){pb+="<button class='btn btn-sm btn-primary' onclick='answerIdx("+i+")'>"+esc(o)+"</button>"});
$('promptb').innerHTML=pb;$('promptb').__h=pb;$('prompt').style.display=''}}
else if(promptKey!==null){promptKey=null;curPrompt=null;$('prompt').style.display='none'}
setDis($('abortbtn'),!!r.finished||abortSent||!!r.aborting);
if(r.finished&&tab==='bench'&&r.kind==='bench'&&benchNeedsRefresh){benchNeedsRefresh=false;loadBench()}}
function findTest(id){for(var i=0;i<catalog.length;i++)if(catalog[i].id===id)return catalog[i];return null}
/* What the operator will be asked to do, shown before it is asked: the
running test's steps for the whole run, the queue's attended tests
before the first one starts, or the test the operator clicked on. The
prompts and notices that follow are these steps, taken in turn. */
var selected=null;
function selectTest(id){selected=(selected===id)?null:id;renderSteps()}
function stepsOf(t,head){var h='';
var acts=(t.actions||[]);
if(head)h+="<div class='sub'>"+head+"</div>";
if(t.kind==='live')h+="<div>This test fires the laser: eye protection, fire watch, exhaust, scrap under the head.</div>";
if(acts.length)h+="<div>Machine actions: <b>"+esc(acts.join(', '))+"</b> (a standing instruction tells you when; the test watches the machine for it)</div>";
if(t.steps&&t.steps.length)h+="<ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>";
return h}
function renderSteps(){var e=$('steps');if(!e||!catalog)return;var h='',r=state.running;
if(r&&r.kind==='test'){var t=findTest(r.id);
if(t&&((t.steps&&t.steps.length)||(t.actions&&t.actions.length)||t.kind==='live'))h="<div class='sh'>What you will do</div>"+stepsOf(t,null)}
else if(batchActive()&&state.batch.pending&&state.batch.pending.length){var parts=[];
state.batch.pending.forEach(function(id){var t=findTest(id);if(t&&t.kind!=='auto')parts.push(stepsOf(t,esc(id)))});
if(parts.length)h="<div class='sh'>Coming up in this queue</div>"+parts.join('')}
else if(selected&&!r){var t=findTest(selected);
if(t)h="<div class='sh'>Before you start "+esc(t.id)+"</div>"+(t.kind==='auto'?"<div class='auto'>Nothing: this test needs nobody at the machine.</div>":stepsOf(t,null))}
setHtml(e,h);e.style.display=h?'':'none'}
function startTest(id){if(isBusy())return;var t=findTest(id);var body={test:id};
if(ignoreReq)body.ignore_requires=true;
if(t&&t.kind==='live'){if(!confirmLive())return;body.ack_live=true}
pending=nowMs();pendingId=id;rowMsg={};updateGroups();
setMsg('actmsg','starting '+id+'...');
api('POST','/start',body,function(s,d){
if(s!==200){pending=0;pendingId=null;rowMsg[id]=d.message||d.error;updateGroups()}
setMsg('actmsg',d.message||d.error,s!==200);kick()})}
/* The live-laser acknowledgment stays a blocking dialog on purpose: it
is the one thing on this page that must not be dismissed by a stray
click or a poll. */
function confirmLive(live){return window.confirm(
(live?('LIVE LASER QUEUE.\n\nThese fire the laser:\n - '+live.join('\n - ')+'\n'):'LIVE LASER TEST.\n')+
'\nConfirm before starting:\n - eye protection on, everyone in the room\n - fire watch present, extinguisher at hand\n - exhaust running, lid closed, scrap in place\n - you will press the physical button to arm when prompted\n\n'+
(live?'Start the queue?':'Start the test?'))}
/* A queue takes the machine for a long stretch, so both what it will run
and the acknowledgment it needs are put in front of the operator once,
before anything starts. */
function startBatch(group){if(isBusy())return;
var ids=(state&&state.batch_available&&state.batch_available[group])||[];
if(!ids.length)return;
var body={group:group};if(ignoreReq)body.ignore_requires=true;
var live=[];catalog.forEach(function(t){if(t.kind==='live'&&ids.indexOf(t.id)>=0)live.push(t.id)});
if(live.length){if(!confirmLive(live))return;body.ack_live=true}
else if(!window.confirm('Run these '+ids.length+' test(s), in this order?\n\n - '+ids.join('\n - ')))return;
pending=nowMs();pendingId=null;rowMsg={};updateGroups();renderQueue();
setMsg('qmsg','starting the '+group+' queue...');
api('POST','/batch',body,function(s,d){
if(s!==200){pending=0;updateGroups();renderQueue()}
setMsg('qmsg',d.message||d.error,s!==200);kick()})}
function stopBatch(){var e=$('q-stop');if(e.disabled)return;setDis(e,true);
setMsg('qmsg','stopping the queue...');
api('POST','/batch/stop',{},function(s,d){setMsg('qmsg',d.message||d.error,s!==200);kick()})}
function promptBusy(on){var b=$('promptb').getElementsByTagName('button');for(var i=0;i<b.length;i++)setDis(b[i],on)}
function answerIdx(i){if(!curPrompt)return;var p=curPrompt,v=p.options[i];
promptBusy(true);setMsg('actmsg','answer sent: '+v);
api('POST','/answer',{prompt_id:p.id,value:v},function(s,d){
if(s!==200){promptBusy(false);setMsg('actmsg',d.message||d.error,true)}kick()})}
function doAbort(){var b=$('abortbtn');if(b.disabled)return;abortSent=true;setDis(b,true);
setMsg('actmsg','aborting...');
api('POST','/abort',{},function(s,d){if(s!==200)abortSent=false;setMsg('actmsg',d.message||d.error,s!==200);kick()})}
function doExport(){var b=$('exportbtn');if(b.disabled)return;setDis(b,true);setMsg('actmsg','exporting...');
api('POST','/export',{},function(s,d){setDis(b,false);
if(s===200){setMsg('actmsg','exported: authorized='+d.authorized+', sha256 '+d.sha256.slice(0,16)+' - download below');$('dljson').style.display='';$('dlmd').style.display=''}
else setMsg('actmsg',d.message||d.error,true)})}
function toggleInv(){var f=$('invform');f.style.display=f.style.display==='none'?'':'none'}
function doInvalidate(){var r=$('invreason').value;if(!r){setMsg('actmsg','a reason is required',true);return}
api('POST','/invalidate',{reason:r},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);
if(s===200){$('invform').style.display='none';$('invreason').value=''}kick()})}
function doReset(){api('POST','/reset',{reason:'operator reset from the page'},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);kick()})}
/* Same rule as the acceptance rows: the tool list is built once and then
only updated, so a run starting never rewrites the arg fields the
operator has typed into or the button being pressed. */
function renderBench(){if(!bench)return;var ids=[];
bench.tools.forEach(function(t){ids.push(t.id+':'+(t.ported?1:0)+(t.installed?1:0))});
var sig=ids.join(',');
if(sig!==benchKey){benchKey=sig;buildBench()}
updateBench()}
function buildBench(){var groups={dry:[],takeover:[],live:[],scope:[]};
bench.tools.forEach(function(t){(groups[t.safety]||(groups[t.safety]=[])).push(t)});var h='';
['dry','takeover','scope','live'].forEach(function(g){if(!groups[g]||!groups[g].length)return;
h+="<div class='card grp'><h2>"+esc(g)+"</h2>";
groups[g].forEach(function(t){var d=esc(t.id);
h+="<div class='tool'><div><b>"+esc(t.title)+"</b> <span class='tid'>"+esc(t.script)+"</span> <span class='tag'>"+esc(t.where)+"</span>"+(t.ported?'':"<span class='tag'>unported</span>")+"</div>";
h+="<div class='hint' style='margin:2px 0 4px'>"+esc(t.desc)+"</div>";
if(t.args&&t.args.length){h+="<div class='argrow'>";t.args.forEach(function(a){var iid='arg-'+t.id+'-'+a.name;
if(a.type==='choice'){h+="<label>"+esc(a.name)+" <select class='form-select form-select-sm' id='"+iid+"'>"+a.choices.map(function(c){return "<option"+(c===a.default?' selected':'')+">"+esc(c)+"</option>"}).join('')+"</select></label>"}
else{h+="<label>"+esc(a.name)+" <input class='form-control form-control-sm' type='"+(a.type==='str'?'text':'number')+"' step='any' id='"+iid+"' value='"+esc(a.default==null?'':a.default)+"' title='"+esc(a.help)+"'></label>"}});h+="</div>"}
h+="<div class='actions'><button class='btn btn-sm btn-primary' id='tbtn-"+d+"' onclick='startTool(\""+d+"\")'>Start</button>";
h+="<span class='hint' id='tlast-"+d+"'></span></div></div>"});
h+="</div>"});
$('tools').innerHTML=h;benchEls={};
bench.tools.forEach(function(t){benchEls[t.id]={btn:$('tbtn-'+t.id),last:$('tlast-'+t.id)}})}
function updateBench(){if(!benchEls)return;var busy=isBusy();
bench.tools.forEach(function(t){var e=benchEls[t.id];if(!e)return;
var can=t.ported&&t.installed&&!busy;
setDis(e.btn,!can);
setProp(e.btn,'title',!t.ported?'not yet ported to the bench page':(!t.installed?'script not installed on this image':(busy?'a run is in progress':'')));
setHtml(e.last,t.last?("last: "+esc(t.last.result?t.last.result.result:'?')+" "+esc(fmtTs(t.last.ts))):'')})}
function startTool(id){if(isBusy())return;var t=null;bench.tools.forEach(function(x){if(x.id===id)t=x});if(!t)return;var args={};
(t.args||[]).forEach(function(a){var e=$('arg-'+id+'-'+a.name);if(e)args[a.name]=e.value});
var body={tool:id,args:args};if(t.safety==='live'){if(!confirmLive())return;body.ack_live=true}
pending=nowMs();pendingId=null;updateBench();updateGroups();
setMsg('benchmsg','starting '+id+'...');
api('POST','/bench/start',body,function(s,d){
if(s!==200){pending=0;updateBench();updateGroups()}
setMsg('benchmsg',d.message||d.error,s!==200);if(s===200)benchNeedsRefresh=true;kick()})}
/* ---- Theme ----------------------------------------------------------
Light, dark, or auto (the system preference); the choice lives in
localStorage under the key forgectrl's panel uses, so the two pages
agree. The head script in index.html applies it before first paint. */
var THEMES=['auto','light','dark'];
function themeChoice(){try{var s=localStorage.getItem('ff_theme');return s==='light'||s==='dark'?s:'auto'}catch(e){return 'auto'}}
function applyTheme(){var c=themeChoice(),t=c;
if(c==='auto')t=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';
document.documentElement.setAttribute('data-bs-theme',t);
$('themebtn').textContent=c==='auto'?'\u25d0 Auto':c==='dark'?'\u263e Dark':'\u2600 Light';
$('themebtn').title='Theme: '+c+' (click to change)'}
function cycleTheme(){var next=THEMES[(THEMES.indexOf(themeChoice())+1)%THEMES.length];
try{if(next==='auto')localStorage.removeItem('ff_theme');else localStorage.setItem('ff_theme',next)}catch(e){}
applyTheme()}
(function(){applyTheme();if(window.matchMedia){var mq=matchMedia('(prefers-color-scheme: dark)');
if(mq.addEventListener)mq.addEventListener('change',applyTheme);else if(mq.addListener)mq.addListener(applyTheme)}})();
initHelp();
setIgnoreReq(ignoreReq);
poll();
-127
View File
@@ -1,127 +0,0 @@
/*
* help.js - forgetest: the page's help text
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*
* Every explanation the page offers lives here, keyed by the data-help
* attribute of the "?" button that opens it (index.html): a title, its
* paragraphs, and the page of the documentation site it deep-links to
* (DOC_BASE + d). The buttons open Bootstrap popovers, one at a time; a
* click anywhere else, Escape, or a tab change closes the open one. The
* buttons sit on static elements only: the rows, prompts and tool entries
* are rebuilt by app.js, and a popover on one of those would be orphaned.
* What the operator must read mid-run (the steps, prompts, notices and
* the live-laser acknowledgment) stays in the page and never moves here.
*/
var DOC_BASE = 'https://docs.forgefirm.org/';
var HELP = {
campaign: {
t: 'Campaign',
d: 'developers/acceptance/',
p: [
'A release is authorized when a campaign is open on this image and every catalog test is satisfied: by a PASS in the campaign, or (never for the core) by an earlier PASS whose domain fingerprint is unchanged. A FAIL ends the campaign.',
'The manifest identity is the image, the catalog hash is the test set; either changing starts the count over.'
]
},
queues: {
t: 'Run what is left',
d: 'developers/acceptance/',
p: [
'Each queue takes every test of its kind that the campaign does not already count as satisfied, runs them one at a time in prerequisite order, and stops on the first result that is not a PASS.',
'The unattended queue needs nobody in the room; with the bench fixture up, the operator tests it can perform by itself run there too. The other one does: it prompts, and it fires the laser. Stop-the-queue cancels what is still waiting and lets the run in progress finish; Abort ends that one too.'
]
},
actions: {
t: 'Campaign actions',
d: 'developers/acceptance/',
p: [
'Export builds the release artifact the release gate reads (acceptance.json, with acceptance.md for people) from the campaign as it stands. The raw log is the append-only record; the journal is the daemon\'s own.',
'Invalidate all forces a full campaign; give the reason. Reset campaign closes the open campaign without invalidating anything: earlier passes stay inheritable under the campaign rules, and the next Start opens a new one.'
]
},
ignore: {
t: 'Ignore prerequisites',
d: 'developers/acceptance/',
p: [
'A test\'s requires list only orders the runs. With the switch on, any test starts alone and its record notes which prerequisites were unmet; the release still needs every test satisfied.'
]
},
bench: {
t: 'Bench diagnostics',
d: 'developers/bench/',
p: [
'The bench tools under scripts/bench, run on the board with their output in the Run pane. Nothing here enters a campaign. Tools not yet ported are listed for completeness.',
'Live tools need the operator acknowledgment; takeover and scope tools stop forgectrl for the duration and start it again when they finish.'
]
}
};
var helpOpen = null;
function helpContent(h) {
var d = document.createElement('div'),
i,
p,
a;
for (i = 0; i < h.p.length; i++) {
p = document.createElement('p');
p.textContent = h.p[i];
d.appendChild(p);
}
a = document.createElement('a');
a.className = 'doclink';
a.href = DOC_BASE + h.d;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Documentation \u2192';
d.appendChild(a);
return d;
}
function closeHelp() {
if (helpOpen) {
helpOpen.hide();
helpOpen = null;
}
}
function initHelp() {
var els = document.querySelectorAll('[data-help]'),
i;
for (i = 0; i < els.length; i++) {
(function (el) {
var h = HELP[el.getAttribute('data-help')];
if (!h) {
el.style.display = 'none';
return;
}
var pop = new bootstrap.Popover(el, {
trigger: 'manual',
html: true,
placement: 'auto',
title: h.t,
content: helpContent(h),
container: 'body'
});
el.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
if (helpOpen === pop) {
closeHelp();
return;
}
closeHelp();
pop.show();
helpOpen = pop;
});
el.addEventListener('hidden.bs.popover', function () {
if (helpOpen === pop) helpOpen = null;
});
})(els[i]);
}
document.addEventListener('click', function (e) {
var tip = document.querySelector('.popover.show');
if (helpOpen && !(tip && tip.contains(e.target))) closeHelp();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeHelp();
});
}
-131
View File
@@ -1,131 +0,0 @@
<!--
index.html - forgetest: the acceptance page
Copyright 2026 514 LLC d/b/a OpenGlow
Written by Scott Wiederhold
SPDX-License-Identifier: MIT
Served at "/" as one self-contained page: page.py inlines every
stylesheet and script linked below, in order, in place of its
link/script tag. No external assets. Bootstrap (vendor/) is the UI
library; theme.css is the OpenGlow theme forgectrl's panel uses, byte
for byte, in a light and a dark variant selected by the data-bs-theme
attribute the head script sets before first paint; page.css is this
page's own layout. Design notes are at the top of app.js.
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>ForgeFIRM acceptance</title>
<script>
(function () {
var t = 'light';
try {
var s = localStorage.getItem('ff_theme');
if (s === 'light' || s === 'dark') t = s;
else if (window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches)
t = 'dark';
} catch (e) {}
document.documentElement.setAttribute('data-bs-theme', t);
})();
</script>
<link rel="stylesheet" href="vendor/bootstrap.min.css" />
<link rel="stylesheet" href="theme.css" />
<link rel="stylesheet" href="page.css" />
</head>
<body>
<header>
<div class="hrow">
<span class="appname">ForgeFIRM acceptance</span><span class="sub" id="hdrsub"></span
><span class="ver" id="hdrver"></span
><button id="themebtn" type="button" onclick="cycleTheme()" title="Theme">Theme</button>
</div>
</header>
<nav>
<div class="nrow">
<a id="tab-acceptance" class="on" onclick='showTab("acceptance")'>Release acceptance</a
><a id="tab-bench" onclick='showTab("bench")'>Bench diagnostics</a>
</div>
</nav>
<main>
<div id="left">
<div id="pane-acceptance">
<div class="card">
<h2>Campaign<button class="hlp" data-help="campaign" type="button">?</button></h2>
<div class="banner">
<div class="auth" id="auth">?</div>
<div class="info" id="banner"></div>
</div>
<div id="invnote"></div>
<div class="card queue">
<h2>Run what is left<button class="hlp" data-help="queues" type="button">?</button></h2>
<div class="actions">
<button class="btn btn-sm btn-primary" id="q-unattended" onclick='startBatch("unattended")'>Unattended</button>
<button class="btn btn-sm btn-primary" id="q-attended" onclick='startBatch("attended")'>Operator and live</button>
<button class="btn btn-sm btn-danger" id="q-stop" onclick="stopBatch()" disabled>Stop the queue</button>
</div>
<div id="qstate"></div>
<div id="qmsg"></div>
<div id="fixture" class="hint"></div>
</div>
<div class="actions" style="margin-top: 10px">
<button class="btn btn-sm btn-primary" id="exportbtn" onclick="doExport()">Export release artifact</button>
<a class="btn btn-sm btn-outline-secondary" id="dljson" href="/export/acceptance.json" style="display: none">acceptance.json</a>
<a class="btn btn-sm btn-outline-secondary" id="dlmd" href="/export/acceptance.md" style="display: none">acceptance.md</a>
<a class="btn btn-sm btn-outline-secondary" href="/log">Raw log</a>
<a class="btn btn-sm btn-outline-secondary" href="/journal">Runner journal</a>
<button class="btn btn-sm btn-outline-secondary" onclick="toggleInv()">Invalidate all&hellip;</button>
<button class="btn btn-sm btn-outline-secondary" onclick="doReset()">Reset campaign</button>
<button class="hlp" data-help="actions" type="button">?</button>
</div>
<div id="invform" style="display: none">
<input type="text" class="form-control form-control-sm" id="invreason" placeholder="reason (required): what changed on the bench" />
<button class="btn btn-sm btn-danger" onclick="doInvalidate()">Invalidate all results</button>
</div>
<div id="actmsg"></div>
<div class="switch">
<input class="form-check-input" type="checkbox" id="ignreq" onchange="setIgnoreReq(this.checked)" />
<label for="ignreq">Ignore prerequisites: start any test on its own, whatever its <i>requires</i> list says</label>
<span id="ignreqon"></span>
<button class="hlp" data-help="ignore" type="button">?</button>
</div>
</div>
<div id="groups"></div>
</div>
<div id="pane-bench" style="display: none">
<div class="card">
<h2>Bench diagnostics<button class="hlp" data-help="bench" type="button">?</button></h2>
<p class="hint">
The bench tools (scripts/bench), run on the board with their output in the Run
pane. Runs here never enter a campaign; live tools need the operator
acknowledgment, and takeover tools stop forgectrl for the duration.
</p>
<div id="benchmsg"></div>
</div>
<div id="tools"></div>
</div>
</div>
<div id="right">
<div class="card">
<h2 id="runtitle">Run</h2>
<div id="runhead" class="info">idle</div>
<div id="steps" style="display: none"></div>
<div id="notice" style="display: none"></div>
<div id="prompt" style="display: none">
<div class="q" id="promptq"></div>
<div class="actions" id="promptb"></div>
</div>
<pre id="log"></pre>
<div class="actions">
<button class="btn btn-sm btn-danger" id="abortbtn" onclick="doAbort()" disabled>Abort</button
><span class="hint" id="runfoot"></span>
</div>
</div>
</div>
</main>
<script src="vendor/bootstrap.bundle.min.js"></script>
<script src="help.js"></script>
<script src="app.js"></script>
</body>
</html>
-428
View File
@@ -1,428 +0,0 @@
/*
* page.css - forgetest: the acceptance page's own styles
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*
* Everything specific to this page, on the tokens theme.css defines
* (theme.css is the same file forgectrl's panel uses, so the two look like
* one product and follow the same light and dark themes). This page is a
* wide two-pane layout, so the 900 px column theme.css gives the panel is
* lifted here.
*/
.hrow,
.nrow,
main {
max-width: none;
}
.hrow .appname {
border-left: 0;
padding-left: 0;
}
.hrow .sub {
color: rgba(255, 255, 255, 0.65);
font-size: 12.5px;
}
.hrow .ver {
margin-left: auto;
color: rgba(255, 255, 255, 0.7);
font-size: 12.5px;
font-family: ui-monospace, Consolas, monospace;
}
nav a {
cursor: pointer;
}
main {
display: flex;
gap: 14px;
padding: 12px 16px;
align-items: flex-start;
}
#left {
flex: 1 1 640px;
min-width: 0;
}
#right {
flex: 0 0 420px;
position: sticky;
top: 10px;
}
@media (max-width: 1000px) {
main {
flex-direction: column;
}
#right {
position: static;
flex: 1 1 auto;
width: 100%;
}
/* A run in progress is what the operator is watching: on a phone the
run pane goes to the top for its duration. */
main.running #right {
order: -1;
}
}
.card {
margin: 0 0 12px;
}
.banner {
display: flex;
flex-wrap: wrap;
gap: 18px;
align-items: center;
background: none;
border: 0;
color: inherit;
padding: 0;
margin: 0;
font-size: inherit;
}
.auth {
font-size: 19px;
font-weight: 700;
padding: 6px 14px;
border-radius: 6px;
color: #fff;
background: var(--og-red);
}
.auth.yes {
background: var(--og-ok);
}
.info {
color: var(--og-dim);
font-size: 12.5px;
line-height: 1.7;
}
.info b {
color: var(--og-txt);
font-weight: 600;
}
.mono {
font-size: 12px;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
font-size: 11.5px;
color: var(--og-dim);
text-align: left;
font-weight: 600;
padding: 5px 8px;
border-bottom: 1px solid var(--og-line);
}
td {
padding: 6px 8px;
border-bottom: 1px solid var(--og-line);
vertical-align: top;
}
tr:last-child td {
border-bottom: 0;
}
/* One grid for every subsystem. The groups are separate tables, so
left to themselves each sizes its own columns from its own content
and no two line up. Fixed widths put them all on the same rails. */
#groups .grp {
overflow-x: auto;
}
#groups table {
table-layout: fixed;
min-width: 700px;
}
#groups col.k {
width: 132px;
}
#groups col.s {
width: 250px;
}
#groups col.l {
width: 210px;
}
#groups col.a {
width: 96px;
}
/* Why a test stands where it does reads as one block under Status; a
requires list of three test ids would otherwise wrap into a ragged
stack under the Start button and drag the columns out of line. */
#groups .req {
overflow-wrap: break-word;
}
/* The details sit in a full-width row of their own: inside the Test
cell they would have to be read through a column, and opening one
would push that group's grid out of step with the rest. */
#groups td {
border-bottom: 0;
}
#groups tr.detrow td {
padding: 0 8px;
border-bottom: 1px solid var(--og-line);
}
#groups tr.detrow:last-child td {
border-bottom: 0;
}
#groups .details {
margin: 2px 0 8px;
}
#groups .details .dsc {
margin-bottom: 6px;
}
.tid {
color: var(--og-dim);
font-size: 11.5px;
font-family: ui-monospace, Consolas, monospace;
}
.tag {
display: inline-block;
font-size: 10.5px;
padding: 2px 6px;
border-radius: 9px;
margin-right: 4px;
background: var(--og-line-soft);
color: var(--og-txt);
font-weight: 600;
letter-spacing: 0.2px;
text-transform: uppercase;
}
.tag.live {
background: var(--og-bad-bg);
color: var(--og-red);
}
.tag.operator {
background: var(--og-note-bg);
color: var(--og-note-txt);
}
.tag.takeover {
background: var(--og-line-soft);
color: var(--og-title);
}
.tag.mode {
background: var(--og-line-soft);
color: var(--og-ok);
}
.tag.core {
background: var(--og-navy);
color: #fff;
}
.tag.queued {
background: var(--og-line-soft);
color: var(--og-action);
}
.st {
font-weight: 600;
}
.st.pass {
color: var(--og-ok);
}
.st.inherited {
color: var(--og-title);
}
.st.fail,
.st.error {
color: var(--og-red);
}
.st.stale,
.st.aborted {
color: var(--og-warn);
}
.st.none {
color: var(--og-dim);
}
.st.running {
color: var(--og-action);
}
.req {
font-size: 11.5px;
color: var(--og-warn);
}
.req.over {
color: var(--og-dim);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.actions .hint {
margin: 0;
}
pre#log {
background: var(--og-term-bg);
color: var(--og-term-txt);
font-family: ui-monospace, Consolas, monospace;
font-size: 11.5px;
padding: 10px;
border-radius: 4px;
height: 380px;
overflow: auto;
margin: 8px 0;
white-space: pre-wrap;
word-break: break-all;
}
#prompt {
background: var(--og-note-bg);
border: 1px solid var(--og-note-line);
color: var(--og-note-txt);
border-radius: 6px;
padding: 10px 12px;
margin: 8px 0;
}
#prompt .q {
font-weight: 600;
margin-bottom: 8px;
}
#notice {
background: var(--og-line-soft);
border: 1px solid var(--og-action);
border-radius: 6px;
padding: 10px 12px;
margin: 8px 0;
font-weight: 600;
}
#steps {
background: var(--og-line-soft);
border-radius: 6px;
padding: 8px 12px;
margin: 8px 0;
font-size: 12.5px;
line-height: 1.5;
}
#steps .sh {
font-weight: 600;
margin-bottom: 4px;
}
#steps .sub {
color: var(--og-dim);
margin: 6px 0 2px;
}
#steps ol {
margin: 2px 0 4px 20px;
}
#steps .auto {
color: var(--og-dim);
font-style: italic;
}
.tsel {
cursor: pointer;
}
.tsel:hover {
text-decoration: underline;
}
.details {
display: none;
background: var(--og-line-soft);
padding: 8px 10px;
border-radius: 4px;
font-size: 12.5px;
line-height: 1.55;
margin-top: 6px;
}
.details.on {
display: block;
}
.msg {
margin: 6px 0;
}
.err {
color: var(--og-red);
font-size: 12.5px;
margin: 6px 0;
}
.switch {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0 0;
font-size: 13px;
}
.switch input {
margin: 0;
}
.switch .on {
color: var(--og-warn);
font-weight: 600;
}
.grp {
margin-top: 6px;
}
.queue {
background: var(--og-line-soft);
margin: 10px 0 0;
padding: 10px 12px;
box-shadow: none;
}
.queue h2 {
margin-bottom: 8px;
}
.qline {
font-size: 12.5px;
color: var(--og-dim);
line-height: 1.7;
margin-top: 8px;
}
.qline b {
color: var(--og-txt);
}
.qbar {
display: flex;
height: 6px;
border-radius: 3px;
overflow: hidden;
background: var(--og-line);
margin: 6px 0;
}
.qbar i {
display: block;
}
.qbar i.ok {
background: var(--og-ok);
}
.qbar i.bad {
background: var(--og-red);
}
.qbar i.skip {
background: var(--og-warn);
}
.qbar i.now {
background: var(--og-action);
}
.tool {
border-bottom: 1px solid var(--og-line);
padding: 8px 0;
}
.tool:last-child {
border-bottom: 0;
}
.tool .argrow {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 6px 0;
align-items: center;
}
.tool .argrow label {
font-size: 12px;
color: var(--og-dim);
display: flex;
align-items: center;
gap: 4px;
}
.tool .argrow input,
.tool .argrow select {
width: auto;
display: inline-block;
}
.tool .argrow input {
width: 90px;
}
#invform {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-top: 8px;
}
#invform input {
flex: 1 1 320px;
}
-706
View File
@@ -1,706 +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);
}
/* ---- 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;
}
.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;
}
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2011-2025 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-41
View File
@@ -1,41 +0,0 @@
2026-08-17T09:44:00.417337+00:00 gfcloud[1927] INFO cnc:set_step_freq 28160
2026-08-17T09:44:00.418627+00:00 gfcloud[1927] INFO cnc:set_x_decay 1
2026-08-17T09:44:00.419858+00:00 gfcloud[1927] INFO cnc:set_x_mode 8
2026-08-17T09:44:00.421083+00:00 gfcloud[1927] INFO cnc:set_x_current 135
2026-08-17T09:44:00.422535+00:00 gfcloud[1927] INFO cnc:set_y_decay 1
2026-08-17T09:44:00.423794+00:00 gfcloud[1927] INFO cnc:set_y_mode 8
2026-08-17T09:44:00.425014+00:00 gfcloud[1927] INFO cnc:set_y_current 22
2026-08-17T09:44:00.432591+00:00 gfcloud[1927] INFO machine:_run_loop starting run
2026-08-17T09:44:00.434005+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:44:00.438277+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:44:01.785230+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:44:01.797728+00:00 gfcloud[1927] INFO machine:_run_loop finished run
2026-08-17T09:44:01.800661+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:01.808504+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (actual/expected): X (-10313/-10313), Y (-649/-649), Z (0/0)
2026-08-17T09:44:01.809326+00:00 gfcloud[1927] INFO machine:_motion_locked motion bytes actual:34952, expected: 34952
2026-08-17T09:44:01.810066+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
2026-08-17T09:44:01.811352+00:00 gfcloud[1927] INFO cnc:set_x_current 33
2026-08-17T09:44:01.812874+00:00 gfcloud[1927] INFO cnc:set_y_current 5
2026-08-17T09:44:01.820631+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (-10313, -649, 0)
2026-08-17T09:44:01.822062+00:00 gfcloud[1927] INFO machine:_motion end motion
2026-08-17T09:44:01.822829+00:00 gfcloud[1927] INFO basemachine:_finish_action motion [1576550614]: finished with event ":completed"
2026-08-17T09:44:01.824200+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:07.993647+00:00 gfcloud[1927] INFO gfuiservice:run service action request: print (ready)
2026-08-17T09:44:07.998655+00:00 gfcloud[1927] INFO machine:_motion start motion
2026-08-17T09:44:07.999643+00:00 gfcloud[1927] INFO gfuiservice:run print (ready) service action print
2026-08-17T09:44:14.199666+00:00 gfcloud[1927] INFO cnc:laser_latch 0
2026-08-17T09:44:14.208291+00:00 gfcloud[1927] INFO machine:_button_wait waiting for button
2026-08-17T09:44:21.270846+00:00 gfcloud[1927] INFO machine:_switch_event lid opened
2026-08-17T09:44:21.275703+00:00 gfcloud[1927] WARNING machine:_button_wait button wait lid opened - relocking the laser
2026-08-17T09:44:21.278615+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:21.288305+00:00 gfcloud[1927] INFO machine:_return_home start return home
2026-08-17T09:44:21.291373+00:00 gfcloud[1927] INFO machine:_return_home return home: already at the job start
2026-08-17T09:44:21.292501+00:00 gfcloud[1927] INFO machine:_motion_locked start cool down
2026-08-17T09:44:21.305578+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
2026-08-17T09:44:21.307322+00:00 gfcloud[1927] INFO cnc:set_x_current 33
2026-08-17T09:44:21.309185+00:00 gfcloud[1927] INFO cnc:set_y_current 5
2026-08-17T09:44:21.316340+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (0, 0, 0)
2026-08-17T09:44:21.318139+00:00 gfcloud[1927] INFO machine:_motion end motion
2026-08-17T09:44:21.318860+00:00 gfcloud[1927] INFO basemachine:_finish_action print [1576550621]: finished with event ":cancelled"
2026-08-17T09:44:21.320214+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:34.058320+00:00 gfcloud[1927] INFO machine:_switch_event lid closed
-207
View File
@@ -1,207 +0,0 @@
2026-08-17T09:44:35.789556+00:00 gfcloud[1927] INFO websocket:img_upload START
2026-08-17T09:44:36.105516+00:00 gfcloud[1927] INFO websocket:img_upload COMPLETE
2026-08-17T09:44:36.109421+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:38.461442+00:00 gfcloud[1927] INFO gfcloud:_shutdown shutdown requested
2026-08-17T09:44:38.910494+00:00 gfcloud[1927] INFO machine:_shutdown shutting down
2026-08-17T09:44:38.913049+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:38.950354+00:00 gfcloud[1927] INFO machine:_shutdown joining switch thread
2026-08-17T09:44:38.956909+00:00 gfcloud[1927] INFO machine:_shutdown shut down complete
2026-08-17T09:44:38.986287+00:00 gfcloud[1927] INFO websocket:_on_close RX-EVENT: closed (None, None)
2026-08-17T09:44:38.989003+00:00 gfcloud[1927] INFO websocket:run CLOSING
2026-08-17T09:44:38.992724+00:00 gfcloud[1927] INFO gfcloud:main gfcloud exit
2026-08-17T09:45:08.449553+00:00 gfcloud[2278] INFO gfuiservice:__init__ INITIALIZED
2026-08-17T09:45:08.451929+00:00 gfcloud[2278] INFO authentication:authenticate_machine START
2026-08-17T09:45:08.965893+00:00 gfcloud[2278] INFO authentication:authenticate_machine SUCCESS
2026-08-17T09:45:08.967947+00:00 gfcloud[2278] INFO websocket:ws_connect CONNECTING
2026-08-17T09:45:09.421918+00:00 gfcloud[2278] INFO _logging:info Websocket connected
2026-08-17T09:45:09.424224+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready
2026-08-17T09:45:09.973615+00:00 gfcloud[2278] INFO websocket:ws_connect ESTABLISHED
2026-08-17T09:45:09.994287+00:00 gfcloud[2278] INFO cnc:set_ignored_faults 0
2026-08-17T09:45:09.996281+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:09.998803+00:00 gfcloud[2278] INFO cnc:set_x_mode Microstep.M_8
2026-08-17T09:45:09.999688+00:00 gfcloud[2278] INFO cnc:set_y_mode Microstep.M_8
2026-08-17T09:45:10.000483+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:10.001229+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:10.002000+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:10.007267+00:00 gfcloud[2278] INFO cnc:set_y_current 33
2026-08-17T09:45:10.008695+00:00 gfcloud[2278] INFO z_axis:reset resetting z
2026-08-17T09:45:10.009124+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: False, current: ZCur.LOW, mode: Microstep.HALF
2026-08-17T09:45:10.021592+00:00 gfcloud[2278] INFO gfuiservice:run service action request: settings (ready)
2026-08-17T09:45:10.024928+00:00 gfcloud[2278] INFO settings:send_report START
2026-08-17T09:45:10.039856+00:00 gfcloud[2278] INFO settings:send_report COMPLETE
2026-08-17T09:45:10.040650+00:00 gfcloud[2278] INFO gfuiservice:run settings (ready) service action dispatched
2026-08-17T09:45:10.591813+00:00 gfcloud[2278] INFO gfuiservice:run service action request: hunt (ready)
2026-08-17T09:45:10.596213+00:00 gfcloud[2278] INFO z_axis:home starting z homing cycle
2026-08-17T09:45:10.599109+00:00 gfcloud[2278] INFO gfuiservice:run hunt (ready) service action hunt
2026-08-17T09:45:10.600433+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: True, current: ZCur.HIGH, mode: Microstep.FULL
2026-08-17T09:45:10.607559+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:11.160422+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:11.161195+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:11.714663+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:11.717907+00:00 gfcloud[2278] INFO z_axis:home pass: 1
2026-08-17T09:45:11.719134+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:12.272747+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:12.274301+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:12.829585+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:12.831305+00:00 gfcloud[2278] INFO z_axis:home pass: 2
2026-08-17T09:45:12.832786+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:13.385567+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:13.397106+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:13.947958+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:13.949754+00:00 gfcloud[2278] INFO z_axis:home pass: 3
2026-08-17T09:45:13.951244+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:14.504840+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:14.506743+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:15.060854+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:15.062536+00:00 gfcloud[2278] INFO z_axis:home pass: 4
2026-08-17T09:45:15.063941+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:15.617907+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:15.619505+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:16.175270+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:16.177421+00:00 gfcloud[2278] INFO z_axis:home pass: 5
2026-08-17T09:45:16.179808+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:16.733698+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:16.735355+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:17.289377+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:17.291109+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: None, current: ZCur.LOW, mode: Microstep.HALF
2026-08-17T09:45:17.296301+00:00 gfcloud[2278] INFO z_axis:home homing cycle complete: [0, 0, 0, 0, 0]
2026-08-17T09:45:17.298209+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:17.968494+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:17.969759+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:17.970993+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:45:17.972200+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:45:17.973616+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:17.974870+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:45:17.976077+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:45:17.983820+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:45:17.985159+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:17.989360+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:45:19.630807+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:19.636283+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:45:19.637979+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:19.651159+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (0/0), Y (0/0), Z (-4/-4)
2026-08-17T09:45:19.652103+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:15874, expected: 15874
2026-08-17T09:45:19.652852+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:45:19.653964+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:19.655305+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:45:19.664032+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (0, 0, -4)
2026-08-17T09:45:19.664853+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:19.665575+00:00 gfcloud[2278] INFO basemachine:_finish_action hunt [1576550660]: finished with event ":completed"
2026-08-17T09:45:19.667114+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:20.027338+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:20.030024+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:20.040337+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:21.195560+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:21.196529+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:21.721382+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:21.725394+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:23.732857+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:23.736044+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:23.737995+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:45:23.738941+00:00 gfcloud[2278] INFO machine:_safe_to_move lid opened, unsafe to move
2026-08-17T09:45:23.739814+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:23.740605+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550667]: finished with event ":cancelled"
2026-08-17T09:45:23.742169+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:42.629674+00:00 gfcloud[2278] INFO machine:_switch_event lid closed
2026-08-17T09:45:42.920194+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:42.924152+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:42.927934+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:43.989152+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:43.990022+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:44.312060+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:44.315952+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:46.652633+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:46.654950+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:46.657522+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:45:48.089456+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:48.090706+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:48.091970+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:45:48.093155+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:45:48.094587+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:48.095866+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:45:48.097182+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:45:48.104758+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:45:48.106128+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:48.110477+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:45:50.393091+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:50.398748+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:45:50.399629+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:50.423949+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (12939/12939), Y (7402/7402), Z (0/0)
2026-08-17T09:45:50.425534+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:22740, expected: 22740
2026-08-17T09:45:50.426258+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:45:50.427835+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:50.429387+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:45:50.437659+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (12939, 7402, 0)
2026-08-17T09:45:50.438474+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:50.439167+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550679]: finished with event ":completed"
2026-08-17T09:45:50.440481+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:50.770056+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:50.772800+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:50.782017+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:51.427848+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:51.428456+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:51.757479+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:51.760692+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:54.222123+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:54.224786+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:54.226855+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:45:54.557705+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:54.562210+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:54.571212+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:45:54.573373+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:45:54.596668+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:54.597612+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:45:54.598479+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:45:54.622488+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:45:54.624223+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:54.629307+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:45:54.835600+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:54.838848+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:45:54.839726+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:54.849011+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (159/159), Y (5/5), Z (0/0)
2026-08-17T09:45:54.849946+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:1222, expected: 1222
2026-08-17T09:45:54.850790+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:45:54.852317+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:54.854073+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:45:54.880450+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (159, 5, 0)
2026-08-17T09:45:54.881925+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:54.882641+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550683]: finished with event ":completed"
2026-08-17T09:45:54.884500+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:55.137070+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:55.139449+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:55.142346+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:55.809276+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:55.810155+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:56.158888+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:56.162644+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:58.467590+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:58.469426+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:58.471091+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:46:00.721960+00:00 gfcloud[2278] INFO cnc:set_step_freq 28160
2026-08-17T09:46:00.725688+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:46:00.727477+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:46:00.728738+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:46:00.730198+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:46:00.731506+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:46:00.732735+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:46:00.740459+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:46:00.741865+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:46:00.745983+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:46:02.491897+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:46:02.494137+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:46:02.495155+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:46:02.530565+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (-13096/-13096), Y (-7400/-7400), Z (0/0)
2026-08-17T09:46:02.531413+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:47243, expected: 47243
2026-08-17T09:46:02.538168+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:46:02.539603+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:46:02.541147+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:46:02.549578+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (-13096, -7400, 0)
2026-08-17T09:46:02.550385+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:46:02.551153+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550685]: finished with event ":completed"
2026-08-17T09:46:02.552496+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:46:03.007623+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:46:03.009488+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:46:03.037155+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:46:03.758520+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:46:03.760049+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:46:04.128126+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
-72
View File
@@ -1,72 +0,0 @@
2026-08-17T09:42:09.781500+00:00 gfcloud[1522] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:42:11.281337+00:00 gfcloud[1522] INFO cnc:set_step_freq 28160
2026-08-17T09:42:11.282627+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
2026-08-17T09:42:11.283850+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
2026-08-17T09:42:11.285121+00:00 gfcloud[1522] INFO cnc:set_x_current 135
2026-08-17T09:42:11.286711+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
2026-08-17T09:42:11.287977+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
2026-08-17T09:42:11.289235+00:00 gfcloud[1522] INFO cnc:set_y_current 22
2026-08-17T09:42:11.297425+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:11.299141+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:11.303264+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:42:12.560917+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:12.572022+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:42:12.572811+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:12.580801+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (-10313/-10313), Y (-649/-649), Z (0/0)
2026-08-17T09:42:12.581620+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:34952, expected: 34952
2026-08-17T09:42:12.582366+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
2026-08-17T09:42:12.583540+00:00 gfcloud[1522] INFO cnc:set_x_current 33
2026-08-17T09:42:12.584928+00:00 gfcloud[1522] INFO cnc:set_y_current 5
2026-08-17T09:42:12.593681+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (-10313, -649, 0)
2026-08-17T09:42:12.604394+00:00 gfcloud[1522] INFO machine:_motion end motion
2026-08-17T09:42:12.604757+00:00 gfcloud[1522] INFO basemachine:_finish_action motion [1576550503]: finished with event ":completed"
2026-08-17T09:42:12.605824+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:15.672518+00:00 gfcloud[1522] INFO gfuiservice:run service action request: print (ready)
2026-08-17T09:42:15.676330+00:00 gfcloud[1522] INFO machine:_motion start motion
2026-08-17T09:42:15.680419+00:00 gfcloud[1522] INFO gfuiservice:run print (ready) service action print
2026-08-17T09:42:21.975587+00:00 gfcloud[1522] INFO cnc:laser_latch 0
2026-08-17T09:42:22.001354+00:00 gfcloud[1522] INFO machine:_button_wait waiting for button
2026-08-17T09:42:24.774117+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
2026-08-17T09:42:24.778509+00:00 gfcloud[1522] INFO cnc:set_step_freq 10000
2026-08-17T09:42:24.781114+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
2026-08-17T09:42:24.783654+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
2026-08-17T09:42:24.786180+00:00 gfcloud[1522] INFO cnc:set_x_current 135
2026-08-17T09:42:24.789088+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
2026-08-17T09:42:24.791559+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
2026-08-17T09:42:24.794380+00:00 gfcloud[1522] INFO cnc:set_y_current 22
2026-08-17T09:42:24.815263+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:24.816687+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:24.820823+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:42:25.049489+00:00 gfcloud[1522] INFO machine:_switch_event button released
2026-08-17T09:42:30.832527+00:00 gfcloud[1522] INFO machine:_switch_event lid opened
2026-08-17T09:42:30.838627+00:00 gfcloud[1522] WARNING machine:_run_loop lid opened mid-run; stopping motion
2026-08-17T09:42:30.941553+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:30.947477+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:42:30.949099+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:30.964916+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (8454/7573), Y (684/684), Z (3/3)
2026-08-17T09:42:30.965704+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:60649, expected: 238043
2026-08-17T09:42:30.972180+00:00 gfcloud[1522] INFO machine:_return_home start return home
2026-08-17T09:42:31.847247+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:31.848617+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:31.852977+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:42:36.970521+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:36.976059+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:42:36.977738+00:00 gfcloud[1522] INFO machine:_return_home return home complete
2026-08-17T09:42:36.979696+00:00 gfcloud[1522] INFO machine:_motion_locked start cool down
2026-08-17T09:42:36.998973+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
2026-08-17T09:42:37.000314+00:00 gfcloud[1522] INFO cnc:set_x_current 33
2026-08-17T09:42:37.001774+00:00 gfcloud[1522] INFO cnc:set_y_current 5
2026-08-17T09:42:37.010089+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (0, 0, 3)
2026-08-17T09:42:37.010885+00:00 gfcloud[1522] INFO machine:_motion end motion
2026-08-17T09:42:37.011563+00:00 gfcloud[1522] INFO basemachine:_finish_action print [1576550507]: finished with event ":cancelled"
2026-08-17T09:42:37.013071+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:37.449180+00:00 gfcloud[1522] INFO gfuiservice:run service action request: hunt (ready)
2026-08-17T09:42:37.451286+00:00 gfcloud[1522] INFO z_axis:home starting z homing cycle
2026-08-17T09:42:37.452782+00:00 gfcloud[1522] INFO gfuiservice:run hunt (ready) service action hunt
2026-08-17T09:42:37.453691+00:00 gfcloud[1522] INFO z_axis:configure configuring z_enable: True, current: ZCur.HIGH, mode: Microstep.FULL
2026-08-17T09:42:37.458283+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:42:37.827610+00:00 gfcloud[1522] INFO z_axis:_step_until took -2 z_steps
2026-08-17T09:42:37.829278+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:42:38.200493+00:00 gfcloud[1522] INFO z_axis:_step_until took 2 z_steps
2026-08-17T09:42:38.202234+00:00 gfcloud[1522] INFO z_axis:home pass: 1
2026-08-17T09:42:38.203788+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: False
-54
View File
@@ -1,54 +0,0 @@
2026-08-17T09:42:15.672518+00:00 gfcloud[1522] INFO gfuiservice:run service action request: print (ready)
2026-08-17T09:42:15.676330+00:00 gfcloud[1522] INFO machine:_motion start motion
2026-08-17T09:42:15.680419+00:00 gfcloud[1522] INFO gfuiservice:run print (ready) service action print
2026-08-17T09:42:21.975587+00:00 gfcloud[1522] INFO cnc:laser_latch 0
2026-08-17T09:42:22.001354+00:00 gfcloud[1522] INFO machine:_button_wait waiting for button
2026-08-17T09:42:24.774117+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
2026-08-17T09:42:24.778509+00:00 gfcloud[1522] INFO cnc:set_step_freq 10000
2026-08-17T09:42:24.781114+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
2026-08-17T09:42:24.783654+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
2026-08-17T09:42:24.786180+00:00 gfcloud[1522] INFO cnc:set_x_current 135
2026-08-17T09:42:24.789088+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
2026-08-17T09:42:24.791559+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
2026-08-17T09:42:24.794380+00:00 gfcloud[1522] INFO cnc:set_y_current 22
2026-08-17T09:42:24.815263+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:24.816687+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:24.820823+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:42:25.049489+00:00 gfcloud[1522] INFO machine:_switch_event button released
2026-08-17T09:42:31.104201+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
2026-08-17T09:42:31.106877+00:00 gfcloud[1522] INFO machine:_run_loop button pressed mid-run; pausing
2026-08-17T09:42:31.288114+00:00 gfcloud[1522] INFO machine:_switch_event button released
2026-08-17T09:42:31.612040+00:00 gfcloud[1522] INFO machine:_run_loop paused at Position(x=AxisPosition(steps=1210, mm=22.68, inch=0.89), y=AxisPosition(steps=402, mm=7.54, inch=0.30), z=AxisPosition(steps=3, mm=0.0, inch=0.0), bytes=PulsPosition(total=238043, processed=61200))
2026-08-17T09:42:34.702311+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
2026-08-17T09:42:34.704560+00:00 gfcloud[1522] INFO machine:_run_loop button pressed while paused
2026-08-17T09:42:34.706100+00:00 gfcloud[1522] INFO machine:_resume_retraced resuming (laser lead 1950 ticks)
2026-08-17T09:42:34.901002+00:00 gfcloud[1522] INFO machine:_switch_event button released
2026-08-17T09:42:58.130807+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:58.136283+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:42:58.137979+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:58.151159+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (7573/7573), Y (684/684), Z (3/3)
2026-08-17T09:42:58.152103+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:238043, expected: 238043
2026-08-17T09:42:58.160180+00:00 gfcloud[1522] INFO machine:_return_home start return home
2026-08-17T09:42:59.047247+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:59.048617+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:59.052977+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:43:04.170521+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:43:04.176059+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:43:04.177738+00:00 gfcloud[1522] INFO machine:_return_home return home complete
2026-08-17T09:43:04.179696+00:00 gfcloud[1522] INFO machine:_motion_locked start cool down
2026-08-17T09:43:04.198973+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
2026-08-17T09:43:04.200314+00:00 gfcloud[1522] INFO cnc:set_x_current 33
2026-08-17T09:43:04.201774+00:00 gfcloud[1522] INFO cnc:set_y_current 5
2026-08-17T09:43:04.210089+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (0, 0, 3)
2026-08-17T09:43:04.210885+00:00 gfcloud[1522] INFO machine:_motion end motion
2026-08-17T09:43:04.211563+00:00 gfcloud[1522] INFO basemachine:_finish_action print [1576550507]: finished with event ":completed"
2026-08-17T09:43:04.213071+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:43:04.649180+00:00 gfcloud[1522] INFO gfuiservice:run service action request: hunt (ready)
2026-08-17T09:43:04.651286+00:00 gfcloud[1522] INFO z_axis:home starting z homing cycle
2026-08-17T09:43:04.652782+00:00 gfcloud[1522] INFO gfuiservice:run hunt (ready) service action hunt
2026-08-17T09:43:11.298209+00:00 gfcloud[1522] INFO machine:_motion start motion
2026-08-17T09:43:11.983820+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:43:13.630807+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:43:13.636283+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:43:13.664853+00:00 gfcloud[1522] INFO machine:_motion end motion
2026-08-17T09:43:13.665575+00:00 gfcloud[1522] INFO basemachine:_finish_action hunt [1576550560]: finished with event ":completed"
-276
View File
@@ -1,276 +0,0 @@
"""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)
-143
View File
@@ -1,143 +0,0 @@
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()
-611
View File
@@ -1,611 +0,0 @@
"""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
class BaselineTests(unittest.TestCase):
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
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 _led2(self, name, target, brightness):
"""Both attributes of one button LED: the trigger's commanded
level and the fade that follows it."""
for a, v in (("target", target), ("brightness", brightness)):
with open(self.leds + name + "/" + a, "w") as f:
f.write(v)
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 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"), "28160")
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_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()
self._pos(221, 0, 0) # 4.144 mm into the held move
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-4.144", 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 _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 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=28160"])
# -- the reference is taken after the controller applied its config -----
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")
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
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"}}))
self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "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"}}))
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):
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)
self.assertEqual(baseline.xy_mode_of({}), 8)
self.assertEqual(baseline.xy_mode_of(None), 8)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 8)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 8)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 8)
self.assertEqual(baseline.ref_xy_mode({}), 8)
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):
os.environ["FORGETEST_BOOT_ID"] = "test-boot-2"
try:
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)
ref = baseline.boot_reference(self.lines.append, self.tmp)
up = baseline.uptime_s()
if up is None or up > baseline.BOOT_MAX_AGE_S:
# 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.assertEqual(ref["ts"], "old")
else:
self.assertTrue(any("retaking" in l for l in self.lines))
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
finally:
os.environ.pop("FORGETEST_BOOT_ID", None)
if __name__ == "__main__":
unittest.main()
class TransientNotLeftoverTests(BaselineTests):
"""A leftover is what a run left behind, not the machine part-way
through its own work. Found on the bench reference, where
laser.arm-wait-lid passed every check it makes and failed its
hand-back on the post-job smoke clear."""
def test_a_fading_button_led_is_not_a_leftover(self):
# the trigger fades brightness toward target: target 0 with
# brightness still high is the fade from a level the machine ended
for name in baseline.BUTTON_LEDS:
self._led2(name, target="0", brightness="900")
left = self.bl().enforce("post", captured=None)
self.assertEqual([x for x in left if x.item.startswith("leds/")], [])
def test_a_lit_button_led_is_a_leftover(self):
# a target the run left standing is dirt, whatever the fade reads
self._led2(baseline.BUTTON_LEDS[0], target="1014", brightness="0")
left = self.bl().enforce("post", captured=None)
items = [x for x in left if x.item.startswith("leds/")]
self.assertEqual(len(items), 1)
self.assertEqual(items[0].found, "1014")
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_a_few_steps_of_xy_are_the_quantization(self):
self.assertTrue(baseline.position_quantized([0, 0, 0], [4, 0, 0]))
self.assertTrue(baseline.position_quantized([0, 0, 0], [-4, 4, 0]))
self.assertTrue(baseline.position_quantized([10, 20, 30], [10, 20, 30]))
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_z_is_exact_because_the_return_never_moves_it(self):
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
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(BaselineTests):
"""The baseline against a fake forgectrl: what the mode in force
owns. Reuses the fake sysfs tree of BaselineTests; only the new
tests run here (the inherited ones are skipped)."""
def setUp(self):
super().setUp()
import helpers
self.fc = helpers.FakeForgectrl().start()
baseline.Baseline._unreachable_until = 0.0
def tearDown(self):
self.fc.stop()
super().tearDown()
def run(self, result=None):
# only this class's own tests, not the base class's
if self._testMethodName not in BaselineModeTests.__dict__:
return
return super().run(result)
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"), "28160")
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"], [])
-128
View File
@@ -1,128 +0,0 @@
"""The bench registry against scripts/bench: every python tool is
registered (or named as not-a-tool), every registered script exists,
every argument spec builds a command line, every script compiles, and
the shared helper resolves host and local mode as documented."""
import glob
import os
import py_compile
import subprocess
import sys
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_flagged_args_build_option_pairs(self):
tool = next(t for t in bench_mod.TOOLS if t["id"] == "resume-dark-lead")
ok, argv, err = self.bench.command(tool, {"run": "live", "feed": 900})
self.assertTrue(ok, err)
self.assertIn("--run", argv)
self.assertEqual(argv[argv.index("--run") + 1], "live")
self.assertEqual(argv[argv.index("--feed") + 1], "900.0")
# an empty optional with an empty default is left off entirely
self.assertNotIn("--auto", argv)
# a choice outside the list is refused before anything runs
ok, _argv, err = self.bench.command(tool, {"run": "sideways"})
self.assertFalse(ok)
self.assertIn("run must be one of", err)
def test_unported_tools_are_host_only(self):
# what stays unported is what cannot run on the machine at all
for t in bench_mod.TOOLS:
if not t["ported"]:
self.assertEqual(t["where"], "host", t["id"])
self.assertIn("not a bench-page tool", t["desc"], t["id"])
def test_scripts_compile(self):
for s in self.scripts:
py_compile.compile(os.path.join(BENCH, s), doraise=True)
class GfbenchTests(unittest.TestCase):
"""gfbench.py in a subprocess (module-level host resolution)."""
def run_snippet(self, code, env_extra):
env = dict(os.environ)
env.pop("GF_HOST", None)
env.pop("FORGETEST_BENCH_DATA", None)
env.update(env_extra)
r = subprocess.run([sys.executable, "-c", code], cwd=BENCH, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
return r.returncode, r.stdout.strip()
def test_host_mode(self):
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL, gfbench.SSH[0])",
{"GF_HOST": "192.0.2.7", "GF_SSH": "fakessh -x"})
self.assertEqual(rc, 0, out)
self.assertEqual(out, "192.0.2.7 False fakessh")
def test_local_when_named_local(self):
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL); "
"print(gfbench.board('echo hi').strip())",
{"GF_HOST": "127.0.0.1"})
self.assertEqual(rc, 0, out)
self.assertEqual(out.splitlines(), ["127.0.0.1 True", "hi"])
def test_refuses_without_a_machine(self):
if os.path.isdir("/sys/glowforge"):
self.skipTest("running on the machine")
rc, out = self.run_snippet("import gfbench", {})
self.assertNotEqual(rc, 0)
self.assertIn("GF_HOST", out)
def test_degc_and_data_dir(self):
import tempfile
d = tempfile.mkdtemp(prefix="forgetest-bench-")
rc, out = self.run_snippet(
"import gfbench, os; print(round(gfbench.degc(700), 2), gfbench.degc('x') != gfbench.degc('x'), "
"os.path.dirname(gfbench.data_path('f.json')) == os.environ['FORGETEST_BENCH_DATA'])",
{"GF_HOST": "127.0.0.1", "FORGETEST_BENCH_DATA": d})
self.assertEqual(rc, 0, out)
val, nan, indir = out.split()
# a plausible coolant reading (700 counts is room temperature territory)
self.assertTrue(15.0 < float(val) < 35.0, val)
self.assertEqual(nan, "True")
self.assertEqual(indir, "True")
if __name__ == "__main__":
unittest.main()
-201
View File
@@ -1,201 +0,0 @@
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()
-260
View File
@@ -1,260 +0,0 @@
"""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()
File diff suppressed because it is too large Load Diff
-116
View File
@@ -1,116 +0,0 @@
"""The setup's checks in the catalog: registered as automatic tests with
no precheck, the takeover ones marked so, every covers map naming the
check runner; and the check driver itself against the fake daemon: a
start, a prompt answered, the result taken, the record read at version
1, and the settings a check may write put back as found."""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(__file__))
import helpers # noqa: E402
from forgetest import catalog # noqa: E402
from forgetest.runner import Context, Run # noqa: E402
IDS = ["commission.check-switches", "commission.check-sensors", "commission.check-airflow",
"commission.check-cameras", "commission.check-motion", "commission.check-flow-verify"]
class RegistrationTests(unittest.TestCase):
def setUp(self):
self.reg = catalog.load_suite()
def test_every_check_runs_unattended_with_the_fixture(self):
# The lid and button checks name their machine actions, which the
# bench fixture performs; the rest need no hands at all.
for tid in IDS:
self.assertIn(tid, self.reg)
for tid in ("commission.check-switches", "commission.check-cameras"):
self.assertEqual(self.reg[tid].kind, "operator", tid)
self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid)
for tid in ("commission.check-sensors", "commission.check-airflow", "commission.check-motion",
"commission.check-flow-verify"):
self.assertEqual(self.reg[tid].kind, "auto", tid)
def test_no_check_asks_the_operator_to_prepare_anything(self):
for tid in IDS:
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
def test_the_checks_that_take_the_machine_say_so(self):
for tid in ("commission.check-airflow", "commission.check-motion", "commission.check-flow-verify"):
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
def test_every_check_covers_the_runner_and_the_page(self):
for tid in IDS:
covers = set(self.reg[tid].covers)
self.assertIn(("forgectrl", "src/wizdark.*"), covers, tid)
self.assertIn(("forgectrl", "src/ui/wizard.*"), covers, tid)
class SensorsCheckTests(unittest.TestCase):
"""The check driver against a scripted daemon."""
RESULT = {"coolant_down_c": 22.4, "coolant_up_c": 21.9, "chassis_c": 28.0, "soc_c": 45.2,
"lid_ir_max": [110, 120, 105, 118], "accel_events": 0, "laser_pgood": 1,
"hv_current_max": 0, "exhaust_rpm_idle": 3400, "intake_rpm_idle": 2200}
def setUp(self):
self.fake = helpers.FakeForgectrl().start()
self.fake.state["settings"].update({"cool_temp_offset_c": "0.5"})
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
"log": [], "prompt": None, "result": None, "error": "",
"shots": {"lid": False, "head": False}}
self.versions = {}
self.answers = []
def on_get(path, q):
if path == "/wiz/dark":
return 200, dict(self.dark)
if path == "/wiz":
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
return None
def on_post(path, form):
s = self.fake.state["settings"]
if path == "/wiz/sensors/start":
self.dark.update({"id": "sensors", "running": True, "phase": "sampling",
"prompt": {"seq": 7, "id": "room-temp", "kind": "number",
"text": "Optional: the room temperature.",
"options": ["Set", "Skip"]}})
return 200, {"started": True, "id": "sensors"}
if path == "/wiz/sensors/answer":
self.answers.append(dict(form))
if form.get("seq") != "7":
return 409, {"error": "no such prompt is open"}
if form.get("value") == "Skip":
self.dark.update({"running": False, "prompt": None, "progress": 100,
"result": dict(self.RESULT)})
self.versions["sensors"] = 1
return 200, {"ok": True}
if path == "/settings":
for k, v in form.items():
if v == "":
s.pop(k, None)
else:
s[k] = v
return 200, dict(s)
return None
self.fake.on_get = on_get
self.fake.on_post = on_post
def tearDown(self):
self.fake.stop()
def test_start_prompt_answer_result_and_restore(self):
t = catalog.load_suite()["commission.check-sensors"]
run = Run("test", t.id, t.title)
t.fn(Context(run, None, t))
self.assertIn(("/wiz/sensors/start", {}), [(p, f) for p, f in self.fake.posts])
self.assertEqual(self.answers, [{"seq": "7", "value": "Skip"}])
self.assertEqual(run.evidence["result"]["laser_pgood"], 1)
self.assertEqual(run.evidence["settings_before"], {"cool_temp_offset_c": "0.5"})
self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5")
if __name__ == "__main__":
unittest.main()
-266
View File
@@ -1,266 +0,0 @@
"""The setup's sheet in the catalog: one live test on the button with a
piece of wood at hand, its covers map naming the live runner, the
renderer, the sender, the page, and the driver's laser module; and the
whole run against a scripted daemon: the placement's jogs and origin,
then each card's preview and program read, its start, the arm prompt and
the pick answered, the witnesses judged, the record read at version 1,
every card's evidence filed under its own key, and the settings the cards
wrote put back as found at the end."""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(__file__))
import helpers # noqa: E402
from forgetest import catalog # noqa: E402
from forgetest.runner import Context, Run # noqa: E402
from forgetest.suite import commission_sheet # noqa: E402
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
# Each wizard as the daemon runs it: its prompts in order (id, kind,
# options) and the result it ends with. A "jog" prompt repeats until the
# origin is set.
SCRIPT = {
"sheet.place": ([("place", "jog", ["X-10", "X+10", "Set origin"]),
("sheet-kind", "choice", ["Full sheet", "One card"]),
("thickness", "number", ["Set", "Skip"])],
{"origin_x": 100, "origin_y": 200, "origin_z": 0, "steps_per_mm": {"x": 40.0, "y": 40.0},
"z_referenced": True, "thickness_mm": 3.2, "alone": False}),
"sheet.frame": ([("frame-arm", "continue", ["Continue"]), ("frame-ok", "choice", ["Yes", "No", "Again"])],
{"mark_s": 400, "mark_feed": 3000, "emission": EMISSION}),
"laser.focus": ([("focus-arm", "continue", ["Continue"]),
("focus-pick", "multichoice", [str(i) for i in range(1, 13)]),
("thickness", "number", ["Keep", "Set"])],
{"pick": 11, "thickness_mm": 3.2, "pick_half_steps": -11, "edge_z_mm": 6.96,
"steps_per_mm": 2.922, "max_height_mm": 13.81,
"focus_range_mm": {"min": 2.17, "max": 13.81},
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
"contact_above": 22, "why": ""}, "emission": EMISSION}),
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
"laser.dose-curve": ([("dose-arm", "continue", ["Continue"])],
{"points": [[10, 0.44], [100, 100]], "curve": "10:0.44,100:100", "emission": EMISSION}),
"laser.corner": ([("corner-arm", "continue", ["Continue"]),
("corner-pick", "choice", ["1.00", "1.25", "1.50", "1.75", "2.00"])],
{"gamma": 1.5, "emission": EMISSION}),
"cooling.flow-load": ([("load-arm", "continue", ["Continue"])],
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
"k_cw": 2.78e-5, "emission": EMISSION}),
}
# What each card writes when it ends.
WRITES = {
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14",
"lens_stop_above_steps": "20"},
"laser.floor": {"laser_floor_density": "10"},
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
"laser.corner": {"laser_corner_gamma": "1.5"},
"cooling.flow-load": {"cool_laser_heat_density": "2.14e-5", "cool_laser_heat_cw": "2.78e-5"},
}
FOUND = {"lens_hall_edge_z_mm": "3.35", "lens_stop_below_steps": "10", "lens_stop_above_steps": "12",
"laser_floor_density": "12",
"laser_dose_curve": "off", "laser_corner_gamma": "2", "cool_laser_heat_density": "1e-5",
"cool_laser_heat_cw": "1e-5"}
class RegistrationTests(unittest.TestCase):
def setUp(self):
self.t = catalog.load_suite()["commission.sheet"]
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
t = self.t
self.assertEqual(t.kind, "live")
self.assertEqual(t.mode, "grbl")
self.assertEqual(t.hardware, "takeover")
self.assertIn("button", t.actions)
self.assertTrue(t.hands)
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
self.assertIsNone(getattr(t, "precheck", None))
for want in ("commission.check-motion", "laser.emission-witness", "cooling.flow-verify"):
self.assertIn(want, t.requires)
def test_no_other_sheet_test_remains(self):
ids = [i for i in catalog.load_suite() if i.startswith("commission.sheet")]
self.assertEqual(ids, ["commission.sheet"])
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
covers = set(self.t.covers)
for want in (("forgectrl", "src/wizlive.*"), ("forgectrl", "src/sheet.*"), ("forgectrl", "src/jobstream.*"),
("forgectrl", "src/curverec.*"), ("forgectrl", "src/font_hershey.*"),
("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/wizrun.h"),
("grblhal-glowforge", "src/glowforge_laser.c")):
self.assertIn(want, covers, "lacks %s" % (want,))
def test_the_steps_ask_for_one_press_and_no_page(self):
text = " ".join(self.t.steps).lower()
self.assertIn("press the machine's button once", text)
self.assertNotIn("when the page", text)
self.assertEqual(len(commission_sheet.CARDS), 6)
class ScriptedDaemon:
"""The wizard runner as the suite sees it: /wiz/<id>/start opens the
first prompt, each answer opens the next or ends the wizard with its
result and its writes, /wiz/dark reports it, GET /wiz carries the
versions, the sheet preview and program are served per card."""
def __init__(self, fake):
self.fake = fake
fake.state["settings"].update(FOUND)
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
"log": [], "prompt": None, "result": None, "error": "",
"shots": {"lid": False, "head": False}}
self.versions = {}
self.answers = {} # wid -> [values]
self.order = [] # the wizards as started
self.previews = []
self.queue = []
self.seq = 0
fake.on_get = self.on_get
fake.on_post = self.on_post
def open_next(self):
if not self.queue:
return False
pid, kind, options = self.queue[0]
self.seq += 1
self.dark["prompt"] = {"seq": self.seq, "id": pid, "kind": kind, "text": pid, "options": options}
return True
def on_get(self, path, q):
if path == "/wiz/dark":
return 200, dict(self.dark)
if path == "/wiz":
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
if path == "/wiz/sheet.svg":
self.previews.append(q.get("card"))
if q.get("card") not in LIVE:
return 404, {"error": "no such card"}
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
if path == "/wiz/sheet.gcode":
# the frame and the text are M4 at the mark dose, as the daemon serves them
return 200, b"; mock\nM4 S400\nG0 X60 Y42\nG1 X120 Y42 F3000\nM5\n", "text/plain"
return None
def on_post(self, path, form):
s = self.fake.state["settings"]
if path.startswith("/wiz/") and path.endswith("/start"):
wid = path[len("/wiz/"):-len("/start")]
if wid not in SCRIPT:
return 404, {"error": "no such wizard"}
self.order.append(wid)
self.queue = list(SCRIPT[wid][0])
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
self.open_next()
return 200, {"started": True, "id": wid}
if path.startswith("/wiz/") and path.endswith("/answer"):
wid = path[len("/wiz/"):-len("/answer")]
p = self.dark.get("prompt")
if not p or form.get("seq") != str(p["seq"]):
return 409, {"error": "no such prompt is open"}
self.answers.setdefault(wid, []).append(form.get("value"))
if not (p["kind"] == "jog" and form.get("value") != "Set origin"):
self.queue.pop(0)
if not self.open_next():
s.update(WRITES.get(wid, {}))
self.dark.update({"running": False, "prompt": None, "progress": 100,
"result": dict(SCRIPT[wid][1])})
self.versions[wid] = 1
return 200, {"ok": True}
if path == "/settings":
for k, v in form.items():
if v == "":
s.pop(k, None)
else:
s[k] = v
return 200, dict(s)
return None
class SheetRunTests(unittest.TestCase):
def setUp(self):
self.fake = helpers.FakeForgectrl().start()
self.daemon = ScriptedDaemon(self.fake)
def tearDown(self):
self.fake.stop()
def test_the_whole_sheet_on_one_piece(self):
t = catalog.load_suite()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True # the ready gate passes; the arm cue is a notice
t.fn(Context(run, None, t))
d = self.daemon
self.assertEqual(d.order, ["sheet.place"] + LIVE)
self.assertEqual(d.answers["sheet.place"], ["X+10", "X-10", "Set origin", "Full sheet", "3.2"])
self.assertEqual(d.answers["sheet.frame"], ["Continue", "Yes"])
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
self.assertEqual(d.previews, LIVE)
cards = run.evidence["cards"]
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
for wid in LIVE:
self.assertEqual(cards[wid]["emission"]["hv_max"], 610, wid)
self.assertEqual(cards[wid]["result"], SCRIPT[wid][1], wid)
self.assertNotIn("emission", run.evidence)
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
self.assertEqual(run.evidence["settings_before"], FOUND)
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
def test_imperial_units_answer_the_thickness_in_inches(self):
# The number prompts read ui_units: on an imperial machine the
# placement's thickness is 0.125 in and the record carries 3.175 mm.
self.fake.state["settings"]["ui_units"] = "imperial"
place_prompts, place_result = SCRIPT["sheet.place"]
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=3.175))
try:
t = catalog.load_suite()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
t.fn(Context(run, None, t))
self.assertEqual(self.daemon.answers["sheet.place"][-1], "0.125")
self.assertEqual(run.evidence["thickness_answer"], "0.125")
finally:
SCRIPT["sheet.place"] = (place_prompts, place_result)
def test_a_metric_answer_on_an_imperial_machine_fails_the_placement(self):
# The defect the bench found: 3.2 read as inches is over the 30 mm
# cap and the wizard records 0; the test must not read that as a pass.
from forgetest.runner import Failed
place_prompts, place_result = SCRIPT["sheet.place"]
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=0.0))
try:
t = catalog.load_suite()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed) as cm:
t.fn(Context(run, None, t))
self.assertIn("thickness 0.0 mm", str(cm.exception))
self.assertEqual(self.daemon.order, ["sheet.place"]) # nothing burned
finally:
SCRIPT["sheet.place"] = (place_prompts, place_result)
def test_a_dark_burn_fails_the_run_and_still_restores(self):
from forgetest.runner import Failed
SCRIPT_DARK = dict(SCRIPT["laser.floor"][1])
SCRIPT_DARK["emission"] = {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0}
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], SCRIPT_DARK)
try:
t = catalog.load_suite()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed):
t.fn(Context(run, None, t))
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus", "laser.floor"])
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k)
finally:
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
if __name__ == "__main__":
unittest.main()
-423
View File
@@ -1,423 +0,0 @@
"""The commission.* suite on the host: the registration (ids, kinds, the
takeover tests, the operator tests' hands), the record builders, the
machine's name, the cookie parsing, the LED cue, the settle rule for a
gated supervisor, and the cloud-off surface test driven end to end
against the fake daemon."""
import json
import os
import shutil
import tempfile
import unittest
import helpers
from forgetest import baseline, catalog
from forgetest.runner import Context, Run
from forgetest.suite import commission
IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot",
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes",
"commission.ssh-until-reboot", "commission.cloud-disabled-surface",
"commission.factory-return", "commission.machine-name", "commission.first-run-flow",
"commission.first-run-page", "commission.what-changed", "commission.record-export",
"commission.mirror")
OPERATOR = ("commission.first-run-flow", "commission.first-run-page")
class RegistrationTests(unittest.TestCase):
def setUp(self):
self.reg = catalog.load_suite()
def test_every_test_is_registered_with_its_kind(self):
kinds = {tid: self.reg[tid].kind for tid in IDS if tid in self.reg}
self.assertEqual(sorted(kinds), sorted(IDS))
for tid in OPERATOR:
self.assertEqual(kinds[tid], "operator", tid)
for tid in IDS:
if tid not in OPERATOR:
self.assertEqual(kinds[tid], "auto", tid)
self.assertNotIn("commission.root-ssh-refused", self.reg) # folded into ssh-until-reboot
self.assertNotIn("commission.wizard-first-run", self.reg) # split into the flow and the page
def test_the_record_swapping_tests_are_takeovers(self):
for tid in ("commission.gate-blocks-controllers", "commission.advisories-rehash",
"commission.first-run-flow", "commission.first-run-page",
"commission.what-changed", "commission.mirror"):
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
self.assertEqual(self.reg["commission.record-export"].hardware, "api")
def test_the_lifecycle_tests_cover_their_files(self):
changed = set(self.reg["commission.what-changed"].covers)
export = set(self.reg["commission.record-export"].covers)
mirror = set(self.reg["commission.mirror"].covers)
self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
self.assertIn(("forgectrl", "src/recordhtml.*"), export)
self.assertIn(("forgectrl", "src/logs.*"), export)
self.assertIn(("forgectrl", "src/wizdark.*"), mirror)
self.assertIn("commission.check-sensors", self.reg["commission.mirror"].requires)
def test_flag_of_reads_the_status_document(self):
w = {"required": ["airflow", {"id": "laser.floor", "reason": "the tube was replaced"}],
"recommended": [{"id": "laser.focus", "reason": "the tray was replaced"}]}
self.assertEqual(commission.flag_of(w, "required", "laser.floor"), "the tube was replaced")
self.assertEqual(commission.flag_of(w, "recommended", "laser.focus"), "the tray was replaced")
self.assertIsNone(commission.flag_of(w, "required", "airflow")) # a plain id is the table, not a flag
self.assertIsNone(commission.flag_of(w, "required", "laser.focus"))
self.assertIsNone(commission.flag_of({}, "recommended", "x"))
def test_the_first_run_flow_runs_unattended_with_the_fixture_and_the_page_walk_does_not(self):
flow = self.reg["commission.first-run-flow"]
self.assertEqual(list(flow.actions), ["button"])
self.assertFalse(flow.hands)
self.assertTrue(flow.fixture_runnable(("button", "lid", "interlock")))
page = self.reg["commission.first-run-page"]
self.assertTrue(page.hands)
self.assertFalse(page.fixture_runnable(("button", "lid", "interlock")))
self.assertIn("commission.first-run-flow", page.requires)
def test_the_first_run_split_keeps_the_backend_and_the_page_apart(self):
flow = set(self.reg["commission.first-run-flow"].covers)
page = set(self.reg["commission.first-run-page"].covers)
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
self.assertIn(want, flow, want)
self.assertNotIn(want, page, want)
for want in (("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/md.js"), ("forgectrl", "src/tls.*")):
self.assertIn(want, page, want)
def test_the_only_attended_tests_need_a_workstation_or_the_sheet(self):
# With the bench actuator up, three commissioning tests keep a
# person: the page walk, the print from the Glowforge app, and
# the sheet. Everything else runs from the queue.
attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("commission.")
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
self.assertEqual(attended, ["commission.cloud-header-capture", "commission.first-run-page",
"commission.sheet"])
def test_the_factory_return_never_runs_the_return(self):
import inspect
t = self.reg["commission.factory-return"]
src = inspect.getsource(t.fn)
self.assertNotIn('"confirm": "1"', src) # the only argument that starts the return
self.assertIn('"confirm": "0"', src)
self.assertEqual(t.kind, "auto") # the return itself is a bench drill, not a test
self.assertFalse(t.hands)
def test_the_machine_name_covers_nothing_by_design(self):
self.assertEqual(self.reg["commission.machine-name"].covers, ())
def test_the_login_test_makes_its_own_account(self):
# No bench credentials, no precheck: the test installs a temporary
# account under a takeover and restores the real one.
t = self.reg["commission.account-login"]
self.assertIsNone(getattr(t, "precheck", None))
self.assertEqual(t.hardware, "takeover")
with open(commission.__file__) as f:
src = f.read()
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
self.assertNotIn(k, src)
class RecordTests(unittest.TestCase):
STATUS = {"sheet_id": "ABCDE-FGHIJ",
"documents": [{"id": "safety-and-risk", "hash": "a" * 64, "consent": "typed"},
{"id": "licenses", "hash": "b" * 64, "consent": "check"}],
"wizards": [{"id": "advisories", "version": 1}, {"id": "account", "version": 1},
{"id": "machine", "version": 2}]}
def test_complete_record_counts_as_commissioned(self):
rec = commission.complete_record(self.STATUS)
self.assertEqual(rec["schema"], 1)
self.assertEqual(rec["advisories"]["safety-and-risk"]["hash"], "a" * 64)
self.assertEqual(rec["advisories"]["safety-and-risk"]["method"], "typed")
self.assertIn("pressed_at", rec["acceptance"])
self.assertEqual(rec["account"]["name"], "bench")
self.assertEqual(rec["wizards"]["machine"]["version"], 2)
self.assertEqual(rec["sheet_id"], "ABCDE-FGHIJ")
self.assertTrue(rec["completed"])
json.dumps(rec)
def test_complete_record_keeps_the_base_account_and_machine(self):
base = {"account": {"name": "owner", "uid": 1000, "created": "x"}, "machine": {"model": "pro"},
"sheet_id": "KKKKK-LLLLL", "created": "then"}
rec = commission.complete_record(self.STATUS, base)
self.assertEqual(rec["account"]["name"], "owner")
self.assertEqual(rec["machine"], {"model": "pro"})
self.assertEqual(rec["sheet_id"], "KKKKK-LLLLL")
self.assertEqual(rec["created"], "then")
def test_without_wizards_keeps_consent_and_account(self):
rec = commission.complete_record(self.STATUS)
rec["flags"] = {"machine": {"level": "required", "reason": "x"}}
out = commission.record_without_wizards(rec)
self.assertEqual(out["wizards"], {})
self.assertEqual(out["flags"], {})
self.assertEqual(out["advisories"], rec["advisories"])
self.assertEqual(out["account"], rec["account"])
self.assertEqual(rec["flags"], {"machine": {"level": "required", "reason": "x"}}) # a copy
def test_write_file_and_remove(self):
tmp = tempfile.mkdtemp(prefix="forgetest-comm-")
try:
p = os.path.join(tmp, "sub", "commissioning.json")
commission.write_file(p, b"{}\n")
self.assertEqual(commission.read_file(p), b"{}\n")
if os.name == "posix": # a mode means nothing on a Windows host
self.assertEqual(oct(os.stat(p).st_mode & 0o777), oct(0o600))
commission.write_file(p, None)
self.assertIsNone(commission.read_file(p))
commission.write_file(p, None) # a second remove is silent
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_paths_follow_the_daemon_environment(self):
os.environ["FORGECTRL_DATA_DIR"] = "/tmp/ffdata"
os.environ["GF_RUN_DIR"] = "/tmp/ffrun"
try:
self.assertEqual(commission.record_path(), "/tmp/ffdata/commissioning.json")
self.assertEqual(commission.users_path(), "/tmp/ffdata/users")
self.assertEqual(commission.override_path(), "/tmp/ffrun/commissioning-override")
self.assertEqual(commission.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
finally:
os.environ.pop("FORGECTRL_DATA_DIR", None)
os.environ.pop("GF_RUN_DIR", None)
class MachineNameTests(unittest.TestCase):
def setUp(self):
self.root = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
def _net(self, **devs):
"""A /sys/class/net tree; commission.mac_suffix reads it through
commission.read_file, which takes an absolute path."""
for dev, mac in devs.items():
d = os.path.join(self.root, dev)
os.makedirs(d)
with open(os.path.join(d, "address"), "w") as f:
f.write(mac + "\n")
real = commission.read_file
def read(path):
head = "/sys/class/net/"
if path.startswith(head):
return real(os.path.join(self.root, path[len(head):]))
return real(path)
commission.read_file = read
self.addCleanup(setattr, commission, "read_file", real)
def test_the_wifi_address_names_the_machine(self):
self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
self.assertEqual(commission.mac_suffix(), "b00a")
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
self._net(eth0="00:11:22:33:44:55")
self.assertEqual(commission.mac_suffix(), "4455")
def test_an_unread_address_is_no_address(self):
self._net(wlan0="00:00:00:00:00:00")
self.assertEqual(commission.mac_suffix(), "")
def test_no_interface_is_no_address(self):
self._net()
self.assertEqual(commission.mac_suffix(), "")
class SmallHelpersTests(unittest.TestCase):
def test_cookie_parsing(self):
sid = "ab" * 32
value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid
self.assertEqual(commission.session_from_cookie(value), sid)
self.assertEqual(commission.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
self.assertIsNone(commission.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
self.assertIsNone(commission.session_from_cookie(""))
def test_the_teal_breathe_cue(self):
self.assertTrue(commission.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
"pulse_off": [0, 1400, 1400]}))
self.assertFalse(commission.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0],
"pulse_off": [0, 0, 0]})) # solid green
self.assertFalse(commission.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800],
"pulse_off": [1800] * 3})) # white
self.assertFalse(commission.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
self.assertFalse(commission.breathes_teal({}))
def test_decode(self):
self.assertEqual(commission.decode(b'{"a": 1}'), {"a": 1})
self.assertEqual(commission.decode(b"cloud mode is not enabled"), "cloud mode is not enabled")
class GatedSettleTests(unittest.TestCase):
"""A supervisor that reports the commissioning gate closed is settled:
it spawns nothing until the gate opens, so a takeover that installs
a gating record must not wait the whole settle timeout for it."""
def setUp(self):
self.fake = helpers.FakeForgectrl().start()
baseline.Baseline._unreachable_until = 0.0
def tearDown(self):
self.fake.stop()
def test_gated_returns_at_once(self):
self.fake.state["mode"] = {"mode": "grbl", "controller": "gated", "pid": 0, "motion": "unverified",
"gated": True, "why": "commissioning required: machine"}
lines = []
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
self.assertEqual(body["controller"], "gated")
self.assertTrue(any("controller=gated" in ln for ln in lines))
class FirstRunSeedTests(unittest.TestCase):
"""The record the first-run tests install: a first run behind the
checks and the sheet."""
STATUS = {"wizards": [{"id": "advisories", "version": 1, "class": "form"},
{"id": "account", "version": 1, "class": "form"},
{"id": "cloud", "version": 1, "class": "form"},
{"id": "switches", "version": 1, "class": "dark"},
{"id": "motion", "version": 2, "class": "dark"},
{"id": "sheet.frame", "version": 1, "class": "live"}]}
def test_no_consent_no_account_not_complete_and_every_check_done(self):
base = {"created": "2026-09-01T00:00:00Z", "completed": "2026-09-02T00:00:00Z",
"advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"},
"account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True},
"wizards": {"advisories": {"version": 1}, "switches": {"version": 1, "result": {"lid": True}}}}
rec = commission.first_run_record(self.STATUS, base)
self.assertNotIn("completed", rec)
self.assertNotIn("acceptance", rec)
self.assertNotIn("account", rec)
self.assertEqual(rec["advisories"], {})
self.assertEqual(rec["created"], "2026-09-01T00:00:00Z")
self.assertEqual(rec["flags"], {"flow_thin": True})
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
self.assertEqual(rec["wizards"]["switches"]["result"], {"lid": True}) # kept from the base
self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version
def test_without_a_base_record(self):
rec = commission.first_run_record(self.STATUS, None)
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
class SshdPolicyTests(unittest.TestCase):
def test_the_three_keys_out_of_sshd_t(self):
from forgetest import hw
calls = []
def fake_run(cmd, timeout=60):
calls.append(cmd)
return 0, "port 22\npermitrootlogin no\npasswordauthentication yes\npermitemptypasswords no\nx11forwarding no\n"
real = hw.run
hw.run = fake_run
try:
self.assertEqual(commission.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
"permitemptypasswords": "no"})
finally:
hw.run = real
self.assertEqual(calls, [["/usr/sbin/sshd", "-T"]])
def test_a_failing_sshd_reports_the_error(self):
from forgetest import hw
real = hw.run
hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available")
try:
self.assertEqual(commission.sshd_policy(), {"error": "sshd: no hostkeys available"})
finally:
hw.run = real
class CloudDisabledSurfaceTests(unittest.TestCase):
"""The cloud-off surface test against the fake daemon: cloud_enabled
is turned off with one write that sweeps the cloud choices, the three
cloud-pointing writes and the phrase-less cloud_enabled=1 are refused,
the settings are left alone, and the prior values come back."""
def setUp(self):
self.fake = helpers.FakeForgectrl().start()
self.fake.state["settings"].update({"controller_mode": "grbl", "homing_mode": "none",
"cloud_enabled": "1", "ui_units": "metric"})
def on_post(path, form):
s = self.fake.state["settings"]
if path == "/settings":
# as the daemon rules it: on from off takes the typed phrase
if form.get("cloud_enabled") == "1" and s.get("cloud_enabled") != "1" \
and form.get("phrase") != "I UNDERSTAND":
return 400, "type I UNDERSTAND to turn cloud mode on"
# the request's own cloud choices, as main.c checks them
enabled = form.get("cloud_enabled", s.get("cloud_enabled")) == "1"
if not enabled and form.get("controller_mode") == "cloud":
return 409, "cloud mode is not enabled on this machine"
if not enabled and form.get("homing_mode") == "gfcloud":
return 409, "cloud homing needs cloud mode enabled"
for k, v in form.items():
if k == "phrase":
continue
if v == "":
s.pop(k, None)
else:
s[k] = v
# off sweeps what pointed at the cloud, as the cloud step does
if form.get("cloud_enabled") == "0":
if "homing_mode" not in form and s.get("homing_mode") == "gfcloud":
s["homing_mode"] = "none"
if "controller_mode" not in form and s.get("controller_mode") == "cloud":
s["controller_mode"] = "grbl"
return 200, dict(s)
if path == "/mode" and form.get("controller") == "cloud" and s.get("cloud_enabled") != "1":
return 409, "cloud mode is not enabled on this machine"
return None
self.fake.on_post = on_post
def tearDown(self):
self.fake.stop()
def run_test(self):
t = catalog.load_suite()["commission.cloud-disabled-surface"]
run = Run("test", t.id, t.title)
t.fn(Context(run, None, t))
return run
def test_refusals_and_restore(self):
run = self.run_test()
s = self.fake.state["settings"]
self.assertEqual(s["cloud_enabled"], "1") # restored, with the phrase
self.assertEqual(s["controller_mode"], "grbl")
self.assertEqual(s["homing_mode"], "none")
self.assertNotIn("phrase", s)
posts = [(p, f) for p, f in self.fake.posts]
self.assertIn(("/settings", {"cloud_enabled": "0"}), posts)
self.assertIn(("/settings", {"cloud_enabled": "1"}), posts) # refused: no phrase
self.assertIn(("/settings", {"cloud_enabled": "1", "phrase": "I UNDERSTAND"}), posts)
self.assertIn(("/mode", {"controller": "cloud"}), posts)
self.assertEqual(run.evidence["mode_cloud"]["status"], 409)
self.assertEqual(run.evidence["settings cloud_enabled=1 no phrase"], 400)
self.assertEqual(run.evidence["found"]["cloud_enabled"], "1")
def test_cloud_homing_and_boot_mode_are_swept_by_the_one_write_and_go_back(self):
# As the daemon rules it: cloud_enabled=0 takes the gfcloud homing
# and the cloud boot mode down with it, so the test writes the one
# key, checks the sweep, and puts the three back, cloud mode first.
self.fake.state["settings"].update({"controller_mode": "cloud", "homing_mode": "gfcloud"})
run = self.run_test()
s = self.fake.state["settings"]
self.assertEqual((s["cloud_enabled"], s["homing_mode"], s["controller_mode"]), ("1", "gfcloud", "cloud"))
posts = [(p, f) for p, f in self.fake.posts if p == "/settings"]
order = [f for _, f in posts]
self.assertEqual(order[:1], [{"cloud_enabled": "0"}])
self.assertEqual(order[-3:], [{"cloud_enabled": "1", "phrase": "I UNDERSTAND"},
{"homing_mode": "gfcloud"}, {"controller_mode": "cloud"}])
self.assertEqual(run.evidence["found"], {"cloud_enabled": "1", "homing_mode": "gfcloud",
"controller_mode": "cloud"})
def test_an_unset_value_is_cleared_back(self):
self.fake.state["settings"].pop("cloud_enabled")
self.run_test()
self.assertNotIn("cloud_enabled", self.fake.state["settings"])
self.assertIn(("/settings", {"cloud_enabled": ""}), self.fake.posts)
if __name__ == "__main__":
unittest.main()
-19
View File
@@ -1,19 +0,0 @@
"""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()
-731
View File
@@ -1,731 +0,0 @@
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
under the real runner Context against a scripted machine: a fake
forgectrl (/status fans, /cool/status phase and verdict, /settings with
the gates table, /logs/tail), a fake kernel sysfs (the fan duties), and
a fake Grbl port that answers '?' and every command.
The bench case that motivated this: the test started one second after
the engine of a previous test went idle, took the tachs still coasting
at the cooldown level as its idle reference, and then waited for the
fans to come back UP to it. The idle reference now needs the engine
idle, the idle duty applied, and tachs that have stopped changing; the
pass condition is at-or-below that level, and every sample is logged.
"""
import os
import shutil
import socket
import tempfile
import threading
import time
import unittest
import helpers
from forgetest.runner import Context, Failed, Run
from forgetest.suite import cooling
class FakeGrbl:
"""Answers '?' with an Idle report and every line with ok."""
def __init__(self):
self.sock = socket.socket()
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(2)
os.environ["GRBL_HOST"] = "127.0.0.1"
os.environ["GRBL_PORT"] = str(self.sock.getsockname()[1])
self.commands = []
self.on_command = None
self._stop = False
threading.Thread(target=self._serve, daemon=True).start()
def _serve(self):
self.sock.settimeout(0.2)
while not self._stop:
try:
c, _ = self.sock.accept()
except OSError:
continue
threading.Thread(target=self._client, args=(c,), daemon=True).start()
def _client(self, c):
c.settimeout(0.1)
buf = b""
c.sendall(b"\r\nGrbl 1.1f ['$' for help]\r\n")
while not self._stop:
try:
d = c.recv(4096)
except socket.timeout:
continue
except OSError:
break
if not d:
break
for ch in d:
if ch == 0x3F: # '?'
c.sendall(b"<Idle|MPos:0.000,0.000,0.000|FS:0,0>\r\n")
elif ch in (0x18, 0x85):
pass
else:
buf += bytes([ch])
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
line = line.decode().strip()
if line:
self.commands.append(line)
if self.on_command:
self.on_command(line)
c.sendall(b"ok\r\n")
c.close()
def close(self):
self._stop = True
self.sock.close()
for k in ("GRBL_HOST", "GRBL_PORT"):
os.environ.pop(k, None)
class FansQuietTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-cool-")
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
os.makedirs(self.sysfs + "thermal")
os.makedirs(self.sysfs + "cnc")
with open(self.sysfs + "cnc/state", "w") as f:
f.write("idle")
os.environ["GF_SYSFS_ROOT"] = self.sysfs
self.fc = helpers.FakeForgectrl().start()
self.grbl = FakeGrbl()
self.saved = (cooling.SAMPLE_S, cooling.IDLE_REF_TIMEOUT_S, cooling.COOLDOWN_TIMEOUT_S,
cooling.RESTART_IDLE_TIMEOUT_S, cooling.hw.initd)
cooling.SAMPLE_S = 0.1
cooling.IDLE_REF_TIMEOUT_S = 3
cooling.COOLDOWN_TIMEOUT_S = 4
cooling.RESTART_IDLE_TIMEOUT_S = 1
# The daemon restart of the last phase, scripted: the init script
# is a stub, and the engine that comes up applies `after_start`
# (the idle duties by default; a daemon that keeps the busy-start
# cooldown airflow applies those instead).
self.initd_calls = []
self.after_start = (0, 0)
def initd(service, action, timeout=60):
self.initd_calls.append((service, action))
if action == "start":
self.duty(*self.after_start)
return 0, ""
cooling.hw.initd = initd
self.fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
self.duty(0, 0)
self.fans(0, 736, 733)
def tearDown(self):
(cooling.SAMPLE_S, cooling.IDLE_REF_TIMEOUT_S, cooling.COOLDOWN_TIMEOUT_S,
cooling.RESTART_IDLE_TIMEOUT_S, cooling.hw.initd) = self.saved
self.grbl.close()
self.fc.stop()
os.environ.pop("GF_SYSFS_ROOT", None)
shutil.rmtree(self.tmp, ignore_errors=True)
# -- the machine -------------------------------------------------------
def duty(self, exhaust, intake):
for attr, v in (("thermal/exhaust_pwm", exhaust), ("thermal/intake_pwm", intake)):
with open(self.sysfs + attr, "w") as f:
f.write(str(v))
def fans(self, exhaust, i1, i2, air=2080):
self.fc.state["status"] = dict(self.fc.state["status"],
fans={"air_assist": air, "exhaust": exhaust, "intake_1": i1, "intake_2": i2})
def run_engine(self):
"""M8 -> run profile; M9 -> smoke phase, then idle duty, then the
tachs coast down (a few samples of spin-down), like forgectrl's
engine on the bench."""
def on_command(line):
if line == "M8":
self.duty(65535, 43278)
self.fc.state["cool"]["phase"] = "run"
self.fans(6753, 3212, 3328, air=10997)
elif line == "M9":
def cooldown():
self.fc.state["cool"]["phase"] = "smoke"
time.sleep(0.3)
self.fc.state["cool"]["phase"] = "idle"
self.duty(0, 0)
for ex, i1 in ((5030, 3319), (3100, 2200), (1200, 1300), (0, 740)):
self.fans(ex, i1, i1 + 5)
time.sleep(0.25)
threading.Thread(target=cooldown, daemon=True).start()
self.grbl.on_command = on_command
def run_test(self):
run = Run("test", "cooling.fans-quiet-after-motion", "t")
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
cooling.fans_quiet(ctx)
return run
# -- cases ------------------------------------------------------------------
def test_idle_machine_passes_and_logs_every_sample(self):
self.run_engine()
run = self.run_test()
self.assertEqual(run.evidence["before"]["exhaust"], 0)
self.assertIsNotNone(run.evidence["settle_s"])
self.assertEqual(run.evidence["duty_after"], cooling.IDLE_DUTY)
self.assertIn("M8", self.grbl.commands)
self.assertIn("M9", self.grbl.commands)
cooldown_lines = [ln for ln in run.lines if "cooldown +" in ln]
self.assertGreaterEqual(len(cooldown_lines), 2, run.lines)
def test_a_spin_down_in_progress_is_not_taken_as_the_idle_reference(self):
"""The bench case: the previous test's fans are still coasting when
this one starts. The reference waits for them to settle, and the
run passes instead of waiting for the fans to come back up."""
self.run_engine()
self.fans(5030, 3319, 3377) # coasting, engine already idle, duty 0
def coast():
for ex, i1 in ((3900, 2600), (2500, 1700), (1100, 1000), (0, 736), (0, 736)):
time.sleep(0.12)
self.fans(ex, i1, i1 + 5)
threading.Thread(target=coast, daemon=True).start()
run = self.run_test()
self.assertEqual(run.evidence["before"]["exhaust"], 0, run.evidence["before"])
self.assertGreater(run.evidence["idle_ref_s"], 0.3)
self.assertIsNotNone(run.evidence["settle_s"])
self.assertTrue(any("idle ref:" in ln for ln in run.lines))
def test_the_restart_phase_stops_disables_starts_and_sees_idle_duty(self):
self.run_engine()
run = self.run_test()
self.assertEqual(run.finished, None)
self.assertEqual(self.initd_calls, [("forgectrl", "stop"), ("forgectrl", "start")])
with open(self.sysfs + "cnc/disable") as f:
self.assertEqual(f.read(), "1")
self.assertEqual(run.evidence["duty_after_restart"], cooling.IDLE_DUTY)
self.assertIsNotNone(run.evidence["restart_idle_s"])
def test_a_daemon_that_keeps_the_busy_start_airflow_fails(self):
# The bench case: after a takeover's restart the exhaust ran at
# cooldown duty on an idle machine for good.
self.run_engine()
self.after_start = (32768, 21639)
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("kept the busy-start airflow", str(cm.exception))
self.assertIn("32768", str(cm.exception))
def test_fans_left_running_fail_with_the_reference_in_the_message(self):
def on_command(line):
if line == "M8":
self.duty(65535, 43278)
self.fc.state["cool"]["phase"] = "run"
self.fans(6753, 3212, 3328)
# M9 ignored: the run profile stays on
self.grbl.on_command = on_command
run = Run("test", "cooling.fans-quiet-after-motion", "t")
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
with self.assertRaises(Failed) as cm:
cooling.fans_quiet(ctx)
self.assertIn("did not return to the idle profile", str(cm.exception))
self.assertIn("idle reference", str(cm.exception))
self.assertIn("65535", str(cm.exception))
def test_a_reference_that_never_settles_fails_early_and_says_so(self):
def churn():
seq = (1000, 4000, 2500, 700, 3300, 1600, 4200) # aperiodic against the sampler
i = 0
while not self.grbl._stop:
v = seq[i % len(seq)]
i += 1
self.fans(v, v, v)
time.sleep(0.03)
threading.Thread(target=churn, daemon=True).start()
run = Run("test", "cooling.fans-quiet-after-motion", "t")
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
with self.assertRaises(Failed) as cm:
cooling.fans_quiet(ctx)
self.assertIn("never settled to an idle reference", str(cm.exception))
self.assertEqual(self.grbl.commands, []) # nothing was jogged
if __name__ == "__main__":
unittest.main()
class GateOffTests(unittest.TestCase):
"""cooling.gate-off against a scripted engine: the fake forgectrl
re-reads the ceiling at every M8 (as the engine reloads its tunables
at run start), trips OVERTEMP when the coolant is over it, skips the
gate and reports gates_off when the ceiling sits at its top, and
writes the run-start line the test looks for."""
TOP, BOTTOM = 60.0, 5.0
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.grbl = FakeGrbl()
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S)
cooling.VERDICT_WAIT_S = 3
cooling.SESSION_END_WAIT_S = 3
self.fc.state["status"] = dict(self.fc.state["status"],
coolant={"down_c": 22.4, "up_c": 22.3, "pump": True, "tec": False},
temps={"chassis_c": 29.0, "supply_raw": 589, "soc_c": 42.8,
"soc_throttle": 0},
gates_off=[])
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
"gates_off": []}
self.fc.state["settings"].update({"cool_temp_max": "", "cool_temp_resume": ""})
self.log_line = True # the engine writes its run-start line
self.temps_line = True # the engine writes its run-end temperature line
self.report_off = True # the engine reports the off gate
self.trips = True # the engine trips a low ceiling
self.sessions = 0 # run sessions the engine saw (M8 with the phase not run)
self._describe()
self.grbl.on_command = self._engine
self.fc.on_post = self._on_post
def tearDown(self):
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S = self.saved
self.grbl.close()
self.fc.stop()
# -- the scripted machine --------------------------------------------------
def ceiling(self):
v = self.fc.state["settings"].get("cool_temp_max") or ""
return float(v) if v else 33.0
def _describe(self):
"""/settings carries the gates table the way forgectrl's gates.c
publishes it, classified from the stored value."""
v = self.ceiling()
state = "off" if v >= self.TOP else ("ok" if 25 <= v <= 38 else "warn")
self.fc.state["settings"]["gates"] = {
"cool_temp_max": {"gate": "coolant_max", "def": 33, "lo": self.BOTTOM, "hi": self.TOP,
"band": [25, 38], "off": "high", "value": v, "state": state}}
def _on_post(self, path, form):
"""The real settings reply re-classifies the gates from the new values."""
if path != "/settings":
return None
self.fc.state["settings"].update(form)
self._describe()
return (200, self.fc.state["settings"])
def _engine(self, line):
"""M8 opens a run session: the engine re-reads the ceiling and
ticks the gate. M9 ends it a report period later (the phase
leaves run); a hold taken against the old ceiling stands until
the next session re-reads."""
self._describe()
cool = self.fc.state["cool"]
if line == "M9":
def end():
time.sleep(0.3)
cool["phase"] = "smoke"
if self.temps_line:
self.fc.state["logs_tail"]["text"] += (
"Aug 22 12:00:30 forgectrl: cool: temps this job: chassis 29.0..29.4 C, "
"soc 42.8..47.1 C, supply raw 587..592\n")
time.sleep(0.2)
cool["phase"] = "idle"
threading.Thread(target=end, daemon=True).start()
return
if line != "M8":
return
self.sessions += 1
cool["phase"] = "run"
v = self.ceiling()
off = v >= self.TOP
if off and self.log_line:
self.fc.state["logs_tail"]["text"] += (
"Aug 21 12:00:00 forgectrl: cool: gate coolant_max OFF: cool_temp_max = 60 "
"(the high end of 5 to 60; recommended 25 to 38, default 33)\n")
gates_off = ["coolant_max"] if off and self.report_off else []
if not off and self.trips and 22.3 > v:
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, gates_off=gates_off)
else:
cool.update(verdict="OK", fire_ok=True, hold=False, gates_off=gates_off)
self.fc.state["status"]["gates_off"] = gates_off
def run_test(self):
run = Run("test", "cooling.gate-off", "t")
ctx = Context(run, None, helpers.make_test("cooling.gate-off", []))
cooling.gate_off(ctx)
return run
def settings_posts(self):
return [f for p, f in self.fc.posts if p == "/settings"]
# -- cases ------------------------------------------------------------------
def test_trip_then_off_then_restored_passes(self):
run = self.run_test()
self.assertEqual(run.evidence["trip"]["verdict"], "OVERTEMP")
self.assertEqual(run.evidence["off"]["gates_off"], ["coolant_max"])
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
posts = self.settings_posts()
self.assertEqual(posts[0], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0"})
self.assertEqual(posts[1], {"cool_temp_max": "60.0", "cool_temp_resume": ""})
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
self.assertEqual(self.grbl.commands.count("M8"), 3)
self.assertEqual(self.grbl.commands.count("M9"), 3)
def test_an_engine_that_does_not_trip_fails_and_restores(self):
self.trips = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("did not trip", str(cm.exception))
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
# The restore cycles a run session so the engine re-reads the
# restored values; the bench is not left holding on the test's.
self.assertEqual(self.grbl.commands.count("M8"), 2)
self.assertEqual(self.grbl.commands.count("M9"), 2)
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
def test_every_session_waits_for_the_previous_one_to_end(self):
"""Each M8 must find the engine out of phase run, or the engine
never re-reads: the bench failure behind this case sent M9 and
the next M8 300 ms apart and the 1 Hz report pipeline swallowed
the session end."""
seen = []
inner = self._engine
def engine(line):
if line == "M8":
seen.append(self.fc.state["cool"]["phase"])
inner(line)
self.grbl.on_command = engine
self.run_test()
self.assertEqual(seen, ["idle", "idle", "idle"])
self.assertEqual(self.sessions, 3)
def test_an_engine_that_hides_the_off_gate_fails(self):
self.report_off = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("gates_off", str(cm.exception))
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
def test_a_missing_run_start_log_line_fails(self):
self.log_line = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("run-start log line", str(cm.exception))
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
def test_a_missing_run_end_temperature_line_fails(self):
self.temps_line = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("board-temperature line", str(cm.exception))
def test_a_custom_ceiling_is_restored_verbatim(self):
self.fc.state["settings"].update({"cool_temp_max": "30", "cool_temp_resume": "28"})
self._describe()
run = self.run_test()
self.assertEqual(run.evidence["orig"], {"cool_temp_max": "30", "cool_temp_resume": "28"})
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "30", "cool_temp_resume": "28"})
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "30")
class CriticalTierTests(unittest.TestCase):
"""cooling.critical-tier against a scripted engine: two tiers on the
coolant, the fail tier winning over the pause tier in a run session
and ending with it, the critical line off at its top, and the settings
cross-check refusing a critical line at or below the ceiling."""
UP = 22.3
ROWS = { # key: (gate, default, lo, hi, band_lo, band_hi, off)
"cool_temp_max": ("coolant_max", 33.0, 5.0, 60.0, 25.0, 38.0, "high"),
"cool_temp_resume": (None, 31.0, 5.0, 59.0, 20.0, 36.0, "none"),
"cool_temp_critical_c": ("coolant_critical", 38.0, 6.0, 70.0, 36.0, 45.0, "high"),
}
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.grbl = FakeGrbl()
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S)
cooling.VERDICT_WAIT_S = 3
cooling.SESSION_END_WAIT_S = 3
self.fc.state["status"] = dict(self.fc.state["status"],
coolant={"down_c": 22.4, "up_c": self.UP, "pump": True, "tec": False},
gates_off=[])
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
"gates_off": []}
for k in self.ROWS:
self.fc.state["settings"].setdefault(k, "")
self.fc.state["logs_tail"] = {"text": ""}
self.faults = True # the engine has the critical tier
self.ends_with_session = True
self.cross_checks = True # the settings API refuses crit <= max
self._describe()
self.grbl.on_command = self._engine
self.fc.on_post = self._on_post
def tearDown(self):
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S = self.saved
self.grbl.close()
self.fc.stop()
def setting(self, key):
v = self.fc.state["settings"].get(key) or ""
return float(v) if v else self.ROWS[key][1]
def _describe(self):
gates = {}
for k, (gate, d, lo, hi, blo, bhi, off) in self.ROWS.items():
v = self.setting(k)
state = "off" if (off == "high" and v >= hi) else ("ok" if blo <= v <= bhi else "warn")
gates[k] = {"gate": gate, "def": d, "lo": lo, "hi": hi, "band": [blo, bhi], "off": off,
"value": v, "state": state}
self.fc.state["settings"]["gates"] = gates
def _on_post(self, path, form):
if path != "/settings":
return None
if self.cross_checks:
merged = dict(self.fc.state["settings"])
merged.update(form)
tmax = float(merged.get("cool_temp_max") or 33.0)
tcrit = float(merged.get("cool_temp_critical_c") or 38.0)
ceiling_off = tmax >= self.ROWS["cool_temp_max"][3]
if not ceiling_off and tcrit <= tmax:
return (400, {"error": "cool_temp_critical_c must be above cool_temp_max"})
self.fc.state["settings"].update(form)
self._describe()
return (200, self.fc.state["settings"])
def _verdict_idle(self):
"""Outside a run only the ceiling's pause tier stands."""
return "OVERTEMP" if self.UP > self.setting("cool_temp_max") else "OK"
def _engine(self, line):
self._describe()
cool = self.fc.state["cool"]
if line == "M9":
def end():
time.sleep(0.3)
cool["phase"] = "idle"
if self.ends_with_session or cool["verdict"] != "CRITICAL":
v = self._verdict_idle()
cool.update(verdict=v, fire_ok=v == "OK", hold=v != "OK", reason="")
threading.Thread(target=end, daemon=True).start()
return
if line != "M8":
return
cool["phase"] = "run"
tmax, tcrit = self.setting("cool_temp_max"), self.setting("cool_temp_critical_c")
crit_off = tcrit >= self.ROWS["cool_temp_critical_c"][3]
off = ["coolant_critical"] if crit_off else []
if crit_off:
self.fc.state["logs_tail"]["text"] += (
"Aug 22 12:00:00 forgectrl: cool: gate coolant_critical OFF: cool_temp_critical_c = 70 "
"(the high end of 6 to 70; recommended 36 to 45, default 38)\n")
if self.faults and not crit_off and self.UP >= tcrit:
cool.update(verdict="CRITICAL", fire_ok=False, hold=True, resume_ok=False,
reason="CRITICAL: coolant %.1f C at or over the %.0f C critical line - hold, "
"no resume this job" % (self.UP, tcrit), gates_off=off)
elif self.UP > tmax:
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, resume_ok=False,
reason="coolant over the ceiling", gates_off=off)
else:
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off)
self.fc.state["status"]["gates_off"] = off
def run_test(self):
run = Run("test", "cooling.critical-tier", "t")
ctx = Context(run, None, helpers.make_test("cooling.critical-tier", []))
cooling.critical_tier(ctx)
return run
def settings_posts(self):
return [f for p, f in self.fc.posts if p == "/settings"]
# -- cases ------------------------------------------------------------------
def test_fault_then_off_then_restored_passes(self):
run = self.run_test()
self.assertEqual(run.evidence["cross_check"]["status"], 400)
self.assertEqual(run.evidence["critical"]["verdict"], "CRITICAL")
self.assertEqual(run.evidence["critical_after"]["verdict"], "OVERTEMP")
self.assertEqual(run.evidence["critical_off"]["gates_off"], ["coolant_critical"])
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
self.assertEqual(run.evidence["ceiling_off_accepted"], 200)
posts = self.settings_posts()
# refused cross-check, the ceiling at its off end (accepted), its undo, then the low leg
self.assertEqual(posts[1], {"cool_temp_max": "60.0"})
self.assertEqual(posts[3], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0",
"cool_temp_critical_c": "7.0"})
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": "", "cool_temp_critical_c": ""})
self.assertEqual(self.grbl.commands.count("M8"), 3)
self.assertEqual(self.grbl.commands.count("M9"), 3)
def test_an_engine_without_the_tier_fails_and_restores(self):
self.faults = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("did not fault", str(cm.exception))
self.assertEqual(self.settings_posts()[-1],
{"cool_temp_max": "", "cool_temp_resume": "", "cool_temp_critical_c": ""})
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
def test_a_fault_that_outlives_its_session_fails(self):
self.ends_with_session = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("after the faulted session", str(cm.exception))
def test_a_missing_cross_check_fails_before_any_session(self):
self.cross_checks = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("was accepted", str(cm.exception))
self.assertEqual(self.grbl.commands.count("M8"), 0)
class FanGateTests(unittest.TestCase):
"""cooling.fan-gate-trips against a scripted engine: the fake reads the
floors at every M8 (the engine reloads at run start), holds every fan
in grace for the configured seconds, then judges the bench readings
(exhaust 6753, intakes 3212/3328, air assist 10997 rpm, purge 628)
against the floors: a floor a reading cannot meet trips AIRFLOW three
ticks after the grace, a floor of zero reads off."""
READINGS = {"exhaust": 6753, "intake_1": 3212, "intake_2": 3328, "air_assist": 10997, "purge": 628}
FLOORS = {"exhaust": ("cool_tach_exhaust_min_rpm", 3700.0, 20000.0),
"intake_1": ("cool_tach_intake_min_rpm", 1800.0, 20000.0),
"intake_2": ("cool_tach_intake_min_rpm", 1800.0, 20000.0),
"air_assist": ("cool_tach_air_assist_min_rpm", 6000.0, 30000.0),
"purge": ("cool_purge_min_current", 300.0, 1023.0)}
GATE_OF = {"exhaust": "exhaust", "intake_1": "intake", "intake_2": "intake",
"air_assist": "air_assist", "purge": "purge"}
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.grbl = FakeGrbl()
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling.FAN_TRIP_WAIT_S)
cooling.VERDICT_WAIT_S = 4
cooling.SESSION_END_WAIT_S = 3
cooling.FAN_TRIP_WAIT_S = 4
self.fc.state["status"] = dict(self.fc.state["status"], gates_off=[])
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
"gates_off": [], "fan_gates": {}}
for key, _d, _h in self.FLOORS.values():
self.fc.state["settings"].setdefault(key, "")
self.fc.state["settings"].setdefault("cool_fan_grace_s", "")
self.trips = True # the engine trips an unmeetable floor
self.reports_off = True # the engine reports a zero floor as off
self.grace_scale = 0.1 # seconds of fake grace per configured second
self._describe()
self.grbl.on_command = self._engine
self.fc.on_post = self._on_post
def tearDown(self):
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling.FAN_TRIP_WAIT_S = self.saved
self.grbl.close()
self.fc.stop()
def setting(self, key, default):
v = self.fc.state["settings"].get(key) or ""
return float(v) if v else default
def _describe(self):
gates = {}
for fan, (key, default, hi) in self.FLOORS.items():
v = self.setting(key, default)
gates[key] = {"gate": self.GATE_OF[fan], "def": default, "lo": 0.0, "hi": hi,
"band": [default * 0.7, default * 1.4], "off": "low", "value": v,
"state": "off" if v <= 0 else "ok"}
g = self.setting("cool_fan_grace_s", 15.0)
gates["cool_fan_grace_s"] = {"gate": None, "def": 15.0, "lo": 0.0, "hi": 120.0, "band": [5, 30],
"off": "none", "value": g, "state": "ok"}
self.fc.state["settings"]["gates"] = gates
def _on_post(self, path, form):
if path != "/settings":
return None
self.fc.state["settings"].update(form)
self._describe()
return (200, self.fc.state["settings"])
def _engine(self, line):
self._describe()
cool = self.fc.state["cool"]
if line == "M9":
def end():
time.sleep(0.3)
cool["phase"] = "idle"
# the fan fault is the session's: it ends with it
cool.update(verdict="OK", fire_ok=True, hold=False, reason="")
for g in cool.get("fan_gates", {}).values():
g["state"] = "idle"
threading.Thread(target=end, daemon=True).start()
return
if line != "M8":
return
cool["phase"] = "run"
floors = {fan: self.setting(key, default) for fan, (key, default, _h) in self.FLOORS.items()}
off = sorted({self.GATE_OF[f] for f, v in floors.items() if v <= 0}) if self.reports_off else []
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off,
fan_gates={f: {"reading": self.READINGS[f], "floor": floors[f],
"state": "off" if floors[f] <= 0 else "grace"} for f in floors})
self.fc.state["status"]["gates_off"] = off
grace = self.setting("cool_fan_grace_s", 15.0) * self.grace_scale
def judge():
time.sleep(grace)
if cool["phase"] != "run":
return
for f in floors:
if floors[f] > 0:
cool["fan_gates"][f]["state"] = "ok" if self.READINGS[f] >= floors[f] else "under"
time.sleep(0.3)
if cool["phase"] != "run" or not self.trips:
return
for f in floors:
if 0 < floors[f] > self.READINGS[f]:
cool["fan_gates"][f]["state"] = "TRIPPED"
cool.update(verdict="AIRFLOW", fire_ok=False, hold=True, resume_ok=False,
reason="AIRFLOW: %s %d under the %d floor for 3 s - hold, no resume this job"
% (f, self.READINGS[f], floors[f]))
break
threading.Thread(target=judge, daemon=True).start()
def run_test(self):
run = Run("test", "cooling.fan-gate-trips", "t")
ctx = Context(run, None, helpers.make_test("cooling.fan-gate-trips", []))
cooling.fan_gate_trips(ctx)
return run
def settings_posts(self):
return [f for p, f in self.fc.posts if p == "/settings"]
def test_trip_purge_off_and_restore_pass(self):
run = self.run_test()
ev = run.evidence
self.assertEqual(ev["exhaust_trip"]["cool"]["verdict"], "AIRFLOW")
self.assertEqual(ev["exhaust_trip"]["gate"]["state"], "TRIPPED")
self.assertEqual(ev["purge_trip"]["gate"]["state"], "TRIPPED")
self.assertEqual(ev["exhaust_off"]["cool"]["gates_off"], ["exhaust"])
self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values()))
posts = self.settings_posts()
self.assertEqual(posts[0], {"cool_tach_exhaust_min_rpm": "20000.0", "cool_fan_grace_s": "8"})
self.assertEqual(posts[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
"cool_fan_grace_s": ""})
self.assertEqual(self.grbl.commands.count("M8"), 4)
self.assertEqual(self.grbl.commands.count("M9"), 4)
def test_an_engine_that_does_not_trip_fails_and_restores(self):
self.trips = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("did not trip", str(cm.exception))
self.assertEqual(self.settings_posts()[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
"cool_fan_grace_s": ""})
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
def test_an_engine_that_hides_an_off_floor_fails(self):
self.reports_off = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("lacks exhaust", str(cm.exception))
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
-712
View File
@@ -1,712 +0,0 @@
"""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 finds it by name without a resolver on the image, 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 socket
import struct
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, Failed, 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
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
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()
class MdnsTests(unittest.TestCase):
def test_query_packet(self):
q = fx.mdns_query("forgefixture.local")
self.assertEqual(q[:12], struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0))
self.assertEqual(q[12:], b"\x0cforgefixture\x05local\x00" + struct.pack("!HH", 1, 0x8001))
def _response(self, name, ip, extra_name=None):
labels = b"".join(struct.pack("B", len(p)) + p.encode() for p in name.split(".")) + b"\x00"
hdr = struct.pack("!HHHHHH", 0, 0x8400, 0, 2 if extra_name else 1, 0, 0)
rr = labels + struct.pack("!HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(ip)
if extra_name:
# a second answer naming the first through a compression pointer
# to offset 12 (the first name), plus its own first label
other = struct.pack("B", len(extra_name)) + extra_name.encode() + struct.pack("!H", 0xC000 | 12)
rr += other + struct.pack("!HHIH", 1, 0x8001, 120, 4) + socket.inet_aton("10.0.0.9")
return hdr + rr
def test_answers_are_the_named_a_records(self):
data = self._response("forgefixture.local", "192.0.2.50")
self.assertEqual(fx.mdns_answers(data, "forgefixture.local"), ["192.0.2.50"])
self.assertEqual(fx.mdns_answers(data, "FORGEFIXTURE.local."), ["192.0.2.50"])
self.assertEqual(fx.mdns_answers(data, "other.local"), [])
# a query (QR clear) is never an answer
self.assertEqual(fx.mdns_answers(fx.mdns_query("forgefixture.local"), "forgefixture.local"), [])
# compression pointers are followed; the other record is not ours
data2 = self._response("forgefixture.local", "192.0.2.50", extra_name="printer")
self.assertEqual(fx.mdns_answers(data2, "forgefixture.local"), ["192.0.2.50"])
self.assertEqual(fx.mdns_answers(data2, "printer.forgefixture.local"), ["10.0.0.9"])
# garbage does not raise
self.assertEqual(fx.mdns_answers(b"\x00\x01", "forgefixture.local"), [])
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, **over):
cfg = {"hostname": "forgefixture", "key": KEY, "channels": ["lid", "interlock", "button"],
"port": self.dev.port}
cfg.update(over)
return fx.Fixture(cfg, resolver=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) # 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_two_presses_are_spaced_so_the_controller_sees_the_release(self):
f = self.client()
f.status()
t0 = time.time()
f.act("button", "press")
f.act("button", "press")
took = time.time() - t0
posts = [c for c in self.dev.calls if c[:2] == ("POST", "/button")]
self.assertEqual(len(posts), 2) # no 409 round trip was needed
self.assertEqual(self.dev.presses, 2)
# the second waited for the first pulse (200 ms as reported) and the gap
self.assertGreaterEqual(took, 0.2 + fx.BUTTON_GAP_S - 0.05)
def test_a_pulse_in_progress_is_waited_out_then_retried(self):
f = self.client()
f.status()
self.dev.state["button"] = "pressed" # a press this client did not time
threading.Timer(0.3, lambda: self.dev.state.__setitem__("button", "idle")).start()
f.act("button", "press")
posts = [c for c in self.dev.calls if c[:2] == ("POST", "/button")]
self.assertEqual(len(posts), 2) # the 409, then the press
self.assertEqual(self.dev.presses, 1)
def test_a_pulse_that_never_ends_is_the_fixtures_error(self):
f = self.client()
f.status()
self.dev.state["button"] = "pressed"
with self.assertRaises(fx.FixtureError) as cm:
f.act("button", "press")
self.assertIn("in progress", str(cm.exception))
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_is_none_and_a_silent_box_is_logged(self):
lines = []
self.assertIsNone(fx.probe(lines.append, path=os.path.join(tempfile.gettempdir(), "no-such-fixture.json")))
self.assertEqual(lines, [])
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)
self.assertTrue(lines and "running without it" in lines[-1], lines)
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)
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):
if self.fail:
raise fx.FixtureError("the box is off")
self.acts.append((channel, state))
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 test_without_the_jumper_the_button_tests_stay_attended(self):
self.stub.button_enabled = False
av, _ = self.selection()
self.assertIn("r.btn", av["attended"])
self.assertIn("r.lid", av["unattended"])
def test_no_fixture_is_the_old_routing(self):
fx.probe = lambda log, path=None, resolver=None: None
self.runner._fixture_probed = 0.0
av, summary = self.selection()
self.assertEqual(av["unattended"], ["r.auto"])
self.assertIsNone(summary)
def test_a_queue_started_during_a_probe_waits_for_the_fixture(self):
# The daemon just came up: nothing probed yet, a page poll starts
# the first probe (slow: an mDNS answer), and the queue start
# lands while it is in flight. The start must see the fixture.
stub = self.stub
def slow_probe(log, path=None, resolver=None):
time.sleep(0.5)
return stub
fx.probe = slow_probe
self.runner._fixture_probed = 0.0
self.runner.fixture = None
poll = threading.Thread(target=self.runner.probe_fixture)
poll.start()
time.sleep(0.1)
order = self.runner.batch_selection("unattended")
poll.join()
self.assertIn("r.lid", order)
self.assertIn("r.btn", order)
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_hands_the_action_to_the_operator(self):
self.stub.fail = True
run = Run("test", "r.lid", "r.lid")
ctx = Context(run, self.runner, self.reg["r.lid"])
self.runner.probe_fixture(force=True)
seen = []
ctx.wait_for = lambda cond, timeout, poll=0.25: 1.0 # the machine shows the state
run.set_notice = lambda text: seen.append(text)
ctx.act("lid", "open")
rec = run.evidence["actions"][0]
self.assertEqual(rec["by"], "operator")
self.assertIn("the box is off", rec["fixture_error"])
self.assertTrue(seen and "lid" in seen[0].lower())
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_presence_is_proved_once_per_test_not_once_per_gate(self):
"""A test with two armed halves reaches the ready gate twice. The
second one must not ask for another press: the operator proved
presence a moment earlier and the actuator has the presses. The
setup line still goes up, because the second half may want the
scrap moved."""
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("First half.")
self.assertTrue(run.fixture_takeover)
presses = [r for r in run.evidence["actions"] if r["state"] == "presence"]
self.assertEqual(len(presses), 1)
# the second gate: returns at once, no new press, notice still shown
seen = []
run.set_notice = lambda text: seen.append(text)
t0 = time.time()
ctx.ready("Second half. Move the scrap.")
self.assertLess(time.time() - t0, 1.0)
self.assertEqual(asked, [])
self.assertEqual(len([r for r in run.evidence["actions"] if r["state"] == "presence"]), 1)
self.assertIn("Second half. Move the scrap.", seen)
self.assertTrue(any("presence already proved" in ln 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_the_press_prompt_is_what_the_actuator_presses_on(self):
"""The machine's own press prompt, not a button LED read from
here: the button is lit through parts of a live check that are
not the arm, and a press then lands before the job waits for it
and is lost. press_now presses at the moment the check asks, with
no LED reading and no waiting thread."""
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: False # never consulted
try:
self.assertTrue(ctx.press_now())
self.assertIn(("button", "press"), self.stub.acts)
finally:
runner_mod.hw.button_lit = saved
rec = [r for r in run.evidence["actions"] if r["state"] == "arm"][-1]
self.assertEqual(rec["by"], "fixture")
def test_the_press_prompt_goes_to_the_operator_without_an_actuator(self):
"""No actuator, or one that never took this test's presses: the
notice goes up for a person, who is looking at the same prompt."""
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 = False
seen = []
run.set_notice = lambda text: seen.append(text)
self.assertFalse(ctx.press_now("Press it now."))
self.assertNotIn(("button", "press"), self.stub.acts)
self.assertIn("Press it now.", seen)
def test_the_arm_press_waits_as_long_as_the_card_says_for_the_light(self):
"""A card that settles the coolant before it lights the button
takes minutes; the caller names the wait, and the actuator presses
when the light comes instead of handing the press to a person at
the default 60 s (found on the bench by the flow-load card)."""
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
lit_at = time.time() + 0.6
saved = runner_mod.hw.button_lit
runner_mod.hw.button_lit = lambda: time.time() >= lit_at
seen = []
run.set_notice = lambda text: seen.append(text)
try:
self.assertTrue(ctx.arm_press(lit_timeout=5))
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)
self.assertEqual([n for n in seen if n], []) # nobody was asked
rec = run.evidence["actions"][-1]
self.assertEqual(rec["by"], "fixture")
self.assertGreaterEqual(rec["took_s"], 0.5)
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")
self.assertTrue(any("WARNING" in ln and "now gone" in ln for ln in run.lines))
def test_ready_still_asks_the_page_with_no_actuator(self):
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.fixture = None
asked = []
def ask(q, o):
asked.append((q, list(o)))
return "Ready"
run.ask = ask
ctx.ready("LIVE FIRE. Scrap under the head.")
self.assertEqual(asked, [("LIVE FIRE. Scrap under the head.", ["Ready", "Cannot"])])
self.assertFalse(run.fixture_takeover)
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
if __name__ == "__main__":
unittest.main()
-24
View File
@@ -1,24 +0,0 @@
import unittest
from forgetest.suite import image
class KernelIdentTests(unittest.TestCase):
def test_localversion_hash_is_stripped(self):
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc-g72a0b1431a9d"), "6.12.20-fslc-fslc")
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc+g72a0b1431a9d"), "6.12.20-fslc-fslc")
def test_plain_name_is_unchanged(self):
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc"), "6.12.20-fslc-fslc")
self.assertEqual(image.kernel_ident("6.12.20-fslc-g12"), "6.12.20-fslc-g12")
def test_release_matches_manifest_with_or_without_hash(self):
rel = "6.12.20-fslc-fslc-g72a0b1431a9d"
self.assertTrue(image.kernel_matches(rel, ["6.12.20-fslc-fslc"]))
self.assertTrue(image.kernel_matches(rel, ["6.12.20-fslc-fslc-gf52af5b522f4"]))
self.assertTrue(image.kernel_matches(rel, []))
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
if __name__ == "__main__":
unittest.main()
-67
View File
@@ -1,67 +0,0 @@
"""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()
-46
View File
@@ -1,46 +0,0 @@
"""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."""
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"])
if __name__ == "__main__":
unittest.main()
-282
View File
@@ -1,282 +0,0 @@
import os
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_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"))
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")])
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)
if __name__ == "__main__":
unittest.main()
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._PARTS.pop(self.path, None)
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._PARTS.pop(sib, None)
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._PARTS.pop(sib, None)
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_line_endings_do_not_count(self):
a1, b1 = self.shas()
catalog._PARTS.pop(self.path, None)
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)
-216
View File
@@ -1,216 +0,0 @@
"""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_the_supervisors_levers_get_the_slow_timeout(self):
from forgetest import hw
fc = hw.Forgectrl(timeout=1.0, slow_timeout=2.0)
self.assertEqual(fc.timeout_for("POST", "/mode"), 2.0)
self.assertEqual(fc.timeout_for("POST", "/controller/start"), 2.0)
self.assertEqual(fc.timeout_for("POST", "/controller/stop"), 2.0)
self.assertEqual(fc.timeout_for("GET", "/mode"), 1.0)
self.assertEqual(fc.timeout_for("POST", "/settings"), 1.0)
self.assertGreater(hw.SLOW_TIMEOUT_S, 15.0) # forgectrl's first-report wait
# a switch that answers after the ordinary timeout still lands
import time
def on_post(path, form):
time.sleep(1.5)
self.fc.state["mode"] = dict(self.fc.state["mode"], mode="cloud")
return None
self.fc.on_post = on_post
st, body = fc.post("/mode", data={"controller": "cloud"})
self.assertEqual(st, 200) # 1.5 s > timeout, < slow_timeout
self.assertEqual(self.fc.state["mode"]["mode"], "cloud")
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()
-350
View File
@@ -1,350 +0,0 @@
"""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. Runner-level events go to the journal, never to the
page state.
"""
import logging
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, journal
class Catch(logging.Handler):
def __init__(self):
logging.Handler.__init__(self)
self.lines = []
def emit(self, rec):
self.lines.append(rec.getMessage())
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_the_tests_context_follows_the_wording(self):
self.lid(False)
self.ctx.act("lid", "close", text="Leave it closed.", until=lambda: True, timeout=1)
self.assertTrue(any("NOTICE: Close the lid. Leave it closed." 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_a_fixture_covering_the_channel_performs_it(self):
class Fixture:
done = []
def covers(self, channel):
return channel == "lid"
def act(self, channel, state):
self.done.append((channel, state))
self_outer.lid(state != "open")
self_outer = self
class R:
fixture = Fixture()
ctx = Context(self.run, R(), helpers.make_test("t.f", [], kind="operator"))
ctx.act("lid", "open", timeout=3)
self.assertEqual(Fixture.done, [("lid", "open")])
self.assertEqual(self.ans.notices, [])
self.assertEqual(self.run.evidence["actions"][0]["by"], "fixture")
# a channel the fixture does not cover falls back to the operator
threading.Timer(0.2, self.interlock, args=(False,)).start()
ctx.act("interlock", "open", timeout=3)
self.assertEqual(self.ans.notices, [ctx.runner and "Open the remote-interlock loop: unplug the "
"Pro's interlock plug, or pull the jumper at J8 on a Basic/Plus."])
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")
def test_notice_is_logged_and_in_the_snapshot(self):
self.ctx.notice("Hold still.")
self.assertEqual(self.run.snapshot()["notice"]["text"], "Hold still.")
self.assertTrue(any("NOTICE: Hold still." in ln for ln in self.run.lines))
self.ctx.clear_notice()
self.assertIsNone(self.run.snapshot()["notice"])
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 JournalTests(unittest.TestCase):
def test_runner_notes_go_to_the_journal_and_the_run_in_progress(self):
tmp = tempfile.mkdtemp(prefix="forgetest-j-")
os.environ["FORGETEST_DATA"] = tmp
os.environ["FORGETEST_MARKER"] = os.path.join(tmp, "marker")
h = Catch()
journal.addHandler(h)
try:
r = Runner(Log(os.path.join(tmp, "r.jsonl")), helpers.make_manifest(),
helpers.registry(helpers.make_test("j.a", [])))
r._note("queue unattended: 1 test(s) to run")
self.assertIn("queue unattended: 1 test(s) to run", h.lines)
self.assertNotIn("messages", r.state()[0])
run = Run("test", "j.a", "j.a")
r.current = run
r._note("queue unattended: stop requested")
self.assertTrue(any("stop requested" in ln for ln in run.lines))
finally:
journal.removeHandler(h)
shutil.rmtree(tmp, ignore_errors=True)
def test_journal_tail_serves_the_daemon_log(self):
from forgetest.server import journal_tail
tmp = tempfile.mkdtemp(prefix="forgetest-jt-")
try:
p = os.path.join(tmp, "daemon.log")
self.assertIn(b"no journal yet", journal_tail(p))
with open(p, "wb") as f:
for i in range(2000):
f.write(b"line %d\n" % i)
tail = journal_tail(p, max_bytes=200)
self.assertTrue(tail.endswith(b"line 1999\n"))
self.assertLess(len(tail), 200)
self.assertTrue(tail.startswith(b"line ")) # a whole line, not a fragment
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
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))
-66
View File
@@ -1,66 +0,0 @@
"""The page assembly: the ui/ files inlined in order, the gzipped install
(what the dev image carries) assembling to the same bytes as a checkout,
the token placeholder carried exactly once, and a missing marker refused
rather than served."""
import gzip
import os
import shutil
import tempfile
import unittest
from forgetest import page
def gzipped_copy(src_dir):
"""A copy of src_dir with every file gzipped in place, the way the
recipe installs ui/."""
tmp = tempfile.mkdtemp(prefix="forgetest-ui-")
for root, _, files in os.walk(src_dir):
rel = os.path.relpath(root, src_dir)
dst_dir = os.path.normpath(os.path.join(tmp, rel))
os.makedirs(dst_dir, exist_ok=True)
for f in files:
with open(os.path.join(root, f), "rb") as i, \
gzip.open(os.path.join(dst_dir, f + ".gz"), "wb") as o:
o.write(i.read())
return tmp
class PageTests(unittest.TestCase):
def test_checkout_and_gzipped_install_agree(self):
plain = page.assemble()
tmp = gzipped_copy(page.UI_DIR)
try:
self.assertFalse(os.path.exists(os.path.join(tmp, "index.html")))
self.assertEqual(page.assemble(tmp), plain)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_page_is_self_contained(self):
html = page.assemble()
self.assertNotIn("<link ", html)
self.assertNotIn("<script src=", html)
self.assertEqual(html.count(page.TOKEN_MARK), 1)
self.assertIn("data-bs-theme", html)
for name in page.CSS_FILES + page.JS_FILES:
self.assertNotIn('href="%s"' % name, html)
self.assertNotIn('src="%s"' % name, html)
def test_render_substitutes_the_token(self):
out = page.render("deadbeef0123")
self.assertEqual(out.count("deadbeef0123"), 1)
self.assertNotIn(page.TOKEN_MARK, out)
def test_missing_marker_is_refused(self):
tmp = tempfile.mkdtemp(prefix="forgetest-ui-")
try:
with open(os.path.join(tmp, "index.html"), "w", encoding="utf-8") as f:
f.write("<html><head></head><body>no markers</body></html>")
with self.assertRaises(ValueError):
page.assemble(tmp)
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
-368
View File
@@ -1,368 +0,0 @@
"""The two queues: what they select, the order they run in, and where
they stop.
A campaign is mostly waiting, so the page offers to run what is still
owed in one go: the unattended tests for an empty room, the attended ones
for an operator at the machine. The rules that matter here are that a
queue takes exactly the unsatisfied tests of its kinds, runs a
prerequisite before the test that names it, stops on the first result
that is not a PASS (a FAIL closes the campaign, so going on would open a
second one behind the operator), and never fires the laser without the
acknowledgment.
"""
import json
import os
import shutil
import tempfile
import threading
import time
import unittest
import urllib.error
import urllib.request
import helpers
from forgetest import catalog, server
from forgetest.log import Log
from forgetest.runner import BATCH_GROUPS, Failed, Runner
def t_pass(ctx):
ctx.log("ok")
def t_fail(ctx):
ctx.check(False, "deliberate")
def t_prompt(ctx):
ctx.prompt("Ready?", ("Continue",))
def t_slow(ctx):
ctx.sleep(30)
class OrderTests(unittest.TestCase):
"""order_by_requires is a pure function over the requires graph."""
def order(self, tests, selected):
return catalog.order_by_requires(tests, selected)
def test_prerequisite_first(self):
a = helpers.make_test("s.a", [], fn=t_pass)
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
# selection order must not matter; registration order breaks ties
self.assertEqual(self.order([b, a], ["s.b", "s.a"]), ["s.a", "s.b"])
self.assertEqual(self.order([a, b], ["s.b", "s.a"]), ["s.a", "s.b"])
def test_chain(self):
c = helpers.make_test("s.c", [], requires=("s.b",), fn=t_pass)
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
a = helpers.make_test("s.a", [], fn=t_pass)
self.assertEqual(self.order([c, b, a], ["s.c", "s.b", "s.a"]), ["s.a", "s.b", "s.c"])
def test_registration_order_otherwise(self):
ts = [helpers.make_test("s.%d" % i, [], fn=t_pass) for i in range(4)]
self.assertEqual(self.order(ts, [t.id for t in ts]), ["s.0", "s.1", "s.2", "s.3"])
def test_prerequisite_outside_the_selection_places_nothing(self):
a = helpers.make_test("s.a", [], fn=t_pass)
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
self.assertEqual(self.order([a, b], ["s.b"]), ["s.b"])
def test_unknown_and_empty(self):
a = helpers.make_test("s.a", [], fn=t_pass)
self.assertEqual(self.order([a], []), [])
self.assertEqual(self.order([a], ["s.nope"]), [])
def test_a_cycle_does_not_recurse_forever(self):
"""validate() rejects cycles; a malformed registry must still not
hang the runner."""
a = helpers.make_test("s.a", [], requires=("s.b",), fn=t_pass)
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
out = self.order([a, b], ["s.a", "s.b"])
self.assertEqual(sorted(out), ["s.a", "s.b"])
class RunClockTests(unittest.TestCase):
"""The page shows the last run until the next one starts, so a
finished run's elapsed figure must be the time it took, not the time
since. Found on the bench reference, where a failed test went on
counting past 5000 s and read as still running."""
def test_a_finished_runs_clock_stops(self):
from forgetest.runner import Run
run = Run("test", "t.x", "t.x")
run.started = time.time() - 30
self.assertGreaterEqual(run.snapshot()["elapsed_s"], 30)
run.finished = {"result": "FAIL", "message": "", "duration_s": 31}
first = run.snapshot()["elapsed_s"]
time.sleep(1.1)
self.assertEqual(first, 31)
self.assertEqual(run.snapshot()["elapsed_s"], 31)
def test_a_running_runs_clock_ticks(self):
from forgetest.runner import Run
run = Run("test", "t.x", "t.x")
run.started = time.time() - 5
first = run.snapshot()["elapsed_s"]
time.sleep(1.1)
self.assertGreater(run.snapshot()["elapsed_s"], first)
class QueueTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.mkdtemp(prefix="forgetest-queue-")
os.environ["FORGETEST_DATA"] = cls.tmp
os.environ["FORGETEST_MARKER"] = os.path.join(cls.tmp, "marker")
cls.man = helpers.make_manifest()
cls.reg = helpers.registry(
# auto: b requires a, so a queue must run a first even though
# b registers earlier
helpers.make_test("q.b", [("forgectrl", "src/ui.c")], requires=("q.a",), fn=t_pass),
helpers.make_test("q.a", [("forgectrl", "src/auth.c")], fn=t_pass),
helpers.make_test("q.c", [("forgectrl", "src/cool.c")], fn=t_pass),
# attended
helpers.make_test("q.op", [("forgectrl", "src/main.c")], kind="operator", fn=t_prompt),
helpers.make_test("q.live", [("grblhal-glowforge", "src/**")], kind="live", fn=t_pass),
)
cls.log = Log(os.path.join(cls.tmp, "results.jsonl"))
cls.runner = Runner(cls.log, cls.man, cls.reg)
cls.token = server.load_token(os.path.join(cls.tmp, "token"))
cls.app = server.App(cls.runner, cls.token, export_dir=os.path.join(cls.tmp, "export"))
cls.srv = server.make_server(cls.app, "127.0.0.1", 0)
cls.port = cls.srv.server_address[1]
threading.Thread(target=cls.srv.serve_forever, daemon=True).start()
@classmethod
def tearDownClass(cls):
cls.srv.shutdown()
cls.srv.server_close()
shutil.rmtree(cls.tmp, ignore_errors=True)
def setUp(self):
# A clean history per test: a PASS left by an earlier one would be
# inherited and drop that test out of the selection. Truncating is
# also the shrunk-file path Log.read has to notice.
open(self.log.path, "w").close()
self.assertEqual(self.log.read(), [])
self.runner.batch = None
def call(self, method, path, body=None):
url = "http://127.0.0.1:%d%s" % (self.port, path)
hdrs = {"Host": "127.0.0.1:%d" % self.port, "X-ForgeFIRM-Token": self.token}
data = None
if body is not None:
data = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read().decode())
def wait_queue(self, timeout=30):
deadline = time.time() + timeout
while time.time() < deadline:
st, d = self.call("GET", "/state")
if d["batch"] and d["batch"]["finished"]:
return d
time.sleep(0.05)
self.fail("the queue did not finish")
# -- selection ---------------------------------------------------------
def test_groups_split_the_catalog_by_who_has_to_be_there(self):
self.assertEqual(BATCH_GROUPS["unattended"], ("auto",))
self.assertEqual(sorted(BATCH_GROUPS["attended"]), ["live", "operator"])
st, d = self.call("GET", "/state")
self.assertEqual(d["batch_available"]["unattended"], ["q.a", "q.b", "q.c"])
self.assertEqual(d["batch_available"]["attended"], ["q.op", "q.live"])
def test_selection_runs_prerequisites_first(self):
"""q.b registers before q.a but requires it."""
st, d = self.call("GET", "/state")
order = d["batch_available"]["unattended"]
self.assertLess(order.index("q.a"), order.index("q.b"))
def test_satisfied_tests_drop_out(self):
st, d = self.call("POST", "/start", {"test": "q.c"})
self.assertEqual(st, 200)
self.wait_idle()
st, d = self.call("GET", "/state")
self.assertEqual(d["tests"]["q.c"]["status"], "pass")
self.assertNotIn("q.c", d["batch_available"]["unattended"])
self.assertEqual(d["batch_available"]["unattended"], ["q.a", "q.b"])
def wait_idle(self, timeout=20):
deadline = time.time() + timeout
while time.time() < deadline:
st, d = self.call("GET", "/state")
if not d["running"]:
return d
time.sleep(0.05)
self.fail("run did not finish")
# -- running -----------------------------------------------------------
def test_unattended_queue_runs_everything_in_order(self):
st, d = self.call("POST", "/batch", {"group": "unattended"})
self.assertEqual(st, 200)
self.assertEqual(d["order"], ["q.a", "q.b", "q.c"])
d = self.wait_queue()
b = d["batch"]
self.assertEqual([x["test"] for x in b["done"]], ["q.a", "q.b", "q.c"])
self.assertEqual(set(x["result"] for x in b["done"]), {"PASS"})
self.assertEqual(b["skipped"], [])
self.assertIsNone(b["stopped"])
for tid in ("q.a", "q.b", "q.c"):
self.assertEqual(d["tests"][tid]["status"], "pass", tid)
# and the run records say which queue put them there
st, rec = self.call("GET", "/result?test=q.b")
self.assertEqual(rec["evidence"]["batch"]["group"], "unattended")
def test_an_empty_queue_is_refused_not_started(self):
self.call("POST", "/batch", {"group": "unattended"})
self.wait_queue()
st, d = self.call("POST", "/batch", {"group": "unattended"})
self.assertEqual(st, 409)
self.assertIn("already satisfied", d["message"])
def test_a_failure_stops_the_queue(self):
reg = helpers.registry(
helpers.make_test("f.a", [("forgectrl", "src/ui.c")], fn=t_pass),
helpers.make_test("f.b", [("forgectrl", "src/auth.c")], fn=t_fail),
helpers.make_test("f.c", [("forgectrl", "src/cool.c")], fn=t_pass),
)
tmp = tempfile.mkdtemp(prefix="forgetest-fail-")
try:
r = Runner(Log(os.path.join(tmp, "r.jsonl")), self.man, reg)
ok, msg, order = r.start_batch("unattended")
self.assertTrue(ok, msg)
self.assertEqual(order, ["f.a", "f.b", "f.c"])
deadline = time.time() + 20
while not r.batch["finished"] and time.time() < deadline:
time.sleep(0.05)
b = r.batch_snapshot()
self.assertEqual([x["test"] for x in b["done"]], ["f.a", "f.b"])
self.assertEqual(b["done"][1]["result"], "FAIL")
self.assertEqual(b["stopped"], "FAIL on f.b")
self.assertEqual(b["pending"], ["f.c"], "f.c ran after a FAIL closed the campaign")
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_a_test_that_cannot_start_is_skipped_not_silently_dropped(self):
"""An unmet prerequisite outside the queue refuses the start; the
queue records why and carries on."""
reg = helpers.registry(
helpers.make_test("k.op", [("forgectrl", "src/main.c")], kind="operator", fn=t_pass),
helpers.make_test("k.a", [("forgectrl", "src/ui.c")], requires=("k.op",), fn=t_pass),
helpers.make_test("k.b", [("forgectrl", "src/auth.c")], fn=t_pass),
)
tmp = tempfile.mkdtemp(prefix="forgetest-skip-")
try:
r = Runner(Log(os.path.join(tmp, "r.jsonl")), self.man, reg)
ok, msg, order = r.start_batch("unattended")
self.assertEqual(order, ["k.a", "k.b"])
deadline = time.time() + 20
while not r.batch["finished"] and time.time() < deadline:
time.sleep(0.05)
b = r.batch_snapshot()
self.assertEqual([x["test"] for x in b["skipped"]], ["k.a"])
self.assertIn("prerequisites not satisfied", b["skipped"][0]["reason"])
self.assertEqual([x["test"] for x in b["done"]], ["k.b"])
self.assertIsNone(b["stopped"])
finally:
shutil.rmtree(tmp, ignore_errors=True)
# -- safety and exclusion ----------------------------------------------
def test_an_attended_queue_with_a_live_test_needs_the_acknowledgment(self):
st, d = self.call("POST", "/batch", {"group": "attended"})
self.assertEqual(st, 409)
self.assertIn("fires the laser", d["message"])
self.assertIn("q.live", d["message"])
st, d = self.call("GET", "/state")
self.assertIsNone(d["batch"], "a refused queue must not exist")
def test_the_acknowledgment_reaches_every_live_run(self):
st, d = self.call("POST", "/batch", {"group": "attended", "ack_live": True})
self.assertEqual(st, 200)
# the operator test prompts; answer it so the queue can reach the live one
deadline = time.time() + 20
while time.time() < deadline:
st, s = self.call("GET", "/state")
if s["running"] and s["running"]["prompt"]:
self.call("POST", "/answer", {"prompt_id": s["running"]["prompt"]["id"],
"value": "Continue"})
break
if s["batch"] and s["batch"]["finished"]:
break
time.sleep(0.05)
d = self.wait_queue()
self.assertEqual([x["test"] for x in d["batch"]["done"]], ["q.op", "q.live"])
st, rec = self.call("GET", "/result?test=q.live")
self.assertTrue(rec["evidence"]["operator"]["ack_live"])
def test_a_single_start_is_refused_while_a_queue_runs(self):
reg = helpers.registry(
helpers.make_test("s.slow", [("forgectrl", "src/ui.c")], fn=t_slow),
helpers.make_test("s.other", [("forgectrl", "src/auth.c")], fn=t_pass),
)
tmp = tempfile.mkdtemp(prefix="forgetest-excl-")
try:
r = Runner(Log(os.path.join(tmp, "r.jsonl")), self.man, reg)
r.start_batch("unattended")
deadline = time.time() + 10
while r.current is None and time.time() < deadline:
time.sleep(0.02)
ok, msg = r.start_test("s.other")
self.assertFalse(ok)
self.assertEqual(msg, "a queue is running")
ok, msg = r.start_bench("anything")
self.assertFalse(ok)
self.assertEqual(msg, "a queue is running")
ok, msg, _ = r.start_batch("unattended")
self.assertFalse(ok)
self.assertIn("already running", msg)
r.stop_batch()
r.abort()
deadline = time.time() + 20
while not r.batch["finished"] and time.time() < deadline:
time.sleep(0.05)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_stop_cancels_what_is_waiting(self):
reg = helpers.registry(
helpers.make_test("p.slow", [("forgectrl", "src/ui.c")], fn=t_slow),
helpers.make_test("p.next", [("forgectrl", "src/auth.c")], fn=t_pass),
)
tmp = tempfile.mkdtemp(prefix="forgetest-stop-")
try:
r = Runner(Log(os.path.join(tmp, "r.jsonl")), self.man, reg)
r.start_batch("unattended")
deadline = time.time() + 10
while r.current is None and time.time() < deadline:
time.sleep(0.02)
ok, msg = r.stop_batch()
self.assertTrue(ok, msg)
self.assertTrue(r.batch_snapshot()["stopping"])
r.abort() # the run in progress needs its own lever
deadline = time.time() + 20
while not r.batch["finished"] and time.time() < deadline:
time.sleep(0.05)
b = r.batch_snapshot()
self.assertNotIn("p.next", [x["test"] for x in b["done"]])
ok, msg = r.stop_batch()
self.assertFalse(ok)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_unknown_group(self):
st, d = self.call("POST", "/batch", {"group": "everything"})
self.assertEqual(st, 409)
self.assertEqual(d["message"], "unknown queue")
if __name__ == "__main__":
unittest.main()
-252
View File
@@ -1,252 +0,0 @@
"""What keeps the page answering the operator instead of the timer.
Two things went wrong on the bench and are pinned here:
- the state cost. Every poll re-read and re-parsed the whole result log,
and recomputed every test's domain fingerprint. A result record carries
its run log, so the file reaches megabytes over a campaign and the poll
grew with it. Both are now parsed and computed once.
- the wasted payload. An idle page polls an unchanged state; it now gets
a 304 instead of the whole thing.
The click-swallowing defect these were found with lives in the page's
JavaScript and is not reachable from here: rows, prompt buttons and tool
entries are built once and afterwards only updated in place, because a
poll that rebuilt them removed the button between the operator's mousedown
and mouseup, and no click event was ever raised. `test_page_never_rebuilds`
holds the shape of that rule.
"""
import json
import os
import re
import shutil
import tempfile
import unittest
import helpers
from forgetest import catalog, manifest as manifest_mod, page
from forgetest.log import Log
def t_noop(ctx):
pass
class LogCacheTests(unittest.TestCase):
"""The log is append-only, so a read parses each line exactly once."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-log-")
self.path = os.path.join(self.tmp, "results.jsonl")
self.log = Log(self.path)
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def write(self, *lines):
with open(self.path, "a", encoding="utf-8") as f:
for line in lines:
f.write(line + "\n")
def test_missing_file_reads_empty(self):
self.assertEqual(self.log.read(), [])
def test_appends_are_picked_up(self):
self.log.append({"t": "campaign", "id": "c1"})
self.assertEqual([r["id"] for r in self.log.read()], ["c1"])
self.log.append({"t": "result", "test": "a.b"})
self.log.append({"t": "result", "test": "c.d"})
recs = self.log.read()
self.assertEqual([r["t"] for r in recs], ["campaign", "result", "result"])
self.assertEqual(recs[2]["test"], "c.d")
def test_only_new_bytes_are_parsed(self):
for i in range(5):
self.log.append({"t": "result", "test": "t%d" % i})
self.log.read()
# a second read must not touch the file at all
real_open = open
opened = []
def counting_open(*a, **kw):
opened.append(a[0])
return real_open(*a, **kw)
import builtins
builtins.open = counting_open
try:
recs = self.log.read()
finally:
builtins.open = real_open
self.assertEqual(opened, [], "an unchanged log was re-opened")
self.assertEqual(len(recs), 5)
def test_read_returns_a_fresh_list(self):
"""Callers filter and sort the result; the cache must not be theirs."""
self.log.append({"t": "result", "test": "a.b"})
first = self.log.read()
first.append({"t": "bogus"})
self.assertEqual(len(self.log.read()), 1)
def test_partial_trailing_line_is_held_not_counted_corrupt(self):
self.write('{"t":"result","test":"a.b"}')
with open(self.path, "a", encoding="utf-8") as f:
f.write('{"t":"result","te') # a line still being written
recs = self.log.read()
self.assertEqual(len(recs), 1)
self.assertEqual(self.log.corrupt, 0)
with open(self.path, "a", encoding="utf-8") as f:
f.write('st":"c.d"}\n') # ... now finished
recs = self.log.read()
self.assertEqual([r["test"] for r in recs], ["a.b", "c.d"])
self.assertEqual(self.log.corrupt, 0)
def test_corrupt_lines_counted_once_not_per_read(self):
self.write('{"t":"result","test":"a.b"}', "not json at all", '["not","a","record"]',
'{"t":"result","test":"c.d"}')
recs = self.log.read()
self.assertEqual([r["test"] for r in recs], ["a.b", "c.d"])
self.assertEqual(self.log.corrupt, 2)
self.log.read()
self.log.read()
self.assertEqual(self.log.corrupt, 2, "corrupt lines re-counted on every read")
def test_replaced_file_is_read_again(self):
self.write('{"t":"result","test":"a.b"}', '{"t":"result","test":"c.d"}')
self.assertEqual(len(self.log.read()), 2)
with open(self.path, "w", encoding="utf-8") as f:
f.write('{"t":"campaign","id":"c9"}\n')
recs = self.log.read()
self.assertEqual([r.get("id") for r in recs], ["c9"])
def test_blank_lines_are_not_corrupt(self):
self.write('{"t":"result","test":"a.b"}', "", " ", '{"t":"result","test":"c.d"}')
self.assertEqual(len(self.log.read()), 2)
self.assertEqual(self.log.corrupt, 0)
class FingerprintCacheTests(unittest.TestCase):
"""Memoized per manifest: the page recomputes every test's fingerprint
on every poll, and a manifest never changes under a running tool."""
def setUp(self):
self.man = helpers.make_manifest()
self.t = helpers.make_test("fake.fp", [("forgectrl", "src/ui.c")], fn=t_noop)
def test_repeat_calls_agree(self):
first = self.t.fingerprint(self.man)
self.assertEqual(self.t.fingerprint(self.man), first)
self.assertEqual(self.t.fingerprint(self.man), first)
def test_a_changed_manifest_is_not_served_from_the_cache(self):
first = self.t.fingerprint(self.man)
man2 = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
self.assertNotEqual(self.t.fingerprint(man2), first)
# and back again: the cache must not have latched the new one either
self.assertEqual(self.t.fingerprint(self.man), first)
def test_a_file_outside_the_coverage_does_not_move_it(self):
first = self.t.fingerprint(self.man)
man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
self.assertEqual(self.t.fingerprint(man2), first)
def test_manifest_without_a_content_hash_is_not_cached(self):
"""The cache is keyed by the manifest's content hash. A manifest
that has none must still fingerprint correctly, not collide."""
a = manifest_mod.Manifest({"components": {"forgectrl": {"files": [["src/ui.c", "aaa"]]}},
"platform": {}})
b = manifest_mod.Manifest({"components": {"forgectrl": {"files": [["src/ui.c", "bbb"]]}},
"platform": {}})
self.assertIsNone(a.content_sha)
self.assertNotEqual(self.t.fingerprint(a), self.t.fingerprint(b))
def test_component_file_list_is_stable_and_read_only(self):
files = self.man.files("forgectrl")
self.assertEqual(files, self.man.files("forgectrl"))
self.assertIsNone(self.man.files("no-such-component"))
class PageTests(unittest.TestCase):
def test_page_never_rebuilds_what_the_operator_may_be_pressing(self):
"""A poll must update rows, prompt buttons and tool entries in
place. Assigning innerHTML to their containers on every poll is
what swallowed the clicks; only the one-time build may do it."""
html = page.render("0" * 32)
for container, builder in (("groups", "buildGroups"), ("tools", "buildBench")):
self.assertIn("function %s(" % builder, html,
"no one-time builder for #%s" % container)
assigns = html.count("$('%s').innerHTML=" % container)
self.assertEqual(assigns, 1,
"#%s is assigned innerHTML %d times; it belongs to the "
"builder alone" % (container, assigns))
# the per-poll path writes through the guarded setters only
self.assertIn("function setHtml(e,h){if(e&&e.__h!==h)", html)
self.assertIn("function updateGroups()", html)
self.assertIn("function updateBench()", html)
# the prompt buttons are rebuilt only when the prompt changes
self.assertIn("if(pk!==promptKey)", html)
# the queue controls are static markup: a poll relabels them and
# flips disabled, it never replaces the node
for bid in ("q-unattended", "q-attended", "q-stop"):
self.assertIn('id="%s"' % bid, html)
self.assertNotIn("id='" + bid + "-", html)
self.assertNotIn('id="' + bid + "-", html)
self.assertIn("function renderQueue()", html)
# the help popovers sit on static markup only: a rebuilt row or
# tool entry would orphan one
self.assertNotIn("data-help", html[html.index("function buildGroups("):])
def test_every_group_is_built_on_the_same_grid(self):
"""The subsystems are separate tables. Left to size themselves
from their own content no two line up, which is what made the
page look busy, so they share one colgroup and a fixed layout."""
html = page.render("0" * 32)
self.assertRegex(html, r"#groups table\s*\{\s*table-layout:\s*fixed")
# one colgroup definition, emitted into every group's table
self.assertEqual(html.count('var COLS="<colgroup>'), 1)
self.assertEqual(html.count('<table>"+COLS+"'), 1,
"a group table built without the shared colgroup")
colgroup = re.search(r'var COLS="(.*?)";', html).group(1)
heads = ["Test", "Kind", "Status", "Last result"]
for h in heads:
self.assertIn("<th>%s</th>" % h, html)
self.assertEqual(len(re.findall(r"<col(?:>| )", colgroup)), len(heads) + 1,
"a column per header, the action column included")
# every fixed column carries a width, or the grid is only a wish
for cls in re.findall(r"<col class='(\w)'", colgroup):
self.assertRegex(html, r"#groups col\.%s\s*\{\s*width:\s*\d+px" % cls)
def test_details_and_the_requires_note_stay_out_of_the_columns(self):
"""Both are long enough to stretch a cell. In a column they would
pull one group's grid out of step with the rest: the details get a
full-width row, and the note sits under Status, which is sized for
it."""
html = page.render("0" * 32)
self.assertIn("<tr class='detrow'><td colspan='5'>", html)
# the status cell carries the status, the unmet note and the row
# message; the action cell carries the button and nothing else
self.assertIn("<td><div id='st-\"+d+\"'></div><div id='unmet-\"+d+"
"\"'></div><div id='note-\"+d+\"'></div></td>", html)
self.assertIn("<td><button class='btn btn-sm btn-primary' id='btn-\"+d+\"' "
"onclick='startTest(\\\"\"+d+\"\\\")'>Start</button></td>", html)
def test_page_is_self_contained_ascii(self):
html = page.render("0" * 32)
self.assertNotIn("__TOKEN__", html)
# our own sources stay ASCII (no stray typography in an embedded
# page); the vendored Bootstrap carries its own glyphs
for name in ("index.html",) + page.CSS_FILES + page.JS_FILES:
if name.startswith("vendor/"):
continue
page.read_ui(name).encode("ascii")
stray = sorted(set(hex(ord(c)) for c in html if ord(c) < 32 and c != "\n"))
self.assertEqual(stray, [], "control characters in the page source")
# nothing fetched from anywhere: the documentation links open a
# site, they are not assets
for remote in ("<link ", "<script src=", "@import", "url(http", "url(//",
'src="http', "src='http", "//cdn"):
self.assertNotIn(remote, html)
if __name__ == "__main__":
unittest.main()
-431
View File
@@ -1,431 +0,0 @@
"""Runner + HTTP API end to end on localhost with a fake catalog and a
fake bench tool. No hardware, no forgectrl."""
import json
import os
import shutil
import sys
import tempfile
import threading
import time
import unittest
import urllib.error
import urllib.request
import helpers
from forgetest import bench as bench_mod
from forgetest import catalog, server
from forgetest.log import Log
from forgetest.runner import Failed, Runner
def t_pass(ctx):
ctx.log("hello")
ctx.evidence["k"] = 1
def t_prompt(ctx):
ans = ctx.prompt("Did the light blink?", ("Yes", "No"))
if ans != "Yes":
raise Failed("operator said no")
def t_fail(ctx):
ctx.check(False, "deliberate")
def t_slow(ctx):
ctx.sleep(30)
def t_error(ctx):
raise RuntimeError("boom")
class ServerTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.mkdtemp(prefix="forgetest-")
os.environ["FORGETEST_DATA"] = cls.tmp
os.environ["FORGETEST_MARKER"] = os.path.join(cls.tmp, "marker")
cls.man = helpers.make_manifest()
cls.reg = helpers.registry(
helpers.make_test("fake.pass", [("forgectrl", "src/ui.c")], always=True, fn=t_pass),
helpers.make_test("fake.prompt", [("forgectrl", "src/auth.c")], fn=t_prompt, kind="operator"),
helpers.make_test("fake.fail", [("forgectrl", "src/cool.c")], fn=t_fail),
helpers.make_test("fake.slow", [("forgectrl", "src/main.c")], fn=t_slow),
helpers.make_test("fake.error", [("forgectrl", "src/main.c")], fn=t_error),
helpers.make_test("fake.live", [("grblhal-glowforge", "src/**")], fn=t_pass, kind="live"),
helpers.make_test("fake.needs", [("kernel-module-glowforge", "**")], fn=t_pass,
requires=("fake.prompt",)),
)
# a fake bench tool
cls.tooldir = os.path.join(cls.tmp, "bench")
os.makedirs(cls.tooldir)
with open(os.path.join(cls.tooldir, "echo_tool.py"), "w") as f:
f.write("import os, sys, time\nprint('args', sys.argv[1:])\n"
"print('env', os.environ.get('GF_HOST'), os.environ.get('FORGETEST_BENCH_DATA'))\n"
"sys.stdout.flush()\n"
"if 'slow' in sys.argv: time.sleep(30)\nsys.exit(0 if 'fail' not in sys.argv else 3)\n")
tools = [{"id": "echo", "title": "Echo", "script": "echo_tool.py", "safety": "dry", "where": "board",
"ported": True, "desc": "echo",
"args": [{"name": "word", "type": "str", "default": "hi", "help": ""},
{"name": "n", "type": "int", "default": 2, "help": ""}]},
{"id": "unported", "title": "U", "script": "nope.py", "safety": "dry", "where": "host",
"ported": False, "desc": "", "args": []},
{"id": "hot", "title": "H", "script": "echo_tool.py", "safety": "live", "where": "board",
"ported": True, "desc": "", "args": []},
{"id": "tk", "title": "T", "script": "echo_tool.py", "safety": "takeover", "where": "board",
"ported": True, "desc": "", "args": []},
{"id": "sc", "title": "S", "script": "echo_tool.py", "safety": "scope", "where": "board",
"ported": True, "desc": "", "args": []}]
cls.bench = bench_mod.Bench(tools, tool_dir=cls.tooldir,
index_path=os.path.join(cls.tmp, "bench.jsonl"))
cls.log = Log(os.path.join(cls.tmp, "results.jsonl"))
cls.runner = Runner(cls.log, cls.man, cls.reg, cls.bench)
cls.token = server.load_token(os.path.join(cls.tmp, "token"))
cls.app = server.App(cls.runner, cls.token, export_dir=os.path.join(cls.tmp, "export"))
cls.srv = server.make_server(cls.app, "127.0.0.1", 0)
cls.port = cls.srv.server_address[1]
cls.th = threading.Thread(target=cls.srv.serve_forever, daemon=True)
cls.th.start()
@classmethod
def tearDownClass(cls):
cls.srv.shutdown()
cls.srv.server_close()
shutil.rmtree(cls.tmp, ignore_errors=True)
# -- helpers ----------------------------------------------------------
def call(self, method, path, body=None, token=True, headers=None):
st, payload, _ = self.call_h(method, path, body, token, headers)
return st, payload
def call_h(self, method, path, body=None, token=True, headers=None):
"""As call, plus the response headers."""
url = "http://127.0.0.1:%d%s" % (self.port, path)
hdrs = {"Host": "127.0.0.1:%d" % self.port}
if token:
hdrs["X-ForgeFIRM-Token"] = self.token
data = None
if body is not None:
data = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
if headers:
hdrs.update(headers)
req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
try:
with urllib.request.urlopen(req, timeout=10) as r:
raw = r.read()
st = r.status
rh = r.headers
except urllib.error.HTTPError as e:
raw = e.read()
st = e.code
rh = e.headers
ct = rh.get("Content-Type", "")
if "json" in ct:
return st, json.loads(raw.decode()), rh
return st, raw, rh
def wait_idle(self, timeout=10):
deadline = time.time() + timeout
while time.time() < deadline:
st, d = self.call("GET", "/state")
if not d["running"]:
return d
time.sleep(0.1)
self.fail("run did not finish")
def wait_prompt(self, timeout=10):
deadline = time.time() + timeout
while time.time() < deadline:
st, d = self.call("GET", "/state")
if d["running"] and d["running"]["prompt"]:
return d["running"]["prompt"]
time.sleep(0.05)
self.fail("no prompt appeared")
# -- tests -----------------------------------------------------------------
def test_01_auth_and_page(self):
st, d = self.call("GET", "/state", token=False)
self.assertEqual(st, 200)
st, d = self.call("GET", "/", token=False)
self.assertEqual(st, 200)
self.assertIn(self.token.encode(), d)
page = d.decode("utf-8")
# one self-contained response: nothing linked, the token once,
# the theme attribute the head script sets
self.assertEqual(page.count(self.token), 1)
self.assertNotIn("<link ", page)
self.assertNotIn("<script src=", page)
self.assertIn("data-bs-theme", page)
st, d = self.call("GET", "/state", headers={"Host": "evil.example.net"})
self.assertEqual(st, 403)
st, d = self.call("GET", "/state", headers={"Origin": "http://evil.example.net"})
self.assertEqual(st, 403)
st, d = self.call("GET", "/state", headers={"Sec-Fetch-Site": "cross-site"})
self.assertEqual(st, 403)
st, d = self.call("POST", "/start", {"test": "fake.pass"}, token=False)
self.assertEqual(st, 403)
self.assertEqual(d["error"], "authentication required")
st, d = self.call("GET", "/catalog")
self.assertEqual(st, 200)
self.assertEqual(len(d["tests"]), 7)
st, d = self.call("GET", "/nope")
self.assertEqual(st, 404)
def test_01c_connection_is_kept_alive(self):
"""A poll per second over a fresh TCP connection each time is
waste the board does not need to pay."""
import http.client
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
for _ in range(3):
c.request("GET", "/state", headers={"Host": "127.0.0.1:%d" % self.port})
r = c.getresponse()
r.read()
self.assertEqual(r.status, 200)
self.assertEqual(r.version, 11)
self.assertNotEqual((r.getheader("Connection") or "").lower(), "close")
self.assertIsNotNone(r.getheader("Content-Length"))
finally:
c.close()
def test_01d_refused_post_does_not_poison_the_connection(self):
"""A POST refused before its body is read leaves that body in the
socket. On a kept-alive connection the next read would take it for
a request line, so a refusal must end the connection instead."""
import http.client
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
body = json.dumps({"test": "fake.pass"}).encode()
c.request("POST", "/start", body=body,
headers={"Host": "127.0.0.1:%d" % self.port,
"Content-Type": "application/json"}) # no token
r = c.getresponse()
payload = json.loads(r.read().decode())
self.assertEqual(r.status, 403)
self.assertEqual(payload["error"], "authentication required")
self.assertEqual((r.getheader("Connection") or "").lower(), "close",
"an unread body was left on a connection kept alive")
finally:
c.close()
# the server is still healthy, and the body was never taken for a request
st, d = self.call("GET", "/state")
self.assertEqual(st, 200)
self.assertIsNone(d["running"])
def test_02_run_pass_opens_campaign(self):
st, d = self.call("GET", "/state")
self.assertIsNone(d["campaign"])
st, d = self.call("POST", "/start", {"test": "fake.pass"})
self.assertEqual(st, 200, d)
state = self.wait_idle()
self.assertIsNotNone(state["campaign"])
self.assertEqual(state["tests"]["fake.pass"]["status"], "pass")
self.assertEqual(state["last_run"]["finished"]["result"], "PASS")
st, rec = self.call("GET", "/result?test=fake.pass")
self.assertEqual(st, 200)
self.assertEqual(rec["evidence"]["k"], 1)
# the baseline passes ran (no machine on the host: nothing to restore)
self.assertEqual(rec["evidence"]["baseline"], {"pre": [], "post": []})
self.assertTrue(any("hello" in l for l in rec["log"]))
def test_03_requires_and_live_gate(self):
st, d = self.call("POST", "/start", {"test": "fake.needs"})
self.assertEqual(st, 409)
self.assertIn("prerequisites", d["message"])
# the operator's override: the test runs alone and the record says so
st, d = self.call("POST", "/start", {"test": "fake.needs", "ignore_requires": True})
self.assertEqual(st, 200)
state = self.wait_idle()
self.assertEqual(state["tests"]["fake.needs"]["status"], "pass")
st, rec = self.call("GET", "/result?test=fake.needs")
self.assertEqual(rec["evidence"]["prerequisites"]["missing"], ["fake.prompt"])
self.assertTrue(rec["evidence"]["prerequisites"]["overridden"])
self.assertTrue(any("prerequisites overridden" in l for l in rec["log"]))
# its prerequisite is still required for the release
self.assertFalse(state["tests"]["fake.prompt"]["satisfied"])
self.assertFalse(state["authorized"])
st, d = self.call("POST", "/start", {"test": "fake.live"})
self.assertEqual(st, 409)
self.assertIn("live", d["message"])
st, d = self.call("POST", "/start", {"test": "fake.live", "ack_live": True})
self.assertEqual(st, 200)
state = self.wait_idle()
self.assertEqual(state["tests"]["fake.live"]["status"], "pass")
st, rec = self.call("GET", "/result?test=fake.live")
self.assertTrue(rec["evidence"]["operator"]["ack_live"])
def test_04_prompt_flow(self):
st, d = self.call("POST", "/start", {"test": "fake.prompt"})
self.assertEqual(st, 200)
p = self.wait_prompt()
self.assertEqual(p["options"], ["Yes", "No"])
st, d = self.call("POST", "/answer", {"prompt_id": p["id"], "value": "Maybe"})
self.assertEqual(st, 409)
st, d = self.call("POST", "/answer", {"prompt_id": p["id"], "value": "Yes"})
self.assertEqual(st, 200)
state = self.wait_idle()
self.assertEqual(state["tests"]["fake.prompt"]["status"], "pass")
st, rec = self.call("GET", "/result?test=fake.prompt")
self.assertEqual(rec["answers"][0]["answer"], "Yes")
# now the dependent test may start
st, d = self.call("POST", "/start", {"test": "fake.needs"})
self.assertEqual(st, 200)
self.wait_idle()
def test_05_busy_abort(self):
st, d = self.call("POST", "/start", {"test": "fake.slow"})
self.assertEqual(st, 200)
st, d = self.call("POST", "/start", {"test": "fake.pass"})
self.assertEqual(st, 409)
st, d = self.call("POST", "/abort")
self.assertEqual(st, 200)
state = self.wait_idle()
self.assertEqual(state["tests"]["fake.slow"]["status"], "aborted")
self.assertIsNotNone(state["campaign"], "an abort does not close the campaign")
def test_06_error_and_fail_close_campaign(self):
st, d = self.call("GET", "/state")
cid = d["campaign"]["id"]
st, d = self.call("POST", "/start", {"test": "fake.error"})
state = self.wait_idle()
self.assertEqual(state["tests"]["fake.error"]["status"], "error")
self.assertIsNone(state["campaign"])
self.assertEqual(state["closed_by"], "fail")
# inherited passes survive; the core is required again
self.assertEqual(state["tests"]["fake.prompt"]["status"], "inherited")
self.assertTrue(state["tests"]["fake.pass"]["required"])
# a new start opens a new campaign
st, d = self.call("POST", "/start", {"test": "fake.pass"})
state = self.wait_idle()
self.assertIsNotNone(state["campaign"])
self.assertNotEqual(state["campaign"]["id"], cid)
def test_07_export_and_invalidate(self):
st, d = self.call("POST", "/export")
self.assertEqual(st, 200)
self.assertFalse(d["authorized"])
st, raw = self.call("GET", "/export/acceptance.json")
self.assertEqual(st, 200)
art = raw if isinstance(raw, dict) else json.loads(raw)
self.assertEqual(art["manifest_sha"], self.man.content_sha)
st, raw = self.call("GET", "/export/acceptance.md")
self.assertEqual(st, 200)
self.assertIn(b"Release authorized: NO", raw)
st, d = self.call("POST", "/invalidate", {"reason": ""})
self.assertEqual(st, 400)
st, d = self.call("POST", "/invalidate", {"reason": "new tube"})
self.assertEqual(st, 200)
st, d = self.call("GET", "/state")
self.assertEqual(d["invalidate"]["reason"], "new tube")
self.assertIsNone(d["campaign"])
for tid, ts in d["tests"].items():
self.assertNotEqual(ts["status"], "inherited", tid)
st, raw = self.call("GET", "/log", token=False)
self.assertEqual(st, 200)
self.assertIn(b'"t": "invalidate"'.replace(b" ", b""), raw.replace(b" ", b""))
def test_08_bench(self):
st, d = self.call("GET", "/bench")
self.assertEqual(st, 200)
ids = [t["id"] for t in d["tools"]]
self.assertEqual(ids, ["echo", "unported", "hot", "tk", "sc"])
st, d = self.call("POST", "/bench/start", {"tool": "unported"})
self.assertEqual(st, 409)
st, d = self.call("POST", "/bench/start", {"tool": "hot"})
self.assertEqual(st, 409)
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "yo", "n": "x"}})
self.assertEqual(st, 409)
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "yo", "n": 5}})
self.assertEqual(st, 200, d)
state = self.wait_idle()
self.assertEqual(state["last_run"]["kind"], "bench")
self.assertEqual(state["last_run"]["finished"]["result"], "OK")
self.assertTrue(any("['yo', '5']" in l for l in state["last_run"]["log"]))
# the bench environment: the machine is local, data under <data>/bench
self.assertTrue(any(" env 127.0.0.1 " in l and l.endswith("bench")
for l in state["last_run"]["log"]), state["last_run"]["log"])
st, d = self.call("GET", "/bench")
self.assertEqual(d["tools"][0]["last"]["result"]["result"], "OK")
# a failing tool and an aborted one
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "fail", "n": 1}})
state = self.wait_idle()
self.assertEqual(state["last_run"]["finished"]["result"], "EXIT 3")
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "slow", "n": 1}})
self.assertEqual(st, 200)
time.sleep(0.5)
st, d = self.call("POST", "/abort")
state = self.wait_idle()
self.assertEqual(state["last_run"]["finished"]["result"], "ABORTED")
# a takeover tool runs inside the takeover wrapper (init.d is absent on the host: rc 127)
st, d = self.call("POST", "/bench/start", {"tool": "tk"})
self.assertEqual(st, 200, d)
state = self.wait_idle(timeout=40) # two unreachable-forgectrl settle waits
log = "\n".join(state["last_run"]["log"])
self.assertIn("takeover: pulse device free", log)
self.assertIn("takeover: forgectrl start", log)
self.assertIn("baseline: forgectrl unreachable for 10 s", log)
self.assertFalse(os.path.exists(os.environ["FORGETEST_MARKER"]))
# a scope tool is a takeover too
st, d = self.call("POST", "/bench/start", {"tool": "sc"})
self.assertEqual(st, 200, d)
state = self.wait_idle(timeout=40)
log = "\n".join(state["last_run"]["log"])
self.assertIn("takeover: pulse device free", log)
self.assertIn("takeover: forgectrl start", log)
# bench runs never touched the acceptance log
recs = self.log.read()
self.assertFalse(any(r.get("t") == "result" and r.get("test") == "echo" for r in recs))
def test_09_recovery_marker(self):
marker = os.environ["FORGETEST_MARKER"]
with open(marker, "w") as f:
f.write("x fake.slow\n")
import logging
from forgetest.runner import journal
seen = []
class Catch(logging.Handler):
def emit(self, rec):
seen.append(rec.getMessage())
h = Catch()
journal.addHandler(h)
try:
r = Runner(self.log, self.man, self.reg, self.bench)
finally:
journal.removeHandler(h)
# the recovery is a journal line, not a page message
self.assertTrue(any("recovered" in m for m in seen), seen)
self.assertNotIn("messages", r.state()[0])
self.assertFalse(os.path.exists(marker))
def test_10_state_is_conditional(self):
"""The page polls; an unchanged state must cost a 304, and the
ETag must move as soon as the state does.
Runs last because it invalidates: timestamps are whole seconds, and
a PASS stamped in the same second as an invalidate is deliberately
not inheritable, so an invalidate early in this class would decide
the inheritance the earlier tests are checking.
"""
st, d, hdrs = self.call_h("GET", "/state")
self.assertEqual(st, 200)
etag = hdrs.get("ETag")
self.assertTrue(etag and etag.startswith('"'), "no ETag on /state")
st, _, hdrs2 = self.call_h("GET", "/state", headers={"If-None-Match": etag})
self.assertEqual(st, 304)
self.assertEqual(hdrs2.get("ETag"), etag)
# a stale validator must not be honored
st, _, _ = self.call_h("GET", "/state", headers={"If-None-Match": '"stale"'})
self.assertEqual(st, 200)
# and the validator moves with the state
self.call("POST", "/invalidate", {"reason": "etag check"})
st, _, hdrs3 = self.call_h("GET", "/state", headers={"If-None-Match": etag})
self.assertEqual(st, 200)
self.assertNotEqual(hdrs3.get("ETag"), etag)
if __name__ == "__main__":
unittest.main()
-225
View File
@@ -1,225 +0,0 @@
"""scripts/source-bundle.py - the source that a release publishes.
The bundle must hold the source of every recipe of the image whose license
makes source necessary. The list comes from the license manifests that the
build writes, so a package cannot reach a machine with its source left
behind. These tests hold the decision (which recipe needs source), the
choice of archive (the version of the image, not an older build), and the
refusal that stops a release with source missing."""
import importlib.util
import json
import os
import tarfile
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", "source-bundle.py")
META_OPENGLOW = os.path.join(REPO, "..", "meta-openglow")
IMAGE = "forgefirm-image-glowforge.rootfs-20260101000000"
def load_script():
spec = importlib.util.spec_from_file_location("source_bundle", SCRIPT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def package_block(recipe, version, license_, package=None):
return ("PACKAGE NAME: %s\nPACKAGE VERSION: %s\nRECIPE NAME: %s\nLICENSE: %s\n\n"
% (package or recipe, version, recipe, license_))
def write(path, text):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(text)
def make_deploy(root, packages, archives, image_packages=()):
"""A deploy directory with the license manifests of one image and the
archive directories of the archiver pass."""
licdir = os.path.join(root, "licenses", "glowforge", IMAGE)
# The bundle answers for one image: its license manifests and its build
# identity must be in the deploy directory.
write(os.path.join(root, "images", "glowforge",
IMAGE + ".forgefirm-manifest.json"),
'{"content_sha256": "0"}\n')
write(os.path.join(licdir, "license.manifest"),
"".join(package_block(*p) for p in packages))
write(os.path.join(licdir, "image_license.manifest"),
"".join("RECIPE NAME: %s\nVERSION: %s\nLICENSE: %s\nFILES: x\n\n" % p
for p in image_packages))
for name in archives:
write(os.path.join(root, "sources", "arm-fslc-linux-gnueabi", name,
"%s.tar.gz" % name), "source of %s\n" % name)
return licdir
class Policy(unittest.TestCase):
"""The archiver pass and the check read one license list."""
def setUp(self):
self.mod = load_script()
self.policy = self.mod.read_policy(self.mod.KAS_OVERLAY)
def test_the_overlay_carries_the_filter(self):
self.assertIn("GPL*", self.policy["COPYLEFT_LICENSE_INCLUDE"])
self.assertIn("LGPL*", self.policy["COPYLEFT_LICENSE_INCLUDE"])
self.assertIn("Proprietary", self.policy["COPYLEFT_LICENSE_EXCLUDE"])
# The ForgeFIRM components are MIT and travel with the release too.
self.assertIn("forgectrl", self.policy["COPYLEFT_PN_INCLUDE"])
self.assertIn("gfcloud", self.policy["COPYLEFT_PN_INCLUDE"])
def test_a_copyleft_license_needs_source(self):
for expr in ("GPL-2.0-only",
"LGPL-2.1-or-later",
"GPL-2.0-only & bzip2-1.0.4",
"AFL-2.1 | GPL-2.0-or-later",
"(GPL-2.0-or-later | LGPL-3.0-only) & Unicode-DFS-2016",
"MIT ; LGPL-2.1-or-later",
"GPL-3.0-with-GCC-exception"):
self.assertTrue(self.mod.source_needed(expr, self.policy), expr)
def test_a_permissive_license_does_not(self):
for expr in ("MIT", "BSD-3-Clause", "Apache-2.0", "ISC", "PSF-2.0",
"Proprietary", "Firmware-imx-sdma_firmware", "PD"):
self.assertFalse(self.mod.source_needed(expr, self.policy), expr)
class Manifests(unittest.TestCase):
def setUp(self):
self.mod = load_script()
def test_a_recipe_collects_the_licenses_of_its_packages(self):
path = os.path.join(self.tmp(), "license.manifest")
write(path, package_block("avahi", "0.8", "LGPL-2.1-or-later", "avahi-daemon")
+ package_block("avahi", "0.8", "GPL-2.0-or-later & LGPL-2.1-or-later",
"avahi-locale-en-gb"))
recipes = self.mod.parse_manifest(path, "PACKAGE VERSION")
self.assertEqual(list(recipes), ["avahi"])
self.assertEqual(recipes["avahi"]["version"], "0.8")
self.assertEqual(len(recipes["avahi"]["licenses"]), 2)
def test_the_archive_of_the_image_version_wins(self):
index = {"linux-fslc": [("6.11.0", "/old"), ("6.12.20+git0+844aa34", "/new")],
"busybox": [("1.36.1", "/b1"), ("1.35.0", "/b0")],
"forgectrl": [("1_0.1.4", "/fc")]}
# An exact version first.
self.assertEqual(self.mod.select_archives(index, "busybox", "1.36.1"), ["/b1"])
# Then a version that starts with it: the kernel carries the
# revision of its git source in PV, and the manifest does not.
self.assertEqual(self.mod.select_archives(index, "linux-fslc", "6.12.20+git"),
["/new"])
# The name of an archive carries the epoch of the recipe, and a
# license manifest does not.
self.assertEqual(self.mod.select_archives(index, "forgectrl", "0.1.4"), ["/fc"])
self.assertEqual(self.mod.select_archives(index, "curl", "8.7.1"), [])
def test_a_directory_belongs_to_the_longest_recipe_name(self):
root = self.tmp()
make_deploy(root, [], ["python3-3.12.13-r0", "python3-certifi-2024.2.2-r0"])
index = self.mod.index_archives(os.path.join(root, "sources"),
{"python3", "python3-certifi"})
self.assertEqual([v for v, _ in index["python3"]], ["3.12.13"])
self.assertEqual([v for v, _ in index["python3-certifi"]], ["2024.2.2"])
def tmp(self):
import tempfile
d = tempfile.mkdtemp()
self.addCleanup(__import__("shutil").rmtree, d, True)
return d
@unittest.skipUnless(os.path.isdir(META_OPENGLOW),
"the meta-openglow sibling checkout is not here")
class Pack(unittest.TestCase):
"""The bundle end to end, on a deploy directory of made-up recipes."""
def setUp(self):
import tempfile
self.mod = load_script()
self.root = tempfile.mkdtemp()
self.addCleanup(__import__("shutil").rmtree, self.root, True)
self.deploy = os.path.join(self.root, "deploy")
self.out = os.path.join(self.root, "out")
def run_main(self, packages, archives, image_packages=()):
make_deploy(self.deploy, packages, archives, image_packages)
import sys
argv = sys.argv
sys.argv = ["source-bundle.py", "0.0.1", "--deploy", self.deploy,
"--image-name", IMAGE, "--out", self.out]
try:
return self.mod.main()
finally:
sys.argv = argv
def test_the_bundle_holds_the_source_and_the_accounting(self):
self.run_main(
packages=[("busybox", "1.36.1", "GPL-2.0-only"),
("jansson", "2.14", "MIT"),
("forgectrl", "0.1.4", "MIT")],
archives=["busybox-1.36.1-r0", "forgectrl-0.1.4-r0", "jansson-2.14-r0",
"u-boot-2020.01-r0"],
image_packages=[("u-boot", "2020.01", "GPL-2.0-or-later")])
path = os.path.join(self.out, "forgefirm-source-v0.0.1.tar.gz")
self.assertTrue(os.path.isfile(path))
with tarfile.open(path) as tar:
names = tar.getnames()
manifest = json.loads(tar.extractfile(
"forgefirm-source-v0.0.1/MANIFEST.json").read().decode())
sums = tar.extractfile(
"forgefirm-source-v0.0.1/sha256sums.txt").read().decode()
# The copyleft recipe and the ForgeFIRM component are in, the
# permissive third party is not.
self.assertIn("forgefirm-source-v0.0.1/sources/busybox-1.36.1-r0/busybox-1.36.1-r0.tar.gz",
names)
self.assertIn("forgefirm-source-v0.0.1/sources/forgectrl-0.1.4-r0/forgectrl-0.1.4-r0.tar.gz",
names)
self.assertNotIn("forgefirm-source-v0.0.1/sources/jansson-2.14-r0/jansson-2.14-r0.tar.gz",
names)
# The accounting travels with the source.
for name in ("README.md", "SOURCES.txt", "MANIFEST.json",
"licenses/license.manifest", "licenses/image_license.manifest",
"metadata/LAYERS.txt", "metadata/meta-forgefirm.tar.gz",
"metadata/kas/source-bundle.yml"):
self.assertIn("forgefirm-source-v0.0.1/" + name, names)
self.assertEqual(manifest["release"], "v0.0.1")
self.assertEqual(sorted(r["recipe"] for r in manifest["recipes"]),
["busybox", "forgectrl", "u-boot"])
self.assertEqual(manifest["recipes_without_source"],
[{"recipe": "jansson", "version": "2.14", "license": "MIT"}])
# Every file of the bundle is in the checksums, and only files that
# the bundle holds.
listed = sorted(line.split(" ", 1)[1] for line in sums.splitlines())
held = sorted(n.split("/", 1)[1] for n in names
if n != "forgefirm-source-v0.0.1/sha256sums.txt")
self.assertEqual(listed, held)
def test_a_recipe_with_no_source_stops_the_release(self):
with self.assertRaises(SystemExit) as caught:
self.run_main(packages=[("busybox", "1.36.1", "GPL-2.0-only"),
("gnutls", "3.8.4", "LGPL-2.1-or-later")],
archives=["busybox-1.36.1-r0"])
self.assertEqual(caught.exception.code, 1)
def test_the_source_of_gcc_covers_libgcc(self):
self.run_main(
packages=[("libgcc", "13.4.0", "GPL-3.0-with-GCC-exception"),
("glibc", "2.39+git", "GPL-2.0-only & LGPL-2.1-or-later"),
("glibc-locale", "2.39+git", "GPL-2.0-only & LGPL-2.1-or-later")],
archives=["gcc-source-13.4.0-13.4.0-r0", "glibc-2.39+git-r0"])
with tarfile.open(os.path.join(self.out, "forgefirm-source-v0.0.1.tar.gz")) as tar:
manifest = json.loads(tar.extractfile(
"forgefirm-source-v0.0.1/MANIFEST.json").read().decode())
holders = {r["recipe"]: r["holder"] for r in manifest["recipes"]}
self.assertEqual(holders["libgcc"], "gcc-source-13.4.0")
self.assertEqual(holders["glibc-locale"], "glibc")
if __name__ == "__main__":
unittest.main()
-122
View File
@@ -1,122 +0,0 @@
"""scripts/manifest-from-tree.py - the workstation/CI mirror of the image
manifest: recipe pins are read through their pin files, and a layer's
content hash leaves the pin files out (a component bump is not a platform
change; a recipe-body change is)."""
import importlib.util
import os
import shutil
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", "manifest-from-tree.py")
def load_script():
spec = importlib.util.spec_from_file_location("manifest_from_tree", SCRIPT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def git_ok():
try:
subprocess.run(["git", "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
return True
except (OSError, subprocess.CalledProcessError):
return False
RECIPE = '''DESCRIPTION = "component"
LICENSE = "MIT"
SRC_URI = "git://github.com/example/comp.git;protocol=https;branch=main"
# SRCREV and PV live in the pin file.
require comp-pin.inc
S = "${WORKDIR}/git"
'''
PIN_A = 'SRCREV = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\nPV = "1.0"\n'
PIN_B = 'SRCREV = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\nPV = "1.1"\n'
@unittest.skipUnless(git_ok(), "git not available")
class TreeManifestTests(unittest.TestCase):
def setUp(self):
self.mod = load_script()
self.tmp = tempfile.mkdtemp(prefix="forgetest-tree-")
self.layer = os.path.join(self.tmp, "meta-x")
self.rdir = os.path.join(self.layer, "recipes-x", "comp")
os.makedirs(self.rdir)
subprocess.run(["git", "init", "-q"], cwd=self.tmp, check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.write("comp.bb", RECIPE)
self.write("comp-pin.inc", PIN_A)
self.write("README.md", "# docs\n")
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def write(self, name, text):
with open(os.path.join(self.rdir, name), "w", encoding="utf-8", newline="\n") as f:
f.write(text)
def test_parse_recipe_follows_the_pin_file(self):
url, rev = self.mod.parse_recipe(os.path.join(self.rdir, "comp.bb"))
self.assertEqual(url, "https://github.com/example/comp.git")
self.assertEqual(rev, "a" * 40)
self.write("comp-pin.inc", PIN_B)
self.assertEqual(self.mod.parse_recipe(os.path.join(self.rdir, "comp.bb"))[1], "b" * 40)
def test_parse_recipe_inline_pin_still_works(self):
self.write("comp.bb", RECIPE.replace("require comp-pin.inc\n", PIN_A))
os.remove(os.path.join(self.rdir, "comp-pin.inc"))
self.assertEqual(self.mod.parse_recipe(os.path.join(self.rdir, "comp.bb"))[1], "a" * 40)
def test_layer_hash_ignores_pin_bumps_and_docs(self):
base = self.mod.layer_content(self.layer)
self.assertIsNotNone(base)
self.write("comp-pin.inc", PIN_B)
self.assertEqual(self.mod.layer_content(self.layer), base, "a pin bump is not layer content")
self.write("README.md", "# docs, revised\n")
self.assertEqual(self.mod.layer_content(self.layer), base, "documentation is not layer content")
def test_layer_hash_sees_recipe_body_changes(self):
base = self.mod.layer_content(self.layer)
self.write("comp.bb", RECIPE + 'EXTRA_OEMAKE += "KCFLAGS=-Werror"\n')
self.assertNotEqual(self.mod.layer_content(self.layer), base)
def test_layer_hash_sees_a_pin_written_into_the_recipe(self):
# The safe direction: a pin that bypasses its pin file still hashes.
base = self.mod.layer_content(self.layer)
self.write("comp.bb", RECIPE + PIN_B)
self.assertNotEqual(self.mod.layer_content(self.layer), base)
def test_layer_hash_matches_the_bbclass_rule(self):
# The same skip list as *.md plus FORGEFIRM_MANIFEST_PIN_SUFFIX and
# FORGEFIRM_MANIFEST_VERSION_SUFFIX in
# forgefirm-image-manifest.bbclass. The two implementations compute
# the same identity - one on the build host, one on a workstation -
# so a suffix added to either without the other is the failure this
# guards.
bbclass = os.path.join(REPO, "meta-forgefirm", "classes", "forgefirm-image-manifest.bbclass")
with open(bbclass, encoding="utf-8") as f:
text = f.read()
self.assertIn('FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"', text)
self.assertIn('FORGEFIRM_MANIFEST_VERSION_SUFFIX ?= "forgefirm-release.inc"', text)
self.assertEqual(self.mod.LAYER_SKIP_SUFFIXES,
(".md", "-pin.inc", "forgefirm-release.inc"))
def test_layer_hash_ignores_the_release_version(self):
# The version is metadata: setting the release number must not move
# the layer hash, or the bump would invalidate the campaign that
# authorizes the release.
self.write("forgefirm-release.inc",'FORGEFIRM_RELEASE ?= "0.0.1"\n')
base = self.mod.layer_content(self.layer)
self.write("forgefirm-release.inc",'FORGEFIRM_RELEASE ?= "0.0.2"\n')
self.assertEqual(self.mod.layer_content(self.layer), base)
if __name__ == "__main__":
unittest.main()

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