mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a9f655916 | ||
|
|
52a91385cd | ||
|
|
b17778f538 | ||
|
|
d5551d7e8a | ||
|
|
41417f820b | ||
|
|
db6015dc81 | ||
|
|
0c87927388 | ||
|
|
ea8a94a7d1 | ||
|
|
79c03641bd | ||
|
|
ea6ac54ab3 | ||
|
|
f080d5d9cb | ||
|
|
d465625a98 | ||
|
|
7089a25721 | ||
|
|
d65a7aa6aa | ||
|
|
70e945a949 | ||
|
|
2f2a4160af | ||
|
|
ea458850f0 | ||
|
|
e9d5f3c842 | ||
|
|
7c0412075c | ||
|
|
d8b8adaef4 | ||
|
|
c0d4302d73 | ||
|
|
884d014d2c | ||
|
|
a9f0ad0d51 | ||
|
|
2dde10cf43 | ||
|
|
f41464cc58 | ||
|
|
20c6256527 | ||
|
|
b7a595ced7 | ||
|
|
5c93e49c8c | ||
|
|
650483c085 | ||
|
|
7b8f72b632 | ||
|
|
e8d4560ff0 | ||
|
|
00cc009871 | ||
|
|
c47f7d9a86 | ||
|
|
c217e24905 | ||
|
|
b94c990bbc | ||
|
|
8d8ca2a8c6 | ||
|
|
4fbf14195b | ||
|
|
d3fe1d90b9 | ||
|
|
594b6990fd | ||
|
|
57ba3454b4 | ||
|
|
d58ee39086 | ||
|
|
b2c42f0cc3 | ||
|
|
1dd6ad608b | ||
|
|
f0c40e7d4f | ||
|
|
d858a23f45 | ||
|
|
c7b80ab2e8 | ||
|
|
468e92655a | ||
|
|
d898b5659d |
@@ -0,0 +1,4 @@
|
||||
# Every text file is stored and checked out with LF; a patch keeps its bytes.
|
||||
* text=auto eol=lf
|
||||
*.patch -text
|
||||
*.diff -text
|
||||
@@ -1,398 +1,403 @@
|
||||
# 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.
|
||||
# AGENTS.md
|
||||
|
||||
Instructions for AI coding agents that work in this repository. A human
|
||||
contributor is welcome to read it too: it is the same set of rules.
|
||||
|
||||
## ForgeFIRM repository rules
|
||||
|
||||
### Safety on the machine
|
||||
|
||||
- The laser never fires with the coolant pump deliberately off. The heater-only
|
||||
flow-calibrate and flow-verify tools are the only pump-off state, and the
|
||||
tube stays dark during them.
|
||||
- The 40 V motor rail stays up while the machine is on. Never cycle it for a
|
||||
controller handover. A rail glitch can leave the stepper drivers
|
||||
unserviceable.
|
||||
|
||||
### The iteration loop
|
||||
|
||||
- Edit, cross-build in the Yocto build environment, hot-deploy to the
|
||||
machine, prove. Then one commit, one push, one pin bump for the proven
|
||||
change.
|
||||
- A full image build happens once, at the end of a work item, never per fix.
|
||||
- Kernel and BSP changes ride one image flash, batched. Validate a `.ko` or
|
||||
overlay change on the image that ships it. Never hot-swap a `.ko` onto a
|
||||
board that is about to be reflashed.
|
||||
|
||||
### Acceptance coverage
|
||||
|
||||
- Evaluate every change against the acceptance catalog in
|
||||
`forgetest/forgetest/suite/`. Does an existing test exercise the changed
|
||||
behavior? Does its `covers` map name the files you touched? If not, add or
|
||||
widen one in the same change. The coverage lint
|
||||
(`python3 -m forgetest.coverage --enforce`) is the floor.
|
||||
- The `SRCREV` of a component and the `PV` that moves with it live in
|
||||
`<recipe>-pin.inc` next to the recipe. Nothing else goes in that file. The
|
||||
image manifest keeps `*-pin.inc` out of the layer content hash. Thus a pin
|
||||
bump invalidates only the acceptance tests that cover that component. A
|
||||
pin in a recipe body counts as a platform change and needs a full
|
||||
acceptance campaign.
|
||||
- Test a harness change yourself, on the host, before you hand it to the
|
||||
operator for a bench run. A harness that fails on its own defect costs the
|
||||
operator a full re-run cycle.
|
||||
- A test declares its `mode`, and the runner switches. Cloud tests stay in
|
||||
cloud mode. Keep `requires` lists minimal, and never gate one test behind a
|
||||
chain of others.
|
||||
- Never exercise a gate or a limit through the `GFCOOL_*` environment
|
||||
overrides in a test. Use the settings API, and restore it in the teardown.
|
||||
- A silence-while-armed injection and a crash during `$H` are catalog
|
||||
drills, not one-off bench scripts.
|
||||
|
||||
### CI couplings that set the push order
|
||||
|
||||
- The CI of `grblHAL-glowforge` gets the laser harnesses from `scripts/bench/`
|
||||
at the head of `master`, unpinned. Push a harness change here before the
|
||||
driver change that needs it.
|
||||
- The acceptance page shares its theme and its vendored Bootstrap with the
|
||||
`forgectrl` panel, byte for byte. Land a UI change in `forgectrl` first.
|
||||
Push it and pin it. Only then push this repository.
|
||||
- The CI of `kernel-module-glowforge` cross-builds against the BSP from
|
||||
`meta-openglow`. Push `meta-openglow` before a module change that needs a
|
||||
BSP change.
|
||||
|
||||
### Builds and images
|
||||
|
||||
- Builds run only in the dedicated Yocto build environment that the Build page
|
||||
of the site defines (kas and bitbake). Never use a native host build of a
|
||||
component as proof, not even for a compile check.
|
||||
- Every build makes both images: `forgefirm-image` (the release image) and
|
||||
`forgefirm-image-dev` (the bench image, with `forgetest` on port 8090). The
|
||||
dev image needs an explicit `--target`. Report both artifact names.
|
||||
- After a kernel-module pin bump, rebuild the kernel and the module in the
|
||||
same run: `bitbake -c cleansstate linux-fslc kernel-module-glowforge`, then
|
||||
the images. The kernel version suffix is not reproducible across
|
||||
re-checkouts, and a rootfs whose kernel and module disagree fails.
|
||||
- Read the layer HEAD lines at the top of a build log before you trust a build
|
||||
that moves a pin.
|
||||
|
||||
### The record of the work
|
||||
|
||||
This repository carries no status document and no dated log. Both were
|
||||
retired: every fact about the machine and the firmware is on the
|
||||
documentation site, and **the record of a piece of work is the commit message
|
||||
that carries it**: what was done, how it was proven, and what it replaced, in
|
||||
the same place as the change itself.
|
||||
|
||||
A measurement session or a bench drill is written up in the commit that
|
||||
lands its result. Nothing accumulates in a file.
|
||||
|
||||
### On the bench
|
||||
|
||||
These rules apply whenever an agent works with the operator at a machine.
|
||||
|
||||
- **One armed run per turn.** Start the run, let it finish, stop, report what
|
||||
happened against what was expected, and wait for the operator's
|
||||
confirmation before any further armed run or machine command. Never chain a
|
||||
jog, a fix, or a re-run onto a burn in the same step. Dry runs (latch
|
||||
locked, no emission) can be batched. Say which is which before you start.
|
||||
- **Zero inference during live fire.** Report only what the combined
|
||||
observations confirm: the operator's eyes, the drill output, the logs, the
|
||||
sysfs readbacks. Everything else is labeled "unconfirmed" and is not acted
|
||||
on.
|
||||
- **One Grbl connection.** Never open a second connection to TCP port 23 while
|
||||
a drill, an acceptance test, or a sender holds one. The last connection
|
||||
wins, the displaced client's cleanup never arrives, and its queued commands
|
||||
wait behind the arm gate. Read state through `forgectrl` (HTTP) and sysfs
|
||||
only. After a displaced or aborted drill: soft reset, confirm that the RX
|
||||
buffer reads full, `M5`, confirm that `armed` is false, before anyone
|
||||
touches the button.
|
||||
- **The operator touches the machine.** The operator runs the install, the
|
||||
service restart, and the reflash on the machine. Never modify the software
|
||||
of a running machine unless the operator asks for that exact step.
|
||||
- **Bench hygiene.** Stage test files in `/tmp`. A file that must survive a
|
||||
reboot goes in `/data/bench-scratch/`, and that directory is deleted whole
|
||||
at the end of the session. Nothing goes loose in `/data`. Remove everything
|
||||
you put on the board, in the same session, and list `/data` before you end.
|
||||
A bench tool worth a second use goes in `scripts/bench/`. The dev image
|
||||
installs those tools under `/usr/share/forgetest/bench/`. Run them from
|
||||
there.
|
||||
|
||||
## Project-wide rules
|
||||
|
||||
The sections above are specific to this repository. The rules below apply to
|
||||
every repository of the OpenGlow ForgeFIRM project: the firmware components,
|
||||
the build base, the documentation, and the hardware designs. Where a
|
||||
repository rule and a rule below conflict, the safety, proof, and hygiene
|
||||
rules below win. The documentation site, <https://docs.forgefirm.org/>, is
|
||||
the source of truth for every fact about the machine and the firmware. Read
|
||||
its Developers section before you change code.
|
||||
|
||||
### What ForgeFIRM is
|
||||
|
||||
ForgeFIRM is open firmware for stock Glowforge lasers (Basic, Plus, Pro). It
|
||||
runs on the factory NXP i.MX6 control board with no hardware modification. It
|
||||
replaces the cloud-dependent factory userspace with an open Linux image that
|
||||
controls the machine locally. In GRBL mode the machine is a standard grblHAL
|
||||
controller for LightBurn and other senders on TCP port 23. Cloud mode runs the
|
||||
factory experience through the Glowforge web service, and it is a deliberate,
|
||||
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
|
||||
and cloud mode runs end to end.
|
||||
|
||||
One controller runs at a time under `forgectrl`, the machine-services daemon,
|
||||
which owns the pulse device, the cooling engine, the cameras, and the control
|
||||
panel. The kernel module plays the pulse stream into the stepper drivers and
|
||||
owns the laser latch and the safety readbacks. The Technical section of the
|
||||
site describes the whole stack.
|
||||
|
||||
### The repositories
|
||||
|
||||
All repositories live under the OpenGlow organization on GitHub,
|
||||
<https://github.com/openglow-org>. For development, check every repository out
|
||||
as a sibling in one base directory named `openglow-forgefirm`. The build
|
||||
scripts expect that layout. Each repository is its own git repository. The
|
||||
base directory is not.
|
||||
|
||||
| Repository | Role | Default branch | License |
|
||||
|---|---|---|---|
|
||||
| `forgefirm` | The build and release base: the `meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the install and release scripts, the acceptance tool `forgetest/`, the bench tools `scripts/bench/`, and the bench actuator firmware `fixture/`. It carries no project documentation. | `master` | MIT (layer metadata) |
|
||||
| `meta-openglow` | The BSP layers `meta-glowforge-bsp` and `meta-openglow-core`: kernel, device tree, U-Boot, board recipes. | `scarthgap` | MIT (layer metadata) |
|
||||
| `forgectrl` | The machine-services daemon (C) on HTTPS port 443, with the read-only routes on HTTP port 80: supervisor, pulse-device broker, motion-liveness gate, cooling engine, cameras, telemetry, settings, diagnostics, control panel, A/B updates. | `main` | MIT |
|
||||
| `grblHAL-glowforge` | The grblHAL driver for the stock board, the GRBL-mode controller. The core is a submodule at `src/grbl`. Machine constants are in `src/boards/glowforge.h`. | `main` | GPL-3.0-or-later |
|
||||
| `grblHAL-core` | The fork of the grblHAL core that the driver uses. Minimum change, no rationale comments. | `forgefirm` | The upstream grblHAL license |
|
||||
| `kernel-module-glowforge` | `glowforge.ko`: the SDMA + EPIT pulse engine, the laser latch, the safety readbacks, the sensors. | `master` | GPL-2.0-or-later |
|
||||
| `python3-gfhardware` | The `gfhardware` hardware library and the cloud-mode applications in `forgefirm-app/`. | `master` | MIT, with one LGPL-2.1-or-later component |
|
||||
| `Glowforge-Utilities` | `gfutilities` on PyPI: the factory protocol and service layer that cloud mode uses. | `master` | MIT |
|
||||
| `forgefirm-docs` | The documentation site. | `main` | CC BY-SA 4.0 |
|
||||
| `openglow-serial-adapter` | The USB-C serial-console adapter for the control board. | `main` | CC BY-NC-SA 4.0 |
|
||||
|
||||
The full table, the license details, and the component diagram are on the
|
||||
site: <https://docs.forgefirm.org/developers/>.
|
||||
|
||||
### Safety first, and in that order
|
||||
|
||||
The machine is a laser, and the operator is the safety authority at it. These
|
||||
rules bind every change, in every repository, that can affect what the machine
|
||||
does.
|
||||
|
||||
- The hardware chain is the safety boundary. Software only adds gates on top
|
||||
of it. `LASER_ON` is never a bare GPIO. The kernel laser latch is locked by
|
||||
default, and every close of the pulse device locks it again.
|
||||
- Never defeat a switch, an interlock, or a readback, in hardware or in
|
||||
software, not even for a test.
|
||||
- Never change a fire gate, a thermal limit, or a safety default to make a
|
||||
test pass.
|
||||
- The order of work is absolute: emission and motion first, robustness and
|
||||
hygiene after. Review a change that touches emission or motion for that
|
||||
before anything else.
|
||||
- A change that can put energy where it was not commanded gets a regression
|
||||
test with the fix, in the same commit, never after.
|
||||
- Position counters, homing anchors, and a homed flag are not proof of
|
||||
physical motion. The head accelerometer is, and so are the operator's eyes.
|
||||
- Unreadable is fail-closed. A failed read of a safety input, a state file,
|
||||
or a verdict is never treated as "still running" or "keep waiting" without
|
||||
a bound.
|
||||
|
||||
### Proof before done
|
||||
|
||||
A feature or fix is not complete until it is proven. The order of preference:
|
||||
|
||||
1. A host test that runs in CI. The Test page of the site lists the host tests
|
||||
of each repository.
|
||||
2. A bench drill on the bench reference, recorded in the commit message that
|
||||
carries the change.
|
||||
3. Documented reasoning.
|
||||
|
||||
Also:
|
||||
|
||||
- Run the host tests of this repository before every commit. Where the build
|
||||
uses `-Werror`, a warning is a failure.
|
||||
- A component that ships in the image is also evaluated against the release
|
||||
acceptance catalog in the `forgefirm` repository (Test page, "Coverage
|
||||
currency"). A behavior change with no catalog consequence gets a sentence
|
||||
of justification in the commit message.
|
||||
- For a component that ships in the image, the proof build is the Yocto
|
||||
cross-build that the Build page of the site defines, never a native host
|
||||
build.
|
||||
- Report outcomes faithfully. Quote failing output as it is. Name a skipped
|
||||
step as skipped. Never narrate an expected result as an observed one.
|
||||
|
||||
### The workflow: prove locally, push when proven
|
||||
|
||||
- Nothing goes to a public repository until it is proven: in the host tests
|
||||
for host-only changes, on the bench for changes that alter what the machine
|
||||
does.
|
||||
- Iterate locally. Then make one commit and one push for the proven change.
|
||||
No chains of fix-up pushes. Every push runs CI, and CI minutes are a budget.
|
||||
- Commit only finished work, and only when asked. Never commit intermediate
|
||||
findings, working notes, plans, audits, or status files. If a conclusion is
|
||||
still moving, it is not ready for a repository. Unpushed mistakes come out
|
||||
with `git reset --mixed <base>`, which keeps the working tree.
|
||||
- "Commit" and "commit and build" include the push of every touched
|
||||
repository, in dependency order. Never hold a push back to protect earlier
|
||||
unpushed commits on the same branch. The only exception is an explicit "do
|
||||
not push" for that session.
|
||||
- Never push, force-push, tag, cut a release, refresh a lockfile, bump a
|
||||
submodule pointer, or bump a pin unless asked.
|
||||
|
||||
### Pins
|
||||
|
||||
A firmware component reaches the image only through its pin: an exact
|
||||
`SRCREV` in `meta-forgefirm` (in the `forgefirm` repository) for ForgeFIRM
|
||||
components, or in `meta-openglow` for BSP components. There is no `AUTOREV`.
|
||||
A commit in a component repository changes nothing on a machine until its pin
|
||||
moves. The order is always: push the source repository, bump its pin, then
|
||||
run `bitbake -c fetch <recipe>` to make sure that the pin resolves. Some
|
||||
pairs of repositories have a CI coupling that fixes the push order between
|
||||
them. The Release flow page of the site lists them, and each affected
|
||||
repository names its own in the section above.
|
||||
|
||||
### Git conventions
|
||||
|
||||
- A commit is attributed to the human who is responsible for it, in both the
|
||||
author and the committer fields. Never list an AI assistant as an author or
|
||||
a co-author. Never add an AI attribution trailer or a "Generated with" line
|
||||
to a commit message or a pull request. In a fresh clone, set the local
|
||||
`user.name` and `user.email` before the first commit.
|
||||
- A commit message says what changed and why, in the present tense, in
|
||||
Simplified Technical English. The "why" of a change to fork code lives here
|
||||
and nowhere else.
|
||||
- Keep line endings LF in every file that Linux tooling consumes: patches,
|
||||
shell scripts, recipes, device trees. Check the bytes after an edit from a
|
||||
Windows tool. A CRLF patch breaks `do_patch`.
|
||||
- Never commit build output, `__pycache__`, virtual environments, editor
|
||||
files, a local knowledge graph (`graphify-out/`), captured machine data that
|
||||
carries an identity, tokens, keys, or passwords.
|
||||
- Never rewrite published history unless the operator asks for it.
|
||||
- A pull request to an upstream repository is never automatic. The developer
|
||||
decides if and when to send one. You can remind them that upstream
|
||||
candidates exist, but the decision is theirs.
|
||||
|
||||
### Writing rules
|
||||
|
||||
They apply to everything: documentation, README files, code comments,
|
||||
docstrings, log and console text, commit messages, issue text, and replies.
|
||||
|
||||
- **American English.** analyze, behavior, color, center, gray, catalog,
|
||||
license, judgment, program, artifact, percent, toward, among, while,
|
||||
learned. The style lint in `forgefirm-docs` rejects the British forms.
|
||||
- **ASD-STE100 Simplified Technical English.** Short sentences: 20 words in
|
||||
a procedure, 25 in a description. One topic per sentence, one instruction
|
||||
per sentence. Active voice, imperative in procedures, simple present in
|
||||
descriptions. "Must" for necessity, "can" for possibility. No "should",
|
||||
"would", "could", "may". Prefer: do, make sure, use, start, show, occur,
|
||||
before, after, because, for example, that is. No "etc.", no "and/or".
|
||||
Noun clusters of three words at most. Warnings and cautions come before the
|
||||
step they protect. Warm and direct is good. A joke that shades a fact is
|
||||
not.
|
||||
- **No em dashes.** Never, anywhere. Use a colon, a period, or a comma.
|
||||
- **Present tense, present state.** Text describes the code and the machine
|
||||
as they are. No history narrative, no "the old version did", no "evolved
|
||||
from", no story of how the code got here. Upstream copyright and
|
||||
derivation attribution to public projects stays: that is license credit,
|
||||
not narrative.
|
||||
- **No workstation paths.** Nothing outside a repository appears as a path in
|
||||
it: no drive letters, no WSL mounts, no home directories, no sibling-repo
|
||||
relative paths, no private reference directories. Name the artifact
|
||||
instead: "the captured factory pulse files", "that bench session".
|
||||
- **No bench-machine identity.** Never a hostname, a serial number, a fuse
|
||||
value, a private IP address, or a login of the bench machine, in any file,
|
||||
comment, commit message, sample, or test fixture. Use neutral placeholders
|
||||
of the `ABC-123` form. Record the fact of a verification, never the values.
|
||||
- **No factory firmware code.** Never cite the factory firmware binary or its
|
||||
code: no offsets, no addresses, no quoted or decompiled code. State the
|
||||
recovered fact (a formula, a constant, a behavior) and stop. Hardware
|
||||
addresses (IOMUX pad values, sensor registers, boot offsets) are fine.
|
||||
- **No rationale comments in fork code.** Code destined upstream (for
|
||||
example, the grblHAL core fork) carries the minimum change and no "why we
|
||||
did this" comments. The reasoning goes in the commit message. Patches
|
||||
carried in the Yocto layers are exempt. The `ForgeFIRM:` comment convention
|
||||
there is deliberate.
|
||||
- **Interface docs describe the lever, not the policy.** A sysfs attribute
|
||||
doc says what the attribute is, its range, its units, its conversion. How
|
||||
the factory firmware drives it does not belong there.
|
||||
- **No definitive legal or regulatory statements.** The project is not a
|
||||
lawyer. The only permitted form: modifying the machine, including
|
||||
replacing its firmware, may have legal and regulatory ramifications, and it
|
||||
is the end user's responsibility to adhere to the laws, regulations,
|
||||
certifications, and insurance terms that apply where they are. Warranty
|
||||
wording is "may void your warranty", never "voids". Never cite a regulation,
|
||||
a standard, or a certification regime as applying or not applying. The
|
||||
affiliation disclaimer lives in the site footer only. It is also the user's
|
||||
responsibility to make sure that they stay within the vendor's terms of
|
||||
service when they use a feature that calls that vendor's cloud services.
|
||||
- **Copyright.** Copyright in this project belongs to 514 LLC d/b/a
|
||||
OpenGlow. Every new source file carries two lines in its header, in the
|
||||
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
|
||||
`Written by Scott Wiederhold`. Keep the year, or the year range, that
|
||||
the file already has. The copyright notices of other holders stay
|
||||
unchanged.
|
||||
- **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.
|
||||
|
||||
@@ -22,13 +22,13 @@ case "$1" in
|
||||
start)
|
||||
echo "Starting forgetest"
|
||||
mkdir -p "$DATA"
|
||||
# The bench seed: the commissioning override lifts the wizard part of
|
||||
# The bench seed: the setup override lifts the wizard part of
|
||||
# the controller gate until the next reboot (/run is tmpfs), so a
|
||||
# campaign runs on a machine whose required wizards are not complete.
|
||||
# Nothing lifts the agreements or the account: the bench accepts them
|
||||
# once, and that stays in /data.
|
||||
mkdir -p /run/forgefirm
|
||||
: > /run/forgefirm/commissioning-override
|
||||
: > /run/forgefirm/setup-override
|
||||
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
|
||||
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
|
||||
;;
|
||||
|
||||
+170
-27
@@ -65,6 +65,21 @@ FIXED_SYSFS = [
|
||||
("head/white_led", "0"),
|
||||
("thermal/heater_pwm", "0"),
|
||||
("thermal/tec_on", "0"),
|
||||
# Purge air runs continuously, as on the factory machine: the engine
|
||||
# turns it on at start and only a listening (the quiet hold) switches
|
||||
# it off, which puts it back on release. A check that switches it off
|
||||
# to measure it and forgets leaves the machine one job away from an
|
||||
# airflow hold mid-cut, judged against the floor that same check just
|
||||
# wrote, and nothing notices until the daemon restarts: the engine's
|
||||
# idle phase never re-applies its own duties. The airflow check did
|
||||
# exactly that, and it reached an operator's first fire.
|
||||
("head/purge_air", "1"),
|
||||
# The lens motor at rest: hold current, half step. The lens checks and
|
||||
# the sheet cards take it to the run current in full or half step and
|
||||
# must hand it back, or the motor sits hot and the next reference
|
||||
# starts from a state nobody chose.
|
||||
("head/z_current", "1"),
|
||||
("head/z_mode", "1"),
|
||||
]
|
||||
|
||||
# The subset the GRBL controller writes at its start: checked and restored
|
||||
@@ -72,9 +87,14 @@ FIXED_SYSFS = [
|
||||
# every pulse header (step_freq 10 kHz, the run currents), runs at the
|
||||
# service's own x8 with the module's ramp whatever xy_microsteps says, and
|
||||
# hands the hold currents back at idle; forcing the GRBL values under it
|
||||
# would be the baseline configuring another controller's machine.
|
||||
# would be the baseline configuring another controller's machine. The Z
|
||||
# pair belongs with them: the cloud client takes the lens microstep mode
|
||||
# from the pulse header it is playing (ZSmd, gfhardware z_axis
|
||||
# set_mode_from_puls) and the current with it, so a job whose header asks
|
||||
# for full step leaves z_mode 0 behind by doing what it was told.
|
||||
GRBL_CONTROLLER_SYSFS = ("cnc/motor_lock", "cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
|
||||
"cnc/step_freq", "cnc/ramp_rate", "pic/x_step_current", "pic/y_step_current")
|
||||
"cnc/step_freq", "cnc/ramp_rate", "pic/x_step_current", "pic/y_step_current",
|
||||
"head/z_current", "head/z_mode")
|
||||
|
||||
# Settings the baseline never hands back as bare settings: controller_mode
|
||||
# is the persisted mirror of the live mode (the mode item restores it
|
||||
@@ -118,6 +138,13 @@ GRBL_PORT_S = 30 # the Grbl port after the supervisor reports
|
||||
|
||||
XY_STEPS_PER_MM = 53.333 # boards/glowforge.h (x8 microstepping)
|
||||
RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most this far
|
||||
# The counters count steps; the controller's own returns land within a
|
||||
# tenth of a millimeter of where they started, not on the step. A
|
||||
# difference under this is that quantization, not a leftover: the head is
|
||||
# still put back, so nothing accumulates over a campaign, but the test
|
||||
# that made it does not fail for it. X and Y only - Z is the lens, which
|
||||
# the return never moves, so any Z difference is still a leftover.
|
||||
POSITION_DEADBAND_MM = 0.1
|
||||
|
||||
# The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset =
|
||||
# 8). The GRBL controller reads it at its start and derives its scale,
|
||||
@@ -160,12 +187,19 @@ def leds_root():
|
||||
return r if r.endswith("/") else r + "/"
|
||||
|
||||
|
||||
def read_led(name):
|
||||
try:
|
||||
with open(leds_root() + name + "/brightness") as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
def read_led(name, attr="target"):
|
||||
"""A button LED's commanded level. The trigger fades brightness toward
|
||||
target, so brightness is where the fade has reached and target is what
|
||||
the machine was told: a run that left the button lit left a target, and
|
||||
a run that ended a moment ago may still be fading from one. Falls back
|
||||
to brightness where no target exists."""
|
||||
for a in (attr, "brightness"):
|
||||
try:
|
||||
with open(leds_root() + name + "/" + a) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def write_led(name, value):
|
||||
@@ -183,6 +217,27 @@ def read_position():
|
||||
return None
|
||||
|
||||
|
||||
def counter_steps_per_mm():
|
||||
"""The scale of the X/Y position counters: the kernel counts the
|
||||
microsteps of the mode it runs (cnc/x_mode), so x16 and x32 count two
|
||||
and four times the x8 steps for the same millimeter. The x8 scale when
|
||||
the attribute is unreadable."""
|
||||
return XY_STEPS_PER_MM_OF.get(hw.sysfs_int("cnc/x_mode"), XY_STEPS_PER_MM)
|
||||
|
||||
|
||||
def position_quantized(was, now, spm=XY_STEPS_PER_MM):
|
||||
"""True when two step-counter readings differ by no more than the
|
||||
dead band on X and Y and not at all on Z: the step quantization of a
|
||||
move that landed where it meant to, rather than a leftover. A cancel
|
||||
that returns the head to the job start lands within a few hundredths
|
||||
of a millimeter, which is a step or four, and whether that rounds to
|
||||
the same integer is chance. spm is the counters' scale (the mode's)."""
|
||||
if was is None or now is None or now[2] != was[2]:
|
||||
return False
|
||||
return (abs(now[0] - was[0]) / spm <= POSITION_DEADBAND_MM and
|
||||
abs(now[1] - was[1]) / spm <= POSITION_DEADBAND_MM)
|
||||
|
||||
|
||||
def read_ring_residue():
|
||||
"""Unplayed bytes queued in the kernel pulse ring: total written minus
|
||||
processed (the cnc/position byte counters), or None when unreadable.
|
||||
@@ -274,7 +329,7 @@ class Baseline:
|
||||
def wait_settled(self, timeout=SETTLE_S, unreachable_s=10):
|
||||
"""Block until forgectrl reports a settled supervisor: motion
|
||||
verified (the probe passed), motion-fault (the ladder exhausted),
|
||||
standby (the manual stop lever), or gated (the commissioning gate
|
||||
standby (the manual stop lever), or gated (the setup gate
|
||||
is closed: no controller spawns until it opens). Gives up after
|
||||
unreachable_s without an answer. Returns the last /mode body (None
|
||||
if unreachable)."""
|
||||
@@ -403,6 +458,9 @@ class Baseline:
|
||||
preserved state to hand back (post) - None compares nothing."""
|
||||
left = []
|
||||
self.mode = None
|
||||
# the counters' scale is the mode the run counted under, read
|
||||
# before the kernel side puts the settings' mode back
|
||||
self._counter_spm = counter_steps_per_mm()
|
||||
self._forgectrl_side(left, captured)
|
||||
self._kernel_side(left)
|
||||
self._lamp_side(left)
|
||||
@@ -421,6 +479,37 @@ class Baseline:
|
||||
time.sleep(1.0)
|
||||
return None
|
||||
|
||||
def stand_down(self, why):
|
||||
"""End whatever the machine is still doing, and prove it ended.
|
||||
|
||||
A run that dies mid-job leaves the job alive behind it: the
|
||||
cooling engine armed and holding for a job nothing will finish,
|
||||
the fans at run duty, and the rest of the program still queued in
|
||||
the pulse ring. Waiting does not end any of that - the engine is
|
||||
holding correctly, for a job that is never coming back.
|
||||
|
||||
Stopping the controller ends it. The supervisor brings the
|
||||
controller back, the arm and the hold go with the job, and the
|
||||
ring is empty on the way in. This is what the ring-residue
|
||||
message has always told an operator to do; the hand-back does it
|
||||
instead of saying it.
|
||||
|
||||
Returns the /mode body the machine settled on, or None."""
|
||||
self.log("baseline: standing the machine down (%s): stopping the controller" % why)
|
||||
st, _b = self.fc_post("/controller/stop")
|
||||
if st != 200:
|
||||
self.log("baseline: stand-down: /controller/stop -> %s" % st)
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline and not self.abort():
|
||||
body = self.fc_get("/mode")[1] or {}
|
||||
if body.get("controller") != "running":
|
||||
break
|
||||
time.sleep(1.0)
|
||||
st, _b = self.fc_post("/controller/start")
|
||||
if st != 200:
|
||||
self.log("baseline: stand-down: /controller/start -> %s" % st)
|
||||
return self.wait_settled()
|
||||
|
||||
def cloud_mode(self):
|
||||
return self.mode == "cloud"
|
||||
|
||||
@@ -479,7 +568,14 @@ class Baseline:
|
||||
before = (mode.get("controller"), mode.get("motion"))
|
||||
mode = self.wait_settled() or mode
|
||||
if mode.get("controller") == "running" and mode.get("motion") == "verified":
|
||||
left.append(Leftover("controller", "%s/%s" % before, "running/verified", "waited"))
|
||||
# The supervisor's own work: a takeover ends by starting
|
||||
# forgectrl again, and the respawn runs the liveness probe
|
||||
# and the lens reference before the controller is up and
|
||||
# motion is verified. Waiting for that is right; calling
|
||||
# it a leftover is not, because the run did put it back -
|
||||
# the machine was still doing what it was asked.
|
||||
self.log("controller: %s/%s settled to running/verified on its own; "
|
||||
"the supervisor's own start, not a leftover" % before)
|
||||
else:
|
||||
left.append(Leftover("controller", "%s/%s" % before, "running/verified",
|
||||
"failed: %s/%s" % (mode.get("controller"), mode.get("motion"))))
|
||||
@@ -487,14 +583,23 @@ class Baseline:
|
||||
st, s = self.fc_get("/status")
|
||||
if st == 200 and isinstance(s, dict):
|
||||
if s.get("state") != "idle":
|
||||
if s.get("state") == "underrun":
|
||||
# An underrun is the run's: the engine ran out of bytes and
|
||||
# the fault is acknowledged here. Any other state is the
|
||||
# machine finishing what it was given - the ring draining
|
||||
# to the end of a job - and it reaches idle on its own.
|
||||
underrun = s.get("state") == "underrun"
|
||||
if underrun:
|
||||
try:
|
||||
hw.sysfs_write("cnc/stop", "1") # ack
|
||||
except OSError:
|
||||
pass
|
||||
w = self._wait("idle", lambda: (self.fc_get("/status")[1] or {}).get("state") == "idle", IDLE_S)
|
||||
left.append(Leftover("state", s.get("state"), "idle",
|
||||
"waited" if w is not None else "failed: not idle"))
|
||||
if w is not None and not underrun:
|
||||
self.log("state: %s reached idle on its own after %.0f s; the machine "
|
||||
"finishing, not a leftover" % (s.get("state"), w))
|
||||
else:
|
||||
left.append(Leftover("state", s.get("state"), "idle",
|
||||
"waited" if w is not None else "failed: not idle"))
|
||||
if s.get("laser_locked") is False:
|
||||
try:
|
||||
hw.sysfs_write("cnc/laser_latch", "1")
|
||||
@@ -507,11 +612,31 @@ class Baseline:
|
||||
if st == 200 and isinstance(c, dict):
|
||||
if c.get("phase") != "idle" or c.get("armed") or c.get("hold"):
|
||||
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
|
||||
w = self._wait("cool idle", lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed")
|
||||
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {}),
|
||||
COOL_IDLE_S)
|
||||
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
||||
"waited" if w is not None else "failed: still %s" % found))
|
||||
idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731
|
||||
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
||||
# Armed, or holding, is the run's doing. A phase alone is
|
||||
# not: the engine clears smoke at run duty after an armed
|
||||
# session and cools down after a hot one, both timed and
|
||||
# both ending on their own. Waiting for that is right;
|
||||
# calling it a leftover is not, because nothing was left -
|
||||
# the machine was still finishing.
|
||||
dirt = bool(c.get("armed") or c.get("hold"))
|
||||
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
||||
if w is not None and not dirt:
|
||||
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
|
||||
"work, not a leftover" % (found, w))
|
||||
else:
|
||||
# The engine holds for a job. A run that ended without
|
||||
# ending its job leaves one alive, and no amount of
|
||||
# waiting ends it: stand the machine down and let the
|
||||
# supervisor bring the controller back clean.
|
||||
act = "waited"
|
||||
if w is None:
|
||||
self.stand_down("the cooling engine is still %s" % found)
|
||||
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
||||
act = "restored (stood the machine down)"
|
||||
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
||||
act if w is not None else "failed: still %s" % found))
|
||||
|
||||
def _lamp_side(self, left):
|
||||
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
|
||||
@@ -575,14 +700,18 @@ class Baseline:
|
||||
except OSError as e:
|
||||
act = "failed: %s" % e
|
||||
left.append(Leftover("leds/" + name, got, "0", act))
|
||||
elif (read_led(name, "brightness") or "0") != "0":
|
||||
self.log("leds/%s: target 0, brightness %s; the fade from a level the machine "
|
||||
"itself ended, not a leftover" % (name, read_led(name, "brightness")))
|
||||
|
||||
def _return_head(self, was, now):
|
||||
def _return_head(self, was, now, spm=XY_STEPS_PER_MM):
|
||||
"""Jog the head back along its own path by the kernel-measured X/Y
|
||||
delta (Z is never touched), through the GRBL controller. Bounded:
|
||||
beyond RETURN_MAX_MM per axis, or without a running GRBL
|
||||
controller, the counters are reported and left."""
|
||||
dx = (now[0] - was[0]) / XY_STEPS_PER_MM
|
||||
dy = (now[1] - was[1]) / XY_STEPS_PER_MM
|
||||
delta (Z is never touched), through the GRBL controller. spm is
|
||||
the counters' scale (the mode's). Bounded: beyond RETURN_MAX_MM
|
||||
per axis, or without a running GRBL controller, the counters are
|
||||
reported and left."""
|
||||
dx = (now[0] - was[0]) / spm
|
||||
dy = (now[1] - was[1]) / spm
|
||||
if abs(dx) < 0.02 and abs(dy) < 0.02:
|
||||
return "unrestorable (Z only)" if now[2] != was[2] else "restored"
|
||||
if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM:
|
||||
@@ -590,10 +719,18 @@ class Baseline:
|
||||
# Never jog on top of stale bytes: a run started now would replay
|
||||
# whatever the ring still holds before the jog, in a direction and
|
||||
# for a distance nobody asked for. Report and leave the head.
|
||||
# Stale bytes in the ring are the last job's, and a jog now would
|
||||
# replay them before its own. The ring empties when the
|
||||
# controller restarts, so the hand-back restarts it rather than
|
||||
# leaving the head where it is and the bytes where they are.
|
||||
residue = read_ring_residue()
|
||||
if residue:
|
||||
return ("unrestorable: %d unplayed bytes queued in the kernel ring - a jog would "
|
||||
"replay them; clear the ring (controller restart) before moving" % residue)
|
||||
self.log("baseline: %d unplayed bytes in the kernel ring before the return" % residue)
|
||||
self.stand_down("%d unplayed bytes in the kernel ring" % residue)
|
||||
residue = read_ring_residue()
|
||||
if residue:
|
||||
return ("unrestorable: %d unplayed bytes still queued in the kernel ring after a "
|
||||
"controller restart - a jog would replay them" % residue)
|
||||
# a controller may be inside a respawn backoff (seconds): wait for it
|
||||
mode = None
|
||||
deadline = time.time() + 30
|
||||
@@ -662,7 +799,13 @@ class Baseline:
|
||||
was = captured.get("position")
|
||||
now = read_position()
|
||||
if was is not None and now is not None and now != was and not self.cloud_mode():
|
||||
left.append(Leftover("position", now, was, self._return_head(was, now)))
|
||||
spm = self._counter_spm
|
||||
act = self._return_head(was, now, spm)
|
||||
if position_quantized(was, now, spm):
|
||||
self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, "
|
||||
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
|
||||
else:
|
||||
left.append(Leftover("position", now, was, act))
|
||||
was = captured.get("settings")
|
||||
if was:
|
||||
st, body = self.fc_get("/settings")
|
||||
|
||||
@@ -247,8 +247,11 @@ TOOLS = [
|
||||
{"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."},
|
||||
"lens stands, and neither a $20 nor a $132 write frees it. A referenced lens (forgectrl's marker "
|
||||
"and the lens settings) opens it to the window the settings hold, the fallback or the focus "
|
||||
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
|
||||
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
|
||||
"bench-page tool."},
|
||||
{"id": "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"])],
|
||||
|
||||
@@ -164,7 +164,12 @@ class Run:
|
||||
notice = dict(self.notice) if self.notice else None
|
||||
return {
|
||||
"kind": self.kind, "id": self.id, "title": self.title,
|
||||
"started": self.started_ts, "elapsed_s": int(time.time() - self.started),
|
||||
# A finished run's clock stops: the page shows the last run
|
||||
# until the next one starts, and a live figure there counts
|
||||
# the time since, not the time it took.
|
||||
"started": self.started_ts,
|
||||
"elapsed_s": (self.finished["duration_s"] if self.finished
|
||||
else int(time.time() - self.started)),
|
||||
"log": lines, "dropped": self.dropped, "prompt": prompt, "notice": notice,
|
||||
"finished": self.finished, "aborting": self.aborted.is_set(),
|
||||
}
|
||||
@@ -424,6 +429,45 @@ class Context:
|
||||
self.log("ACT %s %s: done after %.1f s", channel, state, dt)
|
||||
return dt
|
||||
|
||||
def press_now(self, text="The button is lit white. Press it now: the laser fires after your press."):
|
||||
"""The arm press, at the moment the machine says it is waiting.
|
||||
|
||||
A check that runs a job opens a `press` prompt of its own when
|
||||
it sees the button lit and the tube still dark, which is the
|
||||
machine's own word that the press it wants is this one. Pressing
|
||||
on that is exact; pressing on a button LED read from here is
|
||||
not, because the button is lit through parts of a check that are
|
||||
not the arm (the lens reference, the program on its way to the
|
||||
controller), and a press then lands before the job waits for it
|
||||
and is lost.
|
||||
|
||||
The fixture presses where the bench took this test's presses (or
|
||||
opted in standing); otherwise the notice goes up for a person,
|
||||
who is looking at the same prompt. Returns True when the fixture
|
||||
pressed."""
|
||||
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
|
||||
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
|
||||
self.evidence.setdefault("actions", []).append(rec)
|
||||
opted_in = fixture is not None and fixture.covers("button") and \
|
||||
(fixture.arm_press or self.run.fixture_takeover)
|
||||
if not opted_in:
|
||||
if self.run.fixture_takeover:
|
||||
rec["fixture_lost"] = True
|
||||
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
|
||||
"gone - asking the operator for the arm press")
|
||||
self.notice(text)
|
||||
return False
|
||||
try:
|
||||
fixture.act("button", "press")
|
||||
except _fixture.FixtureError as e:
|
||||
rec["fixture_error"] = str(e)
|
||||
self.log("ARM press: fixture failed (%s) - asking the operator", e)
|
||||
self.notice(text)
|
||||
return False
|
||||
rec["by"] = "fixture"
|
||||
self.log("ARM press by the fixture, at the machine's own press prompt")
|
||||
return True
|
||||
|
||||
def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.",
|
||||
lit_timeout=60):
|
||||
"""The arm cue of a live test. A person's press by default: a
|
||||
@@ -994,7 +1038,21 @@ class Runner:
|
||||
run.log(traceback.format_exc().rstrip())
|
||||
self.fixture_release(run)
|
||||
try:
|
||||
self._baseline_post(run, captured)
|
||||
left = self._baseline_post(run, captured)
|
||||
# A test hands the machine back as it found it. Anything left
|
||||
# behind was recorded here and nothing more, so a check that
|
||||
# measured correctly and walked away with the machine in a
|
||||
# state nobody chose still passed: that is how the airflow
|
||||
# check came to leave the purge fan off, and an operator met
|
||||
# it at their first fire instead of the bench meeting it here.
|
||||
# The pass is now conditional on the machine being whole. A
|
||||
# leftover the baseline put back still fails: the restore is
|
||||
# the bench cleaning up after a defect, not the defect's
|
||||
# absence.
|
||||
if left and result == _campaign.PASS:
|
||||
result = _campaign.FAIL
|
||||
message = ("the machine was not handed back as found: %s"
|
||||
% "; ".join(str(x) for x in left))
|
||||
except Exception as e: # noqa: BLE001 - never lose the result over the cleanup
|
||||
run.log("baseline: post pass errored: %s: %s" % (type(e).__name__, e))
|
||||
duration = int(time.time() - run.started)
|
||||
|
||||
@@ -4,9 +4,9 @@ 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 setup # noqa: F401,E402
|
||||
from . import setup_dark # noqa: F401,E402
|
||||
from . import setup_sheet # noqa: F401,E402
|
||||
from . import logs # noqa: F401,E402
|
||||
from . import motion # noqa: F401,E402
|
||||
from . import cooling # noqa: F401,E402
|
||||
|
||||
@@ -396,7 +396,7 @@ def lid_privacy(ctx):
|
||||
"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 .setup import request
|
||||
from .forgectrl import lan_ip
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
|
||||
@@ -87,6 +87,9 @@ FORGECTRL_LOG = "/data/log/forgefirm/forgectrl/forgectrl.log"
|
||||
LIMITS_MARK = "job limits from the header: "
|
||||
EFFECTIVE_MARK = "effective limits: coolant ceiling "
|
||||
SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
|
||||
# The service's connect-time hunt, as the client logs its request: a few
|
||||
# seconds after the controller starts, right behind the session.
|
||||
HUNT_REQUEST = "service action request: hunt"
|
||||
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
|
||||
|
||||
|
||||
@@ -145,7 +148,7 @@ def return_head(ctx, feed=2400):
|
||||
"head not back at the start after the return jog: %s", pos)
|
||||
|
||||
|
||||
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
|
||||
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90, poll=1.0):
|
||||
t0 = time.time()
|
||||
last = None
|
||||
while time.time() - t0 < timeout:
|
||||
@@ -157,7 +160,7 @@ def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
|
||||
return m
|
||||
if m.get("controller") == "motion-fault":
|
||||
break
|
||||
time.sleep(1)
|
||||
time.sleep(poll)
|
||||
return last
|
||||
|
||||
|
||||
@@ -227,6 +230,11 @@ def gfhome_homing(ctx, ev, g):
|
||||
# homed: the counters are re-anchored at the corner, where the head stays
|
||||
ctx.counters_rezeroed()
|
||||
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing")
|
||||
# The driver answers $H when the session ends; that ok (and the
|
||||
# session's messages) sit in the buffer behind the status reports and
|
||||
# would pass for the reply to the caller's next command.
|
||||
ack = " | ".join(ln.strip() for ln in g.drain().splitlines() if ln.strip())
|
||||
ctx.log("$H acknowledged: %s", ack[:160] or "(nothing buffered)")
|
||||
|
||||
|
||||
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt "
|
||||
@@ -235,17 +243,22 @@ def gfhome_homing(ctx, ev, g):
|
||||
covers=_HOMING_PATH + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"),
|
||||
("grblhal-glowforge", "src/**")],
|
||||
requires=["forgectrl.auth", "motion.pacing"], actions=["lid"],
|
||||
steps=["Bed clear; cloud credentials configured; the machine on the network. The test "
|
||||
"turns cloud mode and the gfcloud homing on itself when they are off, and puts "
|
||||
"the settings back at the end.",
|
||||
"Open the lid when told and leave it open through the cloud client's connect and its "
|
||||
"hunt; close it when told. Nothing else: the switch back and the $H homing run on "
|
||||
"their own, and the head ends parked at the home corner."],
|
||||
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. "
|
||||
"The test turns cloud mode and the gfcloud homing on itself when they are off, and "
|
||||
"puts the settings back at the end.",
|
||||
"Open the lid the moment you are told, with a hand ready on it: the cloud client's "
|
||||
"first hunt begins a few seconds after its controller starts and must find the lid "
|
||||
"open. Leave it open through the connect and the hunt; close it when told. Nothing "
|
||||
"else: the switch back and the $H homing run on their own, and the head ends parked "
|
||||
"at the home corner."],
|
||||
description="One round trip with the two service-driven motions on it. POST /mode switches "
|
||||
"to the cloud controller: gfcloud comes up under supervision, authenticates and "
|
||||
"to the cloud controller with the lid closed (no controller starts with the "
|
||||
"enclosure open): gfcloud comes up under supervision, authenticates and "
|
||||
"establishes its service session (its own log lines are the evidence; the "
|
||||
"connect-time firmware probe is recorded when configured). Its connect-time hunt "
|
||||
"runs with the lid OPEN, as the factory's does - it completes, nothing before its "
|
||||
"connect-time firmware probe is recorded when configured). The lid is opened the "
|
||||
"moment the controller is up, before the client requests its connect-time hunt "
|
||||
"(a hunt requested before the lid was open fails the test), so the hunt runs "
|
||||
"with the lid OPEN, as the factory's does - it completes, nothing before its "
|
||||
"end is refused for the lid, the lens homes - and with the extraction fans off it "
|
||||
"is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row "
|
||||
"reads unjudged); the camera service survives the switch. The lid closed, the "
|
||||
@@ -270,17 +283,32 @@ def mode_switch(ctx):
|
||||
except OSError:
|
||||
pass
|
||||
lamp0 = hw.sysfs_read("pic/lid_led")
|
||||
ctx.check(ctx.switch("lid") is True, "close the lid first: no controller starts with it open")
|
||||
|
||||
ctx.act("lid", "open", text="Leave it open: the cloud client connects and hunts with the lid open.")
|
||||
# The switch is made with the enclosure closed (the supervisor holds
|
||||
# every spawn until it is). The lid opens the moment the controller is
|
||||
# up: the client requests its connect-time hunt a few seconds after
|
||||
# its start, right behind its session, and the hunt must find the lid
|
||||
# open. The poll is tight so the window is not spent waiting.
|
||||
log_offset = log_size(GFCLOUD_LOG)
|
||||
st, body = fc.post("/mode", data={"controller": "cloud"})
|
||||
ctx.log("POST /mode controller=cloud -> %s %s", st, body)
|
||||
ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body)
|
||||
m = wait_mode(ctx, fc, "cloud", timeout=90)
|
||||
m = wait_mode(ctx, fc, "cloud", timeout=90, poll=0.2)
|
||||
ev["mode_cloud"] = m
|
||||
ctx.log("mode after switch: %s", m)
|
||||
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
|
||||
"cloud controller did not come up: %s", m)
|
||||
ctx.act("lid", "open", text="Now, at once: the cloud client's first hunt begins a few seconds "
|
||||
"after its controller starts and must find the lid open. Leave it open through the "
|
||||
"connect and the hunt.")
|
||||
# The order is the proof: the hunt's request is not in the log yet
|
||||
# when the lid reads open.
|
||||
ev["hunt_before_lid_open"] = log_has(log_offset, HUNT_REQUEST)
|
||||
ctx.check(not ev["hunt_before_lid_open"],
|
||||
"the client requested its hunt before the lid was open (the hunt follows the "
|
||||
"controller's start by a few seconds): the lid opened too late, so a hunt with the "
|
||||
"lid open is not proven")
|
||||
# the client's own session lines are the evidence of a live cloud session
|
||||
session = wait_session(ctx, log_offset)
|
||||
probe = None
|
||||
@@ -372,7 +400,13 @@ def mode_switch(ctx):
|
||||
gfhome_homing(ctx, ev, g)
|
||||
finally:
|
||||
if ev["homing_mode"] != "gfcloud":
|
||||
st, body = fc.post("/settings", data={"homing_mode": ev["homing_mode"] or "none"})
|
||||
# Back to exactly what the machine had: unset is the empty
|
||||
# string, and clearing a key needs the query-string form.
|
||||
# "none" is a value, and writing it where the machine had
|
||||
# nothing is a leftover the hand-back reports.
|
||||
st, body = (fc.post("/settings", params={"homing_mode": ""})
|
||||
if not ev["homing_mode"]
|
||||
else fc.post("/settings", data={"homing_mode": ev["homing_mode"]}))
|
||||
ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st)
|
||||
ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> "
|
||||
"grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
|
||||
@@ -1289,42 +1323,96 @@ def lid_during_button_wait_body(ctx, ev, off, job, offset):
|
||||
# ---------------------------------------------------------------- the cooling verdict
|
||||
|
||||
HOLD_KEYS = ("cool_temp_min", "cool_temp_start")
|
||||
WARMUP_ABOVE_C = 1.0 # the start gate this far above the loop: a few heater minutes
|
||||
WARMUP_RELEASE_S = 720 # the flow heater warms the bulk 0.4 to 0.8 C a minute
|
||||
REFUSE_KEYS = HOLD_KEYS + ("cloud_hold_max_s",)
|
||||
HOLD_REFUSE_S = 60 # the shortest bound the client takes
|
||||
HOLD_REFUSE_LINE = "cooling hold held 60 s - relocking the laser"
|
||||
WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP"
|
||||
WARMUP_RELEASE_LINE = "cooling verdict clean after WARMUP; starting the run"
|
||||
|
||||
|
||||
@test("cloud.verdict-hold", title="A cloud print armed under the warm-up gate waits for the engine",
|
||||
subsystem="cloud", kind="operator", est_min=14,
|
||||
@test("cloud.dark-print", title="A dark cloud print: the latch locked until the run, unlocked "
|
||||
"for it, locked after it",
|
||||
subsystem="cloud", kind="operator", est_min=3,
|
||||
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
||||
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
|
||||
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate just above "
|
||||
"the coolant and restores it. Press the button when it lights: the print then waits "
|
||||
"for the warm-up (a few minutes with the loop heater on), runs dark, and finishes."],
|
||||
description="The cooling-engine contract in cloud mode, on the engine itself: the start gate "
|
||||
"set just above the coolant makes the armed session open under the warm-up "
|
||||
"(WARMUP: fire blocked, hold), and the client waits it out after the button "
|
||||
"instead of canceling - nothing runs, the latch stays unlocked for the armed "
|
||||
"window - until the engine releases into run, and the release starts the print, "
|
||||
"which completes. The hold mid-run (the same laser-off pause as the button's, "
|
||||
"resumed on the engine's resume_ok) and the bound on a hold are host-tested: the "
|
||||
"gates apply at session open, so no setting can produce a hold mid-run here.")
|
||||
def verdict_hold(ctx):
|
||||
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
||||
"when it lights: the print waits for the engine, runs, and finishes."],
|
||||
description="The cooling-engine contract in cloud mode at its ordinary end, on the engine "
|
||||
"itself: the print arms on the press, waits for the engine's acknowledgment (the "
|
||||
"fans at their floors), and runs. The laser latch is locked at the button and "
|
||||
"through the wait, unlocks only for the run (immediately before it starts), and "
|
||||
"is locked again when the job ends; the print completes. A hold the engine never "
|
||||
"clears is cloud.verdict-refuse; the engine's own warm-up release is "
|
||||
"cooling.floor-and-warm-up.")
|
||||
def dark_print(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "dark.puls", seconds=30)
|
||||
off = Offline().__enter__()
|
||||
try:
|
||||
dark_print_body(ctx, ev, off, job, offset, fc)
|
||||
finally:
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
|
||||
def dark_print_body(ctx, ev, off, job, offset, fc):
|
||||
off.print_ready(9005, job)
|
||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
||||
ev["latch_locked_at_button"] = latch_locked()
|
||||
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
|
||||
ctx.act("button", "press", text="The button is lit: press it. The print runs dark and finishes.",
|
||||
until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=PRESS_TIMEOUT_S)
|
||||
run = wait_print_running(ctx, offset, 60)
|
||||
ctx.check(run is not None, "the print did not start after the press")
|
||||
ctx.sleep(1.0)
|
||||
ev["latch_locked_in_run"] = latch_locked()
|
||||
ctx.check(ev["latch_locked_in_run"] is False, "the latch is locked during the run")
|
||||
fin = wait_action_finished(ctx, offset, "print", 180)
|
||||
ev["print finished"] = message(fin)
|
||||
ctx.check(fin and COMPLETED in fin, "the print did not complete: %s", message(fin) or "no finish line")
|
||||
st, cs = fc.get("/cool/status")
|
||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
||||
ctx.check(not ev["armed_after"], "armed window still open after the print")
|
||||
ev["latch_locked_after"] = latch_locked()
|
||||
ctx.check(ev["latch_locked_after"], "the latch is unlocked after the print")
|
||||
ev["events"] = offline_events(off)
|
||||
ctx.log("PASS: locked at the button, unlocked for the run, locked after it; the print completed")
|
||||
|
||||
|
||||
@test("cloud.verdict-refuse", title="A cloud print held by the engine past the bound is canceled with "
|
||||
"the latch never unlocked",
|
||||
subsystem="cloud", kind="operator", est_min=4,
|
||||
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
||||
("forgectrl", "src/cool.*")],
|
||||
requires=["cloud.dark-print"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate well above "
|
||||
"the coolant and the hold bound to its minimum, and restores both. Press the button "
|
||||
"when it lights: nothing runs; a minute later the print cancels by itself."],
|
||||
description="The other end of the cooling-engine contract before a run: a verdict that "
|
||||
"refuses fire and never clears. The start gate far above the coolant keeps the "
|
||||
"armed session under the warm-up for longer than the hold bound (cloud_hold_max_s "
|
||||
"at its minimum, 60 s). The client waits with the laser latch locked the whole "
|
||||
"time - it is never unlocked before the verdict passes, so there is nothing to "
|
||||
"relock - then cancels the print at the bound: no run, the armed window closed, "
|
||||
"the print ':cancelled'. Dark by construction.")
|
||||
def verdict_refuse(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
before = fc.settings()
|
||||
orig = {k: before.get(k, "") for k in HOLD_KEYS}
|
||||
orig = {k: before.get(k, "") for k in REFUSE_KEYS}
|
||||
ev["orig"] = orig
|
||||
up = (fc.status().get("coolant") or {}).get("up_c")
|
||||
ctx.check(up is not None and 10.0 <= up <= 30.0, "coolant outside the test's range (up_c %s)", up)
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "hold.puls", seconds=30)
|
||||
job = offline_job(ctx, "refuse.puls", seconds=30)
|
||||
off = Offline().__enter__()
|
||||
try:
|
||||
verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig)
|
||||
verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig)
|
||||
finally:
|
||||
st, body = fc.post("/settings", params=orig)
|
||||
ctx.log("restore: POST /settings %s -> %s", orig, st)
|
||||
@@ -1332,46 +1420,53 @@ def verdict_hold(ctx):
|
||||
offline_cleanup(ctx)
|
||||
|
||||
|
||||
def verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig):
|
||||
gate = round(up + WARMUP_ABOVE_C, 1)
|
||||
def verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig):
|
||||
gate = round(up + 8.0, 1) # eight degrees: the heater cannot get there in a minute
|
||||
st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
|
||||
"cool_temp_min": orig["cool_temp_min"]})
|
||||
ctx.check(st == 200, "POST /settings cool_temp_start=%s -> %s %s", gate, st, body)
|
||||
"cool_temp_min": orig["cool_temp_min"],
|
||||
"cloud_hold_max_s": str(HOLD_REFUSE_S)})
|
||||
ctx.check(st == 200, "POST /settings cool_temp_start=%s cloud_hold_max_s=%s -> %s %s",
|
||||
gate, HOLD_REFUSE_S, st, body)
|
||||
ev["gate"] = gate
|
||||
off.print_ready(9005, job)
|
||||
off.print_ready(9006, job)
|
||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
||||
ctx.act("button", "press", text="The button is lit: press it. The print then waits for the warm-up.",
|
||||
ev["latch_locked_at_button"] = latch_locked()
|
||||
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
|
||||
ctx.act("button", "press", text="The button is lit: press it. Nothing runs; the print cancels "
|
||||
"itself a minute later.",
|
||||
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
|
||||
got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30)
|
||||
ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up")
|
||||
st, c = fc.get("/cool/status")
|
||||
ev["warmup"] = c
|
||||
ctx.check(isinstance(c, dict) and c.get("verdict") == "WARMUP" and c.get("hold") is True
|
||||
and c.get("fire_ok") is False,
|
||||
"the engine is not holding the armed session on the warm-up: %s", c)
|
||||
ctx.check(wait_print_running(ctx, offset, 2) is None, "the run started under the warm-up hold")
|
||||
ctx.notice("Warm-up: the loop heater brings the coolant to the gate; a few minutes. Do nothing.")
|
||||
t0 = time.time()
|
||||
samples = []
|
||||
ctx.notice("The engine holds the print; the client gives up at the bound. Do nothing.")
|
||||
try:
|
||||
t0 = time.time()
|
||||
run = wait_print_running(ctx, offset, WARMUP_RELEASE_S)
|
||||
while time.time() - t0 < HOLD_REFUSE_S + 30:
|
||||
ctx.checkpoint()
|
||||
samples.append(latch_locked())
|
||||
if log_has(offset, HOLD_REFUSE_LINE):
|
||||
break
|
||||
ctx.sleep(2)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
ev["release_s"] = round(time.time() - t0)
|
||||
got = wait_log(ctx, offset, [WARMUP_RELEASE_LINE], 5)
|
||||
ev["log"] = {WARMUP_WAIT_LINE: True, WARMUP_RELEASE_LINE: bool(got[WARMUP_RELEASE_LINE])}
|
||||
ctx.check(got[WARMUP_RELEASE_LINE], "the warm-up did not release into the run within %d s",
|
||||
WARMUP_RELEASE_S)
|
||||
ctx.check(run is not None, "the print did not start after the warm-up")
|
||||
fin = wait_action_finished(ctx, offset, "print", 180)
|
||||
ev["hold_s"] = round(time.time() - t0)
|
||||
ev["latch_samples"] = {"n": len(samples), "unlocked": samples.count(False)}
|
||||
ctx.check(log_has(offset, HOLD_REFUSE_LINE), "the client did not cancel at the hold bound (%d s)",
|
||||
ev["hold_s"])
|
||||
ctx.check(all(samples), "the latch unlocked during the hold (%d of %d samples)",
|
||||
samples.count(False), len(samples))
|
||||
ctx.check(wait_print_running(ctx, offset, 1) is None, "the run started under the refused verdict")
|
||||
fin = wait_action_finished(ctx, offset, "print", 120)
|
||||
ev["print finished"] = message(fin)
|
||||
ctx.check(fin and COMPLETED in fin, "the print did not complete after the warm-up: %s",
|
||||
ctx.check(fin and CANCELLED in fin, "the held print did not end ':cancelled': %s",
|
||||
message(fin) or "no finish line")
|
||||
st, cs = fc.get("/cool/status")
|
||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
||||
ctx.check(not ev["armed_after"], "armed window still open after the print")
|
||||
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
|
||||
ctx.check(latch_locked(), "the latch is unlocked after the cancel")
|
||||
ev["events"] = offline_events(off)
|
||||
ctx.log("PASS: the armed print waited %s s on the warm-up, then ran and completed", ev["release_s"])
|
||||
ctx.log("PASS: held %s s with the latch locked throughout, then canceled at the bound", ev["hold_s"])
|
||||
|
||||
|
||||
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
|
||||
|
||||
@@ -746,7 +746,7 @@ FIRE_WAIT_S = 25 # settings re-read at run start, two-tick breach, 1
|
||||
|
||||
@test("cooling.fire-watch-tiers", title="The lid-IR fire watch pauses at its alert and fails at its critical",
|
||||
subsystem="cooling", kind="auto", mode="grbl", est_min=6,
|
||||
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"],
|
||||
covers=_COOL_COVERS + [("forgectrl", "src/super.*")], requires=["kernel.latch-locked-idle"],
|
||||
steps=["Machine idle, lid closed, lid lamp at its resting level. The test moves the flame "
|
||||
"thresholds under the lamp's own reading and restores them; three M8/M9 sessions, "
|
||||
"no fire."],
|
||||
@@ -759,6 +759,7 @@ FIRE_WAIT_S = 25 # settings re-read at run start, two-tick breach, 1
|
||||
"read as the four flame gates off and the watch reads watch. Restored, the "
|
||||
"watch reads armed at OK.")
|
||||
def fire_watch_tiers(ctx):
|
||||
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings()
|
||||
@@ -810,7 +811,16 @@ def fire_watch_tiers(ctx):
|
||||
ctx.check(c.get("verdict") == "OK", "the alert survived into a fresh session: %s", c)
|
||||
|
||||
# Leg 2: the fail tier. The q1 critical under the lamp reading
|
||||
# stops the session and latches FIRE until it ends.
|
||||
# latches FIRE and locks the laser; the fail tier then stops
|
||||
# the controller through the supervisor and starts it again,
|
||||
# so no run start can relight what was locked and the sender
|
||||
# sees the job end. The Grbl socket dies with the restart -
|
||||
# FIRE is read from the engine, the latch from sysfs, the stop
|
||||
# from the supervisor's log, and a fresh session is opened for
|
||||
# the legs that follow.
|
||||
st, m0 = fc.get("/mode")
|
||||
pid0 = m0.get("pid") if isinstance(m0, dict) else None
|
||||
off = _log_offset(FORGECTRL_LOG)
|
||||
_set_gates(ctx, fc, {"cool_fire_q1_critical": str(max(2, q1 - 5)),
|
||||
"cool_fire_q1_alert": str(max(1, q1 - 10))})
|
||||
grbl.command("M8")
|
||||
@@ -821,19 +831,38 @@ def fire_watch_tiers(ctx):
|
||||
c = _cool(fc)
|
||||
if c.get("verdict") == "FIRE":
|
||||
break
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
ev["critical"] = c
|
||||
ctx.log("critical leg: verdict %s fire_watch %s", c.get("verdict"), c.get("fire_watch"))
|
||||
ev["interlock_circuit"] = ilk
|
||||
ctx.log("critical leg: verdict %s fire_watch %s interlock_circuit %s",
|
||||
c.get("verdict"), c.get("fire_watch"), ilk)
|
||||
ctx.check(c.get("verdict") == "FIRE", "q1 critical under the lamp did not latch FIRE: %s", c)
|
||||
ctx.check(c.get("fire_watch") == "ALARM", "fire_watch %r during FIRE, expected ALARM", c.get("fire_watch"))
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
ev["interlock_circuit"] = ilk
|
||||
ctx.check(ilk is not None and (ilk & 8), "the laser latch is not locked under FIRE (interlock_circuit=%s)", ilk)
|
||||
ctx.check(_tail_wait(ctx, fc, "LID IR FIRE SIGNAL (quartiles "),
|
||||
"the FIRE line is missing from the forgectrl log")
|
||||
# the fail tier stops the controller and starts it again; the
|
||||
# Grbl socket dies with it. Wait out the restart, prove the
|
||||
# supervisor logged the stop, restore the thresholds, and open
|
||||
# a fresh session on the new controller.
|
||||
grbl.close()
|
||||
m1 = None
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 60:
|
||||
st, m1 = fc.get("/mode")
|
||||
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
|
||||
break
|
||||
ctx.sleep(0.5)
|
||||
ev["restart"] = {"pid_before": pid0, "mode_after": m1}
|
||||
ctx.log("after FIRE: %s", ev["restart"])
|
||||
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid0,
|
||||
"the controller was not stopped and started again after FIRE: %s", m1)
|
||||
ctx.check(_log_lines(FORGECTRL_LOG, off, "lid IR fire signal - the controller is stopped"),
|
||||
"the supervisor did not log the fail-tier stop after FIRE")
|
||||
_set_gates(ctx, fc, {"cool_fire_q1_critical": orig["cool_fire_q1_critical"],
|
||||
"cool_fire_q1_alert": orig["cool_fire_q1_alert"]})
|
||||
grbl.command("M9")
|
||||
_session_ended(ctx, fc, "critical leg")
|
||||
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the FIRE restart")
|
||||
grbl.connect() # a fresh session on the restarted controller
|
||||
|
||||
# Leg 3: all four at zero: the watch off, said so, session OK.
|
||||
_set_gates(ctx, fc, {k: "0" for k in FIRE_KEYS})
|
||||
@@ -891,7 +920,7 @@ CRASH_GATES = ["crash_abort", "crash_x_alert", "crash_y_alert"]
|
||||
"read as the three crash gates off by value (gates_off names them) with the "
|
||||
"session OK; a threshold past the register range is refused; restored, the "
|
||||
"gates read ok. The tiers themselves (BUMP, CRASH) trip only on a physical "
|
||||
"knock inside an armed window and are commissioned at the bench, not here.")
|
||||
"knock inside an armed window and are proven at the bench, not here.")
|
||||
def crash_watch_plumbing(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
@@ -1329,3 +1358,150 @@ def flow_under_load(ctx):
|
||||
ctx.check(dt is not None, "the window did not close after M2")
|
||||
ctx.log("PASS: lit through the window, raw rise %.1f C, laser %.1f C off, judged %.1f C against %.1f "
|
||||
"(dT %.1f), window closed %.1f s after M2", raw, share, rise, float(limit), float(dtc), dt)
|
||||
|
||||
|
||||
@test("cooling.fan-duty-readback", title="A fan duty a device lost is read back and put back inside a tick",
|
||||
subsystem="cooling", kind="auto", mode="grbl", est_min=2,
|
||||
covers=_COOL_COVERS + [("kernel-module-glowforge", "src/head*")],
|
||||
requires=["kernel.latch-locked-idle"],
|
||||
description="M8 opens a run session and every fan reads its run duty back from its device. "
|
||||
"The head's air-assist register is then written back to the idle duty behind the "
|
||||
"engine's back (what a head reset or a dropped transaction leaves) and the exhaust "
|
||||
"PWM to zero: within a few ticks each reads its run duty again, the log names each "
|
||||
"loss and its put-back, and the session stays OK with no airflow fault (a fan put "
|
||||
"back gets its spin-up grace). Then M9 and the idle duties.")
|
||||
def fan_duty_readback(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
run = {"head/air_assist_pwm": 1023, "thermal/exhaust_pwm": 65535, "thermal/intake_pwm": 43278}
|
||||
with ctx.grbl() as g:
|
||||
c = _hold_session(ctx, g, fc, lambda c: c.get("phase") == "run", "readback", 20)
|
||||
try:
|
||||
ctx.check(c.get("phase") == "run", "no run session opened: %s", c)
|
||||
ctx.sleep(2)
|
||||
before = {k: hw.sysfs_int(k) for k in run}
|
||||
ctx.log("duties at run: %s", before)
|
||||
ev["run_duties"] = before
|
||||
ctx.check(all(before.get(k) == v for k, v in run.items()),
|
||||
"not every fan reads its run duty: %s", before)
|
||||
hw.sysfs_write("head/air_assist_pwm", 204)
|
||||
hw.sysfs_write("thermal/exhaust_pwm", 0)
|
||||
lost = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
|
||||
ctx.log("written behind the engine's back: %s", lost)
|
||||
back = {}
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 6:
|
||||
ctx.sleep(0.5)
|
||||
back = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
|
||||
if back.get("head/air_assist_pwm") == 1023 and back.get("thermal/exhaust_pwm") == 65535:
|
||||
break
|
||||
took = round(time.time() - t0, 1)
|
||||
ev["put_back"] = {"duties": back, "after_s": took}
|
||||
ctx.log("put back after %s s: %s", took, back)
|
||||
ctx.check(back.get("head/air_assist_pwm") == 1023, "the air-assist duty was not put back: %s", back)
|
||||
ctx.check(back.get("thermal/exhaust_pwm") == 65535, "the exhaust duty was not put back: %s", back)
|
||||
ctx.sleep(2)
|
||||
ctx.check(_tail_has(fc, "head/air_assist_pwm read 204 with 1023 commanded: put back"),
|
||||
"the air-assist loss is not named in the log")
|
||||
ctx.check(_tail_has(fc, "thermal/exhaust_pwm read 0 with 65535 commanded: put back"),
|
||||
"the exhaust loss is not named in the log")
|
||||
for _ in range(8):
|
||||
ctx.sleep(1)
|
||||
c = _cool(fc)
|
||||
if c.get("verdict") != "OK":
|
||||
break
|
||||
ev["verdict_after"] = {"verdict": c.get("verdict"), "hold": c.get("hold"), "reason": c.get("reason")}
|
||||
ctx.check(c.get("verdict") == "OK" and not c.get("hold"),
|
||||
"the session did not stay OK after the put-back: %s %s", c.get("verdict"), c.get("reason"))
|
||||
finally:
|
||||
g.command("M9")
|
||||
_session_ended(ctx, fc, "readback")
|
||||
idle = {k: hw.sysfs_int(k) for k in run}
|
||||
ev["idle_duties"] = idle
|
||||
ctx.log("duties at idle: %s", idle)
|
||||
|
||||
|
||||
@test("cooling.fail-tier-stop", title="A fail-tier verdict ends the controller: CRASH stops motion, "
|
||||
"locks the laser, and the supervisor restarts the controller",
|
||||
subsystem="cooling", kind="operator", mode="grbl", est_min=4,
|
||||
covers=_COOL_COVERS + [("forgectrl", "src/super.*")],
|
||||
requires=["cooling.crash-watch-plumbing", "motion.deadman"], actions=["button"],
|
||||
steps=["Bed clear, lid closed, 40 mm of free +X travel. Nothing fires: the job is dark (S0).",
|
||||
"Press the physical button when it lights white, once. The head moves and stops on "
|
||||
"its own; the controller restarts."],
|
||||
description="The engine's fail tiers (a lid IR fire signal, a head crash signal) do not "
|
||||
"leave the job to the controller: after the kernel writes (motion stopped, the "
|
||||
"latch locked) the engine ends the controller through the supervisor, which "
|
||||
"starts it again, so no run start can relight what was locked and the sender "
|
||||
"sees the job end. Provable without emission or a physical crash: the crash "
|
||||
"watch's thresholds at their lowest (1) make the head's own move trip the abort "
|
||||
"generator inside an armed, dark (S0) job. Expected: the engine logs the crash "
|
||||
"signal, the supervisor logs the stop and starts a new controller (new pid), the "
|
||||
"latch is locked, the kernel idle, no emission, and the thresholds are put back.")
|
||||
def fail_tier_stop(ctx):
|
||||
from .laser import prepare, stream, sample
|
||||
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings()
|
||||
orig = {k: before.get(k, "") for k in CRASH_KEYS}
|
||||
ev["orig"] = orig
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
|
||||
st, m0 = fc.get("/mode")
|
||||
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
|
||||
pid0 = m0.get("pid")
|
||||
_set_gates(ctx, fc, {k: "1" for k in CRASH_KEYS})
|
||||
off = _log_offset(FORGECTRL_LOG)
|
||||
t_arm = None
|
||||
try:
|
||||
try:
|
||||
with ctx.grbl() as g:
|
||||
prepare(ctx, g)
|
||||
ctx.ready("DARK JOB (S0, nothing fires). Bed clear with 40 mm of free +X travel, lid closed. "
|
||||
"The head moves 20 mm and the controller restarts on its own.")
|
||||
stream(g, ["G91", "G21", "M3 S0", "G4 P1", "G1 X20 F1200", "G1 X-20 F1200", "M5", "G90", "M2"])
|
||||
ctx.arm_press()
|
||||
t_arm = ctx.wait_for(lambda: bool((sample(ctx) or {}).get("armed")), 240)
|
||||
ctx.clear_notice()
|
||||
ctx.check(t_arm is not None, "the window never opened (arm refused, or no press)")
|
||||
t0 = time.time()
|
||||
tripped = ctx.wait_for(lambda: _cool(fc).get("verdict") == "CRASH"
|
||||
or (fc.get("/mode")[1] or {}).get("pid") not in (None, pid0), 30)
|
||||
ctx.log("the crash tier tripped after %s s", tripped)
|
||||
except (hw.HwError, OSError) as e:
|
||||
ctx.log("the Grbl connection ended with the restart: %s", e)
|
||||
t_kill = time.time()
|
||||
m1 = None
|
||||
while time.time() - t_kill < 60:
|
||||
st, m1 = fc.get("/mode")
|
||||
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
|
||||
break
|
||||
ctx.sleep(0.5)
|
||||
# the daemon's lines reach the log file through syslog, a moment
|
||||
# after the events they name
|
||||
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL"))
|
||||
and bool(_log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")), 15)
|
||||
engine = _log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL")
|
||||
super_ = _log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")
|
||||
smp = sample(ctx)
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
ev["result"] = {"mode_after": m1, "engine_lines": [ln[-160:] for ln in engine[-2:]],
|
||||
"super_lines": [ln[-160:] for ln in super_[-2:]],
|
||||
"emission": smp and smp["emission"], "kernel": smp and smp["kstate"],
|
||||
"latch_locked": ilk is not None and bool(ilk & (1 << 3))}
|
||||
ctx.log("after the crash tier: %s", ev["result"])
|
||||
ctx.check(engine, "the engine did not log the crash signal")
|
||||
ctx.check(super_, "the supervisor did not log the fail-tier stop")
|
||||
ctx.check(m1 and m1.get("pid") != pid0, "the controller was not stopped and started again: %s", m1)
|
||||
ctx.check(ev["result"]["latch_locked"], "latch not locked after the crash tier")
|
||||
ctx.check(not (smp and smp["emission"]), "emission during a dark job: %s", smp and smp["emission"])
|
||||
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the restart")
|
||||
finally:
|
||||
fc.wait_idle(30, abort=ctx.aborted)
|
||||
st, body = fc.post("/settings", params=orig)
|
||||
ctx.log("restore thresholds: POST /settings %s -> %s", orig, st)
|
||||
after = fc.settings()
|
||||
ctx.check(all(after.get(k, "") == orig[k] for k in CRASH_KEYS),
|
||||
"thresholds not restored: %s", {k: after.get(k) for k in CRASH_KEYS})
|
||||
ctx.log("PASS: the crash tier stopped the job, locked the latch, and the supervisor restarted "
|
||||
"the controller (pid %s -> %s)", pid0, m1 and m1.get("pid"))
|
||||
|
||||
@@ -35,8 +35,9 @@ def lan_ip():
|
||||
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; the "
|
||||
"cooling report channel accepts the loopback peer and refuses a non-loopback "
|
||||
"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; "
|
||||
@@ -55,6 +56,7 @@ def auth(ctx):
|
||||
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
|
||||
("/diag/flow-verify", None), ("/diag/abort", None),
|
||||
("/update/apply", None), ("/boot", {"target": "a"}),
|
||||
("/update/check", None), ("/update/dismiss", {"version": "v0.0.0"}),
|
||||
("/system/reboot", None), ("/restore/factory", None),
|
||||
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
|
||||
("/wiz/advisories/accept", None), ("/wiz/account", None),
|
||||
@@ -96,6 +98,17 @@ def auth(ctx):
|
||||
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)
|
||||
@@ -139,7 +152,7 @@ def auth(ctx):
|
||||
# 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
|
||||
from .setup import request, decode
|
||||
ip = lan_ip()
|
||||
ev["lan_ip"] = ip
|
||||
ctx.check(ip, "cannot determine the board's LAN address")
|
||||
|
||||
@@ -28,6 +28,12 @@ def _wdog1_wcr():
|
||||
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:
|
||||
@@ -104,7 +110,9 @@ def fds_of(pid):
|
||||
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
|
||||
("linux-fslc", "**")],
|
||||
description="The image that is running is the image the manifest describes, with the "
|
||||
"kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
|
||||
"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 "
|
||||
@@ -113,11 +121,39 @@ def image_health(ctx):
|
||||
ev = ctx.evidence
|
||||
manifest = ctx.runner.manifest
|
||||
|
||||
# 1. version stamp
|
||||
# 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()
|
||||
@@ -251,7 +287,8 @@ def image_health(ctx):
|
||||
|
||||
# 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-banner, the sshd host keys). The
|
||||
# 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():
|
||||
@@ -262,7 +299,8 @@ def image_health(ctx):
|
||||
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/issue") if p in mounts}
|
||||
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("/"))
|
||||
@@ -292,6 +330,7 @@ def image_health(ctx):
|
||||
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"
|
||||
|
||||
@@ -252,6 +252,26 @@ def kill_trail(ctx, t0, seconds=5.0):
|
||||
return trail
|
||||
|
||||
|
||||
def kill_fast_trail(t0, seconds=1.5):
|
||||
"""The latch and the kernel state read from sysfs every few
|
||||
milliseconds after a kill: the supervisor's own reaction, which the
|
||||
HTTP samples of kill_trail cannot resolve. A controller death is a
|
||||
signal to the supervisor, not a poll, so the relock lands within
|
||||
milliseconds; the kernel leaves running once its stop ramp is done."""
|
||||
locked_at = stopped_at = None
|
||||
end = t0 + seconds
|
||||
while time.time() < end and (locked_at is None or stopped_at is None):
|
||||
now = time.time() - t0
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
if locked_at is None and ilk is not None and ilk & IL_LASER_LATCH:
|
||||
locked_at = round(now, 3)
|
||||
st = hw.sysfs_read("cnc/state")
|
||||
if stopped_at is None and st is not None and st != "running":
|
||||
stopped_at = round(now, 3)
|
||||
time.sleep(0.005)
|
||||
return {"latch_locked_at_s": locked_at, "kernel_stopped_at_s": stopped_at}
|
||||
|
||||
|
||||
def judge_kill(ctx, trail, what):
|
||||
"""(first zero, tail stayed zero, kernel stopped running) from a trail."""
|
||||
for t in trail:
|
||||
@@ -566,11 +586,19 @@ def m5_rapid_dark(ctx):
|
||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
ctx.ready(ARM_CUE % "20 mm +X")
|
||||
# +20 out on the cut, then the two rapids the test is about, then
|
||||
# a third that puts the head back where the job found it: the
|
||||
# first two net to +20 and a job that walks the head 20 mm and
|
||||
# leaves it there is dirt the hand-back has to jog out. The
|
||||
# return is inside the sampling window and after the M5, so it is
|
||||
# one more rapid that must be dark, which is what the test asks
|
||||
# of the other two.
|
||||
job = ["G91", "G21", "M3", "S400",
|
||||
"G1 X20 F600",
|
||||
"M5", "G4 P2.5",
|
||||
"G0 X-20", "G4 P2.5",
|
||||
"G0 X20", "G4 P2.5",
|
||||
"G0 X-20", "G4 P0.5",
|
||||
"G90", "M2"]
|
||||
ctx.arm_press()
|
||||
try:
|
||||
@@ -632,6 +660,7 @@ def disarm_in_hold(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
k0 = kernel_start(ctx)
|
||||
ctx.ready(ARM_CUE % "40 mm +X")
|
||||
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
|
||||
ctx.log("armed; waiting for motion to start (arm + your button press)...")
|
||||
@@ -674,6 +703,20 @@ def disarm_in_hold(ctx):
|
||||
ctx.sleep(1)
|
||||
if "Alarm" in g.status_report()["state"]:
|
||||
g.command("$X")
|
||||
# The hold parks the head part-way through the move and the reset
|
||||
# abandons the rest of it, so the job ends wherever the feed hold
|
||||
# caught it - about 11 mm in, on the bench reference. That is the
|
||||
# test's own doing and it goes back: the kernel counters say how
|
||||
# far the head actually went, which is what a jog has to undo.
|
||||
wait_idle(ctx, g, 15)
|
||||
dx, dy = kernel_xy_mm(ctx)
|
||||
back_x, back_y = k0[0] - dx, k0[1] - dy
|
||||
ev["left_at_mm"] = [round(dx - k0[0], 3), round(dy - k0[1], 3)]
|
||||
if abs(back_x) >= 0.02 or abs(back_y) >= 0.02:
|
||||
ctx.log("returning the head: %+.3f/%+.3f mm", back_x, back_y)
|
||||
g.command("$J=G91X%.3fY%.3fF1200" % (back_x, back_y))
|
||||
wait_idle(ctx, g, 30)
|
||||
ev["returned_mm"] = list(kernel_xy_mm(ctx))
|
||||
ctx.check(disarmed_at is not None, "still armed after 120 s in Hold")
|
||||
ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold)
|
||||
check_button_dark(ctx, ev)
|
||||
@@ -692,7 +735,9 @@ def disarm_in_hold(ctx):
|
||||
"SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, "
|
||||
"and the restart is a separate operator-judged step. Unexpected: mid-burn SIGKILL "
|
||||
"of the controller - the supervisor's exit safing must end the fire tail inside "
|
||||
"the ring's in-flight window, leave the latch locked, and respawn the controller.")
|
||||
"the ring's in-flight window, leave the latch locked, and respawn the controller. "
|
||||
"The death is a signal to the supervisor, not a poll: the latch relocks within "
|
||||
"300 ms of the kill and the kernel is out of running within a second.")
|
||||
def armed_kill(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
@@ -746,13 +791,20 @@ def armed_kill(ctx):
|
||||
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
|
||||
t_kill = time.time()
|
||||
_os.kill(pid, _signal.SIGKILL)
|
||||
fast = kill_fast_trail(t_kill)
|
||||
trail = kill_trail(ctx, t_kill)
|
||||
ctx.log("after the kill (sysfs): %s", fast)
|
||||
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "kill")
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
locked = ilk is not None and bool(ilk & (1 << 3))
|
||||
ev["sigkill"] = {"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero,
|
||||
"kernel_not_running": not_running, "latch_locked": locked, "trail": trail}
|
||||
"kernel_not_running": not_running, "latch_locked": locked, "trail": trail,
|
||||
"fast": fast}
|
||||
ctx.log("latch locked after the kill: %s", locked)
|
||||
ctx.check(fast["latch_locked_at_s"] is not None and fast["latch_locked_at_s"] < 0.3,
|
||||
"the latch was not locked within 300 ms of the kill: %s", fast)
|
||||
ctx.check(fast["kernel_stopped_at_s"] is not None and fast["kernel_stopped_at_s"] < 1.0,
|
||||
"the kernel was still running 1 s after the kill: %s", fast)
|
||||
ctx.check(zero_at is not None and zero_at < 2.5,
|
||||
"emission did not drop within 2.5 s of the kill (first 0 at %s)", zero_at)
|
||||
ctx.check(tail_zero, "emission returned after the kill")
|
||||
@@ -769,10 +821,187 @@ def armed_kill(ctx):
|
||||
ctx.log("/mode after the kill: %s", m1)
|
||||
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
|
||||
"supervisor did not respawn the controller: %s", m1)
|
||||
# The respawned controller starts locked and stays locked: a latch it
|
||||
# did not unlock is never relit by a run start, and no arm has asked.
|
||||
ctx.check(wait_grbl_port(ctx), "the respawned controller never accepted a Grbl connection")
|
||||
ctx.sleep(2)
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
ev["latch_locked_after_respawn"] = ilk is not None and bool(ilk & IL_LASER_LATCH)
|
||||
ctx.check(ev["latch_locked_after_respawn"],
|
||||
"the respawned controller unlocked the latch with no arm (interlock_circuit=%s)", ilk)
|
||||
ev["beam_at_fire_kill"] = smp.get("beam")
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller "
|
||||
"respawned, button dark", ev["expected"]["zero_at_s"], zero_at)
|
||||
"respawned with the latch still locked, button dark", ev["expected"]["zero_at_s"], zero_at)
|
||||
|
||||
|
||||
@test("laser.verdict-cut", title="A blocked verdict mid-cut locks the latch and holds; the clean "
|
||||
"verdict resumes with no press",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=4,
|
||||
covers=_LASER_COVERS,
|
||||
requires=["laser.emission-witness"], actions=["button"],
|
||||
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white (the arm). Nothing else: the test "
|
||||
"takes the cooling verdict away itself and gives it back."],
|
||||
description="A 40 mm line at constant power (M3 S400/F300, 8 s). About 1.5 s in, the test "
|
||||
"pauses the machine-services daemon for 3.5 s, so the cooling verdict the "
|
||||
"controller reads goes stale: the controller's pause tier. The controller must "
|
||||
"hold the job (grbl Hold) under the still-open armed window with the stream "
|
||||
"masked dark, so the kernel's LASER_ON sample count reads 0 before the cut "
|
||||
"resumes, and it must not write the latch: a lock sets the hardware button "
|
||||
"latch, which only a press clears, so both bit 3 (the SoC lock) and bit 2 "
|
||||
"(the button latch) of interlock_circuit stay clear through the whole run. "
|
||||
"When the daemon returns (the verdict fresh, clean, resume_ok), the controller "
|
||||
"must resume the cut lit from where it stopped, with no button press and no "
|
||||
"prompt; the M2 program end then disarms as usual. The daemon's own dead-man "
|
||||
"(5 s of silence) is not reached: the controller keeps reporting through the "
|
||||
"pause. The beam detector witnesses the cut on both sides of the hold.")
|
||||
def verdict_cut(ctx):
|
||||
import os as _os
|
||||
import signal as _signal
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
# The daemon is frozen long enough for the verdict the controller
|
||||
# caches to expire (VERDICT_MAX_AGE 2.0 s), so the driver's pause tier
|
||||
# holds the job - but not so long that the engine's report dead-man
|
||||
# trips on resume. That dead-man locks the laser latch when the
|
||||
# controller's report is older than REPORT_TIMEOUT_S (5.0 s) while
|
||||
# armed, which is exactly the "latch stayed locked" this test refuses.
|
||||
# The last report is up to 1 s old when the freeze starts, so the
|
||||
# freeze must stay under 4.0 s; 3.0 s clears the stale floor with room
|
||||
# to see the hold and stays a full second under the dead-man.
|
||||
PAUSE_S = 3.0
|
||||
pids = hw.pidof("forgectrl")
|
||||
ctx.check(pids, "no forgectrl process found to pause")
|
||||
ev["daemon_pids"] = pids
|
||||
state_path = "/run/forgefirm/grbl.state"
|
||||
|
||||
def grbl_state_file():
|
||||
try:
|
||||
with open(state_path) as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
def resume_daemon():
|
||||
for p in pids:
|
||||
try:
|
||||
_os.kill(p, _signal.SIGCONT)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
trail = []
|
||||
text = ""
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
k0 = kernel_start(ctx)
|
||||
base = sample(ctx)
|
||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
ctx.ready(ARM_CUE % "40 mm +X")
|
||||
job = ["G91", "G21", "M3", "S400", "G1 X40 F300", "M5", "G0 X-40", "G90", "M2"]
|
||||
smp = arm_and_fire(ctx, g, room="40 mm +X", job=job)
|
||||
beams = [(smp.get("beam"), smp.get("beam_d"))]
|
||||
ctx.log("emission live (%s); pausing the daemon in 1 s", smp["emission"])
|
||||
ctx.sleep(1.0)
|
||||
g.drain()
|
||||
t0 = time.time()
|
||||
try:
|
||||
for p in pids:
|
||||
_os.kill(p, _signal.SIGSTOP)
|
||||
ctx.log("daemon paused (SIGSTOP %s) for %.1f s: the verdict goes stale", pids, PAUSE_S)
|
||||
# No HTTP while the daemon is paused: the controller's socket,
|
||||
# sysfs and the controller's state file are the witnesses.
|
||||
while time.time() - t0 < PAUSE_S:
|
||||
st = g.status_report()["state"]
|
||||
text += g.drain()
|
||||
il = hw.sysfs_int("cnc/interlock_circuit")
|
||||
trail.append({"t": round(time.time() - t0, 2), "gstate": st, "il": il,
|
||||
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
|
||||
"armed": '"armed":true' in grbl_state_file()})
|
||||
time.sleep(0.12)
|
||||
finally:
|
||||
resume_daemon()
|
||||
ctx.log("daemon resumed (SIGCONT) at +%.2f s", time.time() - t0)
|
||||
# Through the resume and to the end of the cut, sampling the same
|
||||
# witnesses (the daemon's own once it answers again).
|
||||
resumed_at = None
|
||||
end = time.time() + 30
|
||||
while time.time() < end:
|
||||
ctx.checkpoint()
|
||||
st = g.status_report()["state"]
|
||||
text += g.drain()
|
||||
il = hw.sysfs_int("cnc/interlock_circuit")
|
||||
row = {"t": round(time.time() - t0, 2), "gstate": st, "il": il,
|
||||
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
|
||||
"armed": '"armed":true' in grbl_state_file()}
|
||||
if time.time() - t0 > PAUSE_S + 1.0:
|
||||
s = sample(ctx)
|
||||
if s:
|
||||
row["beam"] = s.get("beam")
|
||||
beams.append((s.get("beam"), s.get("beam_d")))
|
||||
trail.append(row)
|
||||
if resumed_at is None and st.startswith("Run"):
|
||||
resumed_at = time.time() - t0
|
||||
if st.startswith("Idle") and resumed_at is not None and time.time() - t0 > resumed_at + 3.0:
|
||||
break
|
||||
time.sleep(0.12)
|
||||
for r in trail:
|
||||
ctx.log(" %s", r)
|
||||
ev["trail"] = trail
|
||||
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
|
||||
ctx.log("controller: %s", ev["messages"])
|
||||
dt = wait_disarm(ctx, 75)
|
||||
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
||||
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the job")
|
||||
check_kernel_returned(ctx, ev, k0)
|
||||
|
||||
def locked(r):
|
||||
return r["il"] is not None and bool(r["il"] & IL_LASER_LATCH)
|
||||
|
||||
def button_latched(r):
|
||||
return r["il"] is not None and bool(r["il"] & IL_BUTTON_LATCH)
|
||||
|
||||
held = [r for r in trail if r["gstate"].startswith("Hold")]
|
||||
ev.update({"held_at_s": held[0]["t"] if held else None,
|
||||
"resumed_at_s": round(resumed_at, 2) if resumed_at else None,
|
||||
"latch_locked_samples": sum(1 for r in trail if locked(r)),
|
||||
"button_latch_set_samples": sum(1 for r in trail if button_latched(r))})
|
||||
ctx.check(held and held[0]["t"] < PAUSE_S, "the stale verdict did not hold the job during the "
|
||||
"pause (first Hold at %s)", ev["held_at_s"])
|
||||
ctx.check("fire masked, job held" in text, "the controller did not report the pause tier")
|
||||
ctx.check(ev["latch_locked_samples"] == 0,
|
||||
"the pause tier wrote the latch (locked in %d samples): a lock sets the hardware "
|
||||
"button latch and the resume would need a press", ev["latch_locked_samples"])
|
||||
ctx.check(ev["button_latch_set_samples"] == 0,
|
||||
"the hardware button latch read SET in %d samples: the resume needed a press",
|
||||
ev["button_latch_set_samples"])
|
||||
ctx.check(all(r["armed"] for r in trail if r["t"] < (resumed_at or PAUSE_S + 5)),
|
||||
"the armed window closed during the pause")
|
||||
ctx.check("press the button" not in text, "the resume asked for a button press")
|
||||
ctx.check(DISARMED_MSG not in text.split("resuming")[0], "the pause tier disarmed the job")
|
||||
# Dark from the hold until the resume: the gate masked the stream.
|
||||
span = [r for r in trail if held and r["t"] >= held[0]["t"]
|
||||
and (resumed_at is None or r["t"] < resumed_at)]
|
||||
zero = next((r for r in span if r["emission"] == 0), None)
|
||||
ev["emission_zero_after_hold_s"] = round(zero["t"] - held[0]["t"], 2) if zero else None
|
||||
ctx.check(zero is not None and zero["t"] - held[0]["t"] <= 2.5,
|
||||
"emission did not read 0 within 2.5 s of the hold (before the resume)")
|
||||
ctx.check(resumed_at is not None, "the clean verdict did not resume the cut")
|
||||
ctx.check("resuming" in text, "the controller did not report the resume")
|
||||
after = [r for r in trail if resumed_at and r["t"] >= resumed_at]
|
||||
ctx.check(any(r["emission"] for r in after), "the resumed cut ran dark (no emission after the resume)")
|
||||
ctx.check("ALARM" not in text, "an alarm was raised on the pause tier")
|
||||
ctx.check(dt is not None and dt < 10.0, "the M2 job did not disarm promptly at Idle (%s s)", dt)
|
||||
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base)
|
||||
judge_beam(ctx, ev["beam"], "the cut")
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.log("PASS: held at +%s s with the latch untouched, emission 0 %s s after the hold, resumed "
|
||||
"lit at +%s s with no press, disarmed %.1f s after Idle", ev["held_at_s"],
|
||||
ev["emission_zero_after_hold_s"], ev["resumed_at_s"], dt)
|
||||
|
||||
|
||||
DISARMED_MSG = "laser disarmed - latch locked"
|
||||
|
||||
|
||||
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
|
||||
|
||||
@@ -136,9 +136,68 @@ def clean_slate(ctx, g):
|
||||
ctx.log("unlock: %s", g.command("$X"))
|
||||
st = g.status_report()
|
||||
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
|
||||
clear_error(ctx, g)
|
||||
return st
|
||||
|
||||
|
||||
def clear_error(ctx, g):
|
||||
"""Clear a latched line error before the test's first move.
|
||||
|
||||
Soft limits are armed after a home (the driver's, since the bed has
|
||||
no switches), so a jog past the bed is refused with error:15 - and
|
||||
grblHAL then answers error:15 to every following G-code line, across
|
||||
a fresh connection, until a blank line acknowledges it ($ commands
|
||||
and the ? report are unaffected, so a sender that queries on connect
|
||||
self-clears; a bare G-code stream does not). A prior test's or the
|
||||
baseline hand-back's rejected jog would otherwise make this test's
|
||||
first move fail with a stale error unrelated to the move. A blank
|
||||
line clears it and returns ok. Harmless and idempotent at Idle."""
|
||||
r = g.command("")
|
||||
ctx.check(r and r[-1] == "ok" and not any(x.startswith("error") for x in r),
|
||||
"the controller answered %s to a blank line at the start - not a clean parser", r)
|
||||
|
||||
|
||||
MOVE_START_S = 2.0 # Run is there within 100 ms; a loaded board gets this long
|
||||
|
||||
|
||||
def start_move(ctx, g, line):
|
||||
"""Start a travel job with `line` and see the controller in Run. The
|
||||
wait is bounded, not a fixed sleep. When Run never comes, the failure
|
||||
names what happened instead: the reply (a refused block answers
|
||||
error), the state the controller sits in (Hold, Door, Alarm, or an
|
||||
Idle that never took the block), every line it said meanwhile, and
|
||||
the machine as forgectrl sees it - the evidence a bare "did not
|
||||
start" leaves out. Recorded in the evidence either way."""
|
||||
reply = g.command(line, timeout=1.0)
|
||||
end = time.time() + MOVE_START_S
|
||||
t0 = time.time()
|
||||
said = ""
|
||||
st = None
|
||||
while time.time() < end:
|
||||
ctx.checkpoint()
|
||||
st = g.status_report()
|
||||
if st["state"].startswith("Run"):
|
||||
break
|
||||
said += g.drain()
|
||||
time.sleep(0.1)
|
||||
rec = {"line": line, "reply": reply, "state": st["state"] if st else None,
|
||||
"after_s": round(time.time() - t0, 2)}
|
||||
ctx.evidence.setdefault("move_start", []).append(rec)
|
||||
if st is not None and st["state"].startswith("Run"):
|
||||
return st
|
||||
msgs = [ln.strip() for ln in said.splitlines() if ln.strip()]
|
||||
rec["said"] = msgs
|
||||
s = ctx.forgectrl.status() or {}
|
||||
gr = ((s.get("grbl") or {}).get("report") or {})
|
||||
rec["forgectrl"] = {"state": s.get("state"), "switches": s.get("switches"),
|
||||
"laser_locked": s.get("laser_locked"), "grbl": gr.get("state"),
|
||||
"alarm": gr.get("alarm"), "sender": (gr.get("sender") or {}).get("connected")}
|
||||
ctx.check(False, "the move did not start: %s answered %s; controller %s %.1f s later, said %s; "
|
||||
"forgectrl sees kernel %s, grbl %s (alarm %s), switches %s, laser locked %s",
|
||||
line, reply, st["state"] if st else "no report", rec["after_s"], msgs or "nothing",
|
||||
s.get("state"), gr.get("state"), gr.get("alarm"), s.get("switches"), s.get("laser_locked"))
|
||||
|
||||
|
||||
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=1,
|
||||
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
|
||||
@@ -753,21 +812,30 @@ def _return_x(ctx, delta_mm):
|
||||
machine_idle(ctx)
|
||||
|
||||
|
||||
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=4,
|
||||
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move, "
|
||||
"a kill during the web-service homing",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=6,
|
||||
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
|
||||
requires=["motion.cancel-abort", "kernel.k1-k2"],
|
||||
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail."],
|
||||
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail. "
|
||||
"With cloud mode enabled the test also homes through the web service and kills the "
|
||||
"controller mid-homing (about a minute); the head ends wherever the homing was."],
|
||||
description="SIGKILL of the controller mid-move: the supervisor reaps it, safes (cnc/stop, "
|
||||
"latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a "
|
||||
"hang) mid-move: the ring drains into a kernel underrun (fast halt, latch "
|
||||
"locked); the process resumed from the hang recovers on a soft reset and an unlock "
|
||||
"(the alarm is critical, so $X alone is refused; the reset acknowledges the "
|
||||
"fault to the stream and clears the stale ring) and moves again without a "
|
||||
"restart. forgectrl "
|
||||
"restart mid-move: the busy controller finishes the move unmanaged and the new "
|
||||
"daemon retakes supervision at idle. After each drill the head is jogged back "
|
||||
"by the kernel-measured distance.")
|
||||
"restart. A short SIGSTOP (100 ms, inside the kernel's 200 ms queue) mid-move: "
|
||||
"no underrun, but the step producer comes back later than its lead and its "
|
||||
"late events clamp; unarmed that is a warning in the controller's log and the "
|
||||
"move completes with every step (the armed case faults, proven on the host). "
|
||||
"forgectrl restart mid-move: the busy controller finishes the move unmanaged "
|
||||
"and the new daemon retakes supervision at idle. After each drill the head is "
|
||||
"jogged back by the kernel-measured distance. SIGKILL during $H (the web-service "
|
||||
"homing, with cloud mode enabled): the homing runner the controller left behind "
|
||||
"is ended before the respawn, the kernel is idle when the new controller starts, "
|
||||
"and the pulse device has one controller on it.")
|
||||
def deadman(ctx):
|
||||
import os as _os
|
||||
import signal as _signal
|
||||
@@ -897,6 +965,47 @@ def deadman(ctx):
|
||||
x1 = _kernel_x_mm(ctx)
|
||||
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||
|
||||
# ---- 2b. a short hang (inside the kernel queue): late events clamp, a warning, the move completes
|
||||
m2 = wait_running(10)
|
||||
pid2 = m2["pid"]
|
||||
x0 = _kernel_x_mm(ctx)
|
||||
underruns0 = hw.sysfs_int("cnc/underruns", 0)
|
||||
log0 = _log_offset(GRBLHAL_LOG)
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
g.command("G91")
|
||||
g.command("G1X30F300", timeout=0.5) # ~6 s of motion
|
||||
ctx.sleep(1.0)
|
||||
_os.kill(pid2, _signal.SIGSTOP)
|
||||
time.sleep(0.1)
|
||||
_os.kill(pid2, _signal.SIGCONT)
|
||||
ctx.log("SIGSTOP 100 ms sent to controller pid %d mid-move", pid2)
|
||||
peak, states, st = wait_idle(ctx, g, 30)
|
||||
g.command("G90")
|
||||
machine_idle(ctx) # the kernel's tail plays out behind grbl's Idle
|
||||
warned = None
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 5 and not warned:
|
||||
lines = _log_lines(GRBLHAL_LOG, log0, "late events clamped")
|
||||
warned = lines[-1] if lines else None
|
||||
if not warned:
|
||||
ctx.sleep(0.5)
|
||||
x1 = _kernel_x_mm(ctx)
|
||||
ev["short_stall"] = {"states": states, "underruns": hw.sysfs_int("cnc/underruns", 0) - underruns0,
|
||||
"warning": warned, "kernel_dx_mm": round((x1 - x0), 3) if (x0 is not None and x1 is not None) else None,
|
||||
"state_after": (st or {}).get("state")}
|
||||
ctx.log("short stall: states %s, underruns +%s, warning %r, kernel dx %s mm", states,
|
||||
ev["short_stall"]["underruns"], warned, ev["short_stall"]["kernel_dx_mm"])
|
||||
ctx.check("TIMEOUT" not in states and str((st or {}).get("state", "")).startswith("Idle"),
|
||||
"the move did not complete after the short stall (states %s)", states)
|
||||
ctx.check(ev["short_stall"]["underruns"] == 0, "the short stall drained the ring (underruns +%s)",
|
||||
ev["short_stall"]["underruns"])
|
||||
ctx.check(warned, "the controller did not warn about the clamped events after the short stall")
|
||||
ctx.check(ev["short_stall"]["kernel_dx_mm"] is not None and abs(ev["short_stall"]["kernel_dx_mm"] - 30.0) < 0.3,
|
||||
"the kernel counted %s mm for a 30 mm move after the short stall", ev["short_stall"]["kernel_dx_mm"])
|
||||
ctx.check(latch_locked(), "latch unlocked after the short stall")
|
||||
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||
|
||||
# ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle
|
||||
m2 = wait_running(10)
|
||||
pid2 = m2["pid"]
|
||||
@@ -934,8 +1043,226 @@ def deadman(ctx):
|
||||
x1 = _kernel_x_mm(ctx)
|
||||
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||
machine_idle(ctx)
|
||||
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, restart retook supervision (pid %s)",
|
||||
respawn_s, halt_s, m3.get("pid"))
|
||||
|
||||
# ---- 4. SIGKILL during $H: the homing runner is ended, the kernel idle, one writer
|
||||
homing_kill(ctx, wait_running)
|
||||
|
||||
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, short stall warned and completed, "
|
||||
"restart retook supervision (pid %s), the kill during $H ended the runner (%s)",
|
||||
respawn_s, halt_s, m3.get("pid"), ev.get("homing_kill", {}).get("runner_gone_s", "skipped"))
|
||||
|
||||
|
||||
def _pulse_holders():
|
||||
"""The processes holding the pulse device open, by name."""
|
||||
out = []
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdigit():
|
||||
continue
|
||||
try:
|
||||
for fd in os.listdir("/proc/%s/fd" % pid):
|
||||
if os.readlink("/proc/%s/fd/%s" % (pid, fd)) == "/dev/glowforge":
|
||||
with open("/proc/%s/comm" % pid) as f:
|
||||
out.append(f.read().strip())
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def homing_kill(ctx, wait_running):
|
||||
"""SIGKILL the GRBL controller while gfhome (the web-service homing
|
||||
runner, its own process group on the inherited pulse fd) is moving
|
||||
the head. The supervisor must end the runner before it starts another
|
||||
controller, or two processes write the ring. Skipped when cloud mode
|
||||
is not enabled on the machine (the runner needs the service)."""
|
||||
import os as _os
|
||||
import signal as _signal
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings()
|
||||
if str(before.get("cloud_enabled", "")).lower() not in ("1", "true", "yes", "on"):
|
||||
ctx.log("kill during $H: skipped, cloud mode is not enabled on this machine")
|
||||
return
|
||||
hm = before.get("homing_mode", "")
|
||||
ev["homing_kill"] = {"homing_mode": hm}
|
||||
if hm != "gfcloud":
|
||||
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
|
||||
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
|
||||
try:
|
||||
m4 = wait_running(10)
|
||||
ctx.check(m4, "controller not running before the homing drill")
|
||||
pid4 = m4["pid"]
|
||||
off = _log_offset(FORGECTRL_LOG)
|
||||
runner_up = None
|
||||
try:
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
g.send_raw(b"$H\n")
|
||||
runner_up = ctx.wait_for(lambda: bool(hw.pidof("gfhome.py")), 30)
|
||||
ctx.check(runner_up is not None, "the homing runner never started for $H")
|
||||
ctx.sleep(3.0) # into the session: the runner drives the head
|
||||
ctx.log("SIGKILL sent to controller pid %d during $H (runner pid %s)",
|
||||
pid4, hw.pidof("gfhome.py"))
|
||||
_os.kill(pid4, _signal.SIGKILL)
|
||||
except (hw.HwError, OSError) as e:
|
||||
ctx.log("the Grbl connection ended with the kill: %s", e)
|
||||
t_kill = time.time()
|
||||
gone_s = ctx.wait_for(lambda: not hw.pidof("gfhome.py"), 15)
|
||||
ev["homing_kill"]["runner_gone_s"] = gone_s
|
||||
ctx.check(gone_s is not None, "the homing runner outlived its controller")
|
||||
m5 = None
|
||||
while time.time() - t_kill < 60:
|
||||
st, m5 = fc.get("/mode")
|
||||
if isinstance(m5, dict) and m5.get("controller") == "running" and m5.get("pid") != pid4:
|
||||
break
|
||||
ctx.sleep(0.5)
|
||||
kstate = hw.sysfs_read("cnc/state")
|
||||
holders = _pulse_holders()
|
||||
lines = _log_lines(FORGECTRL_LOG, off, "homing runner")
|
||||
halted = _log_lines(FORGECTRL_LOG, off, "halting it")
|
||||
ev["homing_kill"].update({"respawn_s": round(time.time() - t_kill, 1), "mode_after": m5,
|
||||
"kernel_state_at_respawn": kstate, "pulse_holders": holders,
|
||||
"super_lines": [ln[-160:] for ln in lines[-3:]]})
|
||||
ctx.log("kill during $H: runner gone in %s s, respawned as %s, kernel %s, pulse held by %s",
|
||||
gone_s, m5 and m5.get("pid"), kstate, holders)
|
||||
ctx.check(m5 and m5.get("pid") != pid4, "no respawn after the kill during $H: %s", m5)
|
||||
ctx.check(lines, "the supervisor did not log the runner's end")
|
||||
ctx.check(not halted, "the kernel had to be halted for the respawn: %s", halted[-1:])
|
||||
ctx.check(kstate in ("idle", "disabled"), "the kernel was %s when the new controller started", kstate)
|
||||
ctx.check("gfhome.py" not in holders and holders.count("grblHAL_glowfor") <= 1,
|
||||
"more than one controller on the pulse device: %s", holders)
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the drill")
|
||||
finally:
|
||||
if hm != "gfcloud":
|
||||
st, body = (fc.post("/settings", params={"homing_mode": ""}) if not hm
|
||||
else fc.post("/settings", data={"homing_mode": hm}))
|
||||
ctx.log("restore homing_mode=%r -> %s", hm, st)
|
||||
|
||||
|
||||
@test("motion.respawn-gate", title="A respawn waits for the lid the way a first spawn does",
|
||||
subsystem="motion", kind="operator", mode="grbl", est_min=2,
|
||||
covers=_MOTION_COVERS, requires=["motion.deadman", "motion.gate-waits-for-lid"], actions=["lid"],
|
||||
steps=["Bed clear. Open the lid when told and close it when told; nothing moves."],
|
||||
description="The enclosure check runs before every controller spawn, respawns included. "
|
||||
"With the lid open, the controller is killed: the supervisor safes the machine "
|
||||
"(latch locked), reports waiting with why naming the lid, and starts no controller "
|
||||
"while the lid stays open. When the lid closes the controller comes back verified, "
|
||||
"without a second motion probe (the probe is once per broker hold).")
|
||||
def respawn_gate(ctx):
|
||||
import os as _os
|
||||
import signal as _signal
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
|
||||
st, m0 = fc.get("/mode")
|
||||
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
|
||||
pid0 = m0.get("pid")
|
||||
off = _log_offset(FORGECTRL_LOG)
|
||||
ctx.act("lid", "open")
|
||||
try:
|
||||
ctx.sleep(1.0)
|
||||
_os.kill(pid0, _signal.SIGKILL)
|
||||
t_kill = time.time()
|
||||
ctx.log("SIGKILL sent to controller pid %d with the lid open", pid0)
|
||||
waited = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("controller") == "waiting", 15)
|
||||
st, m1 = fc.get("/mode")
|
||||
ev["after_kill"] = {"waiting_s": waited, "mode": m1}
|
||||
ctx.log("mode %.1f s after the kill: %s", time.time() - t_kill, m1)
|
||||
ctx.check(waited is not None, "the supervisor did not report waiting for the lid: %s", m1)
|
||||
ctx.check("lid" in (m1.get("why") or ""), "why does not name the lid: %r", m1.get("why"))
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
ctx.check(ilk is not None and ilk & (1 << 3), "latch not locked after the kill")
|
||||
ctx.sleep(4.0)
|
||||
st, m2 = fc.get("/mode")
|
||||
ctx.check(isinstance(m2, dict) and m2.get("controller") == "waiting" and not m2.get("pid"),
|
||||
"the wait did not hold with the lid open: %s", m2)
|
||||
finally:
|
||||
ctx.act("lid", "close")
|
||||
t_close = time.time()
|
||||
came = ctx.wait_for(lambda: ((fc.get("/mode")[1] or {}).get("controller") == "running"
|
||||
and (fc.get("/mode")[1] or {}).get("pid") != pid0), 60)
|
||||
st, m3 = fc.get("/mode")
|
||||
ev["after_close"] = {"running_s": came, "mode": m3}
|
||||
ctx.log("mode %.1f s after the lid closed: %s", time.time() - t_close, m3)
|
||||
ctx.check(came is not None and m3.get("motion") == "verified",
|
||||
"the controller did not come back verified after the lid closed: %s", m3)
|
||||
lines = _probe_lines(FORGECTRL_LOG, off)
|
||||
ev["probe_lines"] = lines[-2:]
|
||||
ctx.check(not lines, "the motion probe ran again for the respawn: %s", lines[:1])
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the end")
|
||||
ctx.log("PASS: the respawn waited %.1f s for the lid, then came back verified in %.1f s with no probe",
|
||||
t_close - t_kill, came)
|
||||
|
||||
|
||||
@test("motion.soft-limits", title="After a home the bed is the X/Y envelope",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=3,
|
||||
covers=_MOTION_COVERS, requires=["motion.jog-roundtrip", "cloud.mode-switch"],
|
||||
steps=["Bed clear, lid closed. The test homes the machine through the web service if it is "
|
||||
"not homed (about a minute), then sends moves the controller must refuse."],
|
||||
description="The machine has no limit switches, so the core's $20 cannot be turned on and "
|
||||
"the X/Y soft limits are the driver's: off while the position is not trusted, on "
|
||||
"after a home, when the envelope is the bed ($130 by $131 from the home corner). "
|
||||
"Homed, a program move 5 mm past X max or Y max, or past the near edge, raises "
|
||||
"ALARM:2 before any motion (the kernel counters do not move), a jog past the bed "
|
||||
"is refused with error 15, and a move inside the bed runs. The Z envelope is the "
|
||||
"lens window, as before.")
|
||||
def soft_limits(ctx):
|
||||
from .cloud import gfhome_homing
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
if not fc.status().get("homed"):
|
||||
gfhome_homing(ctx, ev, g)
|
||||
ctx.check(fc.status().get("homed"), "the machine is not homed")
|
||||
x_travel = float(grbl_setting(g, "$130"))
|
||||
y_travel = float(grbl_setting(g, "$131"))
|
||||
ev["travel"] = {"x": x_travel, "y": y_travel}
|
||||
k0 = kernel_xy_mm(ctx)
|
||||
|
||||
def refused(cmd):
|
||||
lines = g.command(cmd, timeout=2)
|
||||
ctx.sleep(0.5)
|
||||
text = "\n".join(lines) + g.drain()
|
||||
k1 = kernel_xy_mm(ctx)
|
||||
moved = max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1]))
|
||||
st = g.status_report()["state"]
|
||||
rec = {"cmd": cmd, "reply": lines, "alarm": "ALARM:2" in text, "moved_mm": round(moved, 3), "state": st}
|
||||
ctx.log("%s", rec)
|
||||
ctx.check(rec["alarm"], "%s was not refused with ALARM:2: %s", cmd, lines)
|
||||
ctx.check(moved < 0.05, "%s moved the kernel %.3f mm before the alarm", cmd, moved)
|
||||
g.realtime(0x18)
|
||||
ctx.sleep(1.5)
|
||||
g.drain()
|
||||
unlock = g.command("$X")
|
||||
ctx.check(unlock and unlock[-1] == "ok", "$X after the soft-limit alarm: %s", unlock)
|
||||
st = g.status_report()["state"]
|
||||
ctx.check(st.startswith("Idle"), "controller is %s after the recovery", st)
|
||||
return rec
|
||||
|
||||
ev["refused"] = [refused("G90 G1 X%.1f F600" % (x_travel + 5)),
|
||||
refused("G90 G1 Y%.1f F600" % (y_travel + 5)),
|
||||
refused("G90 G1 X-1 F600")]
|
||||
# Homed still: the soft-limit alarm and its reset keep the reference.
|
||||
ctx.check(fc.status().get("homed"), "the soft-limit alarm and the reset un-homed the machine")
|
||||
jog = g.command("$J=G91X%.1fF1200" % (x_travel + 5))
|
||||
ev["jog"] = jog
|
||||
ctx.check(any(l.startswith("error:15") for l in jog), "a jog past the bed was not refused with error 15: %s", jog)
|
||||
# The core answers the line after an error with that error again
|
||||
# until an empty line clears it (the sender's acknowledgment).
|
||||
g.command("")
|
||||
g.command("G90 G1 X10 Y10 F600", timeout=0.5)
|
||||
peak, states, st = wait_idle(ctx, g, 20)
|
||||
ev["inside"] = states
|
||||
ctx.check("TIMEOUT" not in states, "a move inside the bed did not run")
|
||||
g.command("G90 G1 X0 Y0 F600", timeout=0.5)
|
||||
wait_idle(ctx, g, 20)
|
||||
k1 = kernel_start(ctx) # at rest: grbl's Idle leads the kernel's tail
|
||||
ev["kernel_back_mm"] = [round(k1[0] - k0[0], 3), round(k1[1] - k0[1], 3)]
|
||||
ctx.check(max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1])) < 0.1, "the head did not come back to the corner: %s",
|
||||
ev["kernel_back_mm"])
|
||||
ctx.log("PASS: X max, Y max and X min refused with ALARM:2 and no motion, the jog refused with "
|
||||
"error 15, a move inside the bed ran")
|
||||
|
||||
|
||||
# ------------------------------------------------- lid / button (the factory's)
|
||||
@@ -1066,9 +1393,7 @@ def button_hold_resume(ctx):
|
||||
"moves; the job holds and the test sees it.")
|
||||
g.command("M5")
|
||||
g.command("G91")
|
||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
||||
ctx.sleep(0.5)
|
||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
||||
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||
g.drain() # the message window opens here
|
||||
w = Watch(g)
|
||||
ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
|
||||
@@ -1144,9 +1469,7 @@ def lid_cancel_home(ctx):
|
||||
"leave it open until the head has come back on its own.")
|
||||
g.command("M5")
|
||||
g.command("G91")
|
||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
||||
ctx.sleep(0.5)
|
||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
||||
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||
g.drain() # the message window opens here
|
||||
ctx.act("lid", "open", text="Leave it open until the head has come back.", timeout=12)
|
||||
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "lid opened", "running")
|
||||
@@ -1239,9 +1562,7 @@ def interlock_cancel_home(ctx):
|
||||
"moves and leave it open until the head has come back on its own.")
|
||||
g.command("M5")
|
||||
g.command("G91")
|
||||
g.command("G1X60F300", timeout=0.5) # a 12 s move
|
||||
ctx.sleep(0.5)
|
||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
||||
start_move(ctx, g, "G1X60F300") # a 12 s move
|
||||
g.drain()
|
||||
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
|
||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
||||
@@ -1285,8 +1606,13 @@ def interlock_cancel_home(ctx):
|
||||
def lid_policy_hold(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
was = (fc.settings() or {}).get("lid_policy") or "cancel"
|
||||
# What the machine had, exactly: an unset lid_policy reads as the empty
|
||||
# string and behaves as cancel, and writing the word back where the
|
||||
# machine had nothing is a leftover the hand-back reports. The default
|
||||
# is for reading the value, never for restoring it.
|
||||
was = (fc.settings() or {}).get("lid_policy", "")
|
||||
ev["lid_policy_before"] = was
|
||||
ev["lid_policy_in_force"] = was or "cancel"
|
||||
st, _b = fc.post("/settings", data={"lid_policy": "hold"})
|
||||
ctx.check(st == 200, "could not set lid_policy=hold (%s)", st)
|
||||
ctx.check(((fc.settings() or {}).get("lid_policy")) == "hold", "lid_policy did not take")
|
||||
@@ -1298,9 +1624,7 @@ def lid_policy_hold(ctx):
|
||||
"the job parks in the door state and waits.")
|
||||
g.command("M5")
|
||||
g.command("G91")
|
||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
||||
ctx.sleep(0.5)
|
||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
||||
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||
g.drain()
|
||||
ctx.act("lid", "open", timeout=12)
|
||||
st, text = wait_state_text(ctx, g, "Door", 8)
|
||||
@@ -1332,8 +1656,12 @@ def lid_policy_hold(ctx):
|
||||
g.command("G90")
|
||||
machine_idle(ctx)
|
||||
finally:
|
||||
st, _b = fc.post("/settings", data={"lid_policy": was})
|
||||
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy")
|
||||
# An empty value clears the key, and only the query-string form
|
||||
# carries one: an empty form field never reaches the request, and
|
||||
# the write is refused with "no known setting in request".
|
||||
st, _b = (fc.post("/settings", params={"lid_policy": ""}) if not was
|
||||
else fc.post("/settings", data={"lid_policy": was}))
|
||||
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy", "")
|
||||
ctx.log("lid_policy restored to %s", ev["lid_policy_restored"])
|
||||
ctx.check(ev["lid_policy_restored"] == was, "lid_policy was not restored to %r", was)
|
||||
ctx.log("PASS: lid_policy=hold parked the job in Door and the cycle start finished it (%.3f mm)",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""commission.* - the first-run commissioning: the record and the
|
||||
"""setup.* - the first-run setup: the record and the
|
||||
controller gate, the advisories, the account and its login, the HTTPS
|
||||
boundary, SSH, the cloud switch, the mDNS name, and the factory return.
|
||||
boundary, SSH, the cloud switch, the machine's name, and the factory
|
||||
return.
|
||||
|
||||
The daemon reads the commissioning record (commissioning.json in the
|
||||
The daemon reads the setup record (setup.json in the
|
||||
data directory) and the account record (users) once, at its start, and
|
||||
keeps them in memory; only its own routes write them. A test that needs
|
||||
a different record installs the file under a forgectrl restart (a
|
||||
@@ -11,7 +12,7 @@ never saves a test record over the real one, and a restore is complete
|
||||
when the daemon is up again.
|
||||
|
||||
The layer content of this work (the account replay init script, the
|
||||
sshd policy, the console banner, the avahi service) is part of the
|
||||
sshd policy, the hostname and console banner scripts) is part of the
|
||||
platform identity that every fingerprint carries, not of a component.
|
||||
No coverage map names it: a change there makes every test necessary
|
||||
again.
|
||||
@@ -23,10 +24,9 @@ import http.client
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import select
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
@@ -34,21 +34,18 @@ from ..catalog import test
|
||||
from .. import hw
|
||||
from .forgectrl import lan_ip
|
||||
|
||||
RECORD = "commissioning.json"
|
||||
RECORD = "setup.json"
|
||||
USERS = "users"
|
||||
OVERRIDE = "commissioning-override"
|
||||
OVERRIDE = "setup-override"
|
||||
SSH_FLAG = "ssh-enabled"
|
||||
TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests)
|
||||
|
||||
# The image's required wizards, all at version 1 (commission.c).
|
||||
# The image's required wizards, all at version 1 (setup.c).
|
||||
REQUIRED_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
|
||||
ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service")
|
||||
SAFETY_PHRASE = "I UNDERSTAND"
|
||||
LOGIN_FAILS = 5
|
||||
LOGIN_LOCK_S = 30
|
||||
MDNS_NAME = "forgefirm.local"
|
||||
MDNS_GROUP = "224.0.0.251"
|
||||
MDNS_PORT = 5353
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- files
|
||||
@@ -179,8 +176,8 @@ def record_without_wizards(record):
|
||||
|
||||
|
||||
def complete_record(status, base=None, account_name="bench"):
|
||||
"""A complete commissioning record for a machine that must count as
|
||||
commissioned: every advisory at the hash GET /wiz reports, the
|
||||
"""A complete setup record for a machine that must count as
|
||||
set up: every advisory at the hash GET /wiz reports, the
|
||||
press recorded, an account (the one of `base` when it has one),
|
||||
every wizard of the catalog at its version, and the run complete.
|
||||
`status` is a GET /wiz body; `base` is an existing record to keep
|
||||
@@ -355,12 +352,12 @@ def gate_is_open():
|
||||
|
||||
# -------------------------------------------------------------- the gate
|
||||
|
||||
_GATE_COVERS = [("forgectrl", "src/commission.*"), ("forgectrl", "src/super.c"),
|
||||
_GATE_COVERS = [("forgectrl", "src/setup.*"), ("forgectrl", "src/super.c"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")]
|
||||
|
||||
|
||||
@test("commission.gate-blocks-controllers", title="The commissioning gate spawns no controller",
|
||||
subsystem="commission", kind="auto", hardware="takeover", mode="grbl", est_min=5,
|
||||
@test("setup.gate-blocks-controllers", title="The setup gate spawns no controller",
|
||||
subsystem="setup", kind="auto", hardware="takeover", mode="grbl", est_min=5,
|
||||
covers=_GATE_COVERS, requires=["forgectrl.auth"], precheck=gate_is_open,
|
||||
description="With the override removed and a record that lacks every required wizard "
|
||||
"(installed under a forgectrl restart, the consent and the account kept), "
|
||||
@@ -377,11 +374,11 @@ def gate_blocks_controllers(ctx):
|
||||
ctx.log("before: gate %s, override %s, required %s", before.get("gate"), before.get("override"),
|
||||
before.get("required"))
|
||||
raw = read_file(record_path())
|
||||
ctx.check(raw, "no commissioning record at %s", record_path())
|
||||
ctx.check(raw, "no setup record at %s", record_path())
|
||||
try:
|
||||
record = json.loads(raw.decode("utf-8"))
|
||||
except ValueError as e:
|
||||
ctx.fail("the commissioning record is not JSON: %s", e)
|
||||
ctx.fail("the setup record is not JSON: %s", e)
|
||||
lacking = json.dumps(record_without_wizards(record), indent=1, sort_keys=True).encode() + b"\n"
|
||||
had_override = override_present()
|
||||
ev["override_before"] = had_override
|
||||
@@ -394,7 +391,7 @@ def gate_blocks_controllers(ctx):
|
||||
ctx.log("/mode after the restart: %s (after %s s)", m, took)
|
||||
ctx.check(m.get("controller") == "gated" and m.get("gated") is True,
|
||||
"the supervisor did not report the gate: %s", m)
|
||||
ctx.check("commissioning required" in (m.get("why") or ""),
|
||||
ctx.check("setup required" in (m.get("why") or ""),
|
||||
"the reason does not name the missing wizards: %r", m.get("why"))
|
||||
w = wiz(fc)
|
||||
ev["gated_wiz"] = wiz_summary(w)
|
||||
@@ -442,11 +439,11 @@ def gate_blocks_controllers(ctx):
|
||||
ctx.log("restored: gate %s, controller %s", after.get("gate"), m.get("controller"))
|
||||
|
||||
|
||||
@test("commission.override-until-reboot", title="The override stands until the next reboot",
|
||||
subsystem="commission", kind="auto", est_min=1,
|
||||
@test("setup.override-until-reboot", title="The override stands until the next reboot",
|
||||
subsystem="setup", kind="auto", est_min=1,
|
||||
covers=_GATE_COVERS, requires=["forgectrl.auth"],
|
||||
description="The bench seed: the dev image's forgetest init script writes "
|
||||
"/run/forgefirm/commissioning-override at boot. With it present, GET /wiz "
|
||||
"/run/forgefirm/setup-override at boot. With it present, GET /wiz "
|
||||
"reports the override and an open gate, the reason names the wizards it lifts "
|
||||
"when any is required, and GET /mode reports the controller running and not "
|
||||
"gated. The file lives on tmpfs (/proc/mounts), so a reboot removes it by "
|
||||
@@ -489,9 +486,9 @@ def override_until_reboot(ctx):
|
||||
|
||||
# ------------------------------------------------------------ advisories
|
||||
|
||||
@test("commission.advisories-rehash", title="An advisory is accepted at its current hash only",
|
||||
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
|
||||
covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/commission.*"),
|
||||
@test("setup.advisories-rehash", title="An advisory is accepted at its current hash only",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||
covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/sha256.*"), ("forgectrl", "src/main.c"),
|
||||
("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake")],
|
||||
requires=["forgectrl.auth"],
|
||||
@@ -507,7 +504,7 @@ def advisories_rehash(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
raw = read_file(record_path())
|
||||
ctx.check(raw, "no commissioning record at %s", record_path())
|
||||
ctx.check(raw, "no setup record at %s", record_path())
|
||||
before = wiz(fc)
|
||||
ev["before"] = wiz_summary(before)
|
||||
docs = {d.get("id"): d for d in before.get("documents") or []}
|
||||
@@ -582,7 +579,7 @@ def advisories_rehash(ctx):
|
||||
|
||||
# -------------------------------------------------------------- the login
|
||||
|
||||
@test("commission.account-login", title="The panel login over HTTPS", subsystem="commission",
|
||||
@test("setup.account-login", title="The panel login over HTTPS", subsystem="setup",
|
||||
kind="auto", est_min=2,
|
||||
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
||||
("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
||||
@@ -705,7 +702,7 @@ def login_checks(ctx, name, pw):
|
||||
|
||||
# ------------------------------------------------------- HTTPS boundary
|
||||
|
||||
@test("commission.https-only-writes", title="Writes from the LAN go to HTTPS", subsystem="commission",
|
||||
@test("setup.https-only-writes", title="Writes from the LAN go to HTTPS", subsystem="setup",
|
||||
kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"),
|
||||
("forgectrl", "src/main.c")],
|
||||
@@ -777,7 +774,7 @@ def https_only_writes(ctx):
|
||||
# ------------------------------------------------------------------ ssh
|
||||
|
||||
def sshd_policy():
|
||||
"""The three ForgeFIRM keys of the effective sshd policy (sshd -T,
|
||||
"""The ForgeFIRM keys of the effective sshd policy (sshd -T,
|
||||
lowercase keys), or {"error": ...} when sshd cannot report it."""
|
||||
for exe in ("/usr/sbin/sshd", "sshd"):
|
||||
rc, out = hw.run([exe, "-T"], timeout=15)
|
||||
@@ -789,7 +786,7 @@ def sshd_policy():
|
||||
for line in out.splitlines():
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) == 2 and parts[0] in ("permitrootlogin", "permitemptypasswords",
|
||||
"passwordauthentication"):
|
||||
"passwordauthentication", "banner"):
|
||||
policy[parts[0]] = parts[1].strip()
|
||||
return policy
|
||||
return {"error": "no sshd binary"}
|
||||
@@ -805,8 +802,8 @@ def restore_ssh(fc, before):
|
||||
write_file(flag, b"" if before.get("enabled") else None)
|
||||
|
||||
|
||||
@test("commission.ssh-until-reboot", title="SSH is switched from the panel until the next reboot",
|
||||
subsystem="commission", kind="auto", est_min=1,
|
||||
@test("setup.ssh-until-reboot", title="SSH is switched from the panel until the next reboot",
|
||||
subsystem="setup", kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="GET /system/ssh reports enabled, running, and dev_image, and agrees with the "
|
||||
@@ -814,7 +811,9 @@ def restore_ssh(fc, before):
|
||||
"sshd listens on 22, with the effective policy (sshd -T) at PasswordAuthentication "
|
||||
"yes, and on a release image PermitRootLogin no and PermitEmptyPasswords no (the "
|
||||
"dev image turns root over SSH back on for the bench; the release gate checks "
|
||||
"the release image's sshd_config as built). An enable outside 0 and 1 is refused (400). On a release "
|
||||
"the release image's sshd_config as built), and with no pre-authentication "
|
||||
"banner, so the machine says nothing to a client that has not logged in. "
|
||||
"An enable outside 0 and 1 is refused (400). On a release "
|
||||
"image POST enable=0 removes the flag and stops sshd; on the dev image the boot "
|
||||
"rule keeps sshd up and the stop is never gated, so the test removes the flag "
|
||||
"by hand (as a reboot does, the flag is on tmpfs) and sshd stays. The prior "
|
||||
@@ -848,6 +847,13 @@ def ssh_until_reboot(ctx):
|
||||
ctx.log("sshd -T: %s", policy)
|
||||
ctx.check(policy.get("passwordauthentication") == "yes",
|
||||
"PasswordAuthentication is %r: the account cannot log in", policy.get("passwordauthentication"))
|
||||
|
||||
# No pre-authentication banner: the machine says nothing to a
|
||||
# client that has not logged in. The mark and the version go in
|
||||
# the motd, which a login prints (image.health).
|
||||
ctx.check(policy.get("banner") in (None, "none"),
|
||||
"sshd sends a pre-authentication banner (%r)", policy.get("banner"))
|
||||
|
||||
if not before["dev_image"]:
|
||||
ctx.check(policy.get("permitrootlogin") == "no" and policy.get("permitemptypasswords") == "no",
|
||||
"a release image runs sshd with PermitRootLogin %r, PermitEmptyPasswords %r",
|
||||
@@ -882,8 +888,8 @@ def ssh_until_reboot(ctx):
|
||||
|
||||
# ---------------------------------------------------------------- cloud
|
||||
|
||||
@test("commission.cloud-disabled-surface", title="Nothing points at the cloud while it is off",
|
||||
subsystem="commission", kind="auto", mode="grbl", est_min=1,
|
||||
@test("setup.cloud-disabled-surface", title="Nothing points at the cloud while it is off",
|
||||
subsystem="setup", kind="auto", mode="grbl", est_min=1,
|
||||
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/settings.*"),
|
||||
("forgectrl", "src/wiz.c"), ("forgectrl", "src/hooks.h")],
|
||||
requires=["forgectrl.settings-bounds"],
|
||||
@@ -965,8 +971,8 @@ def cloud_disabled_surface(ctx):
|
||||
|
||||
# ---------------------------------------------------------- the operator
|
||||
|
||||
@test("commission.factory-return", title="The return to the factory firmware is guarded",
|
||||
subsystem="commission", kind="auto", est_min=1,
|
||||
@test("setup.factory-return", title="The return to the factory firmware is guarded",
|
||||
subsystem="setup", kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.js")],
|
||||
requires=["update.slots-and-signature"],
|
||||
description="The return itself reboots the machine as a Glowforge, so it never runs from the "
|
||||
@@ -1002,165 +1008,89 @@ def factory_return(ctx):
|
||||
ctx.log("PASS: the return is refused without confirm=1; the page confirms before it calls")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- mDNS
|
||||
# --------------------------------------------------- the machine's name
|
||||
|
||||
def mdns_query(name, qid=None):
|
||||
"""A DNS query packet for the A record of name with the unicast-
|
||||
response bit set (RFC 6762 5.4), so the responder answers this
|
||||
socket directly."""
|
||||
qid = random.randrange(1, 65536) if qid is None else qid
|
||||
labels = b"".join(struct.pack("B", len(p)) + p.encode("ascii") for p in name.strip(".").split("."))
|
||||
return struct.pack(">HHHHHH", qid, 0, 1, 0, 0, 0) + labels + b"\x00" + struct.pack(">HH", 1, 0x8001)
|
||||
def mac_suffix():
|
||||
"""The last four hex digits of the MAC address the hostname is built
|
||||
from: wlan0, or eth0 on a machine with no WiFi."""
|
||||
for dev in ("wlan0", "eth0"):
|
||||
raw = read_file("/sys/class/net/%s/address" % dev)
|
||||
mac = (raw or b"").decode("ascii", "replace").strip().replace(":", "").lower()
|
||||
if mac and mac != "0" * 12:
|
||||
return mac[-4:]
|
||||
return ""
|
||||
|
||||
|
||||
def _dns_name(pkt, off):
|
||||
"""(name, offset after the name); follows compression pointers."""
|
||||
parts = []
|
||||
jumped = False
|
||||
end = off
|
||||
hops = 0
|
||||
while True:
|
||||
if off >= len(pkt):
|
||||
raise ValueError("truncated name")
|
||||
n = pkt[off]
|
||||
if n == 0:
|
||||
off += 1
|
||||
break
|
||||
if n & 0xC0 == 0xC0:
|
||||
if off + 1 >= len(pkt):
|
||||
raise ValueError("truncated pointer")
|
||||
ptr = ((n & 0x3F) << 8) | pkt[off + 1]
|
||||
if not jumped:
|
||||
end = off + 2
|
||||
jumped = True
|
||||
off = ptr
|
||||
hops += 1
|
||||
if hops > 32:
|
||||
raise ValueError("pointer loop")
|
||||
def cmdlines():
|
||||
"""The command line of every process on the machine, one string
|
||||
each."""
|
||||
out = []
|
||||
for name in os.listdir("/proc"):
|
||||
if not name.isdigit():
|
||||
continue
|
||||
off += 1
|
||||
parts.append(pkt[off:off + n].decode("ascii", "replace"))
|
||||
off += n
|
||||
if not jumped:
|
||||
end = off
|
||||
return ".".join(parts), end
|
||||
raw = read_file("/proc/%s/cmdline" % name)
|
||||
if raw:
|
||||
out.append(raw.decode("utf-8", "replace").replace("\0", " ").strip())
|
||||
return out
|
||||
|
||||
|
||||
def mdns_answers(pkt, qid=None):
|
||||
"""The A records of a DNS response: [(name, address)]. A qid, when
|
||||
given, must match the packet's id; the answer flag (QR) must be set."""
|
||||
if len(pkt) < 12:
|
||||
return []
|
||||
pid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", pkt[:12])
|
||||
if qid is not None and pid != qid:
|
||||
return []
|
||||
if not flags & 0x8000:
|
||||
return []
|
||||
off = 12
|
||||
try:
|
||||
for _ in range(qd):
|
||||
_name, off = _dns_name(pkt, off)
|
||||
off += 4
|
||||
out = []
|
||||
for _ in range(an + ns + ar):
|
||||
name, off = _dns_name(pkt, off)
|
||||
if off + 10 > len(pkt):
|
||||
break
|
||||
rtype, rclass, _ttl, rdlen = struct.unpack(">HHIH", pkt[off:off + 10])
|
||||
off += 10
|
||||
rdata = pkt[off:off + rdlen]
|
||||
off += rdlen
|
||||
if rtype == 1 and rdlen == 4:
|
||||
out.append((name.lower(), socket.inet_ntoa(rdata)))
|
||||
return out
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
|
||||
def mdns_resolve(ip, name=MDNS_NAME, timeout=3.0, tries=3):
|
||||
"""Ask the LAN for the A record of name over mDNS from the interface
|
||||
that holds ip, and collect the answers: {address}. The query goes to
|
||||
the multicast group; the responder on this machine answers the
|
||||
unicast-response bit directly (a legacy query from a port that is
|
||||
not 5353 is answered the same way), and a listener on 5353 catches
|
||||
a multicast answer too."""
|
||||
found = set()
|
||||
qid = random.randrange(1, 65536)
|
||||
q = mdns_query(name, qid)
|
||||
tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
rx = None
|
||||
try:
|
||||
tx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
tx.bind((ip, 0))
|
||||
tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(ip))
|
||||
tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
|
||||
tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1)
|
||||
try:
|
||||
rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
if hasattr(socket, "SO_REUSEPORT"):
|
||||
rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
||||
rx.bind(("", MDNS_PORT))
|
||||
rx.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
|
||||
socket.inet_aton(MDNS_GROUP) + socket.inet_aton(ip))
|
||||
except OSError:
|
||||
rx = None
|
||||
socks = [s for s in (tx, rx) if s is not None]
|
||||
for _ in range(tries):
|
||||
tx.sendto(q, (MDNS_GROUP, MDNS_PORT))
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
ready, _w, _x = select.select(socks, [], [], max(0.05, deadline - time.time()))
|
||||
for s in ready:
|
||||
try:
|
||||
pkt, _peer = s.recvfrom(4096)
|
||||
except OSError:
|
||||
continue
|
||||
# a multicast answer carries id 0; the direct one echoes the query's
|
||||
for n, addr in mdns_answers(pkt, qid if s is tx else None):
|
||||
if n == name.lower():
|
||||
found.add(addr)
|
||||
if found:
|
||||
return found
|
||||
finally:
|
||||
tx.close()
|
||||
if rx is not None:
|
||||
rx.close()
|
||||
return found
|
||||
|
||||
|
||||
@test("commission.mdns-announce", title="The machine answers forgefirm.local", subsystem="commission",
|
||||
kind="auto", est_min=1,
|
||||
@test("setup.machine-name", title="The machine names itself from its MAC address",
|
||||
subsystem="setup", kind="auto", est_min=1,
|
||||
requires=["forgectrl.auth"],
|
||||
description="avahi-daemon runs, its configuration names the host forgefirm on the WiFi and "
|
||||
"wired links, and the service file advertises the panel on 443 and 80. An mDNS "
|
||||
"query for the A record of forgefirm.local, sent to the multicast group from "
|
||||
"the board's own LAN interface, is answered with the board's LAN address; that "
|
||||
"is what `ping forgefirm.local` on a workstation resolves. No component "
|
||||
"covers this: the avahi files are layer content, in the platform identity of "
|
||||
description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex "
|
||||
"digits of its WiFi MAC address, so two machines on one network answer to "
|
||||
"different names and no name carries a serial number. The live name, "
|
||||
"/etc/hostname (a bind-mounted copy, because the rootfs is read-only) and the "
|
||||
"MAC address agree; the DHCP client sends the name as the hostname option, "
|
||||
"which is what a network with dynamic DNS publishes; the console banner offers "
|
||||
"the machine's addresses and nothing else, with no marker line and no .local "
|
||||
"name; and no mDNS responder is on the image. No component covers this: the "
|
||||
"hostname and banner scripts are layer content, in the platform identity of "
|
||||
"every fingerprint.")
|
||||
def mdns_announce(ctx):
|
||||
def machine_name(ctx):
|
||||
ev = ctx.evidence
|
||||
pids = hw.pidof("avahi-daemon")
|
||||
ev["avahi_pids"] = pids
|
||||
ctx.check(pids, "avahi-daemon is not running")
|
||||
conf = read_file("/etc/avahi/avahi-daemon.conf") or b""
|
||||
ctx.check(b"host-name=forgefirm" in conf, "avahi-daemon.conf does not name the host forgefirm")
|
||||
ctx.check(b"allow-interfaces=wlan0,eth0" in conf, "avahi-daemon.conf does not restrict the interfaces")
|
||||
svc = read_file("/etc/avahi/services/forgefirm.service") or b""
|
||||
ev["service_file_bytes"] = len(svc)
|
||||
ctx.check(b"_https._tcp" in svc and b"<port>443</port>" in svc, "the service file lacks HTTPS 443")
|
||||
ctx.check(b"_http._tcp" in svc and b"<port>80</port>" in svc, "the service file lacks HTTP 80")
|
||||
|
||||
# 1. the name, the file and the MAC address agree
|
||||
suffix = mac_suffix()
|
||||
ev["mac_suffix"] = suffix
|
||||
ctx.check(suffix, "no MAC address to build a name from")
|
||||
live = socket.gethostname()
|
||||
on_file = ((read_file("/etc/hostname") or b"").decode("utf-8", "replace")).strip()
|
||||
ev["hostname"] = live
|
||||
ev["hostname_file"] = on_file
|
||||
ctx.log("hostname %r (MAC suffix %s)", live, suffix or "none")
|
||||
ctx.check(live == "forgefirm-" + suffix,
|
||||
"the hostname is %r, expected forgefirm-%s", live, suffix)
|
||||
ctx.check(on_file == live, "/etc/hostname holds %r, the live name is %r", on_file, live)
|
||||
|
||||
# 2. the DHCP client sends it (option 12), so dynamic DNS can publish it
|
||||
dhcp = [c for c in cmdlines() if c.split(" ")[0].split("/")[-1] == "udhcpc"]
|
||||
ev["udhcpc"] = dhcp
|
||||
ctx.check(dhcp, "no DHCP client is running")
|
||||
ctx.check(any("-x hostname:" + live in c for c in dhcp),
|
||||
"the DHCP client does not send the machine's name: %s", dhcp)
|
||||
|
||||
# 3. the console banner: the addresses, and nothing that is not the banner
|
||||
issue = (read_file("/etc/issue") or b"").decode("utf-8", "replace")
|
||||
ev["issue"] = issue
|
||||
ip = lan_ip()
|
||||
ev["lan_ip"] = ip
|
||||
ctx.check(ip, "cannot determine the board's LAN address")
|
||||
t0 = time.time()
|
||||
found = mdns_resolve(ip)
|
||||
ev["answers"] = sorted(found)
|
||||
ev["resolve_s"] = round(time.time() - t0, 2)
|
||||
ctx.log("%s -> %s (%.1f s)", MDNS_NAME, sorted(found) or "no answer", ev["resolve_s"])
|
||||
ctx.check(found, "no mDNS answer for %s from the LAN interface (%s)", MDNS_NAME, ip)
|
||||
ctx.check(ip in found, "%s resolves to %s, not the LAN address %s", MDNS_NAME, sorted(found), ip)
|
||||
ctx.check("Control panel:" in issue, "the console banner names no control panel")
|
||||
ctx.check("https://%s/" % ip in issue,
|
||||
"the console banner does not carry the LAN address %s", ip)
|
||||
for junk in ("# end", "# ForgeFIRM addresses", ".local"):
|
||||
ctx.check(junk not in issue, "the console banner shows %r", junk)
|
||||
urls = re.findall(r"https://(\S+)/", issue)
|
||||
ev["banner_urls"] = urls
|
||||
for u in urls:
|
||||
literal = u.startswith("[") or all(c.isdigit() or c == "." for c in u)
|
||||
ctx.check(literal, "the console banner offers %r, which is not an address", u)
|
||||
|
||||
# 4. no mDNS on the image; the name is the only name the machine has
|
||||
ev["avahi_pids"] = hw.pidof("avahi-daemon")
|
||||
ctx.check(not ev["avahi_pids"], "an mDNS responder is running: %s", ev["avahi_pids"])
|
||||
ctx.check(not os.path.exists("/etc/avahi"), "/etc/avahi is on the image")
|
||||
|
||||
|
||||
# --------------------------------------------------------- the first run
|
||||
@@ -1283,12 +1213,12 @@ def finished_first_run(ctx):
|
||||
"the controller did not come back after the setup: %s", m)
|
||||
|
||||
|
||||
_FIRST_RUN_REQUIRES = ["commission.gate-blocks-controllers", "commission.advisories-rehash"]
|
||||
_FIRST_RUN_REQUIRES = ["setup.gate-blocks-controllers", "setup.advisories-rehash"]
|
||||
|
||||
|
||||
@test("commission.first-run-flow", title="The first run, driven as the page drives it", subsystem="commission",
|
||||
@test("setup.first-run-flow", title="The first run, driven as the page drives it", subsystem="setup",
|
||||
kind="operator", hardware="takeover", est_min=3,
|
||||
covers=[("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"),
|
||||
covers=[("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
|
||||
("forgectrl", "src/session.*"), ("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"),
|
||||
("forgectrl", "src/sheetid.*"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/hooks.h"),
|
||||
("forgectrl", "src/main.c")],
|
||||
@@ -1379,11 +1309,11 @@ def first_run_flow(ctx):
|
||||
ctx.log("the run completed: gate open, the controller back")
|
||||
|
||||
|
||||
@test("commission.first-run-page", title="The first-run page, walked once", subsystem="commission",
|
||||
@test("setup.first-run-page", title="The first-run page, walked once", subsystem="setup",
|
||||
kind="operator", hardware="takeover", est_min=6,
|
||||
covers=[("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/forms.js"), ("forgectrl", "src/ui/md.js"),
|
||||
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/embed_docs.cmake"), ("forgectrl", "src/tls.*")],
|
||||
requires=_FIRST_RUN_REQUIRES + ["commission.first-run-flow"],
|
||||
requires=_FIRST_RUN_REQUIRES + ["setup.first-run-flow"],
|
||||
actions=["button"], hands=["workstation"],
|
||||
steps=["From the workstation open https://<address>/ and accept the self-signed certificate.",
|
||||
"Click through the setup: scroll each document to its end and accept it (type I "
|
||||
@@ -1398,7 +1328,7 @@ def first_run_flow(ctx):
|
||||
"clicks through (the documents accepted, the press step with the LEDs "
|
||||
"breathing teal, the account created, the run complete, the gate open, the "
|
||||
"controller back). The operator confirms the panel opened. Everything the run "
|
||||
"wrote is restored as commission.first-run-flow restores it.")
|
||||
"wrote is restored as setup.first-run-flow restores it.")
|
||||
def first_run_page(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
@@ -1434,8 +1364,8 @@ def first_run_page(ctx):
|
||||
ctx.confirm("Did the control panel open after Finish?")
|
||||
|
||||
|
||||
@test("commission.cert-page", title="The certificate is checkable before the warning",
|
||||
subsystem="commission", kind="auto", est_min=1,
|
||||
@test("setup.cert-page", title="The certificate is checkable before the warning",
|
||||
subsystem="setup", kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c"),
|
||||
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.js")],
|
||||
requires=["forgectrl.auth"],
|
||||
@@ -1458,7 +1388,8 @@ def cert_page(ctx):
|
||||
ctx.check(st == 200, "GET %s/cert -> %s, expected 200", base, st)
|
||||
ctx.check("location" not in hdrs, "GET %s/cert redirected", base)
|
||||
ctx.check(fp in text, "the fingerprint is not on the page from %s", base)
|
||||
ctx.check("forgefirm.local" in text, "forgefirm.local is not among the names on the page")
|
||||
ctx.check(socket.gethostname() in text,
|
||||
"the machine's hostname is not among the names on the page")
|
||||
st, body, hdrs = request(base, "GET", "/cert.pem")
|
||||
ctx.check(st == 200 and body.startswith(b"-----BEGIN CERTIFICATE-----"),
|
||||
"GET %s/cert.pem -> %s, not a PEM certificate", base, st)
|
||||
@@ -1492,9 +1423,9 @@ def flag_of(w, level, wid):
|
||||
return None
|
||||
|
||||
|
||||
@test("commission.what-changed", title="A replaced part asks for its checks again",
|
||||
subsystem="commission", kind="auto", est_min=2, hardware="takeover",
|
||||
covers=[("forgectrl", "src/commission.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"),
|
||||
@test("setup.what-changed", title="A replaced part asks for its checks again",
|
||||
subsystem="setup", kind="auto", est_min=2, hardware="takeover",
|
||||
covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"),
|
||||
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html"),
|
||||
("forgectrl", "src/ui/help.js")],
|
||||
requires=["forgectrl.auth"],
|
||||
@@ -1562,9 +1493,9 @@ def what_changed(ctx):
|
||||
ctx.check(bool(mode(fc).get("gated")) == gated_before, "the gate does not read as before")
|
||||
|
||||
|
||||
@test("commission.record-export", title="The record: JSON, the printable page, and the log bundle",
|
||||
subsystem="commission", kind="auto", est_min=2,
|
||||
covers=[("forgectrl", "src/commission.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"),
|
||||
@test("setup.record-export", title="The record: JSON, the printable page, and the log bundle",
|
||||
subsystem="setup", kind="auto", est_min=2,
|
||||
covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"),
|
||||
("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
|
||||
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/wizard.html")],
|
||||
requires=["forgectrl.auth", "logs.tree-tail-export"],
|
||||
@@ -1573,7 +1504,7 @@ def what_changed(ctx):
|
||||
"named after the sheet id; GET /wiz/record.html is a page with no script that "
|
||||
"carries the sheet id and every completed wizard; without the token and without "
|
||||
"a login both are refused (403); and the sanitized log export carries "
|
||||
"system/commissioning.json, parseable, with the same sheet id and no panel token.")
|
||||
"system/setup.json, parseable, with the same sheet id and no panel token.")
|
||||
def record_export(ctx):
|
||||
import gzip
|
||||
import io
|
||||
@@ -1613,7 +1544,7 @@ def record_export(ctx):
|
||||
ctx.check(st == 200 and "text/html" in (hdrs.get("content-type") or ""), "GET /wiz/record.html -> %s %s",
|
||||
st, hdrs.get("content-type"))
|
||||
ctx.check("<script" not in page.lower(), "the printable page carries a script")
|
||||
ctx.check(sid in page and "commissioning record" in page, "the page lacks the sheet id or its title")
|
||||
ctx.check(sid in page and "setup record" in page, "the page lacks the sheet id or its title")
|
||||
titles = {z["id"]: z["title"] for z in w.get("wizards") or []}
|
||||
missing = [d for d in done if titles.get(d) and ("<h3>" + titles[d]) not in page]
|
||||
ev["missing_on_page"] = missing
|
||||
@@ -1634,11 +1565,11 @@ def record_export(ctx):
|
||||
try:
|
||||
tf = tarfile.open(fileobj=io.BytesIO(gzip.decompress(data)))
|
||||
for m in tf.getmembers():
|
||||
if m.isfile() and m.name.endswith("system/commissioning.json"):
|
||||
if m.isfile() and m.name.endswith("system/setup.json"):
|
||||
member = tf.extractfile(m).read()
|
||||
except (OSError, tarfile.TarError, EOFError) as e:
|
||||
ctx.fail("export is not a readable tar.gz: %s", e)
|
||||
ctx.check(member is not None, "the bundle lacks system/commissioning.json")
|
||||
ctx.check(member is not None, "the bundle lacks system/setup.json")
|
||||
if member is not None:
|
||||
try:
|
||||
inside = json.loads(member.decode("utf-8"))
|
||||
@@ -1656,12 +1587,12 @@ def record_export(ctx):
|
||||
ctx.log("record %d wizards; page %d bytes; bundle carries the record", len(done), ev["page_bytes"])
|
||||
|
||||
|
||||
@test("commission.mirror", title="One browser drives a check; a second one follows",
|
||||
subsystem="commission", kind="auto", est_min=2, hardware="takeover",
|
||||
@test("setup.mirror", title="One browser drives a check; a second one follows",
|
||||
subsystem="setup", kind="auto", est_min=2, hardware="takeover",
|
||||
covers=[("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), ("forgectrl", "src/wiz.*"),
|
||||
("forgectrl", "src/session.*"), ("forgectrl", "src/ui/wizard.js"),
|
||||
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.css")],
|
||||
requires=["forgectrl.auth", "commission.account-login", "commission.check-sensors"],
|
||||
requires=["forgectrl.auth", "setup.account-login", "setup.check-sensors"],
|
||||
description="With a temporary account (made and removed as the login test does) and two "
|
||||
"login sessions: the first session starts the sensors check; GET /wiz/dark "
|
||||
"reports the run as owned and its own to the first session, not to the second, "
|
||||
+82
-33
@@ -1,4 +1,4 @@
|
||||
"""commission.check-* - the setup's checks (the dark wizards), driven the
|
||||
"""setup.check-* - the setup's checks (the dark wizards), driven the
|
||||
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
|
||||
the prompts answered from here (the bench fixture works the lid and the
|
||||
button; a confirmation is answered yes once the snapshot exists), the
|
||||
@@ -12,11 +12,17 @@ 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
|
||||
from .setup 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/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
||||
|
||||
|
||||
@@ -76,6 +82,12 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
||||
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
|
||||
@@ -101,8 +113,8 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
||||
return last
|
||||
|
||||
|
||||
@test("commission.check-switches", title="The switches check follows the lid and the button",
|
||||
subsystem="commission", kind="operator", est_min=3,
|
||||
@test("setup.check-switches", title="The switches check follows the lid and the button",
|
||||
subsystem="setup", kind="operator", est_min=3,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
||||
requires=["forgectrl.auth"], actions=["lid", "button"],
|
||||
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
||||
@@ -136,30 +148,40 @@ def check_switches(ctx):
|
||||
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,
|
||||
@test("setup.check-sensors", title="The sensors check reads a plausible machine at rest",
|
||||
subsystem="setup", 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.")
|
||||
description="POST /wiz/sensors/start; ten seconds of readings and no question asked. 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 the "
|
||||
"settings read as before (the check writes none).")
|
||||
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"))
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings() or {}
|
||||
ev["settings_before"] = dict(before)
|
||||
|
||||
def on_prompt(p):
|
||||
ctx.fail("the sensors check asked a question: %s", p)
|
||||
last = run_check(ctx, "sensors", on_prompt, 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"))
|
||||
after = fc.settings() or {}
|
||||
changed = {k: (before.get(k), after.get(k)) for k in set(before) | set(after)
|
||||
if before.get(k) != after.get(k)}
|
||||
ctx.check(not changed, "the sensors check wrote a setting: %s", changed)
|
||||
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,
|
||||
@test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
||||
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
||||
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
||||
@@ -167,7 +189,8 @@ def check_sensors(ctx):
|
||||
"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 and the controller must be running again.")
|
||||
"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"]
|
||||
@@ -186,12 +209,38 @@ def check_airflow(ctx):
|
||||
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,
|
||||
@test("setup.check-cameras", title="The cameras check captures both cameras",
|
||||
subsystem="setup", 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 "
|
||||
@@ -225,10 +274,10 @@ def check_cameras(ctx):
|
||||
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,
|
||||
@test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
||||
("forgectrl", "src/lenshome.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 "
|
||||
@@ -266,8 +315,8 @@ def check_motion(ctx):
|
||||
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,
|
||||
@test("setup.check-flow-verify", title="The flow check runs as a setup check",
|
||||
subsystem="setup", 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 "
|
||||
@@ -284,8 +333,8 @@ def check_flow_verify(ctx):
|
||||
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,
|
||||
@test("setup.cloud-header-capture", title="The cloud header check takes one print's envelope",
|
||||
subsystem="setup", kind="operator", hardware="takeover", mode="grbl", est_min=8,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
|
||||
("python3-gfhardware", "gfhardware/machine.py"),
|
||||
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
|
||||
+111
-30
@@ -1,4 +1,4 @@
|
||||
"""commission.sheet - the setup's sheet (the live wizards) in one run,
|
||||
"""setup.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
|
||||
@@ -18,13 +18,13 @@ restored. The page is where the real numbers go in.
|
||||
import re
|
||||
|
||||
from ..catalog import test
|
||||
from .commission_dark import run_check, Restore
|
||||
from .setup_dark import run_check, Restore
|
||||
|
||||
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
||||
("forgectrl", "src/sheet.*"),
|
||||
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
|
||||
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
||||
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
||||
("forgectrl", "src/super.c"),
|
||||
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
||||
@@ -36,19 +36,18 @@ SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
||||
|
||||
# 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), the keys its result must carry, and how long the bench actuator
|
||||
# waits for the button to light before the arm press goes to a person.
|
||||
# The flow-load card settles the coolant loop before it lights the button
|
||||
# (60 to 240 s); the others light within seconds of the lens reference.
|
||||
# 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"], 120),
|
||||
("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"], 120),
|
||||
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"], 120),
|
||||
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"], 120),
|
||||
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"], 120),
|
||||
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"], 420),
|
||||
"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",
|
||||
@@ -72,6 +71,22 @@ def preview_ok(ctx, wid):
|
||||
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
||||
"no program for %s (%s)", wid, st)
|
||||
ctx.evidence["program_lines"] = body.count(b"\n")
|
||||
# Every Z the program commands is a height the head reaches in the
|
||||
# window the machine holds now: the focus card's own numbers once it
|
||||
# has them, and the conservative fallback on a head whose travel is
|
||||
# not known yet. The controller refuses a Z outside it with ALARM:2,
|
||||
# and it refuses it while the body is still burning, so the card dies
|
||||
# where the parser is and not where the bad line is.
|
||||
lens = (fc.status() or {}).get("lens") or {}
|
||||
lo, hi = lens.get("reach_min"), lens.get("reach_max")
|
||||
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
|
||||
ctx.evidence.setdefault("program_z", {})[wid] = {
|
||||
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
|
||||
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
|
||||
if lo is not None and hi is not None:
|
||||
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
|
||||
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
|
||||
wid, lo, hi, out)
|
||||
|
||||
|
||||
def emission_ok(ctx, last):
|
||||
@@ -90,6 +105,61 @@ def file_card(ctx, wid):
|
||||
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
||||
|
||||
|
||||
# The lens settings: the head's numbers, which a fresh machine does not
|
||||
# have. Cleared before the cards so the run is a fresh machine's.
|
||||
LENS_SETTINGS = CARD_SETTINGS[:3]
|
||||
FALLBACK_WINDOW = (10, 12)
|
||||
|
||||
|
||||
def fresh_lens(ctx):
|
||||
"""The lens as a fresh machine has it: the head's numbers unknown, so
|
||||
the frame runs in the fallback window and the focus card has to find
|
||||
the stops and write them before its ladder burns. The machine's own
|
||||
numbers come back with the restore at the end of the run."""
|
||||
fc = ctx.forgectrl
|
||||
for k in LENS_SETTINGS:
|
||||
st, _ = fc.post("/settings", params={k: ""})
|
||||
ctx.check(st == 200, "clearing %s -> %s", k, st)
|
||||
lens = (fc.status() or {}).get("lens") or {}
|
||||
ctx.evidence["lens_fresh"] = lens
|
||||
ctx.check(lens.get("stops_found") is False and (lens.get("below"), lens.get("above")) == FALLBACK_WINDOW,
|
||||
"the lens window is not the fallback after the clear: %s", lens)
|
||||
|
||||
|
||||
def focus_window_ok(ctx):
|
||||
"""The window the focus card burned its ladder in is the one it wrote
|
||||
before its controller started, and the one the machine holds now: the
|
||||
result's window is the stops it found (or the fallback), the settings
|
||||
hold that window, every ladder height lies in its reach, and the
|
||||
program served now agrees with /status."""
|
||||
fc = ctx.forgectrl
|
||||
r = ((ctx.evidence.get("cards") or {}).get("laser.focus") or {}).get("result") or {}
|
||||
w, stops = r.get("window") or {}, r.get("stops") or {}
|
||||
s = fc.settings() or {}
|
||||
held = (str(s.get("lens_stop_below_steps")), str(s.get("lens_stop_above_steps")))
|
||||
ctx.check(held == (str(w.get("below")), str(w.get("above"))),
|
||||
"the settings hold the window %s, the ladder ran in %s/%s", held, w.get("below"), w.get("above"))
|
||||
if stops.get("found"):
|
||||
ctx.check((w.get("below"), w.get("above")) == (stops.get("below"), stops.get("above")),
|
||||
"the ladder's window %s is not the stops found %s", w, stops)
|
||||
else:
|
||||
ctx.check((w.get("below"), w.get("above")) == FALLBACK_WINDOW,
|
||||
"stops not found (%s), but the ladder's window is %s", stops.get("why"), w)
|
||||
zs = r.get("ladder_z") or []
|
||||
lo, hi = w.get("reach_min"), w.get("reach_max")
|
||||
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
|
||||
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
|
||||
ev = ctx.evidence
|
||||
before = dict(ev.get("program_z") or {}).get("laser.focus")
|
||||
preview_ok(ctx, "laser.focus")
|
||||
ev["focus_window"] = {"window": w, "stops": stops, "ladder_z": zs,
|
||||
"settings": {k: s.get(k) for k in LENS_SETTINGS},
|
||||
"program_z_before": before,
|
||||
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
|
||||
"preview_bytes": ev.pop("preview_bytes", None),
|
||||
"program_lines": ev.pop("program_lines", None)}
|
||||
|
||||
|
||||
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
|
||||
@@ -130,12 +200,12 @@ def place(ctx):
|
||||
file_card(ctx, "sheet.place")
|
||||
|
||||
|
||||
def burn(ctx, wid, answers, want, lit_s):
|
||||
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`. The actuator waits `lit_s` for the button to light."""
|
||||
result in `want`. run_check makes the arm press when the card asks
|
||||
for it."""
|
||||
preview_ok(ctx, wid)
|
||||
ctx.arm_press(lit_timeout=lit_s)
|
||||
|
||||
def on_prompt(p):
|
||||
return answers.get(p.get("id"))
|
||||
@@ -152,30 +222,38 @@ def burn(ctx, wid, answers, want, lit_s):
|
||||
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,
|
||||
@test("setup.sheet", title="The setup sheet: the placement, the frame, and the five cards",
|
||||
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=35,
|
||||
covers=SHEET_COVERS,
|
||||
requires=["forgectrl.auth", "commission.check-motion", "laser.emission-witness", "cooling.flow-verify"],
|
||||
requires=["forgectrl.auth", "setup.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. With no actuator "
|
||||
"the start is the Ready answer instead, and you press the button each time it lights "
|
||||
"white: six times, the frame and the five cards.",
|
||||
"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. "
|
||||
description="One run over the sheet's seven wizards on one piece of wood, as a fresh "
|
||||
"machine runs them: the three lens settings are cleared first, so the frame "
|
||||
"runs in the fallback window and the focus card has to find the stops and write "
|
||||
"them before its ladder burns. "
|
||||
"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 "
|
||||
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the "
|
||||
"program commands inside the reach /status reports: 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 "
|
||||
"lens on its hall edge, the stops found by the head accelerometer and written "
|
||||
"as the window before the controller starts, twelve lines from the top of the "
|
||||
"window to the bottom; the pick is line 11, the thickness kept; after it the "
|
||||
"settings, the result's window, and the served program must hold the window "
|
||||
"the ladder ran in); 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 "
|
||||
@@ -191,6 +269,9 @@ def sheet(ctx):
|
||||
"each. The bench presses when the button lights.")
|
||||
place(ctx)
|
||||
with Restore(ctx, CARD_SETTINGS):
|
||||
for wid, answers, want, lit_s in CARDS:
|
||||
burn(ctx, wid, answers, want, lit_s)
|
||||
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
|
||||
fresh_lens(ctx)
|
||||
for wid, answers, want in CARDS:
|
||||
burn(ctx, wid, answers, want)
|
||||
if wid == "laser.focus":
|
||||
focus_window_ok(ctx)
|
||||
ctx.log("PASS: the sheet's seven wizards ran on one piece as a fresh machine's; settings restored")
|
||||
@@ -1,5 +1,6 @@
|
||||
"""update.* - the A/B slot inventory and the firmware verification path."""
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import shutil
|
||||
@@ -8,6 +9,7 @@ from ..catalog import test
|
||||
from .. import hw
|
||||
|
||||
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
|
||||
("forgectrl", "src/relcheck.c"), ("forgectrl", "src/relcheck.h"),
|
||||
("ffboot", "**")]
|
||||
|
||||
|
||||
@@ -117,3 +119,76 @@ def slots_and_signature(ctx):
|
||||
except OSError:
|
||||
pass
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
_VERSION_RX = re.compile(r"^v?\d+\.\d+\.\d+")
|
||||
|
||||
|
||||
@test("update.release-check", title="Release check answers from the releases API; the alert dismissal is per release",
|
||||
subsystem="update", kind="auto", est_min=1,
|
||||
covers=_UPDATE_COVERS + [("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="GET /update/release answers the kept answer of the daily check (available, version, "
|
||||
"current, new, checked, dismissed). POST /update/check asks the releases API now and "
|
||||
"answers the same shape; a machine with no route to the API answers 502, which the drill "
|
||||
"records and steps over. A published release is a v<semver> tag with the firmware "
|
||||
"file's size and its notes, and `new` follows the version order: a development build "
|
||||
"sees every release as newer. POST /update/dismiss marks that version dismissed, an "
|
||||
"empty version undoes it, and a version that is not a tag is refused. The dismissal "
|
||||
"the machine had before the drill is put back.")
|
||||
def release_check(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
keys = ("available", "version", "current", "new", "checked", "dismissed", "detail", "bytes")
|
||||
|
||||
st, before = fc.get("/update/release")
|
||||
ctx.log("GET /update/release -> %s %s", st, {k: before.get(k) for k in keys} if isinstance(before, dict) else before)
|
||||
ctx.check(st == 200 and isinstance(before, dict) and "available" in before and "checked" in before,
|
||||
"GET /update/release -> %s", st)
|
||||
ev["before"] = {k: before.get(k) for k in keys}
|
||||
prior = before.get("version", "") if before.get("dismissed") else ""
|
||||
|
||||
st, now = fc.post("/update/check")
|
||||
ctx.log("POST /update/check -> %s %s", st, {k: now.get(k) for k in keys} if isinstance(now, dict) else now)
|
||||
if st == 502:
|
||||
ctx.log("the machine has no route to the releases API; the kept answer stands")
|
||||
now = before
|
||||
else:
|
||||
ctx.check(st == 200 and isinstance(now, dict) and "available" in now, "POST /update/check -> %s", st)
|
||||
ctx.check(isinstance(now.get("checked"), int) and now["checked"] >= before.get("checked", 0),
|
||||
"the check did not move `checked`: %s", now.get("checked"))
|
||||
ev["after_check"] = {k: now.get(k) for k in keys}
|
||||
|
||||
if now.get("available"):
|
||||
ctx.check(_VERSION_RX.match(str(now.get("version", ""))) is not None,
|
||||
"the release version is not a v<semver> tag: %r", now.get("version"))
|
||||
ctx.check(isinstance(now.get("bytes"), int) and now["bytes"] > 0,
|
||||
"the firmware file's size is missing: %r", now.get("bytes"))
|
||||
ctx.check(isinstance(now.get("notes"), str), "the notes are not a string")
|
||||
ctx.check(isinstance(now.get("new"), bool), "`new` is not a bool")
|
||||
if not _VERSION_RX.match(str(now.get("current", ""))):
|
||||
ctx.check(now.get("new") is True,
|
||||
"a development build (%r) must see release %s as newer", now.get("current"), now["version"])
|
||||
else:
|
||||
ctx.log("no release available: %s", now.get("detail"))
|
||||
ctx.check(isinstance(now.get("detail"), str) and now["detail"], "an unavailable release names no reason")
|
||||
|
||||
try:
|
||||
st, body = fc.post("/update/dismiss", params={"version": "v0.0.1; rm -rf /"})
|
||||
ctx.log("POST /update/dismiss version=<not a tag> -> %s %s", st, body)
|
||||
ctx.check(st == 400, "a version that is not a tag was not refused: %s", st)
|
||||
|
||||
tag = now["version"] if now.get("available") else "v0.0.0"
|
||||
st, body = fc.post("/update/dismiss", params={"version": tag})
|
||||
ctx.log("POST /update/dismiss version=%s -> %s %s", tag, st,
|
||||
{k: body.get(k) for k in keys} if isinstance(body, dict) else body)
|
||||
ctx.check(st == 200 and isinstance(body, dict), "POST /update/dismiss -> %s", st)
|
||||
if now.get("available"):
|
||||
ctx.check(body.get("dismissed") is True, "the dismissal of %s was not recorded: %r", tag, body.get("dismissed"))
|
||||
st, body = fc.post("/update/dismiss", params={"version": ""})
|
||||
ctx.log("POST /update/dismiss version= -> %s", st)
|
||||
ctx.check(st == 200 and isinstance(body, dict) and body.get("dismissed") is False,
|
||||
"the empty version did not undo the dismissal: %s %r", st, body.get("dismissed") if isinstance(body, dict) else body)
|
||||
finally:
|
||||
st, body = fc.post("/update/dismiss", params={"version": prior})
|
||||
ctx.log("dismissal put back to %r -> %s", prior, st)
|
||||
|
||||
@@ -206,6 +206,100 @@
|
||||
border-left: 4px solid var(--og-red);
|
||||
}
|
||||
|
||||
/* ---- The release dialog -------------------------------------------- */
|
||||
#rel-view h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--og-dim);
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
.relnotes {
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--og-line);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin: 4px 0 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.relnotes h1,
|
||||
.relnotes h2,
|
||||
.relnotes h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
.relnotes h1:first-child,
|
||||
.relnotes h2:first-child,
|
||||
.relnotes h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.relnotes p,
|
||||
.relnotes ul {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.relnotes ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.relnotes code {
|
||||
font-size: 12px;
|
||||
}
|
||||
.relnotes table {
|
||||
border-collapse: collapse;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
.relnotes th,
|
||||
.relnotes td {
|
||||
border: 1px solid var(--og-line);
|
||||
padding: 2px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.relwarn {
|
||||
background: var(--og-warn-bg);
|
||||
border: 1px solid var(--og-warn);
|
||||
color: var(--og-warn-txt);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.relsteps {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 22px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.relsteps li {
|
||||
color: var(--og-dim);
|
||||
}
|
||||
.relsteps li.cur {
|
||||
color: var(--og-txt);
|
||||
font-weight: 600;
|
||||
}
|
||||
.relsteps li.done {
|
||||
color: var(--og-ok);
|
||||
}
|
||||
.relsteps li.done::after {
|
||||
content: ' \2713';
|
||||
}
|
||||
.progress {
|
||||
--bs-progress-bg: var(--og-line-soft);
|
||||
--bs-progress-bar-bg: var(--og-action);
|
||||
--bs-progress-height: 10px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
#rel-phase {
|
||||
min-height: 1.4em;
|
||||
font-size: 12.5px;
|
||||
margin: 0;
|
||||
}
|
||||
#rel-err {
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
/* ---- Chrome: header, tabs, footer ---------------------------------- */
|
||||
body {
|
||||
margin: 0;
|
||||
@@ -238,6 +332,7 @@ header {
|
||||
margin-left: auto;
|
||||
}
|
||||
#themebtn,
|
||||
#logsbtn,
|
||||
#logoutbtn {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
@@ -249,6 +344,7 @@ header {
|
||||
white-space: nowrap;
|
||||
}
|
||||
#themebtn:hover,
|
||||
#logsbtn:hover,
|
||||
#logoutbtn:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
@@ -692,3 +788,13 @@ footer a {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,13 @@ class BaselineTests(unittest.TestCase):
|
||||
# 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"
|
||||
@@ -119,6 +126,21 @@ class BaselineTests(unittest.TestCase):
|
||||
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
|
||||
self.assertEqual(items["position"].found, [1000, 0, 0])
|
||||
|
||||
def test_the_counters_are_read_at_the_modes_scale(self):
|
||||
# 6400 steps is 30 mm at x32 (a return within the bound; on the
|
||||
# host it stops at the missing controller) and 120 mm at x8
|
||||
# (beyond the bound). Read at the x8 scale, an x32 machine's
|
||||
# displaced head is never jogged back.
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
self._attr("cnc/x_mode", "32")
|
||||
self._pos(6400, 0, 0)
|
||||
items = {x.item: x for x in b.enforce("post", captured=cap)}
|
||||
self.assertEqual(items["position"].action, "unrestorable: no running GRBL controller")
|
||||
self._attr("cnc/x_mode", "8")
|
||||
items = {x.item: x for x in b.enforce("post", captured=cap)}
|
||||
self.assertEqual(items["position"].action, "unrestorable: 120.0/0.0 mm exceeds 100 mm")
|
||||
|
||||
def test_a_held_controller_is_reset_before_the_return_jog(self):
|
||||
# a pause test that failed while held leaves the controller in
|
||||
# Hold, which refuses a jog: the baseline resets out of it first
|
||||
@@ -154,16 +176,32 @@ class BaselineTests(unittest.TestCase):
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
# the run left 40 unplayed bytes queued in the kernel ring and the
|
||||
# head 1000 counts out: the residue is reported, and the return jog
|
||||
# is refused (it would replay the residue first)
|
||||
# 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("unplayed bytes queued", items["position"].action)
|
||||
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)
|
||||
|
||||
@@ -354,6 +392,78 @@ 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_the_dead_band_scales_with_the_counters(self):
|
||||
# the same step count is a quarter of the distance at x32
|
||||
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
|
||||
x32 = baseline.XY_STEPS_PER_MM_OF[32]
|
||||
self.assertTrue(baseline.position_quantized([0, 0, 0], [over, 0, 0], x32))
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [4 * over, 0, 0], x32))
|
||||
|
||||
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
|
||||
|
||||
@@ -558,12 +558,16 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
|
||||
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
|
||||
|
||||
def mode_switch_setup(self, hunt_lines=None, home_complete=True):
|
||||
def mode_switch_setup(self, hunt_lines=None, home_complete=True, lid_late=False):
|
||||
"""The fakes a mode-switch run needs: grbl to answer $H, the lid
|
||||
lamp attr, homing_mode = gfcloud, the service lines landing on
|
||||
the switch to cloud (the hunt reads as a run to the cooling
|
||||
engine while it lasts), the re-hunt on the lid close, and gfhome
|
||||
finishing the homing after $H."""
|
||||
the switch to cloud - the client's start at once, its session and
|
||||
hunt once the lid reads open, as on the bench where the lid opens
|
||||
behind the controller's start and ahead of the hunt (the hunt
|
||||
reads as a run to the cooling engine while it lasts) - the re-hunt
|
||||
on the lid close, and gfhome finishing the homing after $H. With
|
||||
lid_late the hunt is requested before the lid opens: the race the
|
||||
test must call."""
|
||||
self.grbl = helpers.FakeGrbl().start()
|
||||
os.makedirs(self.sysfs + "pic", exist_ok=True)
|
||||
self._attr("pic/lid_led", "236")
|
||||
@@ -575,22 +579,35 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
hunt_part = hunt_lines(hunt_part)
|
||||
fc = self.fc
|
||||
|
||||
def hunt():
|
||||
self.append(hunt_part, delay=0.0)
|
||||
time.sleep(4.0) # the hunt outlasts the session wait on the bench
|
||||
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/mode" and form.get("controller") == "cloud":
|
||||
fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE)
|
||||
|
||||
def land():
|
||||
self.append(pre, delay=0.0)
|
||||
if lid_late:
|
||||
return
|
||||
t0 = time.time()
|
||||
while fc.state["status"]["switches"]["lid"] and time.time() - t0 < 5:
|
||||
time.sleep(0.02)
|
||||
time.sleep(0.3)
|
||||
self.append(hunt_part, delay=0.0)
|
||||
time.sleep(4.0) # the hunt outlasts the session wait on the bench
|
||||
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
|
||||
hunt()
|
||||
threading.Thread(target=land, daemon=True).start()
|
||||
elif path == "/mode" and form.get("controller") == "grbl":
|
||||
self.grbl.state = "Idle"
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
|
||||
def lid_opened():
|
||||
if lid_late:
|
||||
self.append(hunt_part, delay=0.0)
|
||||
self.lid(False)
|
||||
|
||||
def lid_closed():
|
||||
self.lid(True)
|
||||
self.append(close_part, delay=0.2)
|
||||
@@ -605,7 +622,7 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete "
|
||||
b"(service quiet 8s, 3 motion windows)\n")
|
||||
self.home_hook = homing
|
||||
return {"Open the lid.": lambda: self.lid(False), "Close the lid.": lid_closed}
|
||||
return {"Open the lid.": lid_opened, "Close the lid.": lid_closed}
|
||||
|
||||
def wait_home_command(self):
|
||||
while "$H" not in self.grbl.sent:
|
||||
@@ -633,6 +650,29 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.assertEqual(self.script.asked, [])
|
||||
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
|
||||
|
||||
def test_mode_switch_opens_the_lid_behind_the_controller_and_ahead_of_the_hunt(self):
|
||||
# No controller starts with the enclosure open: the switch is made
|
||||
# lid closed, the lid opens once the controller is up, and the
|
||||
# hunt's request lands after that - the order the test records.
|
||||
hooks = self.mode_switch_setup()
|
||||
threading.Thread(target=self.wait_home_command, daemon=True).start()
|
||||
run = self.run_test(cloud.mode_switch, hooks=hooks, test_id="cloud.mode-switch")
|
||||
self.assertFalse(run.evidence["hunt_before_lid_open"])
|
||||
acts = [(a["channel"], a["state"]) for a in run.evidence["actions"]]
|
||||
self.assertEqual(acts, [("lid", "open"), ("lid", "close")])
|
||||
i_post = next(i for i, l in enumerate(run.lines) if "POST /mode controller=cloud" in l)
|
||||
i_open = next(i for i, l in enumerate(run.lines) if "ACT lid open" in l)
|
||||
self.assertLess(i_post, i_open)
|
||||
|
||||
def test_mode_switch_refuses_to_start_with_the_lid_open(self):
|
||||
hooks = self.mode_switch_setup()
|
||||
self.lid(False)
|
||||
self.assertFails(cloud.mode_switch, "close the lid first", hooks=hooks)
|
||||
|
||||
def test_mode_switch_fails_when_the_hunt_was_requested_before_the_lid_opened(self):
|
||||
hooks = self.mode_switch_setup(lid_late=True)
|
||||
self.assertFails(cloud.mode_switch, "before the lid was open", hooks=hooks)
|
||||
|
||||
def test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self):
|
||||
def refused(hunt_part):
|
||||
i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)
|
||||
@@ -914,22 +954,63 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertTrue(any("PASS: lid open at the button prompt" in l for l in run.lines))
|
||||
|
||||
# -- the cooling verdict: the armed print waits on the warm-up ------------
|
||||
def test_verdict_hold_on_the_bench_excerpt(self):
|
||||
self.in_offline()
|
||||
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
||||
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"})
|
||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
|
||||
# -- the cooling verdict: locked until the run, a hold that never clears --
|
||||
def dark_parts(self):
|
||||
"""(the print prologue through the button prompt, the run and the
|
||||
print's end with no pause in it)."""
|
||||
lines = fixture("pause")
|
||||
pre, rest = cut(lines, "waiting for button")
|
||||
pre = pre + [rest[0]]
|
||||
# the run and the print's end, without the pause in the middle
|
||||
_, run = cut(rest, "machine:_run_loop starting run")
|
||||
run = [l for l in run if "paus" not in l and "resum" not in l]
|
||||
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] INFO machine:_verdict_wait "
|
||||
wait = [stamp % 100000 + "waiting on the cooling engine: WARMUP (WARM-UP: coolant 21.5 C under "
|
||||
"the 22.5 C start gate - heater on, hold)"]
|
||||
release = [stamp % 200000 + "cooling verdict clean after WARMUP; starting the run"]
|
||||
return pre, run
|
||||
|
||||
def test_dark_print_on_the_bench_excerpt(self):
|
||||
self.in_offline()
|
||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
|
||||
pre, run = self.dark_parts()
|
||||
self.offline_print_hooks(pre)
|
||||
|
||||
def press():
|
||||
# the run: the client unlocks the latch right before it, and
|
||||
# relocks when the job ends
|
||||
self._attr("cnc/interlock_circuit", "37")
|
||||
self.append(run[:1], delay=0.05)
|
||||
|
||||
def ended():
|
||||
self._attr("cnc/interlock_circuit", "45")
|
||||
self.append(run[1:], delay=0.0)
|
||||
threading.Timer(2.0, ended).start()
|
||||
hooks = {"press it. The print runs dark": press}
|
||||
run_ = self.run_test(cloud.dark_print, hooks=hooks, test_id="cloud.dark-print")
|
||||
ev = run_.evidence
|
||||
self.assertTrue(ev["latch_locked_at_button"])
|
||||
self.assertFalse(ev["latch_locked_in_run"])
|
||||
self.assertTrue(ev["latch_locked_after"])
|
||||
self.assertIn(":completed", ev["print finished"])
|
||||
self.assertTrue(any("PASS: locked at the button" in l for l in run_.lines))
|
||||
|
||||
def test_dark_print_fails_when_the_latch_is_open_at_the_button(self):
|
||||
self.in_offline()
|
||||
pre, run = self.dark_parts()
|
||||
self._attr("cnc/interlock_circuit", "37") # unlocked before the press
|
||||
self.offline_print_hooks(pre)
|
||||
hooks = {"press it. The print runs dark": lambda: self.append(run, delay=0.05)}
|
||||
self.assertFails(cloud.dark_print, "the latch is unlocked at the button wait", hooks=hooks)
|
||||
|
||||
def test_verdict_refuse_on_the_bench_excerpt(self):
|
||||
self.in_offline()
|
||||
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
||||
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16",
|
||||
"cloud_hold_max_s": ""})
|
||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
|
||||
pre, run = self.dark_parts()
|
||||
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] %s machine:_verdict_wait "
|
||||
wait = [stamp % (100000, "INFO") + "waiting on the cooling engine: WARMUP (WARM-UP: coolant "
|
||||
"21.5 C under the 29.5 C start gate - heater on, hold)"]
|
||||
refuse = [stamp % (200000, "WARNING") + "cooling hold held 60 s - relocking the laser",
|
||||
"2026-08-17T09:44:15.000000+00:00 gfcloud[1927] INFO basemachine:_finish_action print "
|
||||
"[9006]: finished with event \":cancelled\""]
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/settings" and form.get("cool_temp_start") not in (None, "0", "16"):
|
||||
@@ -942,37 +1023,44 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
def press():
|
||||
self.append(wait, delay=0.05)
|
||||
|
||||
def released():
|
||||
def refused():
|
||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True,
|
||||
"armed": False, "phase": "run"})
|
||||
self.append(release + run, delay=0.0)
|
||||
threading.Timer(3.0, released).start()
|
||||
hooks = {"press it. The print then waits": press}
|
||||
run_ = self.run_test(cloud.verdict_hold, hooks=hooks, test_id="cloud.verdict-hold")
|
||||
"armed": False, "phase": "idle"})
|
||||
self.append(refuse, delay=0.0)
|
||||
threading.Timer(2.5, refused).start()
|
||||
hooks = {"press it. Nothing runs": press}
|
||||
run_ = self.run_test(cloud.verdict_refuse, hooks=hooks, test_id="cloud.verdict-refuse")
|
||||
ev = run_.evidence
|
||||
self.assertEqual(ev["gate"], 22.5)
|
||||
self.assertEqual(ev["warmup"]["verdict"], "WARMUP")
|
||||
self.assertTrue(ev["log"]["cooling verdict clean after WARMUP; starting the run"])
|
||||
self.assertIn(":completed", ev["print finished"])
|
||||
posted = [f for p, f in self.fc.posts if p == "/settings"]
|
||||
self.assertEqual(posted[0]["cool_temp_start"], "22.5")
|
||||
self.assertEqual(posted[-1], {"cool_temp_min": "5", "cool_temp_start": "16"})
|
||||
self.assertTrue(any("PASS: the armed print waited" in l for l in run_.lines))
|
||||
self.assertEqual(ev["gate"], 29.5)
|
||||
self.assertTrue(ev["latch_locked_at_button"])
|
||||
self.assertEqual(ev["latch_samples"]["unlocked"], 0)
|
||||
self.assertGreaterEqual(ev["latch_samples"]["n"], 1)
|
||||
self.assertIn(":cancelled", ev["print finished"])
|
||||
posted = [f for p_, f in self.fc.posts if p_ == "/settings"]
|
||||
self.assertEqual(posted[0]["cool_temp_start"], "29.5")
|
||||
self.assertEqual(posted[0]["cloud_hold_max_s"], "60")
|
||||
self.assertEqual(posted[-1], {"cool_temp_min": "5", "cool_temp_start": "16", "cloud_hold_max_s": ""})
|
||||
self.assertTrue(any("PASS: held" in l for l in run_.lines))
|
||||
|
||||
def test_verdict_hold_fails_when_the_print_runs_under_the_hold(self):
|
||||
def test_verdict_refuse_fails_when_the_latch_unlocks_during_the_hold(self):
|
||||
self.in_offline()
|
||||
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
||||
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"})
|
||||
self.fc.state["cool"].update({"verdict": "WARMUP", "hold": True, "fire_ok": False, "armed": True})
|
||||
lines = fixture("pause")
|
||||
pre, rest = cut(lines, "waiting for button")
|
||||
pre = pre + [rest[0]]
|
||||
_, run = cut(rest, "machine:_run_loop starting run")
|
||||
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] INFO machine:_verdict_wait "
|
||||
wait = [stamp % 100000 + "waiting on the cooling engine: WARMUP (no reason given)"]
|
||||
pre, run = self.dark_parts()
|
||||
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] %s machine:_verdict_wait "
|
||||
wait = [stamp % (100000, "INFO") + "waiting on the cooling engine: WARMUP (no reason given)"]
|
||||
refuse = [stamp % (200000, "WARNING") + "cooling hold held 60 s - relocking the laser",
|
||||
"2026-08-17T09:44:15.000000+00:00 gfcloud[1927] INFO basemachine:_finish_action print "
|
||||
"[9006]: finished with event \":cancelled\""]
|
||||
self.offline_print_hooks(pre)
|
||||
hooks = {"press it. The print then waits": lambda: self.append(wait + run[:1], delay=0.05)}
|
||||
self.assertFails(cloud.verdict_hold, "the run started under the warm-up hold", hooks=hooks)
|
||||
|
||||
def press():
|
||||
self.append(wait, delay=0.05)
|
||||
self._attr("cnc/interlock_circuit", "37") # unlocked under the hold
|
||||
threading.Timer(2.5, lambda: self.append(refuse, delay=0.0)).start()
|
||||
hooks = {"press it. Nothing runs": press}
|
||||
self.assertFails(cloud.verdict_refuse, "the latch unlocked during the hold", hooks=hooks)
|
||||
|
||||
# -- a paused print canceled by the lid, a running one by the app --------
|
||||
def cancel_parts(self):
|
||||
|
||||
+712
-677
File diff suppressed because it is too large
Load Diff
@@ -84,6 +84,32 @@ class OrderTests(unittest.TestCase):
|
||||
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):
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""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."""
|
||||
start, the run polled to its end with no question asked, the result
|
||||
taken, the record read at version 1, and the settings read as before."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
@@ -12,8 +12,8 @@ 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"]
|
||||
IDS = ["setup.check-switches", "setup.check-sensors", "setup.check-airflow",
|
||||
"setup.check-cameras", "setup.check-motion", "setup.check-flow-verify"]
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
@@ -25,11 +25,11 @@ class RegistrationTests(unittest.TestCase):
|
||||
# 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"):
|
||||
for tid in ("setup.check-switches", "setup.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"):
|
||||
for tid in ("setup.check-sensors", "setup.check-airflow", "setup.check-motion",
|
||||
"setup.check-flow-verify"):
|
||||
self.assertEqual(self.reg[tid].kind, "auto", tid)
|
||||
|
||||
def test_no_check_asks_the_operator_to_prepare_anything(self):
|
||||
@@ -37,7 +37,7 @@ class RegistrationTests(unittest.TestCase):
|
||||
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"):
|
||||
for tid in ("setup.check-airflow", "setup.check-motion", "setup.check-flow-verify"):
|
||||
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||
|
||||
def test_every_check_covers_the_runner_and_the_page(self):
|
||||
@@ -62,9 +62,17 @@ class SensorsCheckTests(unittest.TestCase):
|
||||
"shots": {"lid": False, "head": False}}
|
||||
self.versions = {}
|
||||
self.answers = []
|
||||
self.polls = 0
|
||||
|
||||
def on_get(path, q):
|
||||
if path == "/wiz/dark":
|
||||
# One poll sees the sampling; the next sees the result.
|
||||
if self.dark["running"]:
|
||||
self.polls += 1
|
||||
if self.polls >= 2:
|
||||
self.dark.update({"running": False, "prompt": None, "progress": 100,
|
||||
"result": dict(self.RESULT)})
|
||||
self.versions["sensors"] = 1
|
||||
return 200, dict(self.dark)
|
||||
if path == "/wiz":
|
||||
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
|
||||
@@ -74,19 +82,11 @@ class SensorsCheckTests(unittest.TestCase):
|
||||
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"]}})
|
||||
"prompt": None})
|
||||
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}
|
||||
return 409, {"error": "no such prompt is open"}
|
||||
if path == "/settings":
|
||||
for k, v in form.items():
|
||||
if v == "":
|
||||
@@ -101,15 +101,35 @@ class SensorsCheckTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
self.fake.stop()
|
||||
|
||||
def test_start_prompt_answer_result_and_restore(self):
|
||||
t = catalog.load_suite()["commission.check-sensors"]
|
||||
def test_start_no_question_result_and_settings_untouched(self):
|
||||
t = catalog.load_suite()["setup.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(self.answers, [])
|
||||
self.assertEqual(run.evidence["result"]["laser_pgood"], 1)
|
||||
self.assertEqual(run.evidence["settings_before"], {"cool_temp_offset_c": "0.5"})
|
||||
self.assertEqual(run.evidence["settings_before"].get("cool_temp_offset_c"), "0.5")
|
||||
self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5")
|
||||
self.assertNotIn("/settings", [p for p, _ in self.fake.posts])
|
||||
|
||||
def test_a_question_fails_the_check(self):
|
||||
t = catalog.load_suite()["setup.check-sensors"]
|
||||
run = Run("test", t.id, t.title)
|
||||
real_get = self.fake.on_get
|
||||
|
||||
def on_get(path, q):
|
||||
if path == "/wiz/dark" and self.dark["running"]:
|
||||
d = dict(self.dark)
|
||||
d["prompt"] = {"seq": 3, "id": "room-temp", "kind": "number", "text": "?",
|
||||
"options": ["Set", "Skip"]}
|
||||
return 200, d
|
||||
return real_get(path, q)
|
||||
self.fake.on_get = on_get
|
||||
with self.assertRaises(Exception) as cm:
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertIn("asked a question", str(cm.exception))
|
||||
self.assertEqual(self.answers, [])
|
||||
self.assertIn(("/wiz/sensors/abort", {}), [(p, f) for p, f in self.fake.posts])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -14,10 +14,47 @@ 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
|
||||
from forgetest.suite import setup_sheet # noqa: E402
|
||||
|
||||
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
|
||||
# The focus card's program is read again after it ran: the served ladder
|
||||
# must then lie in the window the card wrote.
|
||||
PREVIEWS = ["sheet.frame", "laser.focus", "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}
|
||||
SPM = 2.922 # the lens screw, half-steps per mm ($102)
|
||||
EDGE_DEFAULT = 3.35
|
||||
|
||||
|
||||
def lens_block(s):
|
||||
"""The /status lens block as the daemon builds it from the settings: a
|
||||
stop count from 1 to 40 stands, anything else is the fallback."""
|
||||
def count(key, fallback):
|
||||
try:
|
||||
n = float(s.get(key, ""))
|
||||
except ValueError:
|
||||
return fallback, False
|
||||
return (int(n), True) if 1 <= n <= 40 else (fallback, False)
|
||||
try:
|
||||
edge = float(s.get("lens_hall_edge_z_mm", ""))
|
||||
except ValueError:
|
||||
edge = EDGE_DEFAULT
|
||||
below, a = count("lens_stop_below_steps", 10)
|
||||
above, b = count("lens_stop_above_steps", 12)
|
||||
return {"edge_z": round(edge, 2), "below": below, "above": above, "stops_found": a and b,
|
||||
"reach_min": round(edge - below / SPM, 2), "reach_max": round(edge + above / SPM, 2)}
|
||||
|
||||
|
||||
def ladder_z(edge, below, above):
|
||||
"""The focus ladder the daemon builds: twelve whole half-steps from
|
||||
the top of the window to the bottom."""
|
||||
return [round(edge + round(above - (above + below) * i / 11) / SPM, 2) for i in range(12)]
|
||||
|
||||
|
||||
# The window the focus card finds and writes before its controller starts.
|
||||
STOPS = {"lens_stop_below_steps": "14", "lens_stop_above_steps": "20"}
|
||||
FOCUS_WINDOW = {"below": 14, "above": 20, "reach_min": round(EDGE_DEFAULT - 14 / SPM, 2),
|
||||
"reach_max": round(EDGE_DEFAULT + 20 / SPM, 2)}
|
||||
|
||||
# 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
|
||||
@@ -37,7 +74,9 @@ SCRIPT = {
|
||||
"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}),
|
||||
"contact_above": 22, "why": ""},
|
||||
"window": dict(FOCUS_WINDOW), "ladder_z": ladder_z(EDGE_DEFAULT, 14, 20),
|
||||
"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}),
|
||||
@@ -50,10 +89,11 @@ SCRIPT = {
|
||||
{"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.
|
||||
# What a card writes when it starts (the focus card's window, before its
|
||||
# controller starts) and when it ends.
|
||||
START_WRITES = {"laser.focus": dict(STOPS)}
|
||||
WRITES = {
|
||||
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14",
|
||||
"lens_stop_above_steps": "20"},
|
||||
"laser.focus": {"lens_hall_edge_z_mm": "6.96"},
|
||||
"laser.floor": {"laser_floor_density": "10"},
|
||||
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
|
||||
"laser.corner": {"laser_corner_gamma": "1.5"},
|
||||
@@ -67,7 +107,7 @@ FOUND = {"lens_hall_edge_z_mm": "3.35", "lens_stop_below_steps": "10", "lens_sto
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.t = catalog.load_suite()["commission.sheet"]
|
||||
self.t = catalog.load_suite()["setup.sheet"]
|
||||
|
||||
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
|
||||
t = self.t
|
||||
@@ -78,12 +118,12 @@ class RegistrationTests(unittest.TestCase):
|
||||
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"):
|
||||
for want in ("setup.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"])
|
||||
ids = [i for i in catalog.load_suite() if i.startswith("setup.sheet")]
|
||||
self.assertEqual(ids, ["setup.sheet"])
|
||||
|
||||
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
|
||||
covers = set(self.t.covers)
|
||||
@@ -97,7 +137,7 @@ class RegistrationTests(unittest.TestCase):
|
||||
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)
|
||||
self.assertEqual(len(setup_sheet.CARDS), 6)
|
||||
|
||||
|
||||
class ScriptedDaemon:
|
||||
@@ -115,9 +155,11 @@ class ScriptedDaemon:
|
||||
self.versions = {}
|
||||
self.answers = {} # wid -> [values]
|
||||
self.order = [] # the wizards as started
|
||||
self.settings_at_start = {} # wid -> the settings as the wizard found them
|
||||
self.previews = []
|
||||
self.queue = []
|
||||
self.seq = 0
|
||||
self.stray_z = None # a Z the served program commands beyond the reach, when set
|
||||
fake.on_get = self.on_get
|
||||
fake.on_post = self.on_post
|
||||
|
||||
@@ -130,6 +172,10 @@ class ScriptedDaemon:
|
||||
return True
|
||||
|
||||
def on_get(self, path, q):
|
||||
s = self.fake.state["settings"]
|
||||
if path == "/status":
|
||||
# the lens block follows the settings, as the daemon's does
|
||||
return 200, dict(self.fake.state["status"], lens=lens_block(s))
|
||||
if path == "/wiz/dark":
|
||||
return 200, dict(self.dark)
|
||||
if path == "/wiz":
|
||||
@@ -140,8 +186,18 @@ class ScriptedDaemon:
|
||||
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"
|
||||
# the frame and the text are M4 at the mark dose, as the daemon
|
||||
# serves them; the head's Z is the edge, the tail goes back to
|
||||
# it, and the focus card's ladder spans the window the settings
|
||||
# hold now
|
||||
lens = lens_block(s)
|
||||
lines = ["; mock", "G0 Z%.2f" % lens["edge_z"], "M4 S400", "G0 X60 Y42", "G1 X120 Y42 F3000"]
|
||||
if q.get("card") == "laser.focus":
|
||||
lines += ["G0 Z%.2f" % z for z in ladder_z(lens["edge_z"], lens["below"], lens["above"])]
|
||||
if self.stray_z is not None:
|
||||
lines.append("G0 Z%.2f" % self.stray_z)
|
||||
lines += ["M5", "G0 Z%.2f" % lens["edge_z"], "G0 X0 Y0", "M2"]
|
||||
return 200, ("\n".join(lines) + "\n").encode(), "text/plain"
|
||||
return None
|
||||
|
||||
def on_post(self, path, form):
|
||||
@@ -151,6 +207,8 @@ class ScriptedDaemon:
|
||||
if wid not in SCRIPT:
|
||||
return 404, {"error": "no such wizard"}
|
||||
self.order.append(wid)
|
||||
self.settings_at_start[wid] = dict(s)
|
||||
s.update(START_WRITES.get(wid, {}))
|
||||
self.queue = list(SCRIPT[wid][0])
|
||||
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
|
||||
self.open_next()
|
||||
@@ -188,7 +246,7 @@ class SheetRunTests(unittest.TestCase):
|
||||
self.fake.stop()
|
||||
|
||||
def test_the_whole_sheet_on_one_piece(self):
|
||||
t = catalog.load_suite()["commission.sheet"]
|
||||
t = catalog.load_suite()["setup.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))
|
||||
@@ -199,7 +257,7 @@ class SheetRunTests(unittest.TestCase):
|
||||
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)
|
||||
self.assertEqual(d.previews, PREVIEWS)
|
||||
cards = run.evidence["cards"]
|
||||
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
|
||||
for wid in LIVE:
|
||||
@@ -208,6 +266,24 @@ class SheetRunTests(unittest.TestCase):
|
||||
self.assertNotIn("emission", run.evidence)
|
||||
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
|
||||
self.assertEqual(run.evidence["settings_before"], FOUND)
|
||||
# A fresh machine's run: the lens settings were gone before the
|
||||
# frame, the frame's program ran in the fallback window, the focus
|
||||
# card's window was in the settings when the floor card started,
|
||||
# and the window check filed what it compared.
|
||||
for k in setup_sheet.LENS_SETTINGS:
|
||||
self.assertNotIn(k, d.settings_at_start["sheet.frame"], k)
|
||||
self.assertEqual(run.evidence["lens_fresh"]["stops_found"], False)
|
||||
self.assertEqual(run.evidence["program_z"]["sheet.frame"]["reach"], [-0.07, 7.46])
|
||||
floor_start = d.settings_at_start["laser.floor"]
|
||||
self.assertEqual({k: floor_start[k] for k in setup_sheet.LENS_SETTINGS},
|
||||
{"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14", "lens_stop_above_steps": "20"})
|
||||
fw = run.evidence["focus_window"]
|
||||
self.assertEqual(fw["window"], FOCUS_WINDOW)
|
||||
self.assertEqual(fw["settings"]["lens_stop_below_steps"], "14")
|
||||
self.assertEqual(len(fw["ladder_z"]), 12)
|
||||
self.assertEqual(fw["program_z_before"]["reach"], [-0.07, 7.46]) # the fallback ladder
|
||||
self.assertEqual(fw["program_z_after"]["reach"], [2.17, 13.8]) # the written window at the new edge
|
||||
self.assertEqual(len(fw["program_z_after"]["z"]), 14) # head, twelve lines, tail
|
||||
for k, v in FOUND.items():
|
||||
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
|
||||
|
||||
@@ -218,7 +294,7 @@ class SheetRunTests(unittest.TestCase):
|
||||
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"]
|
||||
t = catalog.load_suite()["setup.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True
|
||||
t.fn(Context(run, None, t))
|
||||
@@ -234,7 +310,7 @@ class SheetRunTests(unittest.TestCase):
|
||||
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"]
|
||||
t = catalog.load_suite()["setup.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True
|
||||
with self.assertRaises(Failed) as cm:
|
||||
@@ -250,7 +326,7 @@ class SheetRunTests(unittest.TestCase):
|
||||
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"]
|
||||
t = catalog.load_suite()["setup.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True
|
||||
with self.assertRaises(Failed):
|
||||
@@ -261,6 +337,44 @@ class SheetRunTests(unittest.TestCase):
|
||||
finally:
|
||||
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
|
||||
|
||||
def test_a_focus_window_the_settings_do_not_hold_fails_the_run(self):
|
||||
# The defect the bench found: the focus card burned its ladder over
|
||||
# the stops it found while the controller's Z limit stood on the
|
||||
# settings, which did not hold them. A daemon whose focus result
|
||||
# names a window the settings do not hold fails the run before the
|
||||
# floor card, and the settings still go back.
|
||||
from forgetest.runner import Failed
|
||||
saved = dict(START_WRITES["laser.focus"])
|
||||
START_WRITES["laser.focus"] = {}
|
||||
try:
|
||||
t = catalog.load_suite()["setup.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("the settings hold", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus"])
|
||||
for k, v in FOUND.items():
|
||||
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||
finally:
|
||||
START_WRITES["laser.focus"] = saved
|
||||
|
||||
def test_a_program_z_beyond_the_reach_fails_before_the_burn(self):
|
||||
# A served program that commands a Z the lens does not reach is
|
||||
# refused before the card starts: nothing burns.
|
||||
from forgetest.runner import Failed
|
||||
self.daemon.stray_z = 10.6
|
||||
t = catalog.load_suite()["setup.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("outside the lens window", str(cm.exception))
|
||||
self.assertIn("10.6", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, ["sheet.place"])
|
||||
for k, v in FOUND.items():
|
||||
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,28 +1,26 @@
|
||||
"""The commission.* suite on the host: the registration (ids, kinds, the
|
||||
"""The setup.* suite on the host: the registration (ids, kinds, the
|
||||
takeover tests, the operator tests' hands), the record builders, the
|
||||
mDNS packet code, the cookie parsing, the LED cue, the settle rule for a
|
||||
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 socket
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest import baseline, catalog
|
||||
from forgetest.runner import Context, Run
|
||||
from forgetest.suite import commission
|
||||
from forgetest.suite import setup
|
||||
|
||||
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.mdns-announce", "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")
|
||||
IDS = ("setup.gate-blocks-controllers", "setup.override-until-reboot",
|
||||
"setup.advisories-rehash", "setup.account-login", "setup.https-only-writes",
|
||||
"setup.ssh-until-reboot", "setup.cloud-disabled-surface",
|
||||
"setup.factory-return", "setup.machine-name", "setup.first-run-flow",
|
||||
"setup.first-run-page", "setup.what-changed", "setup.record-export",
|
||||
"setup.mirror")
|
||||
OPERATOR = ("setup.first-run-flow", "setup.first-run-page")
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
@@ -37,49 +35,49 @@ class RegistrationTests(unittest.TestCase):
|
||||
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
|
||||
self.assertNotIn("setup.root-ssh-refused", self.reg) # folded into ssh-until-reboot
|
||||
self.assertNotIn("setup.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"):
|
||||
for tid in ("setup.gate-blocks-controllers", "setup.advisories-rehash",
|
||||
"setup.first-run-flow", "setup.first-run-page",
|
||||
"setup.what-changed", "setup.mirror"):
|
||||
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||
self.assertEqual(self.reg["commission.record-export"].hardware, "api")
|
||||
self.assertEqual(self.reg["setup.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)
|
||||
changed = set(self.reg["setup.what-changed"].covers)
|
||||
export = set(self.reg["setup.record-export"].covers)
|
||||
mirror = set(self.reg["setup.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)
|
||||
self.assertIn("setup.check-sensors", self.reg["setup.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"))
|
||||
self.assertEqual(setup.flag_of(w, "required", "laser.floor"), "the tube was replaced")
|
||||
self.assertEqual(setup.flag_of(w, "recommended", "laser.focus"), "the tray was replaced")
|
||||
self.assertIsNone(setup.flag_of(w, "required", "airflow")) # a plain id is the table, not a flag
|
||||
self.assertIsNone(setup.flag_of(w, "required", "laser.focus"))
|
||||
self.assertIsNone(setup.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"]
|
||||
flow = self.reg["setup.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"]
|
||||
page = self.reg["setup.first-run-page"]
|
||||
self.assertTrue(page.hands)
|
||||
self.assertFalse(page.fixture_runnable(("button", "lid", "interlock")))
|
||||
self.assertIn("commission.first-run-flow", page.requires)
|
||||
self.assertIn("setup.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.*"),
|
||||
flow = set(self.reg["setup.first-run-flow"].covers)
|
||||
page = set(self.reg["setup.first-run-page"].covers)
|
||||
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
|
||||
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
|
||||
self.assertIn(want, flow, want)
|
||||
self.assertNotIn(want, page, want)
|
||||
@@ -87,33 +85,33 @@ class RegistrationTests(unittest.TestCase):
|
||||
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
|
||||
# With the bench actuator up, three setup 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.")
|
||||
attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("setup.")
|
||||
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"])
|
||||
self.assertEqual(attended, ["setup.cloud-header-capture", "setup.first-run-page",
|
||||
"setup.sheet"])
|
||||
|
||||
def test_the_factory_return_never_runs_the_return(self):
|
||||
import inspect
|
||||
t = self.reg["commission.factory-return"]
|
||||
t = self.reg["setup.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_mdns_covers_nothing_by_design(self):
|
||||
self.assertEqual(self.reg["commission.mdns-announce"].covers, ())
|
||||
def test_the_machine_name_covers_nothing_by_design(self):
|
||||
self.assertEqual(self.reg["setup.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"]
|
||||
t = self.reg["setup.account-login"]
|
||||
self.assertIsNone(getattr(t, "precheck", None))
|
||||
self.assertEqual(t.hardware, "takeover")
|
||||
with open(commission.__file__) as f:
|
||||
with open(setup.__file__) as f:
|
||||
src = f.read()
|
||||
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
|
||||
self.assertNotIn(k, src)
|
||||
@@ -126,8 +124,8 @@ class RecordTests(unittest.TestCase):
|
||||
"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)
|
||||
def test_complete_record_counts_as_set_up(self):
|
||||
rec = setup.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")
|
||||
@@ -141,16 +139,16 @@ class RecordTests(unittest.TestCase):
|
||||
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)
|
||||
rec = setup.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 = setup.complete_record(self.STATUS)
|
||||
rec["flags"] = {"machine": {"level": "required", "reason": "x"}}
|
||||
out = commission.record_without_wizards(rec)
|
||||
out = setup.record_without_wizards(rec)
|
||||
self.assertEqual(out["wizards"], {})
|
||||
self.assertEqual(out["flags"], {})
|
||||
self.assertEqual(out["advisories"], rec["advisories"])
|
||||
@@ -160,14 +158,14 @@ class RecordTests(unittest.TestCase):
|
||||
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")
|
||||
p = os.path.join(tmp, "sub", "setup.json")
|
||||
setup.write_file(p, b"{}\n")
|
||||
self.assertEqual(setup.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
|
||||
setup.write_file(p, None)
|
||||
self.assertIsNone(setup.read_file(p))
|
||||
setup.write_file(p, None) # a second remove is silent
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
@@ -175,72 +173,82 @@ class RecordTests(unittest.TestCase):
|
||||
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")
|
||||
self.assertEqual(setup.record_path(), "/tmp/ffdata/setup.json")
|
||||
self.assertEqual(setup.users_path(), "/tmp/ffdata/users")
|
||||
self.assertEqual(setup.override_path(), "/tmp/ffrun/setup-override")
|
||||
self.assertEqual(setup.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
|
||||
finally:
|
||||
os.environ.pop("FORGECTRL_DATA_DIR", None)
|
||||
os.environ.pop("GF_RUN_DIR", None)
|
||||
|
||||
|
||||
class MdnsTests(unittest.TestCase):
|
||||
def test_query_is_a_unicast_response_question(self):
|
||||
q = commission.mdns_query("forgefirm.local", qid=0x1234)
|
||||
qid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", q[:12])
|
||||
self.assertEqual((qid, flags, qd, an, ns, ar), (0x1234, 0, 1, 0, 0, 0))
|
||||
self.assertEqual(q[12:], b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 0x8001))
|
||||
class MachineNameTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||
|
||||
def _response(self, qid, name_bytes, addr, flags=0x8400, extra=b""):
|
||||
q = b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 1)
|
||||
rr = name_bytes + struct.pack(">HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(addr)
|
||||
return struct.pack(">HHHHHH", qid, flags, 1, 1, 0, 0) + q + rr + extra
|
||||
def _net(self, **devs):
|
||||
"""A /sys/class/net tree; setup.mac_suffix reads it through
|
||||
setup.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 = setup.read_file
|
||||
|
||||
def test_answers_follow_a_compression_pointer(self):
|
||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
||||
self.assertEqual(commission.mdns_answers(pkt, 7), [("forgefirm.local", "192.168.1.9")])
|
||||
def read(path):
|
||||
head = "/sys/class/net/"
|
||||
if path.startswith(head):
|
||||
return real(os.path.join(self.root, path[len(head):]))
|
||||
return real(path)
|
||||
|
||||
def test_answers_with_the_name_spelled_out(self):
|
||||
pkt = self._response(0, b"\x09forgefirm\x05local\x00", "10.0.0.5")
|
||||
self.assertEqual(commission.mdns_answers(pkt), [("forgefirm.local", "10.0.0.5")])
|
||||
setup.read_file = read
|
||||
self.addCleanup(setattr, setup, "read_file", real)
|
||||
|
||||
def test_wrong_id_or_a_query_yields_nothing(self):
|
||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
||||
self.assertEqual(commission.mdns_answers(pkt, 8), [])
|
||||
self.assertEqual(commission.mdns_answers(self._response(7, b"\xc0\x0c", "1.2.3.4", flags=0), 7), [])
|
||||
self.assertEqual(commission.mdns_answers(b"\x00" * 5), [])
|
||||
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(setup.mac_suffix(), "b00a")
|
||||
|
||||
def test_a_truncated_packet_yields_nothing(self):
|
||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
||||
self.assertEqual(commission.mdns_answers(pkt[:20], 7), [])
|
||||
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(setup.mac_suffix(), "4455")
|
||||
|
||||
def test_an_unread_address_is_no_address(self):
|
||||
self._net(wlan0="00:00:00:00:00:00")
|
||||
self.assertEqual(setup.mac_suffix(), "")
|
||||
|
||||
def test_no_interface_is_no_address(self):
|
||||
self._net()
|
||||
self.assertEqual(setup.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(""))
|
||||
self.assertEqual(setup.session_from_cookie(value), sid)
|
||||
self.assertEqual(setup.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
|
||||
self.assertIsNone(setup.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
|
||||
self.assertIsNone(setup.session_from_cookie(""))
|
||||
|
||||
def test_the_teal_breathe_cue(self):
|
||||
self.assertTrue(commission.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
|
||||
self.assertTrue(setup.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],
|
||||
self.assertFalse(setup.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],
|
||||
self.assertFalse(setup.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({}))
|
||||
self.assertFalse(setup.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
|
||||
self.assertFalse(setup.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")
|
||||
self.assertEqual(setup.decode(b'{"a": 1}'), {"a": 1})
|
||||
self.assertEqual(setup.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:
|
||||
"""A supervisor that reports the setup 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."""
|
||||
|
||||
@@ -253,7 +261,7 @@ class GatedSettleTests(unittest.TestCase):
|
||||
|
||||
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"}
|
||||
"gated": True, "why": "setup required: machine"}
|
||||
lines = []
|
||||
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
|
||||
self.assertEqual(body["controller"], "gated")
|
||||
@@ -276,7 +284,7 @@ class FirstRunSeedTests(unittest.TestCase):
|
||||
"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)
|
||||
rec = setup.first_run_record(self.STATUS, base)
|
||||
self.assertNotIn("completed", rec)
|
||||
self.assertNotIn("acceptance", rec)
|
||||
self.assertNotIn("account", rec)
|
||||
@@ -288,7 +296,7 @@ class FirstRunSeedTests(unittest.TestCase):
|
||||
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)
|
||||
rec = setup.first_run_record(self.STATUS, None)
|
||||
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
|
||||
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
|
||||
|
||||
@@ -304,7 +312,7 @@ class SshdPolicyTests(unittest.TestCase):
|
||||
real = hw.run
|
||||
hw.run = fake_run
|
||||
try:
|
||||
self.assertEqual(commission.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
|
||||
self.assertEqual(setup.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
|
||||
"permitemptypasswords": "no"})
|
||||
finally:
|
||||
hw.run = real
|
||||
@@ -315,7 +323,7 @@ class SshdPolicyTests(unittest.TestCase):
|
||||
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"})
|
||||
self.assertEqual(setup.sshd_policy(), {"error": "sshd: no hostkeys available"})
|
||||
finally:
|
||||
hw.run = real
|
||||
|
||||
@@ -367,7 +375,7 @@ class CloudDisabledSurfaceTests(unittest.TestCase):
|
||||
self.fake.stop()
|
||||
|
||||
def run_test(self):
|
||||
t = catalog.load_suite()["commission.cloud-disabled-surface"]
|
||||
t = catalog.load_suite()["setup.cloud-disabled-surface"]
|
||||
run = Run("test", t.id, t.title)
|
||||
t.fn(Context(run, None, t))
|
||||
return run
|
||||
@@ -94,13 +94,28 @@ class TreeManifestTests(unittest.TestCase):
|
||||
self.assertNotEqual(self.mod.layer_content(self.layer), base)
|
||||
|
||||
def test_layer_hash_matches_the_bbclass_rule(self):
|
||||
# The same skip list as FORGEFIRM_MANIFEST_PIN_SUFFIX + *.md in
|
||||
# forgefirm-image-manifest.bbclass.
|
||||
# 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.assertEqual(self.mod.LAYER_SKIP_SUFFIXES, (".md", "-pin.inc"))
|
||||
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__":
|
||||
|
||||
@@ -11,4 +11,4 @@ overrides:
|
||||
meta-freescale-distro:
|
||||
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
||||
meta-openglow:
|
||||
commit: b7ad6d9377fbb4215dc4d898837906b06b47e5e9
|
||||
commit: dd8f74c2d58e998913e77e28450e8dca5e164a48
|
||||
|
||||
@@ -44,6 +44,13 @@
|
||||
FORGEFIRM_MANIFEST_DIR ?= "${sysconfdir}/forgefirm-manifest.d"
|
||||
FORGEFIRM_MANIFEST_CONTENT_LAYERS ?= "meta-forgefirm meta-glowforge-bsp meta-openglow-core"
|
||||
FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"
|
||||
# The release version file. Like a pin file it is metadata, not platform
|
||||
# content: it carries FORGEFIRM_RELEASE and nothing else, the version
|
||||
# string is already outside the identity hash (below), and the release
|
||||
# gate proves the number against the rootfs stamp, the .fw meta-version
|
||||
# and the tag. Hashing it would make every version bump a platform
|
||||
# change and invalidate the campaign that authorizes the release.
|
||||
FORGEFIRM_MANIFEST_VERSION_SUFFIX ?= "forgefirm-release.inc"
|
||||
|
||||
do_rootfs[depends] += "virtual/kernel:do_deploy kernel-module-glowforge:do_deploy"
|
||||
|
||||
@@ -88,7 +95,8 @@ def forgefirm_manifest_layers(d):
|
||||
and dirty flag of every layer checkout (informational)."""
|
||||
import os, subprocess
|
||||
content_layers = (d.getVar('FORGEFIRM_MANIFEST_CONTENT_LAYERS') or '').split()
|
||||
skip = ('.md',) + tuple((d.getVar('FORGEFIRM_MANIFEST_PIN_SUFFIX') or '').split())
|
||||
skip = (('.md',) + tuple((d.getVar('FORGEFIRM_MANIFEST_PIN_SUFFIX') or '').split())
|
||||
+ tuple((d.getVar('FORGEFIRM_MANIFEST_VERSION_SUFFIX') or '').split()))
|
||||
identity, build = {}, {}
|
||||
for layer in (d.getVar('BBLAYERS') or '').split():
|
||||
name = os.path.basename(layer.rstrip('/'))
|
||||
|
||||
@@ -8,19 +8,19 @@ DISTRO_FEATURES:remove = " \
|
||||
3g alsa avahi bluetooth bluez5 ext2 irda nfc nfs pci pcmcia \
|
||||
pulseaudio vulkan wayland x11 zeroconf "
|
||||
|
||||
# mDNS: the image installs avahi-daemon by name (forgefirm-image.bb), so
|
||||
# the control panel answers at forgefirm.local. The avahi and zeroconf
|
||||
# features stay removed above: zeroconf would install
|
||||
# packagegroup-base-zeroconf (the daemon plus libnss-mdns), and avahi
|
||||
# would switch other recipes' avahi options on. The build is trimmed to
|
||||
# the daemon: no D-Bus (so no libavahi-client, no avahi-utils, no bus
|
||||
# activation; the daemon reads /etc/avahi/services itself), no GTK or Qt
|
||||
# front ends, no libdns_sd compatibility library, no libevent binding.
|
||||
# Python bindings are off in the recipe itself.
|
||||
PACKAGECONFIG:pn-avahi = ""
|
||||
# libnss-mdns is what avahi-daemon recommends: an NSS module so the
|
||||
# machine itself resolves .local names. Nothing on the machine does.
|
||||
BAD_RECOMMENDATIONS += "libnss-mdns"
|
||||
# The name the image ships in /etc/hostname. forgefirm-hostname replaces
|
||||
# it at S38 in rcS with forgefirm-<xxxx>, from the machine's MAC address;
|
||||
# this is what the few seconds before that show, and the fallback on a
|
||||
# machine whose MAC address cannot be read.
|
||||
hostname:pn-base-files = "forgefirm"
|
||||
|
||||
# No mDNS. The panel is reached at the machine's address, which every
|
||||
# network resolves; the machine's own name (forgefirm-<xxxx>, from
|
||||
# forgefirm-hostname) goes out in the DHCP request, so a network with
|
||||
# dynamic DNS publishes it as well. The avahi and zeroconf features stay
|
||||
# removed above: zeroconf would install packagegroup-base-zeroconf (the
|
||||
# daemon plus libnss-mdns), and avahi would switch other recipes' avahi
|
||||
# options on.
|
||||
|
||||
# TLS: forgectrl serves HTTPS on 443 with a self-signed certificate
|
||||
# through libmicrohttpd and ulfius, and links GnuTLS itself. GnuTLS is
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# ForgeFIRM avahi-daemon configuration (avahi-daemon.conf(5)).
|
||||
# The machine answers forgefirm.local on the WiFi link (and on eth0 when
|
||||
# the machine has one), over IPv4 and IPv6. It publishes its addresses
|
||||
# and the services in /etc/avahi/services, nothing else: no workstation
|
||||
# record, no host information, no wide-area lookups, no reflector.
|
||||
|
||||
[server]
|
||||
host-name=forgefirm
|
||||
use-ipv4=yes
|
||||
use-ipv6=yes
|
||||
allow-interfaces=wlan0,eth0
|
||||
ratelimit-interval-usec=1000000
|
||||
ratelimit-burst=1000
|
||||
|
||||
[wide-area]
|
||||
enable-wide-area=no
|
||||
|
||||
[publish]
|
||||
publish-hinfo=no
|
||||
publish-workstation=no
|
||||
publish-addresses=yes
|
||||
publish-domain=yes
|
||||
|
||||
[reflector]
|
||||
enable-reflector=no
|
||||
|
||||
[rlimits]
|
||||
rlimit-core=0
|
||||
rlimit-data=8388608
|
||||
rlimit-fsize=0
|
||||
rlimit-nofile=768
|
||||
rlimit-stack=8388608
|
||||
rlimit-nproc=3
|
||||
@@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" standalone='no'?>
|
||||
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
|
||||
|
||||
<!-- ForgeFIRM control panel: HTTPS on 443 and HTTP on 80, served by
|
||||
forgectrl. %h is the host name (avahi.service(5)). -->
|
||||
|
||||
<service-group>
|
||||
|
||||
<name replace-wildcards="yes">ForgeFIRM on %h</name>
|
||||
|
||||
<service>
|
||||
<type>_https._tcp</type>
|
||||
<port>443</port>
|
||||
</service>
|
||||
|
||||
<service>
|
||||
<type>_http._tcp</type>
|
||||
<port>80</port>
|
||||
</service>
|
||||
|
||||
</service-group>
|
||||
@@ -1,17 +0,0 @@
|
||||
FILESEXTRAPATHS:prepend := "${THISDIR}/${BPN}:"
|
||||
|
||||
# mDNS for the control panel: the machine answers forgefirm.local and
|
||||
# advertises the panel on HTTPS 443 and HTTP 80. Only avahi-daemon is
|
||||
# installed (forgefirm-image.bb); the build options that keep it to the
|
||||
# daemon are in conf/distro/forgefirm.conf. The daemon reads the service
|
||||
# file itself: no D-Bus is involved.
|
||||
SRC_URI += " \
|
||||
file://avahi-daemon.conf \
|
||||
file://forgefirm.service \
|
||||
"
|
||||
|
||||
do_install:append() {
|
||||
install -m 0644 ${WORKDIR}/avahi-daemon.conf ${D}${sysconfdir}/avahi/avahi-daemon.conf
|
||||
install -d ${D}${sysconfdir}/avahi/services
|
||||
install -m 0644 ${WORKDIR}/forgefirm.service ${D}${sysconfdir}/avahi/services/forgefirm.service
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||
# layer content hash because the component entry already identifies the
|
||||
# pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "468ee215dd1148b92c979decacecc41aa6a75ccf"
|
||||
PV = "0.1.12"
|
||||
SRCREV = "da3eddcc0f4324dc6ec8a0042ab7db0f1b16fd4f"
|
||||
PV = "0.1.25"
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
# because the component entry already identifies the pinned source
|
||||
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
||||
# meta-glowforge-bsp pins the same repository; move both together.
|
||||
SRCREV = "56f134a1e6393a2096c16e8226027fbbd38d79c7"
|
||||
SRCREV = "5ca279a1f6008ddf6e19a17c6d6375972d27ed7c"
|
||||
|
||||
# Bump PV with every SRCREV move: the hash-derived package version is not
|
||||
# monotonic on its own and buildhistory QA fails the build when it sorts
|
||||
# backwards.
|
||||
PV = "0.1.28+git"
|
||||
PV = "0.1.29+git"
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Rewrites the address block of /etc/issue, the serial-console login
|
||||
# banner: the control panel by mDNS name, then one https:// URL per
|
||||
# global address of wlan0 (and eth0 when the machine has one). Every
|
||||
# other line of /etc/issue stays as the image build wrote it. The block
|
||||
# sits between the marker lines "# ForgeFIRM addresses" and "# end" and
|
||||
# is appended when absent. Called by the init script at boot and by the
|
||||
# udhcpc hook on every lease event. Idempotent: the file is written only
|
||||
# when the block changes.
|
||||
# Rewrites /etc/issue, the serial-console login banner: the lines the
|
||||
# image build wrote, then one https:// URL per global address of wlan0
|
||||
# (and eth0 when the machine has one). The address is the only way in
|
||||
# that the banner gives, because it is the one that works on every
|
||||
# network. Called by the init script at boot and by the udhcpc hook on
|
||||
# every lease event. Idempotent: the file is written only when the text
|
||||
# changes.
|
||||
#
|
||||
# The rootfs is read-only: at the first change after boot the file is
|
||||
# bind-mounted from a copy under /run/forgefirm (tmpfs) and the block is
|
||||
# written through the mount. Before that the image's own file shows.
|
||||
# bind-mounted from a copy under /run/forgefirm (tmpfs) and the banner
|
||||
# is written through the mount. Before that the image's own file shows.
|
||||
# The image's own text is kept at that moment in a second copy, so the
|
||||
# banner needs no marker line in the file and shows nothing but itself.
|
||||
|
||||
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
||||
|
||||
ISSUE=/etc/issue
|
||||
STATE=/run/forgefirm/issue
|
||||
MARK_BEGIN='# ForgeFIRM addresses'
|
||||
MARK_END='# end'
|
||||
PANEL='Control panel: https://forgefirm.local/'
|
||||
BASE=/run/forgefirm/issue.base
|
||||
|
||||
# One URL per global address; an IPv6 address gets its URL brackets.
|
||||
# Tentative, deprecated and temporary addresses are left out.
|
||||
@@ -32,45 +31,40 @@ addresses () {
|
||||
a = $2
|
||||
sub(/\/.*/, "", a)
|
||||
if ($1 == "inet6") a = "[" a "]"
|
||||
print "https://" a "/"
|
||||
print " https://" a "/"
|
||||
}'
|
||||
done
|
||||
}
|
||||
|
||||
block () {
|
||||
echo "$MARK_BEGIN"
|
||||
echo "$PANEL"
|
||||
addrs=$(addresses)
|
||||
if [ -n "$addrs" ]; then
|
||||
echo "$addrs"
|
||||
else
|
||||
echo "no network address yet"
|
||||
fi
|
||||
echo "$MARK_END"
|
||||
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
|
||||
# file by its device numbers alone).
|
||||
is_mounted () {
|
||||
awk -v t="$1" '$2 == t { f = 1 } END { exit !f }' /proc/mounts
|
||||
}
|
||||
|
||||
[ -f "$ISSUE" ] || exit 0
|
||||
|
||||
new=$(block)
|
||||
old=$(awk -v b="$MARK_BEGIN" -v e="$MARK_END" \
|
||||
'$0 == b { p = 1 } p { print } $0 == e { p = 0 }' "$ISSUE")
|
||||
[ "$new" = "$old" ] && exit 0
|
||||
|
||||
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
|
||||
# file by its device numbers alone).
|
||||
if ! awk -v t="$ISSUE" '$2 == t { f = 1 } END { exit !f }' /proc/mounts; then
|
||||
mkdir -p "${STATE%/*}" \
|
||||
&& cp -p "$ISSUE" "$STATE" \
|
||||
# The image's own text: the file as it is before the first write of the
|
||||
# boot. /run is a tmpfs, so the copy is made once per boot, and the
|
||||
# first caller is the one that finds the file untouched.
|
||||
mkdir -p "${STATE%/*}" || exit 1
|
||||
[ -f "$BASE" ] || cp -p "$ISSUE" "$BASE" || exit 1
|
||||
if ! is_mounted "$ISSUE"; then
|
||||
cp -p "$BASE" "$STATE" \
|
||||
&& mount --bind "$STATE" "$ISSUE" || exit 1
|
||||
fi
|
||||
|
||||
base=$(cat "$BASE")
|
||||
addrs=$(addresses)
|
||||
if [ -n "$addrs" ]; then
|
||||
new=$(printf '%s\n\nControl panel:\n%s\n' "$base" "$addrs")
|
||||
else
|
||||
new=$(printf '%s\n\nControl panel: no network address yet\n' "$base")
|
||||
fi
|
||||
[ "$new" = "$(cat "$ISSUE")" ] && exit 0
|
||||
|
||||
tmp="$STATE.tmp.$$"
|
||||
awk -v b="$MARK_BEGIN" -v e="$MARK_END" -v blk="$new" '
|
||||
$0 == b { print blk; seen = 1; skip = 1; next }
|
||||
$0 == e && skip { skip = 0; next }
|
||||
!skip { print }
|
||||
END { if (!seen) print blk }
|
||||
' "$ISSUE" > "$tmp" || { rm -f "$tmp"; exit 1; }
|
||||
printf '%s\n\n' "$new" > "$tmp" || { rm -f "$tmp"; exit 1; }
|
||||
cat "$tmp" > "$ISSUE"
|
||||
rm -f "$tmp"
|
||||
exit 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
|
||||
DESCRIPTION = "Keeps an address block in the serial-console login banner \
|
||||
(/etc/issue): the control panel by mDNS name and by every global address \
|
||||
of wlan0 and eth0. Refreshed at boot and on every DHCP lease event."
|
||||
(/etc/issue): one control panel URL per global address of wlan0 and eth0. \
|
||||
Refreshed at boot and on every DHCP lease event."
|
||||
LICENSE = "MIT"
|
||||
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/bin/sh
|
||||
# Sets the machine's hostname to forgefirm-<xxxx>, where xxxx is the
|
||||
# last four hex digits of the wlan0 MAC address (eth0 on a machine with
|
||||
# no WiFi). The name is the same at every boot, two machines on one
|
||||
# network answer to different names, and the name carries no serial
|
||||
# number. The DHCP client sends it as the hostname option
|
||||
# (/etc/network/interfaces), so a network with dynamic DNS resolves it.
|
||||
#
|
||||
# The rootfs is read-only: /etc/hostname shows a copy under
|
||||
# /run/forgefirm (tmpfs), bind-mounted, and the name is written through
|
||||
# the mount. The init script calls this at boot, before the network
|
||||
# starts and before anything reads the name.
|
||||
|
||||
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
||||
|
||||
FILE=/etc/hostname
|
||||
STATE=/run/forgefirm/hostname
|
||||
PREFIX=forgefirm
|
||||
WAIT_S=10
|
||||
|
||||
# The MAC address of the first interface that has one. An interface
|
||||
# before its driver reads the address reports all zeros.
|
||||
mac () {
|
||||
for dev in wlan0 eth0; do
|
||||
a=$(cat "/sys/class/net/$dev/address" 2>/dev/null) || continue
|
||||
case "$a" in
|
||||
''|00:00:00:00:00:00) continue ;;
|
||||
esac
|
||||
printf '%s\n' "$a"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
|
||||
# file by its device numbers alone).
|
||||
is_mounted () {
|
||||
awk -v t="$1" '$2 == t { f = 1 } END { exit !f }' /proc/mounts
|
||||
}
|
||||
|
||||
# An interface registers when its driver probes, which udev does earlier
|
||||
# in rcS. The wait is for a slow probe and ends at once in the normal
|
||||
# case.
|
||||
n=0
|
||||
while [ "$n" -lt "$WAIT_S" ] && ! mac >/dev/null; do
|
||||
sleep 1
|
||||
n=$((n + 1))
|
||||
done
|
||||
|
||||
suffix=$(mac | tr -d ':' | tr 'A-Z' 'a-z' | cut -c9-12)
|
||||
if [ -n "$suffix" ]; then
|
||||
name="$PREFIX-$suffix"
|
||||
else
|
||||
name="$PREFIX"
|
||||
fi
|
||||
|
||||
[ "$(hostname)" = "$name" ] || hostname "$name"
|
||||
|
||||
# The file follows the live name, so every reader agrees.
|
||||
[ -f "$FILE" ] || exit 0
|
||||
[ "$(cat "$FILE" 2>/dev/null)" = "$name" ] && exit 0
|
||||
if ! is_mounted "$FILE"; then
|
||||
mkdir -p "${STATE%/*}" \
|
||||
&& cp -p "$FILE" "$STATE" \
|
||||
&& mount --bind "$STATE" "$FILE" || exit 1
|
||||
fi
|
||||
printf '%s\n' "$name" > "$FILE" || exit 1
|
||||
exit 0
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
### BEGIN INIT INFO
|
||||
# Provides: forgefirm-hostname
|
||||
# Required-Start: $local_fs
|
||||
# Required-Stop:
|
||||
# Default-Start: S
|
||||
# Default-Stop:
|
||||
# Short-Description: ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address
|
||||
### END INIT INFO
|
||||
|
||||
# Writes the hostname before the network starts. At boot this runs at
|
||||
# S38 in rcS: /run is mounted (mountall, S03), udev has probed the
|
||||
# network drivers (S04), and poky's own hostname.sh (S39) reads the file
|
||||
# this leaves.
|
||||
|
||||
case "$1" in
|
||||
start|restart|reload|force-reload)
|
||||
/usr/sbin/forgefirm-hostname
|
||||
;;
|
||||
stop)
|
||||
;;
|
||||
status)
|
||||
hostname
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|restart|status}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,28 @@
|
||||
SUMMARY = "ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address"
|
||||
DESCRIPTION = "Names the machine forgefirm-<xxxx>, where xxxx is the last \
|
||||
four hex digits of the wlan0 MAC address (eth0 on a machine with no WiFi). \
|
||||
The name is the same at every boot, two machines on one network answer to \
|
||||
different names, and the name carries no serial number. The DHCP client \
|
||||
sends it as the hostname option, so a network with dynamic DNS resolves it."
|
||||
LICENSE = "MIT"
|
||||
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
|
||||
|
||||
SRC_URI = " \
|
||||
file://forgefirm-hostname \
|
||||
file://forgefirm-hostname.init \
|
||||
"
|
||||
|
||||
S = "${WORKDIR}"
|
||||
|
||||
inherit update-rc.d
|
||||
|
||||
INITSCRIPT_NAME = "forgefirm-hostname"
|
||||
# 38 in rcS: after udev (S04) has probed the network drivers, before
|
||||
# poky's hostname.sh (S39) reads /etc/hostname and before the network
|
||||
# starts (rc5 S01).
|
||||
INITSCRIPT_PARAMS = "start 38 S ."
|
||||
|
||||
do_install() {
|
||||
install -Dm 0755 ${WORKDIR}/forgefirm-hostname ${D}${sbindir}/forgefirm-hostname
|
||||
install -Dm 0755 ${WORKDIR}/forgefirm-hostname.init ${D}${sysconfdir}/init.d/forgefirm-hostname
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# changes; keep only SRCREV and PV here - the image manifest leaves
|
||||
# *-pin.inc out of the layer content hash because the component entry
|
||||
# already identifies the pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "9ee624bbc1d1fd929f1f5587b6fc6acaa13a4561"
|
||||
PV = "0.1.10"
|
||||
SRCREV = "ecebe9c8eb8dc220e928f8039a0f922327e42c6f"
|
||||
PV = "0.1.17"
|
||||
|
||||
@@ -48,15 +48,12 @@ IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome gfcloud v4l-utils fw
|
||||
# forgefirm-users: renders the operator account record
|
||||
# (/data/forgefirm/users, written by forgectrl) into the system account
|
||||
# files at boot, before sshd, and on reload; also installs the warning an
|
||||
# interactive root shell prints. forgefirm-banner: keeps the control
|
||||
# panel addresses in the serial-console banner (/etc/issue).
|
||||
# interactive root shell prints. forgefirm-hostname: names the machine
|
||||
# forgefirm-<xxxx> from its MAC address, before the network starts.
|
||||
# forgefirm-banner: keeps the control panel addresses in the
|
||||
# serial-console banner (/etc/issue).
|
||||
# forgefirm-persist: the boot timestamp and the random seed on /data.
|
||||
# avahi-daemon: mDNS, so the panel answers at https://forgefirm.local/
|
||||
# and shows up in service browsers. The daemon is installed by name (the
|
||||
# zeroconf distro feature stays off: it would bring libnss-mdns); the
|
||||
# build options and the configuration are in conf/distro/forgefirm.conf
|
||||
# and recipes-connectivity/avahi.
|
||||
IMAGE_INSTALL:append = " forgefirm-users forgefirm-banner forgefirm-persist avahi-daemon"
|
||||
IMAGE_INSTALL:append = " forgefirm-users forgefirm-hostname forgefirm-banner forgefirm-persist"
|
||||
|
||||
# The rootfs mounts read-only on both images; /data (p3) is the writable
|
||||
# partition. read-only-rootfs is poky's feature for it: the root line of
|
||||
@@ -67,8 +64,9 @@ IMAGE_INSTALL:append = " forgefirm-users forgefirm-banner forgefirm-persist avah
|
||||
# package whose post-install must run on the machine, and the removal of
|
||||
# the packages a read-only rootfs cannot use (shadow, base-passwd,
|
||||
# update-rc.d, update-alternatives; the account files stay). What must
|
||||
# last or change at run time is handled file by file: the account files
|
||||
# and /etc/issue (forgefirm-users, forgefirm-banner), the sshd host keys
|
||||
# last or change at run time is handled file by file: the account files,
|
||||
# /etc/hostname and /etc/issue (forgefirm-users, forgefirm-hostname,
|
||||
# forgefirm-banner), the sshd host keys
|
||||
# (recipes-connectivity/openssh), the timestamp and the random seed
|
||||
# (forgefirm-persist). The facts are on the docs site,
|
||||
# technical/forgefirm/image-and-bsp; scripts/release.sh checks the built
|
||||
@@ -108,19 +106,81 @@ IMAGE_OVERHEAD_FACTOR = "1.0"
|
||||
IMAGE_ROOTFS_EXTRA_SPACE = "40960"
|
||||
IMAGE_ROOTFS_MAXSIZE = "204800"
|
||||
|
||||
# Version stamp: /etc/forgefirm-version (machine-readable), echoed on the
|
||||
# serial-console login prompt (/etc/issue) and at SSH login (motd).
|
||||
# The ForgeFIRM mark and the version stamp:
|
||||
# /etc/forgefirm-version (machine-readable), and, under the OpenGlow mark
|
||||
# the base image carries (base-files, meta-openglow), the ForgeFIRM mark
|
||||
# with the version on its last line, right-justified to the mark's last
|
||||
# column, in the two files a person reads - the serial-console login
|
||||
# prompt (/etc/issue) and the motd, which every login prints, the network
|
||||
# ones included. The mark names the firmware, so the version stands
|
||||
# alone.
|
||||
#
|
||||
# The mark is written once here and rendered for each reader, because the
|
||||
# two files are read by different programs:
|
||||
# /etc/issue busybox getty parses it, and both a backslash and a
|
||||
# percent sign start an escape (libbb/login.c,
|
||||
# print_login_issue). An unrecognized escape prints the
|
||||
# character and swallows the backslash, so the art goes in
|
||||
# with every backslash doubled.
|
||||
# /etc/motd a login writes it out as it is: nothing is doubled.
|
||||
# Both keep their color: a getty passes an escape character through, and
|
||||
# so does a login writing the motd. Only the ssh client escapes one, and
|
||||
# it does that to a banner alone.
|
||||
# The pre-authentication banner (/etc/issue.net) stays unused: the ssh
|
||||
# client prints a control character in a banner as an octal escape, and
|
||||
# the machine tells a client that has not logged in nothing anyway.
|
||||
#
|
||||
# Release images carry the release version; the dev image overrides the
|
||||
# string with the build timestamp (the same DATETIME as the artifact
|
||||
# name) plus a dev tag.
|
||||
FORGEFIRM_RELEASE ?= "0.0.1"
|
||||
#
|
||||
# FORGEFIRM_RELEASE lives in its own file, which the manifest leaves out
|
||||
# of the layer content hash: the version is metadata, and a bump must not
|
||||
# read as a platform change and invalidate a campaign
|
||||
# (forgefirm-release.inc).
|
||||
require forgefirm-release.inc
|
||||
FORGEFIRM_VERSION_STRING ?= "v${FORGEFIRM_RELEASE}"
|
||||
|
||||
write_forgefirm_version() {
|
||||
echo "${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/forgefirm-version
|
||||
echo "ForgeFIRM ${FORGEFIRM_VERSION_STRING}" >> ${IMAGE_ROOTFS}${sysconfdir}/issue
|
||||
|
||||
mark=${WORKDIR}/forgefirm-mark
|
||||
cat > $mark <<'MARK'
|
||||
___ [1;39m___ ___ ___ __ __[0m
|
||||
| __|__ _ _ __ _ ___[1;39m| __|_ _| _ \ \/ |[0m
|
||||
| _/ _ \ '_/ _` / -_) [1;39m_| | || / |\/| |[0m
|
||||
|_|\___/_| \__, \___|[1;39m_| |___|_|_\_| |_|[0m
|
||||
|___/
|
||||
MARK
|
||||
|
||||
# The version rides the mark's own last line, its last character on
|
||||
# the mark's last column: under the FIRM half, in the room the
|
||||
# descender of the Forge half leaves. The mark carries the name, so
|
||||
# the version stands alone. Measured in columns, not in bytes: the
|
||||
# color sequences take no room on the screen, and the doubling below
|
||||
# is undone by the getty that reads it. A version with no room left
|
||||
# keeps one space and runs past the mark rather than being cut.
|
||||
stamped=${WORKDIR}/forgefirm-mark-stamped
|
||||
awk -v v="${FORGEFIRM_VERSION_STRING}" '
|
||||
{ line[NR] = $0
|
||||
bare = $0
|
||||
gsub(/\033\[[0-9;]*m/, "", bare)
|
||||
col[NR] = length(bare)
|
||||
if (col[NR] > w) w = col[NR] }
|
||||
END { for (i = 1; i < NR; i++) print line[i]
|
||||
pad = w - col[NR] - length(v)
|
||||
if (pad < 1) pad = 1
|
||||
gap = ""
|
||||
while (length(gap) < pad) gap = gap " "
|
||||
print line[NR] gap v }' $mark > $stamped
|
||||
|
||||
sed 's|\\|\\\\|g' $stamped >> ${IMAGE_ROOTFS}${sysconfdir}/issue
|
||||
echo "" >> ${IMAGE_ROOTFS}${sysconfdir}/issue
|
||||
echo "ForgeFIRM ${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/motd
|
||||
|
||||
cat $stamped >> ${IMAGE_ROOTFS}${sysconfdir}/motd
|
||||
echo "" >> ${IMAGE_ROOTFS}${sysconfdir}/motd
|
||||
|
||||
rm -f $mark $stamped
|
||||
}
|
||||
write_forgefirm_version[vardepsexclude] += "DATETIME"
|
||||
# No semicolon after a function name here or below (the vardeps rule in
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# The release version, and nothing else.
|
||||
#
|
||||
# This file is metadata, not platform content: the image manifest leaves
|
||||
# it out of the layer content hash the way it leaves out the component
|
||||
# pin files (forgefirm-image-manifest.bbclass,
|
||||
# FORGEFIRM_MANIFEST_VERSION_SUFFIX). A version bump therefore changes
|
||||
# the version and invalidates nothing, so the number can be set at the
|
||||
# moment a release is cut rather than before the campaign that authorizes
|
||||
# it. Setting it inside forgefirm-image.bb instead would hash as layer
|
||||
# content and invalidate every acceptance result, which is what it did
|
||||
# until v0.0.1.
|
||||
#
|
||||
# Nothing else belongs here. The version is still proven end to end:
|
||||
# scripts/release.sh reads FORGEFIRM_RELEASE from this file and requires
|
||||
# it to equal the rootfs stamp, the .fw meta-version and the release tag,
|
||||
# and the acceptance test image.health compares the stamp on the running
|
||||
# machine with the manifest's.
|
||||
FORGEFIRM_RELEASE ?= "0.0.5"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
# ForgeFIRM acceptance - 20260910181308 (dev)
|
||||
|
||||
- Image: `20260910181308 (dev)` (forgefirm-image-dev)
|
||||
- Manifest identity: `168e0307cef8f58f6bb871102f98ee56f9661b852e738d054891c5f072fd33b9`
|
||||
- Catalog: `da9da478f3df1e5ad87319fd9d831ee224a3c88e5d3896162a09b4de14aad6b1`
|
||||
- Campaign: `c-20260910181641-11a2` opened 2026-09-10T18:16:41Z
|
||||
- Exported: 2026-09-10T18:32:07Z
|
||||
- **Release authorized: YES**
|
||||
- Tests: 83 total, 83 satisfied (57 inherited), 0 required
|
||||
|
||||
| Test | Kind | Result | Run at | Campaign | Inherited |
|
||||
|---|---|---|---|---|---|
|
||||
| `image.health` | auto, core | PASS | 2026-09-10T18:23:17Z | `c-20260910181641-11a2` | no |
|
||||
| `image.license-bundle` | auto | PASS | 2026-09-10T13:11:39Z | `c-20260910131134-5cb0` | yes |
|
||||
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-10T18:23:17Z | `c-20260910181641-11a2` | no |
|
||||
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-10T18:23:39Z | `c-20260910181641-11a2` | no |
|
||||
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-10T18:23:50Z | `c-20260910181641-11a2` | no |
|
||||
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-10T18:24:08Z | `c-20260910181641-11a2` | no |
|
||||
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-10T18:24:51Z | `c-20260910181641-11a2` | no |
|
||||
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-10T13:13:35Z | `c-20260910131134-5cb0` | yes |
|
||||
| `kernel.pic-soc-load` | auto | PASS | 2026-09-10T13:13:39Z | `c-20260910131134-5cb0` | yes |
|
||||
| `forgectrl.auth` | auto | PASS | 2026-09-10T13:13:40Z | `c-20260910131134-5cb0` | yes |
|
||||
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-10T13:13:41Z | `c-20260910131134-5cb0` | yes |
|
||||
| `forgectrl.panel-serves` | auto | PASS | 2026-09-10T13:11:36Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-10T13:14:00Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.override-until-reboot` | auto | PASS | 2026-09-10T13:14:00Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.advisories-rehash` | auto, takeover | PASS | 2026-09-10T13:14:09Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.account-login` | auto, takeover | PASS | 2026-09-10T13:14:59Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.https-only-writes` | auto | PASS | 2026-09-10T13:15:00Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.ssh-until-reboot` | auto | PASS | 2026-09-10T13:15:00Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.cloud-disabled-surface` | auto | PASS | 2026-09-10T13:15:01Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.factory-return` | auto | PASS | 2026-09-10T13:15:04Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.machine-name` | auto | PASS | 2026-09-10T13:15:04Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.first-run-flow` | operator, takeover | PASS | 2026-09-10T13:15:26Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.first-run-page` | operator, takeover | PASS | 2026-09-10T17:25:08Z | `c-20260910171338-9149` | yes |
|
||||
| `commission.cert-page` | auto | PASS | 2026-09-10T13:15:26Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.what-changed` | auto, takeover | PASS | 2026-09-10T13:15:36Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.record-export` | auto | PASS | 2026-09-10T13:16:52Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.mirror` | auto, takeover | PASS | 2026-09-10T13:17:25Z | `c-20260910131134-5cb0` | yes |
|
||||
| `commission.check-switches` | operator | PASS | 2026-09-10T16:06:37Z | `c-20260910160456-619b` | yes |
|
||||
| `commission.check-sensors` | auto | PASS | 2026-09-10T16:06:50Z | `c-20260910160456-619b` | yes |
|
||||
| `commission.check-airflow` | auto, takeover | PASS | 2026-09-10T16:08:24Z | `c-20260910160456-619b` | yes |
|
||||
| `commission.check-cameras` | operator | PASS | 2026-09-10T16:08:39Z | `c-20260910160456-619b` | yes |
|
||||
| `commission.check-motion` | auto, takeover | PASS | 2026-09-10T16:09:05Z | `c-20260910160456-619b` | yes |
|
||||
| `commission.check-flow-verify` | auto, takeover | PASS | 2026-09-10T16:16:37Z | `c-20260910160456-619b` | yes |
|
||||
| `commission.cloud-header-capture` | operator, takeover | PASS | 2026-09-10T17:26:21Z | `c-20260910171338-9149` | yes |
|
||||
| `commission.sheet` | live, takeover | PASS | 2026-09-10T17:39:28Z | `c-20260910171338-9149` | yes |
|
||||
| `logs.tree-tail-export` | auto | PASS | 2026-09-10T13:16:14Z | `c-20260910131134-5cb0` | yes |
|
||||
| `logs.routing` | auto | PASS | 2026-09-10T13:27:51Z | `c-20260910131134-5cb0` | yes |
|
||||
| `logs.level-settings` | auto | PASS | 2026-09-10T13:27:52Z | `c-20260910131134-5cb0` | yes |
|
||||
| `motion.pacing` | auto | PASS | 2026-09-10T18:25:07Z | `c-20260910181641-11a2` | no |
|
||||
| `motion.jog-roundtrip` | auto | PASS | 2026-09-10T18:25:21Z | `c-20260910181641-11a2` | no |
|
||||
| `motion.microstep-modes` | auto | PASS | 2026-09-10T18:25:46Z | `c-20260910181641-11a2` | no |
|
||||
| `motion.liveness-probe` | auto | PASS | 2026-09-10T13:28:39Z | `c-20260910131134-5cb0` | yes |
|
||||
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-10T13:28:56Z | `c-20260910131134-5cb0` | yes |
|
||||
| `motion.cancel-abort` | auto | PASS | 2026-09-10T18:25:53Z | `c-20260910181641-11a2` | no |
|
||||
| `motion.deadman` | auto | PASS | 2026-09-10T18:26:22Z | `c-20260910181641-11a2` | no |
|
||||
| `motion.button-hold-resume` | operator | PASS | 2026-09-10T18:26:33Z | `c-20260910181641-11a2` | no |
|
||||
| `motion.lid-cancel-home` | operator | PASS | 2026-09-10T18:26:49Z | `c-20260910181641-11a2` | no |
|
||||
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-10T18:26:58Z | `c-20260910181641-11a2` | no |
|
||||
| `motion.lid-policy-hold` | operator | PASS | 2026-09-10T18:27:12Z | `c-20260910181641-11a2` | no |
|
||||
| `motion.step-timing-under-load` | auto | PASS | 2026-09-10T18:27:42Z | `c-20260910181641-11a2` | no |
|
||||
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-10T16:10:38Z | `c-20260910160456-619b` | yes |
|
||||
| `cooling.flow-verify` | auto | PASS | 2026-09-10T16:13:21Z | `c-20260910160456-619b` | yes |
|
||||
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-10T16:17:38Z | `c-20260910160456-619b` | yes |
|
||||
| `cooling.gate-off` | auto | PASS | 2026-09-10T16:06:57Z | `c-20260910160456-619b` | yes |
|
||||
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-10T16:19:05Z | `c-20260910160456-619b` | yes |
|
||||
| `cooling.tec-drive` | auto | PASS | 2026-09-10T16:19:13Z | `c-20260910160456-619b` | yes |
|
||||
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-10T16:19:29Z | `c-20260910160456-619b` | yes |
|
||||
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-10T16:19:36Z | `c-20260910160456-619b` | yes |
|
||||
| `cooling.critical-tier` | auto | PASS | 2026-09-10T16:19:44Z | `c-20260910160456-619b` | yes |
|
||||
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-10T16:07:43Z | `c-20260910160456-619b` | yes |
|
||||
| `cooling.flow-under-load` | live | PASS | 2026-09-10T17:41:03Z | `c-20260910171338-9149` | yes |
|
||||
| `laser.power-floor` | auto | PASS | 2026-09-10T18:27:43Z | `c-20260910181641-11a2` | no |
|
||||
| `laser.emission-witness` | live, core | PASS | 2026-09-10T18:17:19Z | `c-20260910181641-11a2` | no |
|
||||
| `laser.m5-rapid-dark` | live | PASS | 2026-09-10T18:18:01Z | `c-20260910181641-11a2` | no |
|
||||
| `laser.disarm-in-hold` | live | PASS | 2026-09-10T18:19:29Z | `c-20260910181641-11a2` | no |
|
||||
| `laser.armed-kill` | live | PASS | 2026-09-10T18:20:12Z | `c-20260910181641-11a2` | no |
|
||||
| `laser.arm-wait-lid` | operator | PASS | 2026-09-10T18:28:02Z | `c-20260910181641-11a2` | no |
|
||||
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-10T18:20:41Z | `c-20260910181641-11a2` | no |
|
||||
| `camera.snapshot` | auto | PASS | 2026-09-10T13:19:21Z | `c-20260910131134-5cb0` | yes |
|
||||
| `camera.sensor-profile` | auto | PASS | 2026-09-10T14:55:23Z | `c-20260910144945-3072` | yes |
|
||||
| `camera.h264-stream` | auto | PASS | 2026-09-10T14:55:37Z | `c-20260910144945-3072` | yes |
|
||||
| `camera.frame-health` | auto | PASS | 2026-09-10T14:55:50Z | `c-20260910144945-3072` | yes |
|
||||
| `camera.lid-privacy` | operator | PASS | 2026-09-10T14:56:05Z | `c-20260910144945-3072` | yes |
|
||||
| `camera.key-read` | auto | PASS | 2026-09-10T14:56:06Z | `c-20260910144945-3072` | yes |
|
||||
| `update.slots-and-signature` | auto | PASS | 2026-09-10T13:15:03Z | `c-20260910131134-5cb0` | yes |
|
||||
| `cloud.mode-switch` | operator | PASS | 2026-09-10T18:29:38Z | `c-20260910181641-11a2` | no |
|
||||
| `cloud.service-protocol` | operator | PASS | 2026-09-10T18:21:52Z | `c-20260910181641-11a2` | no |
|
||||
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-10T17:19:59Z | `c-20260910171338-9149` | yes |
|
||||
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-10T14:58:32Z | `c-20260910144945-3072` | yes |
|
||||
| `cloud.verdict-hold` | operator | PASS | 2026-09-10T17:18:09Z | `c-20260910171338-9149` | yes |
|
||||
| `cloud.pause-resume` | live | PASS | 2026-09-10T18:23:07Z | `c-20260910181641-11a2` | no |
|
||||
| `cloud.oversize-stream` | live | PASS | 2026-09-10T17:22:39Z | `c-20260910171338-9149` | yes |
|
||||
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-10T17:18:57Z | `c-20260910171338-9149` | yes |
|
||||
|
||||
Artifact sha256: `191f416666d40efd92c77973fa392f38ca3d5ca160533ff83b4475d47b5fa6e3`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
# ForgeFIRM acceptance - 20260911172215 (dev)
|
||||
|
||||
- Image: `20260911172215 (dev)` (forgefirm-image-dev)
|
||||
- Manifest identity: `fb24c3f4508ae54592f8d0b98ed0b2a07a933274980d8c1e4e5a967618ef9bd8`
|
||||
- Catalog: `ceabc830345fc4cac4832de691c0571e93a5c4e155666ce91601c844c40942fc`
|
||||
- Campaign: `c-20260911182256-078f` opened 2026-09-11T18:22:56Z
|
||||
- Exported: 2026-09-11T18:54:20Z
|
||||
- **Release authorized: YES**
|
||||
- Tests: 83 total, 83 satisfied (57 inherited), 0 required
|
||||
|
||||
| Test | Kind | Result | Run at | Campaign | Inherited |
|
||||
|---|---|---|---|---|---|
|
||||
| `image.health` | auto, core | PASS | 2026-09-11T18:22:57Z | `c-20260911182256-078f` | no |
|
||||
| `image.license-bundle` | auto | PASS | 2026-09-11T17:29:26Z | `c-20260911172921-9426` | yes |
|
||||
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-11T18:22:58Z | `c-20260911182256-078f` | no |
|
||||
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-11T18:23:19Z | `c-20260911182256-078f` | no |
|
||||
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-11T18:23:29Z | `c-20260911182256-078f` | no |
|
||||
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-11T18:23:48Z | `c-20260911182256-078f` | no |
|
||||
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-11T18:24:32Z | `c-20260911182256-078f` | no |
|
||||
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-11T17:31:23Z | `c-20260911172921-9426` | yes |
|
||||
| `kernel.pic-soc-load` | auto | PASS | 2026-09-11T17:31:26Z | `c-20260911172921-9426` | yes |
|
||||
| `forgectrl.auth` | auto | PASS | 2026-09-11T17:31:27Z | `c-20260911172921-9426` | yes |
|
||||
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-11T17:31:27Z | `c-20260911172921-9426` | yes |
|
||||
| `forgectrl.panel-serves` | auto | PASS | 2026-09-11T17:29:23Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-11T17:31:46Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.override-until-reboot` | auto | PASS | 2026-09-11T17:31:46Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.advisories-rehash` | auto, takeover | PASS | 2026-09-11T17:31:55Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.account-login` | auto, takeover | PASS | 2026-09-11T17:32:44Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.https-only-writes` | auto | PASS | 2026-09-11T17:32:45Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.ssh-until-reboot` | auto | PASS | 2026-09-11T17:32:46Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.cloud-disabled-surface` | auto | PASS | 2026-09-11T17:32:47Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.factory-return` | auto | PASS | 2026-09-11T17:32:49Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.machine-name` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.first-run-flow` | operator, takeover | PASS | 2026-09-11T17:54:32Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.first-run-page` | operator, takeover | PASS | 2026-09-11T17:55:49Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.cert-page` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.what-changed` | auto, takeover | PASS | 2026-09-11T17:32:59Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.record-export` | auto | PASS | 2026-09-11T17:34:22Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.mirror` | auto, takeover | PASS | 2026-09-11T17:34:54Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.check-switches` | operator | PASS | 2026-09-11T17:56:07Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.check-sensors` | auto | PASS | 2026-09-11T17:34:34Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.check-airflow` | auto, takeover | PASS | 2026-09-11T17:36:26Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.check-cameras` | operator | PASS | 2026-09-11T17:56:22Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.check-motion` | auto, takeover | PASS | 2026-09-11T17:37:06Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.check-flow-verify` | auto, takeover | PASS | 2026-09-11T17:44:42Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.cloud-header-capture` | operator, takeover | PASS | 2026-09-11T17:59:41Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.sheet` | live, takeover | PASS | 2026-09-11T18:17:29Z | `c-20260911172921-9426` | yes |
|
||||
| `logs.tree-tail-export` | auto | PASS | 2026-09-11T17:33:40Z | `c-20260911172921-9426` | yes |
|
||||
| `logs.routing` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
|
||||
| `logs.level-settings` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.pacing` | auto | PASS | 2026-09-11T17:36:41Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.jog-roundtrip` | auto | PASS | 2026-09-11T17:45:00Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.microstep-modes` | auto | PASS | 2026-09-11T17:45:25Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.liveness-probe` | auto | PASS | 2026-09-11T17:45:33Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-11T18:24:48Z | `c-20260911182256-078f` | no |
|
||||
| `motion.cancel-abort` | auto | PASS | 2026-09-11T17:45:40Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.deadman` | auto | PASS | 2026-09-11T17:46:09Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.button-hold-resume` | operator | PASS | 2026-09-11T18:24:59Z | `c-20260911182256-078f` | no |
|
||||
| `motion.lid-cancel-home` | operator | PASS | 2026-09-11T18:25:15Z | `c-20260911182256-078f` | no |
|
||||
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-11T18:25:24Z | `c-20260911182256-078f` | no |
|
||||
| `motion.lid-policy-hold` | operator | PASS | 2026-09-11T18:25:37Z | `c-20260911182256-078f` | no |
|
||||
| `motion.step-timing-under-load` | auto | PASS | 2026-09-11T17:46:40Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-11T17:38:38Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.flow-verify` | auto | PASS | 2026-09-11T17:41:29Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-11T17:47:35Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.gate-off` | auto | PASS | 2026-09-11T17:35:02Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-11T17:49:02Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.tec-drive` | auto | PASS | 2026-09-11T17:49:10Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-11T17:49:25Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-11T17:49:32Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.critical-tier` | auto | PASS | 2026-09-11T17:49:41Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-11T17:35:45Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.flow-under-load` | live | PASS | 2026-09-11T18:41:47Z | `c-20260911182256-078f` | no |
|
||||
| `laser.power-floor` | auto | PASS | 2026-09-11T17:49:42Z | `c-20260911172921-9426` | yes |
|
||||
| `laser.emission-witness` | live, core | PASS | 2026-09-11T18:40:15Z | `c-20260911182256-078f` | no |
|
||||
| `laser.m5-rapid-dark` | live | PASS | 2026-09-11T18:42:25Z | `c-20260911182256-078f` | no |
|
||||
| `laser.disarm-in-hold` | live | PASS | 2026-09-11T18:43:51Z | `c-20260911182256-078f` | no |
|
||||
| `laser.armed-kill` | live | PASS | 2026-09-11T18:44:43Z | `c-20260911182256-078f` | no |
|
||||
| `laser.arm-wait-lid` | operator | PASS | 2026-09-11T18:25:57Z | `c-20260911182256-078f` | no |
|
||||
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-11T18:45:12Z | `c-20260911182256-078f` | no |
|
||||
| `camera.snapshot` | auto | PASS | 2026-09-11T17:49:57Z | `c-20260911172921-9426` | yes |
|
||||
| `camera.sensor-profile` | auto | PASS | 2026-09-11T17:50:10Z | `c-20260911172921-9426` | yes |
|
||||
| `camera.h264-stream` | auto | PASS | 2026-09-11T17:50:23Z | `c-20260911172921-9426` | yes |
|
||||
| `camera.frame-health` | auto | PASS | 2026-09-11T17:50:36Z | `c-20260911172921-9426` | yes |
|
||||
| `camera.lid-privacy` | operator | PASS | 2026-09-11T18:26:12Z | `c-20260911182256-078f` | no |
|
||||
| `camera.key-read` | auto | PASS | 2026-09-11T17:50:37Z | `c-20260911172921-9426` | yes |
|
||||
| `update.slots-and-signature` | auto | PASS | 2026-09-11T17:32:49Z | `c-20260911172921-9426` | yes |
|
||||
| `cloud.mode-switch` | operator | PASS | 2026-09-11T17:58:28Z | `c-20260911172921-9426` | yes |
|
||||
| `cloud.service-protocol` | operator | PASS | 2026-09-11T18:46:13Z | `c-20260911182256-078f` | no |
|
||||
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-11T18:47:21Z | `c-20260911182256-078f` | no |
|
||||
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-11T18:26:51Z | `c-20260911182256-078f` | no |
|
||||
| `cloud.verdict-hold` | operator | PASS | 2026-09-11T18:38:58Z | `c-20260911182256-078f` | no |
|
||||
| `cloud.pause-resume` | live | PASS | 2026-09-11T18:49:33Z | `c-20260911182256-078f` | no |
|
||||
| `cloud.oversize-stream` | live | PASS | 2026-09-11T18:52:19Z | `c-20260911182256-078f` | no |
|
||||
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-11T18:52:48Z | `c-20260911182256-078f` | no |
|
||||
|
||||
Artifact sha256: `d7bb5d900f9237711a17ae207df90025605340c2eb2eaf34f9ed508cc3a899a6`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
# ForgeFIRM acceptance - 20260912180956 (dev)
|
||||
|
||||
- Image: `20260912180956 (dev)` (forgefirm-image-dev)
|
||||
- Manifest identity: `8bb5b6f39ca4e0a5afca9b620aa38980c4ede8cdfda240b24bbc01fd6c28fa05`
|
||||
- Catalog: `f906de649b7533e3713309f141afacbc06420f96824936a60b70febd71bb2df7`
|
||||
- Campaign: `c-20260912182427-e1ee` opened 2026-09-12T18:24:27Z
|
||||
- Exported: 2026-09-12T19:21:35Z
|
||||
- **Release authorized: YES**
|
||||
- Tests: 85 total, 85 satisfied (50 inherited), 0 required
|
||||
|
||||
| Test | Kind | Result | Run at | Campaign | Inherited |
|
||||
|---|---|---|---|---|---|
|
||||
| `image.health` | auto, core | PASS | 2026-09-12T18:24:28Z | `c-20260912182427-e1ee` | no |
|
||||
| `image.license-bundle` | auto | PASS | 2026-09-12T16:56:02Z | `c-20260912165539-ac51` | yes |
|
||||
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-12T18:24:29Z | `c-20260912182427-e1ee` | no |
|
||||
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-12T18:24:50Z | `c-20260912182427-e1ee` | no |
|
||||
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-12T18:25:02Z | `c-20260912182427-e1ee` | no |
|
||||
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-12T18:25:21Z | `c-20260912182427-e1ee` | no |
|
||||
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-12T18:26:04Z | `c-20260912182427-e1ee` | no |
|
||||
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-11T17:31:23Z | `c-20260911172921-9426` | yes |
|
||||
| `kernel.pic-soc-load` | auto | PASS | 2026-09-11T17:31:26Z | `c-20260911172921-9426` | yes |
|
||||
| `forgectrl.auth` | auto | PASS | 2026-09-12T16:58:12Z | `c-20260912165539-ac51` | yes |
|
||||
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-12T16:58:19Z | `c-20260912165539-ac51` | yes |
|
||||
| `forgectrl.panel-serves` | auto | PASS | 2026-09-12T16:55:54Z | `c-20260912165539-ac51` | yes |
|
||||
| `commission.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-12T16:58:44Z | `c-20260912165539-ac51` | yes |
|
||||
| `commission.override-until-reboot` | auto | PASS | 2026-09-12T16:58:44Z | `c-20260912165539-ac51` | yes |
|
||||
| `commission.advisories-rehash` | auto, takeover | PASS | 2026-09-12T16:58:53Z | `c-20260912165539-ac51` | yes |
|
||||
| `commission.account-login` | auto, takeover | PASS | 2026-09-12T16:59:43Z | `c-20260912165539-ac51` | yes |
|
||||
| `commission.https-only-writes` | auto | PASS | 2026-09-12T16:59:44Z | `c-20260912165539-ac51` | yes |
|
||||
| `commission.ssh-until-reboot` | auto | PASS | 2026-09-12T16:59:46Z | `c-20260912165539-ac51` | yes |
|
||||
| `commission.cloud-disabled-surface` | auto | PASS | 2026-09-12T16:59:46Z | `c-20260912165539-ac51` | yes |
|
||||
| `commission.factory-return` | auto | PASS | 2026-09-12T16:59:49Z | `c-20260912165539-ac51` | yes |
|
||||
| `commission.machine-name` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.first-run-flow` | operator, takeover | PASS | 2026-09-12T17:10:18Z | `c-20260912170819-8dec` | yes |
|
||||
| `commission.first-run-page` | operator, takeover | PASS | 2026-09-11T17:55:49Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.cert-page` | auto | PASS | 2026-09-12T16:59:50Z | `c-20260912165539-ac51` | yes |
|
||||
| `commission.what-changed` | auto, takeover | PASS | 2026-09-12T16:59:59Z | `c-20260912165539-ac51` | yes |
|
||||
| `commission.record-export` | auto | PASS | 2026-09-12T17:01:26Z | `c-20260912165539-ac51` | yes |
|
||||
| `commission.mirror` | auto, takeover | PASS | 2026-09-11T17:34:54Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.check-switches` | operator | PASS | 2026-09-12T17:10:23Z | `c-20260912170819-8dec` | yes |
|
||||
| `commission.check-sensors` | auto | PASS | 2026-09-12T18:26:17Z | `c-20260912182427-e1ee` | no |
|
||||
| `commission.check-airflow` | auto, takeover | PASS | 2026-09-12T18:27:51Z | `c-20260912182427-e1ee` | no |
|
||||
| `commission.check-cameras` | operator | PASS | 2026-09-12T17:10:54Z | `c-20260912170819-8dec` | yes |
|
||||
| `commission.check-motion` | auto, takeover | PASS | 2026-09-12T18:28:17Z | `c-20260912182427-e1ee` | no |
|
||||
| `commission.check-flow-verify` | auto, takeover | PASS | 2026-09-12T17:16:41Z | `c-20260912170819-8dec` | yes |
|
||||
| `commission.cloud-header-capture` | operator, takeover | PASS | 2026-09-12T17:29:14Z | `c-20260912170819-8dec` | yes |
|
||||
| `commission.sheet` | live, takeover | PASS | 2026-09-12T19:02:59Z | `c-20260912182427-e1ee` | no |
|
||||
| `logs.tree-tail-export` | auto | PASS | 2026-09-12T17:00:43Z | `c-20260912165539-ac51` | yes |
|
||||
| `logs.routing` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
|
||||
| `logs.level-settings` | auto | PASS | 2026-09-12T17:16:42Z | `c-20260912170819-8dec` | yes |
|
||||
| `motion.pacing` | auto | PASS | 2026-09-11T17:36:41Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.jog-roundtrip` | auto | PASS | 2026-09-11T17:45:00Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.microstep-modes` | auto | PASS | 2026-09-12T17:17:07Z | `c-20260912170819-8dec` | yes |
|
||||
| `motion.liveness-probe` | auto | PASS | 2026-09-11T17:45:33Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-12T17:17:23Z | `c-20260912170819-8dec` | yes |
|
||||
| `motion.cancel-abort` | auto | PASS | 2026-09-11T17:45:40Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.deadman` | auto | PASS | 2026-09-12T17:17:53Z | `c-20260912170819-8dec` | yes |
|
||||
| `motion.button-hold-resume` | operator | PASS | 2026-09-11T18:24:59Z | `c-20260911182256-078f` | yes |
|
||||
| `motion.lid-cancel-home` | operator | PASS | 2026-09-11T18:25:15Z | `c-20260911182256-078f` | yes |
|
||||
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-11T18:25:24Z | `c-20260911182256-078f` | yes |
|
||||
| `motion.lid-policy-hold` | operator | PASS | 2026-09-11T18:25:37Z | `c-20260911182256-078f` | yes |
|
||||
| `motion.step-timing-under-load` | auto | PASS | 2026-09-11T17:46:40Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-12T18:29:50Z | `c-20260912182427-e1ee` | no |
|
||||
| `cooling.flow-verify` | auto | PASS | 2026-09-12T18:32:33Z | `c-20260912182427-e1ee` | no |
|
||||
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-12T18:33:39Z | `c-20260912182427-e1ee` | no |
|
||||
| `cooling.gate-off` | auto | PASS | 2026-09-12T18:26:26Z | `c-20260912182427-e1ee` | no |
|
||||
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-12T18:35:06Z | `c-20260912182427-e1ee` | no |
|
||||
| `cooling.tec-drive` | auto | PASS | 2026-09-12T18:35:15Z | `c-20260912182427-e1ee` | no |
|
||||
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-12T18:35:31Z | `c-20260912182427-e1ee` | no |
|
||||
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-12T18:35:38Z | `c-20260912182427-e1ee` | no |
|
||||
| `cooling.critical-tier` | auto | PASS | 2026-09-12T18:35:47Z | `c-20260912182427-e1ee` | no |
|
||||
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-12T18:27:10Z | `c-20260912182427-e1ee` | no |
|
||||
| `cooling.flow-under-load` | live | PASS | 2026-09-12T19:05:38Z | `c-20260912182427-e1ee` | no |
|
||||
| `cooling.fan-duty-readback` | auto | PASS | 2026-09-12T18:36:02Z | `c-20260912182427-e1ee` | no |
|
||||
| `laser.power-floor` | auto | PASS | 2026-09-12T17:20:57Z | `c-20260912170819-8dec` | yes |
|
||||
| `laser.emission-witness` | live, core | PASS | 2026-09-12T18:50:46Z | `c-20260912182427-e1ee` | no |
|
||||
| `laser.m5-rapid-dark` | live | PASS | 2026-09-12T19:06:30Z | `c-20260912182427-e1ee` | no |
|
||||
| `laser.disarm-in-hold` | live | PASS | 2026-09-12T19:08:04Z | `c-20260912182427-e1ee` | no |
|
||||
| `laser.armed-kill` | live | PASS | 2026-09-12T19:08:58Z | `c-20260912182427-e1ee` | no |
|
||||
| `laser.arm-wait-lid` | operator | PASS | 2026-09-12T18:36:21Z | `c-20260912182427-e1ee` | no |
|
||||
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-12T19:09:43Z | `c-20260912182427-e1ee` | no |
|
||||
| `camera.snapshot` | auto | PASS | 2026-09-12T17:10:39Z | `c-20260912170819-8dec` | yes |
|
||||
| `camera.sensor-profile` | auto | PASS | 2026-09-12T17:21:13Z | `c-20260912170819-8dec` | yes |
|
||||
| `camera.h264-stream` | auto | PASS | 2026-09-12T17:21:27Z | `c-20260912170819-8dec` | yes |
|
||||
| `camera.frame-health` | auto | PASS | 2026-09-12T17:21:40Z | `c-20260912170819-8dec` | yes |
|
||||
| `camera.lid-privacy` | operator | PASS | 2026-09-12T17:21:56Z | `c-20260912170819-8dec` | yes |
|
||||
| `camera.key-read` | auto | PASS | 2026-09-12T17:21:57Z | `c-20260912170819-8dec` | yes |
|
||||
| `update.slots-and-signature` | auto | PASS | 2026-09-12T16:59:48Z | `c-20260912165539-ac51` | yes |
|
||||
| `update.release-check` | auto | PASS | 2026-09-12T17:21:58Z | `c-20260912170819-8dec` | yes |
|
||||
| `cloud.mode-switch` | operator | PASS | 2026-09-12T18:38:00Z | `c-20260912182427-e1ee` | no |
|
||||
| `cloud.service-protocol` | operator | PASS | 2026-09-12T19:10:50Z | `c-20260912182427-e1ee` | no |
|
||||
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-12T19:11:59Z | `c-20260912182427-e1ee` | no |
|
||||
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-12T17:24:21Z | `c-20260912170819-8dec` | yes |
|
||||
| `cloud.verdict-hold` | operator | PASS | 2026-09-12T18:40:33Z | `c-20260912182427-e1ee` | no |
|
||||
| `cloud.pause-resume` | live | PASS | 2026-09-12T19:14:09Z | `c-20260912182427-e1ee` | no |
|
||||
| `cloud.oversize-stream` | live | PASS | 2026-09-12T19:16:55Z | `c-20260912182427-e1ee` | no |
|
||||
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-12T19:17:30Z | `c-20260912182427-e1ee` | no |
|
||||
|
||||
Artifact sha256: `9650b1ed9862eefab808ca32cdaa8f6b9b6debd8ee640adde9d18dcdfc03ba25`
|
||||
@@ -0,0 +1,5 @@
|
||||
ForgeFIRM v0.0.4 is the first public beta release.
|
||||
|
||||
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
|
||||
|
||||
Every release below 0.1.0 is a beta release. Installation, usage, and safety are documented at https://docs.forgefirm.org/.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
# ForgeFIRM acceptance - 20260915225440 (dev)
|
||||
|
||||
- Image: `20260915225440 (dev)` (forgefirm-image-dev)
|
||||
- Manifest identity: `371bf2b8e0779a641b844b48e18b7fbe1a13adee0f6bf8706d1a720d3e000d22`
|
||||
- Catalog: `7929ef2ce875fd4cccec3f794f7d49ceadd46bdd5068a3cab75b021947198525`
|
||||
- Campaign: `c-20260915231006-07cc` opened 2026-09-15T23:10:06Z
|
||||
- Exported: 2026-09-15T23:59:10Z
|
||||
- **Release authorized: YES**
|
||||
- Tests: 90 total, 90 satisfied (53 inherited), 0 required
|
||||
|
||||
| Test | Kind | Result | Run at | Campaign | Inherited |
|
||||
|---|---|---|---|---|---|
|
||||
| `image.health` | auto, core | PASS | 2026-09-15T23:10:06Z | `c-20260915231006-07cc` | no |
|
||||
| `image.license-bundle` | auto | PASS | 2026-09-15T18:50:17Z | `c-20260915185011-0c8a` | yes |
|
||||
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-15T23:10:07Z | `c-20260915231006-07cc` | no |
|
||||
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-15T23:10:28Z | `c-20260915231006-07cc` | no |
|
||||
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-15T23:10:38Z | `c-20260915231006-07cc` | no |
|
||||
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-15T23:10:56Z | `c-20260915231006-07cc` | no |
|
||||
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-15T23:11:39Z | `c-20260915231006-07cc` | no |
|
||||
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-11T17:31:23Z | `c-20260911172921-9426` | yes |
|
||||
| `kernel.pic-soc-load` | auto | PASS | 2026-09-11T17:31:26Z | `c-20260911172921-9426` | yes |
|
||||
| `forgectrl.auth` | auto | PASS | 2026-09-15T18:51:51Z | `c-20260915185011-0c8a` | yes |
|
||||
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-15T18:51:52Z | `c-20260915185011-0c8a` | yes |
|
||||
| `forgectrl.panel-serves` | auto | PASS | 2026-09-15T23:11:41Z | `c-20260915231006-07cc` | no |
|
||||
| `setup.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-15T18:52:10Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.override-until-reboot` | auto | PASS | 2026-09-15T18:52:11Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.advisories-rehash` | auto, takeover | PASS | 2026-09-15T18:52:19Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.account-login` | auto, takeover | PASS | 2026-09-15T18:53:08Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.https-only-writes` | auto | PASS | 2026-09-15T18:53:09Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.ssh-until-reboot` | auto | PASS | 2026-09-15T18:53:10Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.cloud-disabled-surface` | auto | PASS | 2026-09-15T18:53:11Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.factory-return` | auto | PASS | 2026-09-15T18:53:12Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.machine-name` | auto | PASS | 2026-09-15T18:53:13Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.first-run-flow` | operator, takeover | PASS | 2026-09-15T18:53:33Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.first-run-page` | operator, takeover | PASS | 2026-09-15T23:26:39Z | `c-20260915231006-07cc` | no |
|
||||
| `setup.cert-page` | auto | PASS | 2026-09-15T18:53:34Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.what-changed` | auto, takeover | PASS | 2026-09-15T18:53:44Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.record-export` | auto | PASS | 2026-09-15T18:55:14Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.mirror` | auto, takeover | PASS | 2026-09-15T18:55:44Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.check-switches` | operator | PASS | 2026-09-15T18:55:49Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.check-sensors` | auto | PASS | 2026-09-15T18:55:25Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.check-airflow` | auto, takeover | PASS | 2026-09-15T18:57:23Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.check-cameras` | operator | PASS | 2026-09-15T18:57:51Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.check-motion` | auto, takeover | PASS | 2026-09-15T18:58:27Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.check-flow-verify` | auto, takeover | PASS | 2026-09-15T19:05:54Z | `c-20260915185011-0c8a` | yes |
|
||||
| `setup.cloud-header-capture` | operator, takeover | PASS | 2026-09-15T23:28:02Z | `c-20260915231006-07cc` | no |
|
||||
| `setup.sheet` | live, takeover | PASS | 2026-09-15T23:40:57Z | `c-20260915231006-07cc` | no |
|
||||
| `logs.tree-tail-export` | auto | PASS | 2026-09-15T18:54:29Z | `c-20260915185011-0c8a` | yes |
|
||||
| `logs.routing` | auto | PASS | 2026-09-15T19:05:59Z | `c-20260915185011-0c8a` | yes |
|
||||
| `logs.level-settings` | auto | PASS | 2026-09-15T19:06:00Z | `c-20260915185011-0c8a` | yes |
|
||||
| `motion.pacing` | auto | PASS | 2026-09-15T23:11:57Z | `c-20260915231006-07cc` | no |
|
||||
| `motion.jog-roundtrip` | auto | PASS | 2026-09-15T23:12:10Z | `c-20260915231006-07cc` | no |
|
||||
| `motion.microstep-modes` | auto | PASS | 2026-09-15T23:12:36Z | `c-20260915231006-07cc` | no |
|
||||
| `motion.liveness-probe` | auto | PASS | 2026-09-15T20:59:39Z | `c-20260915205702-a7b9` | yes |
|
||||
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-15T20:59:55Z | `c-20260915205702-a7b9` | yes |
|
||||
| `motion.cancel-abort` | auto | PASS | 2026-09-15T23:12:43Z | `c-20260915231006-07cc` | no |
|
||||
| `motion.deadman` | auto | PASS | 2026-09-15T23:13:29Z | `c-20260915231006-07cc` | no |
|
||||
| `motion.respawn-gate` | operator | PASS | 2026-09-15T23:13:38Z | `c-20260915231006-07cc` | no |
|
||||
| `motion.soft-limits` | auto | PASS | 2026-09-15T23:15:35Z | `c-20260915231006-07cc` | no |
|
||||
| `motion.button-hold-resume` | operator | PASS | 2026-09-15T23:15:46Z | `c-20260915231006-07cc` | no |
|
||||
| `motion.lid-cancel-home` | operator | PASS | 2026-09-15T23:16:02Z | `c-20260915231006-07cc` | no |
|
||||
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-15T23:16:10Z | `c-20260915231006-07cc` | no |
|
||||
| `motion.lid-policy-hold` | operator | PASS | 2026-09-15T23:16:24Z | `c-20260915231006-07cc` | no |
|
||||
| `motion.step-timing-under-load` | auto | PASS | 2026-09-15T23:16:54Z | `c-20260915231006-07cc` | no |
|
||||
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-15T19:00:00Z | `c-20260915185011-0c8a` | yes |
|
||||
| `cooling.flow-verify` | auto | PASS | 2026-09-15T19:02:43Z | `c-20260915185011-0c8a` | yes |
|
||||
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-15T21:04:26Z | `c-20260915205702-a7b9` | yes |
|
||||
| `cooling.gate-off` | auto | PASS | 2026-09-15T18:55:57Z | `c-20260915185011-0c8a` | yes |
|
||||
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-15T21:05:53Z | `c-20260915205702-a7b9` | yes |
|
||||
| `cooling.tec-drive` | auto | PASS | 2026-09-15T21:06:01Z | `c-20260915205702-a7b9` | yes |
|
||||
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-15T21:06:17Z | `c-20260915205702-a7b9` | yes |
|
||||
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-15T21:06:25Z | `c-20260915205702-a7b9` | yes |
|
||||
| `cooling.critical-tier` | auto | PASS | 2026-09-15T21:06:34Z | `c-20260915205702-a7b9` | yes |
|
||||
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-15T18:56:43Z | `c-20260915185011-0c8a` | yes |
|
||||
| `cooling.flow-under-load` | live | PASS | 2026-09-15T23:45:14Z | `c-20260915231006-07cc` | no |
|
||||
| `cooling.fan-duty-readback` | auto | PASS | 2026-09-15T21:06:50Z | `c-20260915205702-a7b9` | yes |
|
||||
| `cooling.fail-tier-stop` | operator | PASS | 2026-09-15T21:19:25Z | `c-20260915211732-5800` | yes |
|
||||
| `laser.power-floor` | auto | PASS | 2026-09-15T23:16:56Z | `c-20260915231006-07cc` | no |
|
||||
| `laser.emission-witness` | live, core | PASS | 2026-09-15T23:28:39Z | `c-20260915231006-07cc` | no |
|
||||
| `laser.m5-rapid-dark` | live | PASS | 2026-09-15T23:45:54Z | `c-20260915231006-07cc` | no |
|
||||
| `laser.disarm-in-hold` | live | PASS | 2026-09-15T23:47:56Z | `c-20260915231006-07cc` | no |
|
||||
| `laser.armed-kill` | live | PASS | 2026-09-15T23:48:48Z | `c-20260915231006-07cc` | no |
|
||||
| `laser.verdict-cut` | live | PASS | 2026-09-15T23:49:28Z | `c-20260915231006-07cc` | no |
|
||||
| `laser.arm-wait-lid` | operator | PASS | 2026-09-15T23:16:59Z | `c-20260915231006-07cc` | no |
|
||||
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-15T23:49:58Z | `c-20260915231006-07cc` | no |
|
||||
| `camera.snapshot` | auto | PASS | 2026-09-15T18:57:38Z | `c-20260915185011-0c8a` | yes |
|
||||
| `camera.sensor-profile` | auto | PASS | 2026-09-15T21:19:42Z | `c-20260915211732-5800` | yes |
|
||||
| `camera.h264-stream` | auto | PASS | 2026-09-15T21:19:55Z | `c-20260915211732-5800` | yes |
|
||||
| `camera.frame-health` | auto | PASS | 2026-09-15T21:20:07Z | `c-20260915211732-5800` | yes |
|
||||
| `camera.lid-privacy` | operator | PASS | 2026-09-15T21:20:21Z | `c-20260915211732-5800` | yes |
|
||||
| `camera.key-read` | auto | PASS | 2026-09-15T21:20:22Z | `c-20260915211732-5800` | yes |
|
||||
| `update.slots-and-signature` | auto | PASS | 2026-09-12T16:59:48Z | `c-20260912165539-ac51` | yes |
|
||||
| `update.release-check` | auto | PASS | 2026-09-15T21:20:23Z | `c-20260915211732-5800` | yes |
|
||||
| `cloud.mode-switch` | operator | PASS | 2026-09-15T23:15:16Z | `c-20260915231006-07cc` | no |
|
||||
| `cloud.service-protocol` | operator | PASS | 2026-09-15T23:51:07Z | `c-20260915231006-07cc` | no |
|
||||
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-15T23:52:16Z | `c-20260915231006-07cc` | no |
|
||||
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-15T21:21:03Z | `c-20260915211732-5800` | yes |
|
||||
| `cloud.dark-print` | operator | PASS | 2026-09-15T21:21:58Z | `c-20260915211732-5800` | yes |
|
||||
| `cloud.verdict-refuse` | operator | PASS | 2026-09-15T21:23:20Z | `c-20260915211732-5800` | yes |
|
||||
| `cloud.pause-resume` | live | PASS | 2026-09-15T23:54:31Z | `c-20260915231006-07cc` | no |
|
||||
| `cloud.oversize-stream` | live | PASS | 2026-09-15T23:57:18Z | `c-20260915231006-07cc` | no |
|
||||
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-15T23:57:46Z | `c-20260915231006-07cc` | no |
|
||||
|
||||
Artifact sha256: `35e54b5db01fb23eb9091302e86d158852f9b4eaddca2a127a1bc957efb2ea0c`
|
||||
@@ -0,0 +1,7 @@
|
||||
ForgeFIRM v0.0.5 is a beta release.
|
||||
|
||||
It follows a review of the Grbl control path, tightening the laser emission and motion gates, the stream and protocol handling, and the machine-services supervisor. The connect banner, the soft-limit envelope after a home, and the cooling fail tiers all behave more predictably, and a fresh controller or a new sender starts from a clean state.
|
||||
|
||||
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
|
||||
|
||||
Every release below 0.1.0 is a beta release. Installation, usage, and safety are documented at https://docs.forgefirm.org/.
|
||||
@@ -39,6 +39,20 @@ reported messages:
|
||||
not to where it was paused (the core restarts a held cycle through
|
||||
Idle); a job abandoned in a hold and reset ends there, so the next
|
||||
job's start is captured afresh where it begins
|
||||
11. the cooling verdict's pause tier (OVERTEMP) holds the job under the
|
||||
open window without writing the latch (a lock sets the hardware
|
||||
button latch, which only a press clears); the clean verdict then
|
||||
resumes it with no new button press: no prompt, no second arm
|
||||
12. the verdict's fail tier (AIRFLOW) ends the job: disarmed, reset with
|
||||
ALARM:3, and a ~ under the clean verdict that follows resumes nothing
|
||||
13. a sender change during a re-arm cancels it: the press that follows
|
||||
resumes nothing, the job stays held, and the new sender's own ~
|
||||
prompts afresh
|
||||
14. a jog does not hold the armed window open: with the spindle off the
|
||||
grace counts down through jogs, and the window closes on time
|
||||
15. a press counts only after the button has been seen up: a press that
|
||||
began before the wait is not consent (the harness's own presses
|
||||
follow a fresh prompt for the same reason)
|
||||
|
||||
The disarm grace is shortened via a temp config (GFHOME_CONF), the
|
||||
cooling verdict is published hermetically (GF_VERDICT_FILE), the same
|
||||
@@ -141,23 +155,29 @@ def wait_idle(sock, log):
|
||||
fail("controller never returned to Idle")
|
||||
|
||||
|
||||
def publish_verdicts(path, stop, fire_ok, armed_ack=True, ack_after_s=0.0):
|
||||
"""Stand in for the cooling engine. "armed" is the engine's
|
||||
acknowledgment that it has taken the controller's armed window and
|
||||
applied the run airflow; the controller refuses to fire on a verdict
|
||||
that lacks it, so an engine that never acknowledges (armed_ack
|
||||
False) must produce a refused arm and no emission. ack_after_s
|
||||
withholds the acknowledgment for that long first, which is the real
|
||||
engine's case: it answers on its next tick, and the controller has
|
||||
to see the refreshed verdict to get past the arm."""
|
||||
def publish_verdicts(path, stop, verdict):
|
||||
"""Stand in for the cooling engine. `verdict` is the session's live
|
||||
dict: fire_ok, hold, resume_ok, name, armed_ack, ack_after_s. "armed"
|
||||
is the engine's acknowledgment that it has taken the controller's
|
||||
armed window and applied the run airflow; the controller refuses to
|
||||
fire on a verdict that lacks it, so an engine that never
|
||||
acknowledges (armed_ack False) must produce a refused arm and no
|
||||
emission. ack_after_s withholds the acknowledgment for that long
|
||||
first, which is the real engine's case: it answers on its next
|
||||
tick, and the controller has to see the refreshed verdict to get
|
||||
past the arm."""
|
||||
t0 = time.monotonic()
|
||||
while not stop.is_set():
|
||||
acked = armed_ack and time.monotonic() - t0 >= ack_after_s
|
||||
body = ('{"ts_mono":%.3f,"fire_ok":%s,"hold":false,'
|
||||
'"resume_ok":true,"armed":%s,"reason":""}'
|
||||
v = dict(verdict)
|
||||
acked = v["armed_ack"] and time.monotonic() - t0 >= v["ack_after_s"]
|
||||
body = ('{"ts_mono":%.3f,"fire_ok":%s,"verdict":"%s","hold":%s,'
|
||||
'"resume_ok":%s,"armed":%s,"reason":"%s"}'
|
||||
% (time.clock_gettime(time.CLOCK_MONOTONIC),
|
||||
"true" if fire_ok else "false",
|
||||
"true" if acked else "false"))
|
||||
"true" if v["fire_ok"] else "false", v["name"],
|
||||
"true" if v["hold"] else "false",
|
||||
"true" if v["resume_ok"] else "false",
|
||||
"true" if acked else "false",
|
||||
"" if v["fire_ok"] else "harness: %s" % v["name"]))
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(body)
|
||||
@@ -165,6 +185,20 @@ def publish_verdicts(path, stop, fire_ok, armed_ack=True, ack_after_s=0.0):
|
||||
stop.wait(0.5)
|
||||
|
||||
|
||||
def wait_for_new(log, needle, n_before, timeout, sock=None):
|
||||
"""Wait until needle appears in the log more times than n_before: a
|
||||
fresh occurrence, never a stale match from an earlier job."""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if "".join(log).count(needle) > n_before:
|
||||
return True
|
||||
if sock is not None:
|
||||
read_avail(sock, log, 0.2)
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
class Session:
|
||||
"""One controller process with the lifecycle overrides applied."""
|
||||
|
||||
@@ -189,8 +223,10 @@ class Session:
|
||||
self.set_switches(switches)
|
||||
env["GF_SWITCH_FILE"] = self.switch_file
|
||||
self.stop = threading.Event()
|
||||
self.verdict = {"fire_ok": fire_ok, "hold": False, "resume_ok": True, "name": "OK",
|
||||
"armed_ack": armed_ack, "ack_after_s": ack_after_s}
|
||||
self.pub = threading.Thread(target=publish_verdicts,
|
||||
args=(verdict, self.stop, fire_ok, armed_ack, ack_after_s),
|
||||
args=(verdict, self.stop, self.verdict),
|
||||
daemon=True)
|
||||
self.pub.start()
|
||||
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
|
||||
@@ -220,6 +256,16 @@ class Session:
|
||||
f.write("%d\n" % word)
|
||||
os.replace(tmp, self.switch_file)
|
||||
|
||||
def set_verdict(self, name="OK", fire_ok=True, hold=False, resume_ok=True):
|
||||
"""The engine's next verdicts: a pause tier (OVERTEMP: fire
|
||||
blocked, hold, no resume), a fail tier (AIRFLOW: the same under a
|
||||
name the client ends the job on), or clean."""
|
||||
self.verdict.update({"name": name, "fire_ok": fire_ok, "hold": hold,
|
||||
"resume_ok": resume_ok})
|
||||
|
||||
def count(self, needle):
|
||||
return "".join(self.log).count(needle)
|
||||
|
||||
def send_raw(self, line):
|
||||
"""Send a line without waiting for ok/error (the arm wait blocks
|
||||
the gcode stream, so the ok only comes once the button is pressed)."""
|
||||
@@ -867,13 +913,17 @@ def test_lid_open_in_wait():
|
||||
|
||||
def start_armed_move(s, tag, gcode="G1 X30 F60"):
|
||||
"""Arm through the button and get a long move under way; returns once
|
||||
the controller reports Run."""
|
||||
the controller reports Run. The prompt and the armed message are
|
||||
waited for as fresh occurrences: a press that lands before the
|
||||
wait has begun is not consent (rule 15), so the press must follow
|
||||
THIS job's prompt, not an earlier job's."""
|
||||
prompts, armed = s.count(PROMPT), s.count(ARMED)
|
||||
s.send_raw("M4 S100")
|
||||
s.send_raw(gcode)
|
||||
if not wait_for(s.log, PROMPT, 5, s.sock):
|
||||
if not wait_for_new(s.log, PROMPT, prompts, 5, s.sock):
|
||||
fail("[%s] no button prompt" % tag)
|
||||
s.press_button()
|
||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||
if not wait_for_new(s.log, ARMED, armed, 5, s.sock):
|
||||
fail("[%s] the button press did not arm" % tag)
|
||||
if not s.wait_state("Run", 5):
|
||||
fail("[%s] the job never reported Run" % tag)
|
||||
@@ -1045,9 +1095,158 @@ def test_lid_policy_hold():
|
||||
s.close()
|
||||
|
||||
|
||||
def test_verdict_pause_resumes_without_press():
|
||||
"""Rule 11: the pause tier holds under the open window without a
|
||||
latch write; the clean verdict resumes with no press. The window is
|
||||
proven still open by the absence of any prompt and of a second arm."""
|
||||
s = Session("verdict-pause", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
start_armed_move(s, "verdict-pause", gcode="G1 X30 F120") # 15 s of motion
|
||||
time.sleep(0.5)
|
||||
s.set_verdict("OVERTEMP", fire_ok=False, hold=True, resume_ok=False)
|
||||
if not s.wait_state("Hold", 5):
|
||||
fail("[verdict-pause] the pause tier did not hold the job (state %s)" % s.state())
|
||||
if not wait_for(s.log, "fire masked, job held", 3, s.sock):
|
||||
fail("[verdict-pause] the pause tier did not report the masked hold")
|
||||
if "latch locked" in "".join(s.log):
|
||||
fail("[verdict-pause] the pause tier wrote the latch")
|
||||
if DISARMED in "".join(s.log):
|
||||
fail("[verdict-pause] the pause tier closed the armed window")
|
||||
time.sleep(1.5)
|
||||
s.set_verdict("OK", fire_ok=True, hold=False, resume_ok=True)
|
||||
if not s.wait_state("Run", 5):
|
||||
fail("[verdict-pause] the clean verdict did not resume the job (state %s)" % s.state())
|
||||
if not wait_for(s.log, "resuming", 2, s.sock):
|
||||
fail("[verdict-pause] the resume was not reported")
|
||||
if RESUME_PROMPT in "".join(s.log) or s.count(PROMPT) != 1:
|
||||
fail("[verdict-pause] the resume asked for a button press")
|
||||
if s.armed_count() != 1:
|
||||
fail("[verdict-pause] the resume armed again (armed messages: %d)" % s.armed_count())
|
||||
if "ALARM" in "".join(s.log):
|
||||
fail("[verdict-pause] the pause tier raised an alarm")
|
||||
s.sock.sendall(b"\x18")
|
||||
print("PASS [verdict-pause]: OVERTEMP held under the open window with no latch write; "
|
||||
"the clean verdict resumed with no press")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_verdict_fail_tier_ends_job():
|
||||
"""Rule 12: the fail tier disarms, resets the job with ALARM:3, and
|
||||
nothing resumes it."""
|
||||
s = Session("verdict-fail", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
start_armed_move(s, "verdict-fail", gcode="G1 X30 F120")
|
||||
time.sleep(0.5)
|
||||
s.set_verdict("AIRFLOW", fire_ok=False, hold=True, resume_ok=False)
|
||||
if not s.wait_state("Alarm", 5):
|
||||
fail("[verdict-fail] the fail tier did not end the job in Alarm (state %s)" % s.state())
|
||||
if not wait_for(s.log, DISARMED, 3, s.sock):
|
||||
fail("[verdict-fail] the fail tier did not close the armed window")
|
||||
text = "".join(s.log)
|
||||
if "ALARM:3" not in text:
|
||||
fail("[verdict-fail] no ALARM:3 on the fail tier")
|
||||
if "AIRFLOW" not in text:
|
||||
fail("[verdict-fail] the fail tier did not name the verdict")
|
||||
s.set_verdict("OK", fire_ok=True, hold=False, resume_ok=True)
|
||||
time.sleep(1.5)
|
||||
s.sock.sendall(b"~")
|
||||
read_avail(s.sock, s.log, 1.0)
|
||||
if not s.state().startswith("Alarm"):
|
||||
fail("[verdict-fail] a ~ after the fail tier resumed something (state %s)" % s.state())
|
||||
if "resuming" in "".join(s.log):
|
||||
fail("[verdict-fail] the client resumed after the fail tier")
|
||||
send_line(s.sock, "$X", s.log)
|
||||
if not s.wait_state("Idle", 3):
|
||||
fail("[verdict-fail] $X did not unlock the alarm")
|
||||
print("PASS [verdict-fail]: AIRFLOW disarmed, reset the job with ALARM:3, and nothing "
|
||||
"resumed it")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_sender_change_during_rearm_cancels():
|
||||
"""Rule 13: a sender change while a re-arm waits for the press
|
||||
cancels the re-arm; the press resumes nothing and the job stays
|
||||
held; the new sender's ~ prompts afresh."""
|
||||
s = Session("rearm-sender-change", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
start_armed_move(s, "rearm-sender-change", gcode="G1 X30 F120")
|
||||
time.sleep(1.0)
|
||||
s.sock.close() # mid-move: held, disarmed
|
||||
time.sleep(0.5)
|
||||
s.sock = s.connect()
|
||||
if s.wait_state("Hold", 5) is None:
|
||||
fail("[rearm-sender-change] the job was not held on the sender change")
|
||||
s.sock.sendall(b"~")
|
||||
if not wait_for(s.log, RESUME_PROMPT, 5, s.sock):
|
||||
fail("[rearm-sender-change] the resume did not prompt for the button")
|
||||
# A second sender change, inside the re-arm wait. The cancel is
|
||||
# reported at the disconnect, to nobody (output with no client is
|
||||
# discarded), so the evidence is what the press does next: nothing.
|
||||
s.sock.close()
|
||||
time.sleep(0.5)
|
||||
s.sock = s.connect()
|
||||
st = read_state(s, '"connected":true')
|
||||
if '"arming":false' not in st:
|
||||
fail("[rearm-sender-change] the re-arm wait survived the sender change: %r" % st)
|
||||
# A press now is the machine's resume button for the new sender:
|
||||
# it resumes nothing and arms nothing, it opens a fresh re-arm
|
||||
# prompt of its own (the displaced consent is gone), and only a
|
||||
# second press, against that prompt, re-arms the held job.
|
||||
before = s.armed_count()
|
||||
prompts = s.count(RESUME_PROMPT)
|
||||
s.press_button()
|
||||
read_avail(s.sock, s.log, 1.0)
|
||||
if s.armed_count() != before or not s.state().startswith("Hold"):
|
||||
fail("[rearm-sender-change] a press after the canceled re-arm resumed the job "
|
||||
"(armed %d -> %d, state %s)" % (before, s.armed_count(), s.state()))
|
||||
if not wait_for_new(s.log, RESUME_PROMPT, prompts, 3, s.sock):
|
||||
fail("[rearm-sender-change] the press after the cancel did not open a fresh re-arm prompt")
|
||||
s.press_button()
|
||||
end = time.time() + 10
|
||||
while time.time() < end and s.armed_count() != before + 1:
|
||||
read_avail(s.sock, s.log, 0.2)
|
||||
if s.armed_count() != before + 1:
|
||||
fail("[rearm-sender-change] the second press did not re-arm the held job")
|
||||
s.sock.sendall(b"\x18")
|
||||
print("PASS [rearm-sender-change]: the sender change canceled the re-arm; the next press "
|
||||
"resumed nothing and prompted afresh, and the press after it re-armed")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_jog_does_not_extend_window():
|
||||
"""Rule 14: with the spindle off, jogs do not reset the disarm grace:
|
||||
the window closes on time through them."""
|
||||
s = Session("jog-grace", disarm_s=2)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X1 F600", s.log)
|
||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||
fail("[jog-grace] job did not arm")
|
||||
send_line(s.sock, "M5", s.log)
|
||||
wait_idle(s.sock, s.log)
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 5.0 and DISARMED not in "".join(s.log):
|
||||
send_line(s.sock, "$J=G91X1F1200", s.log)
|
||||
read_avail(s.sock, s.log, 0.3)
|
||||
dt = time.time() - t0
|
||||
if DISARMED not in "".join(s.log):
|
||||
fail("[jog-grace] the window stayed open through %.1f s of jogging (grace 2 s)" % dt)
|
||||
wait_idle(s.sock, s.log)
|
||||
print("PASS [jog-grace]: the window closed after %.1f s of jogging with the spindle off" % dt)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isfile(BIN):
|
||||
fail("controller binary not found at %s" % BIN)
|
||||
test_verdict_pause_resumes_without_press()
|
||||
test_verdict_fail_tier_ends_job()
|
||||
test_sender_change_during_rearm_cancels()
|
||||
test_jog_does_not_extend_window()
|
||||
test_job_window()
|
||||
test_status_files()
|
||||
test_laser_keys_reload()
|
||||
|
||||
@@ -79,12 +79,45 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
the default gamma of 2 than at gamma 1, and the cruise middle
|
||||
renders the same - the exponent shapes only the velocity-scaled
|
||||
rolloff, never the programmed level
|
||||
24. a hold verdict is held again after a resume: with the engine's
|
||||
verdict at its fail tier (hold, fire blocked, no resume) the client
|
||||
holds the job; a ~ under that verdict, which is what a button press
|
||||
or a sender does, moves the head for at most one client poll, dark,
|
||||
before the client holds it again and says so; the clean verdict then
|
||||
resumes the hold the client took, and the rest of the line cuts lit
|
||||
24. the verdict's pause tier: with the engine's verdict at a pause
|
||||
(OVERTEMP: hold, fire blocked, no resume) the client holds the job
|
||||
under the open window; the deceleration into that first hold runs
|
||||
lit to the stop, as a feed hold's does (the segments already planned
|
||||
at speed would otherwise play dark and leave a gap in the cut), and
|
||||
the stream engine's per-tick gate masks the stream from the stop on;
|
||||
a ~ under that verdict, which is what a button press or a sender
|
||||
does, moves the head dark for at most one client poll before the
|
||||
client holds it again and says so; the clean verdict then resumes
|
||||
the hold the client took with no new press, and the rest of the line
|
||||
cuts lit. The latch is never written by a pause (a lock sets the
|
||||
hardware button latch, which only a press clears): the latch
|
||||
sideband (GFSINK_LATCH_LOG) carries the unlock at the arm and the
|
||||
lock at the program end and nothing between
|
||||
25. the verdict's fail tier: a verdict named AIRFLOW (fire blocked, hold,
|
||||
no resume) mid-M3 ends the job: the window closes, the latch locks,
|
||||
the job is reset with ALARM:3, the stream ends dark well short of the
|
||||
line, and a ~ under the clean verdict that follows resumes nothing;
|
||||
the sideband ends on the lock and carries no unlock after it
|
||||
26. a sender change mid-M3 closes the window and holds the job, and the
|
||||
deceleration into that hold ships dark: the gate follows the window
|
||||
on every tick, so a fire state the core never updates cannot outlive
|
||||
the consent it rode on
|
||||
27. a verdict that goes stale (the engine stops publishing mid-cut)
|
||||
holds the job the moment the client's cache expires, on its own
|
||||
clock between two of its file reads, lit to the stop like rule 24:
|
||||
no dark cut runs out while the client waits for its next read; the
|
||||
engine's return resumes the cut lit
|
||||
28. a scheduling stall of the step producer (GFSINK_STALL_MS: the
|
||||
producer is kept off the CPU longer than its lead) maps events
|
||||
behind the ship cursor. Inside an armed window that is a fault:
|
||||
ALARM:17, the window closed, the stream ended dark, and no step
|
||||
burst shipped (the clamped events are never compressed onto later
|
||||
bytes). Unarmed it is a warning in the log and the move completes
|
||||
with every step in the stream, compressed
|
||||
29. a stall of the kernel write (GFSINK_WRITE_STALL_MS: the sink holds
|
||||
the write the way a full ring holds it) stalls nothing but the
|
||||
shipper: the producer keeps mapping at wall pace behind it, so the
|
||||
job completes lit with no clamp, no burst, and a dark end
|
||||
|
||||
The analog sessions select the reference mode through the config; on
|
||||
hardware the controller ignores it (density is the only product model -
|
||||
@@ -401,28 +434,37 @@ def wait_state(sock, log, prefix, timeout=5.0):
|
||||
fail("controller never reached %s" % prefix)
|
||||
|
||||
|
||||
# The published verdict is clean unless a session sets this: then it is
|
||||
# the engine's fail tier (hold, fire blocked, no resume), what an airflow
|
||||
# fault publishes. A ("verdict", "hold") step sets it, ("verdict",
|
||||
# The published verdict is clean unless a session sets a mode: "hold" is
|
||||
# the engine's pause tier (OVERTEMP: hold, fire blocked, no resume) and
|
||||
# "fail" its fail tier (AIRFLOW: the same flags under a name the client
|
||||
# ends the job on). A ("verdict", <mode>) step sets it, ("verdict",
|
||||
# "clean") clears it.
|
||||
VERDICT_HOLD = threading.Event()
|
||||
VERDICT_MODE = {"mode": "clean"}
|
||||
VERDICT_NAMES = {"hold": "OVERTEMP", "fail": "AIRFLOW"}
|
||||
|
||||
|
||||
def publish_verdicts(path, stop):
|
||||
"""Publish a fresh cooling verdict every 0.5 s (the arm flow refuses
|
||||
without one; freshness window is 2 s), clean unless VERDICT_HOLD is
|
||||
set. Same-host monotonic clock, atomic rename so the reader never
|
||||
sees a torn file. "armed" is the engine's acknowledgment that it has
|
||||
taken the controller's armed window; the arm waits for it, so a
|
||||
stand-in engine that means to let jobs run must assert it."""
|
||||
without one; freshness window is 2 s), clean unless VERDICT_MODE
|
||||
says otherwise. Same-host monotonic clock, atomic rename so the
|
||||
reader never sees a torn file. "armed" is the engine's
|
||||
acknowledgment that it has taken the controller's armed window; the
|
||||
arm waits for it, so a stand-in engine that means to let jobs run
|
||||
must assert it."""
|
||||
while not stop.is_set():
|
||||
hold = VERDICT_HOLD.is_set()
|
||||
body = ('{"ts_mono":%.3f,"fire_ok":%s,"hold":%s,'
|
||||
mode = VERDICT_MODE["mode"]
|
||||
if mode == "stale":
|
||||
stop.wait(0.5) # the engine has stopped publishing
|
||||
continue
|
||||
blocked = mode != "clean"
|
||||
body = ('{"ts_mono":%.3f,"fire_ok":%s,"verdict":"%s","hold":%s,'
|
||||
'"resume_ok":%s,"armed":true,"reason":"%s"}'
|
||||
% (time.clock_gettime(time.CLOCK_MONOTONIC),
|
||||
"false" if hold else "true", "true" if hold else "false",
|
||||
"false" if hold else "true",
|
||||
"harness: airflow fault" if hold else ""))
|
||||
"false" if blocked else "true",
|
||||
VERDICT_NAMES.get(mode, "OK"),
|
||||
"true" if blocked else "false",
|
||||
"false" if blocked else "true",
|
||||
("harness: %s" % VERDICT_NAMES[mode]) if blocked else ""))
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(body)
|
||||
@@ -431,19 +473,23 @@ def publish_verdicts(path, stop):
|
||||
|
||||
|
||||
def run_session(name, steps, conf=None, workdir=None, keep=False,
|
||||
arm_required=True):
|
||||
arm_required=True, env_extra=None):
|
||||
"""Launch the controller, run the job steps, return the dump bytes.
|
||||
|
||||
Pass workdir + keep to chain launches over one settings file: the
|
||||
core precomputes the spindle PWM mapping once, when the spindle is
|
||||
enabled, so a $35 written at runtime only takes effect on the next
|
||||
controller start."""
|
||||
controller start. env_extra adds to the controller's environment
|
||||
(the host stall knobs). The controller's log (stderr) is kept in
|
||||
run_session.err."""
|
||||
if workdir is None:
|
||||
workdir = tempfile.mkdtemp(prefix="laser-test-")
|
||||
dump = os.path.join(workdir, "stream.bin")
|
||||
verdict = os.path.join(workdir, "cooling.state")
|
||||
latch_log = os.path.join(workdir, "latch.log")
|
||||
env = dict(os.environ, GFSINK_DUMP=dump, GF_VERDICT_FILE=verdict,
|
||||
FFLOG_STDERR="1")
|
||||
GFSINK_LATCH_LOG=latch_log, FFLOG_STDERR="1")
|
||||
env.update(env_extra or {})
|
||||
# The lens reference the daemon leaves before a controller starts:
|
||||
# forgectrl sweeps the carriage onto the hall edge and marks it, and
|
||||
# the controller opens the Z envelope on that mark. Without one Z
|
||||
@@ -462,7 +508,7 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
|
||||
env["GFHOME_CONF"] = conf_path
|
||||
|
||||
stop = threading.Event()
|
||||
VERDICT_HOLD.clear()
|
||||
VERDICT_MODE["mode"] = "clean"
|
||||
pub = threading.Thread(target=publish_verdicts, args=(verdict, stop), daemon=True)
|
||||
pub.start()
|
||||
|
||||
@@ -494,12 +540,18 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
|
||||
elif isinstance(step, tuple) and step[0] == "rt":
|
||||
sock.sendall(step[1]) # a realtime character: no ok follows
|
||||
elif isinstance(step, tuple) and step[0] == "wait_state":
|
||||
wait_state(sock, log, step[1])
|
||||
wait_state(sock, log, step[1], step[2] if len(step) > 2 else 5.0)
|
||||
elif isinstance(step, tuple) and step[0] == "verdict":
|
||||
if step[1] == "hold":
|
||||
VERDICT_HOLD.set()
|
||||
else:
|
||||
VERDICT_HOLD.clear()
|
||||
VERDICT_MODE["mode"] = step[1]
|
||||
elif isinstance(step, tuple) and step[0] == "expect_text":
|
||||
if not wait_text(sock, log, step[1], step[2] if len(step) > 2 else 5.0):
|
||||
fail("[%s] the controller never said %r" % (name, step[1]))
|
||||
elif isinstance(step, tuple) and step[0] == "reconnect":
|
||||
# A sender change: the socket closes and a new one connects.
|
||||
sock.close()
|
||||
time.sleep(step[1] if len(step) > 1 else 0.3)
|
||||
sock = socket.create_connection(("127.0.0.1", PORT), timeout=1)
|
||||
read_avail(sock, log, 0.5)
|
||||
else:
|
||||
send_line(sock, step, log)
|
||||
|
||||
@@ -520,11 +572,19 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
|
||||
proc.wait(5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
try:
|
||||
run_session.err = (proc.stderr.read() or b"").decode(errors="replace")
|
||||
except (OSError, ValueError):
|
||||
run_session.err = ""
|
||||
stop.set()
|
||||
pub.join(2)
|
||||
VERDICT_HOLD.clear()
|
||||
VERDICT_MODE["mode"] = "clean"
|
||||
|
||||
data = open(dump, "rb").read()
|
||||
try:
|
||||
run_session.latch = open(latch_log).read().split()
|
||||
except OSError:
|
||||
run_session.latch = []
|
||||
if not data and arm_required:
|
||||
fail("[%s] empty stream dump" % name)
|
||||
if not keep:
|
||||
@@ -532,6 +592,89 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
|
||||
return data
|
||||
|
||||
|
||||
def wait_text(sock, log, needle, timeout):
|
||||
"""Drain the socket until needle appears in the accumulated log."""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if needle in "".join(log):
|
||||
return True
|
||||
read_avail(sock, log, 0.2)
|
||||
return needle in "".join(log)
|
||||
|
||||
|
||||
def latch_transitions(lines):
|
||||
"""The sideband's lock/unlock lines with repeats collapsed: the
|
||||
ownership sequence as transitions."""
|
||||
out = []
|
||||
for ln in lines:
|
||||
if ln in ("lock", "unlock") and (not out or out[-1] != ln):
|
||||
out.append(ln)
|
||||
return out
|
||||
|
||||
|
||||
def holds_in(ticks, min_run=2000):
|
||||
"""The stationary stretches (no X/Y/Z step for min_run ticks) inside
|
||||
the motion, as (start, end) tick spans."""
|
||||
step = [1 if t & 0x25 else 0 for t in ticks]
|
||||
first = step.index(1)
|
||||
last = len(step) - 1 - step[::-1].index(1)
|
||||
holds, run = [], 0
|
||||
for i in range(first, last + 1):
|
||||
if step[i]:
|
||||
if run >= min_run:
|
||||
holds.append((i - run, i))
|
||||
run = 0
|
||||
else:
|
||||
run += 1
|
||||
return holds
|
||||
|
||||
|
||||
# The deceleration into a hold from F3000 (50 mm/s) at the board's
|
||||
# 700 mm/s^2: 71 ms, about 2000 ticks. A gate that closes with the
|
||||
# hold darkens all of it but the producer's lead (10 ms, ~280 ticks);
|
||||
# a fire state that outlives the gate lights it to the last step.
|
||||
DECEL_TICKS = int(50.0 / 700.0 * MACHINE_TICK_HZ)
|
||||
DECEL_DARK_MIN = DECEL_TICKS - 600
|
||||
|
||||
|
||||
def dark_lead(ticks, hold):
|
||||
"""Ticks between the last FIRE tick before `hold` and its stop."""
|
||||
h0 = hold[0]
|
||||
last = next((i for i in range(h0 - 1, -1, -1) if ticks[i] & 0x10), None)
|
||||
return h0 - last if last is not None else h0
|
||||
|
||||
|
||||
def check_decel_dark(name, ticks, hold, what):
|
||||
"""No FIRE tick in the deceleration into `hold` beyond the lead: the
|
||||
gate closed with the cause (a closed window, a lost sender)."""
|
||||
lead = dark_lead(ticks, hold)
|
||||
if lead < DECEL_DARK_MIN:
|
||||
fail("[%s] the deceleration into the hold ran lit: the last FIRE tick is %d "
|
||||
"ticks before the stationary stretch, expected at least %d (%s)"
|
||||
% (name, lead, DECEL_DARK_MIN, what))
|
||||
return lead
|
||||
|
||||
|
||||
# A lit deceleration ends within the producer's lead (10 ms) plus one
|
||||
# shipper period (10 ms) of the stop: the gate closes when the core
|
||||
# reports the hold complete, and the bytes produced ahead of the cursor
|
||||
# by then ship dark. 1000 ticks is 35 ms, under 0.2 mm at the end of a
|
||||
# ramp from 50 mm/s; a gate that closed before the head stopped shows
|
||||
# as the whole deceleration (~2000 ticks) or more.
|
||||
DECEL_LIT_MAX = 1000
|
||||
|
||||
|
||||
def check_decel_lit(name, ticks, hold, what):
|
||||
"""FIRE ran to the stop: the pause tier keeps the beam through the
|
||||
deceleration it planned, so no dark motion precedes the hold."""
|
||||
lead = dark_lead(ticks, hold)
|
||||
if lead > DECEL_LIT_MAX:
|
||||
fail("[%s] %d ticks (%.0f ms) of dark motion before the stop, expected at most %d: "
|
||||
"the gate closed before the head stopped (%s)"
|
||||
% (name, lead, lead * 1e3 / MACHINE_TICK_HZ, DECEL_LIT_MAX, what))
|
||||
return lead
|
||||
|
||||
|
||||
def tick_bytes(data):
|
||||
"""The stream with power bytes stripped (tick bytes only)."""
|
||||
return bytes(b for b in data if not b & 0x80)
|
||||
@@ -1092,42 +1235,45 @@ def main():
|
||||
"(%d ticks), lit from the first step out (%d fire ticks)"
|
||||
% (mode, decel, dlen, accel))
|
||||
|
||||
# --- rule 24: a hold verdict is held again after a resume -----------
|
||||
# --- rule 24: the verdict's pause tier -------------------------------
|
||||
# One long line at 50 mm/s. Mid-move the engine's verdict goes to its
|
||||
# fail tier (hold, fire blocked, no resume: an airflow fault) and the
|
||||
# client takes the feed hold. A ~ then resumes the job under the
|
||||
# standing verdict, which is what a button press or a sender does;
|
||||
# the client must hold it again within its poll, saying so, and the
|
||||
# stretch it moved in between ships dark. The clean verdict then
|
||||
# resumes the hold the client took, and the rest of the line cuts lit.
|
||||
# pause tier (OVERTEMP: hold, fire blocked, no resume): the client
|
||||
# takes the feed hold, and the stream's gate darkens the deceleration
|
||||
# into it; the latch is not written. A ~ then resumes the job under
|
||||
# the standing verdict, which is what a button press or a sender
|
||||
# does; the client must hold it again within its poll, saying so,
|
||||
# and the stretch it moved in between ships dark. The clean verdict
|
||||
# then resumes the hold the client took, and the rest of the line
|
||||
# cuts lit; M2 closes the window and locks.
|
||||
TICK_HZ = 28160
|
||||
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
|
||||
("verdict", "hold"), ("wait_state", "Hold:0"), ("sleep", 0.5),
|
||||
("rt", b"~"), ("sleep", 1.2), ("wait_state", "Hold:0"),
|
||||
("sleep", 0.5), ("verdict", "clean"), WAIT_IDLE, "M5"]
|
||||
("sleep", 0.5), ("verdict", "clean"), WAIT_IDLE, "M5", "M2",
|
||||
("expect_text", "laser disarmed")]
|
||||
data = run_session("verdict-rehold", steps, conf=DENSITY_CONF_FLOORED)
|
||||
text = run_session.text
|
||||
if "held again" not in text:
|
||||
fail("[verdict-rehold] the client did not say it held the job again")
|
||||
if "resuming" not in text:
|
||||
fail("[verdict-rehold] the client did not resume its own hold once the verdict cleared")
|
||||
if "fire masked, job held" not in text:
|
||||
fail("[verdict-rehold] the pause tier did not report the masked hold")
|
||||
if "latch locked" in text.split("Pgm End")[0]:
|
||||
fail("[verdict-rehold] the pause tier locked the latch")
|
||||
if "ALARM" in text:
|
||||
fail("[verdict-rehold] the pause tier raised an alarm")
|
||||
ticks = tick_bytes(data)
|
||||
step = [1 if t & 0x05 else 0 for t in ticks]
|
||||
fire = [1 if t & 0x10 else 0 for t in ticks]
|
||||
first = step.index(1)
|
||||
step = [1 if t & 0x05 else 0 for t in ticks]
|
||||
last = len(step) - 1 - step[::-1].index(1)
|
||||
holds, run = [], 0 # the stationary stretches inside the motion
|
||||
for i in range(first, last + 1):
|
||||
if step[i]:
|
||||
if run >= 2000:
|
||||
holds.append((i - run, i))
|
||||
run = 0
|
||||
else:
|
||||
run += 1
|
||||
holds = holds_in(ticks)
|
||||
if len(holds) != 2:
|
||||
fail("[verdict-rehold] expected two holds in the stream, found %d: %s"
|
||||
% (len(holds), holds))
|
||||
(_h1s, h1e), (h2s, h2e) = holds
|
||||
lit_lead = check_decel_lit("verdict-rehold", ticks, holds[0],
|
||||
"a pause keeps the beam through its first deceleration")
|
||||
between = sum(fire[h1e:h2s])
|
||||
if between:
|
||||
fail("[verdict-rehold] FIRE while resumed under the hold verdict: %d fire ticks "
|
||||
@@ -1139,9 +1285,184 @@ def main():
|
||||
if lit_after < 50:
|
||||
fail("[verdict-rehold] the resume after the clean verdict ran dark (%d fire ticks)"
|
||||
% lit_after)
|
||||
print("PASS [verdict-rehold]: held, resumed dark for %d ticks (%.2f s), held again, "
|
||||
"lit after the clear (%d fire ticks)"
|
||||
% (h2s - h1e, (h2s - h1e) / float(TICK_HZ), lit_after))
|
||||
seq = latch_transitions(run_session.latch)
|
||||
if seq != ["unlock", "lock"]:
|
||||
fail("[verdict-rehold] latch ownership sequence %s, expected unlock (arm) and lock "
|
||||
"(program end) only: a pause must not write the latch" % seq)
|
||||
print("PASS [verdict-rehold]: held lit to %d ticks before the stop, resumed dark for "
|
||||
"%d ticks (%.2f s), held again, lit after the clear (%d fire ticks), latch %s"
|
||||
% (lit_lead, h2s - h1e, (h2s - h1e) / float(TICK_HZ), lit_after, seq))
|
||||
|
||||
# --- rule 25: the verdict's fail tier --------------------------------
|
||||
# The same line; mid-move the verdict goes to AIRFLOW. The job ends:
|
||||
# window closed, latch locked, ALARM:3, the stream short and dark at
|
||||
# its end, and the clean verdict that follows resumes nothing.
|
||||
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
|
||||
("verdict", "fail"), ("wait_state", "Alarm", 5.0),
|
||||
("expect_text", "laser disarmed"), ("verdict", "clean"), ("sleep", 1.0),
|
||||
("rt", b"~"), ("sleep", 0.5), ("wait_state", "Alarm", 2.0), "$X"]
|
||||
data = run_session("verdict-fail", steps, conf=DENSITY_CONF_FLOORED)
|
||||
text = run_session.text
|
||||
if "ALARM:3" not in text:
|
||||
fail("[verdict-fail] the fail tier did not end the job with ALARM:3")
|
||||
if "AIRFLOW" not in text:
|
||||
fail("[verdict-fail] the fail tier did not name the verdict")
|
||||
if "resuming" in text or "held again" in text:
|
||||
fail("[verdict-fail] the fail tier was treated as a pause")
|
||||
check_termination("verdict-fail", data)
|
||||
line_ticks = 150.0 / 50.0 * TICK_HZ
|
||||
lit = count_fire(data)
|
||||
if lit > line_ticks * 0.5:
|
||||
fail("[verdict-fail] %d fire ticks: the job ran on past the fail-tier verdict "
|
||||
"(the whole line is %d)" % (lit, line_ticks))
|
||||
seq = latch_transitions(run_session.latch)
|
||||
if seq != ["unlock", "lock"]:
|
||||
fail("[verdict-fail] latch ownership sequence %s, expected unlock (arm), lock "
|
||||
"(the fail tier), and nothing after" % seq)
|
||||
print("PASS [verdict-fail]: AIRFLOW mid-cut ended the job with ALARM:3, %d of %d ticks "
|
||||
"lit, ends dark, latch %s, ~ resumed nothing" % (lit, line_ticks, seq))
|
||||
|
||||
# --- rule 26: a sender change mid-M3 darkens the hold's decel ---------
|
||||
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
|
||||
("reconnect", 0.3), ("wait_state", "Hold:0", 5.0), ("sleep", 0.5),
|
||||
("rt", b"\x18"), ("sleep", 0.5)]
|
||||
data = run_session("sender-drop", steps, conf=DENSITY_CONF_FLOORED)
|
||||
ticks = tick_bytes(data)
|
||||
# No cycle follows the hold here, so the stream ends on the hold's
|
||||
# deceleration: the stop is the tick after the last step.
|
||||
step = [1 if t & 0x25 else 0 for t in ticks]
|
||||
if 1 not in step:
|
||||
fail("[sender-drop] no motion in the stream")
|
||||
stop = len(step) - step[::-1].index(1)
|
||||
if count_fire(data) < 1000:
|
||||
fail("[sender-drop] the cut before the sender change ran dark (%d fire ticks)"
|
||||
% count_fire(data))
|
||||
dark = check_decel_dark("sender-drop", ticks, (stop, stop),
|
||||
"the gate must follow the window closed on the sender change")
|
||||
seq = latch_transitions(run_session.latch)
|
||||
if seq[:2] != ["unlock", "lock"]:
|
||||
fail("[sender-drop] latch ownership sequence %s, expected unlock (arm), lock "
|
||||
"(the sender change)" % seq)
|
||||
print("PASS [sender-drop]: the sender change held the job with the decel dark from %d "
|
||||
"ticks before the stop, latch %s" % (dark, seq))
|
||||
|
||||
# --- rule 27: a stale verdict holds the moment the cache expires -----
|
||||
# The engine stops publishing mid-cut. The client reads the file
|
||||
# every 500 ms and its cache expires on its own clock between two
|
||||
# reads: the hold must land at the expiry, not at the next read, and
|
||||
# the deceleration runs lit to the stop like rule 24's, so no dark
|
||||
# cut runs out in between. The engine's return resumes the cut lit.
|
||||
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
|
||||
("verdict", "stale"), ("wait_state", "Hold:0", 6.0), ("sleep", 0.5),
|
||||
("verdict", "clean"), WAIT_IDLE, "M5", "M2", ("expect_text", "laser disarmed")]
|
||||
data = run_session("verdict-stale", steps, conf=DENSITY_CONF_FLOORED)
|
||||
text = run_session.text
|
||||
if "cooling service lost" not in text:
|
||||
fail("[verdict-stale] the client did not report the engine gone")
|
||||
if "resuming" not in text or "restored" not in text:
|
||||
fail("[verdict-stale] the client did not resume once the engine returned")
|
||||
ticks = tick_bytes(data)
|
||||
holds = holds_in(ticks)
|
||||
if len(holds) != 1:
|
||||
fail("[verdict-stale] expected one hold in the stream, found %d: %s" % (len(holds), holds))
|
||||
lit_lead = check_decel_lit("verdict-stale", ticks, holds[0],
|
||||
"the hold must land at the cache's expiry, lit to the stop")
|
||||
fire = [1 if t & 0x10 else 0 for t in ticks]
|
||||
step = [1 if t & 0x05 else 0 for t in ticks]
|
||||
last = len(step) - 1 - step[::-1].index(1)
|
||||
lit_after = sum(fire[holds[0][1]:last + 1])
|
||||
if lit_after < 50:
|
||||
fail("[verdict-stale] the resume after the engine's return ran dark (%d fire ticks)" % lit_after)
|
||||
seq = latch_transitions(run_session.latch)
|
||||
if seq != ["unlock", "lock"]:
|
||||
fail("[verdict-stale] latch ownership sequence %s, expected unlock (arm) and lock "
|
||||
"(program end) only" % seq)
|
||||
print("PASS [verdict-stale]: the expired cache held the job lit to %d ticks (%.0f ms) "
|
||||
"before the stop, lit after the return (%d fire ticks), latch %s"
|
||||
% (lit_lead, lit_lead * 1e3 / MACHINE_TICK_HZ, lit_after, seq))
|
||||
|
||||
# --- rules 28 and 29: the stalls -------------------------------------
|
||||
# The step density the job plans: F3000 at 53.333 steps/mm is 2667
|
||||
# steps/s, 9.5 per 100 ticks. A clamp compresses the late events onto
|
||||
# consecutive bytes, which is what the window count catches.
|
||||
STALL_JOB = ["G90", "G21", "G1 X150 F3000"]
|
||||
ARMED_STALL_JOB = ["G90", "G21", "M3 S500", "G1 X150 F3000"]
|
||||
PLANNED_PER_100 = 3000.0 / 60.0 * STEPS_PER_MM / MACHINE_TICK_HZ * 100.0
|
||||
BURST_LIMIT = int(PLANNED_PER_100 * 1.3) + 1
|
||||
|
||||
def max_steps_per_window(ticks, win=100):
|
||||
worst = 0
|
||||
for i in range(0, max(1, len(ticks) - win), win // 2):
|
||||
worst = max(worst, sum(1 for t in ticks[i:i + win] if t & 0x01))
|
||||
return worst
|
||||
|
||||
# 28a: the armed stall is a fault. The job ends in ALARM:17; a reset
|
||||
# and an unlock bring the controller back so the session can end.
|
||||
steps = ARMED_STALL_JOB + [("wait_state", "Alarm", 6.0), ("expect_text", "laser disarmed", 3.0),
|
||||
("rt", b"\x18"), ("sleep", 1.0), "$X"]
|
||||
data = run_session("stall-armed", steps, conf=DENSITY_CONF_FLOORED,
|
||||
env_extra={"GFSINK_STALL_MS": "300"})
|
||||
text, err = run_session.text, run_session.err
|
||||
if "ALARM:17" not in text:
|
||||
fail("[stall-armed] a producer stall inside the armed window did not fault (no ALARM:17)")
|
||||
if "late events while the laser is armed" not in err:
|
||||
fail("[stall-armed] the fault was not named in the log")
|
||||
ticks = tick_bytes(data)
|
||||
burst = max_steps_per_window(ticks)
|
||||
if burst > BURST_LIMIT:
|
||||
fail("[stall-armed] %d steps in a 100-tick window (planned %.1f): the clamped events "
|
||||
"were shipped as a burst" % (burst, PLANNED_PER_100))
|
||||
check_termination("stall-armed", data)
|
||||
seq = latch_transitions(run_session.latch)
|
||||
if seq != ["unlock", "lock"]:
|
||||
fail("[stall-armed] latch ownership sequence %s, expected unlock (arm), lock (the fault)" % seq)
|
||||
print("PASS [stall-armed]: a 300 ms producer stall while armed faulted with ALARM:17, "
|
||||
"%d steps per 100 ticks at most (planned %.1f), ends dark, latch %s"
|
||||
% (burst, PLANNED_PER_100, seq))
|
||||
|
||||
# 28b: unarmed, the same stall is a warning and the move completes with
|
||||
# every step, compressed.
|
||||
data = run_session("stall-unarmed", STALL_JOB, conf=DENSITY_CONF_FLOORED,
|
||||
arm_required=False, env_extra={"GFSINK_STALL_MS": "300"})
|
||||
text, err = run_session.text, run_session.err
|
||||
if "ALARM" in text:
|
||||
fail("[stall-unarmed] an unarmed stall raised an alarm")
|
||||
if "late events clamped" not in err:
|
||||
fail("[stall-unarmed] the clamp was not warned about in the log")
|
||||
ticks = tick_bytes(data)
|
||||
total = sum(1 for t in ticks if t & 0x01)
|
||||
want = round(150 * STEPS_PER_MM)
|
||||
if abs(total - want) > 2:
|
||||
fail("[stall-unarmed] %d X steps in the stream, expected %d: steps were lost" % (total, want))
|
||||
burst = max_steps_per_window(ticks)
|
||||
if burst <= BURST_LIMIT:
|
||||
fail("[stall-unarmed] no step burst in the stream (%d per 100 ticks): the stall knob "
|
||||
"did not stall the producer" % burst)
|
||||
print("PASS [stall-unarmed]: the same stall unarmed was warned, the move completed with "
|
||||
"all %d steps, the clamp visible as %d steps per 100 ticks" % (total, burst))
|
||||
|
||||
# 29: a write stall stalls only the shipper. The producer keeps its
|
||||
# pace behind it, so nothing clamps and nothing bursts.
|
||||
data = run_session("write-stall", ARMED_STALL_JOB + [WAIT_IDLE, "M5"], conf=DENSITY_CONF_FLOORED,
|
||||
env_extra={"GFSINK_WRITE_STALL_MS": "300"})
|
||||
text, err = run_session.text, run_session.err
|
||||
if "ALARM" in text:
|
||||
fail("[write-stall] a write stall raised an alarm")
|
||||
if "clamped" in err:
|
||||
fail("[write-stall] a write stall clamped producer events: the back-off held the lock")
|
||||
ticks = tick_bytes(data)
|
||||
total = sum(1 for t in ticks if t & 0x01)
|
||||
if abs(total - want) > 2:
|
||||
fail("[write-stall] %d X steps in the stream, expected %d" % (total, want))
|
||||
burst = max_steps_per_window(ticks)
|
||||
if burst > BURST_LIMIT:
|
||||
fail("[write-stall] %d steps in a 100-tick window (planned %.1f): a burst" % (burst, PLANNED_PER_100))
|
||||
lit = count_fire(data)
|
||||
if lit < 0.8 * want:
|
||||
fail("[write-stall] the cut ran dark through the stall (%d fire ticks)" % lit)
|
||||
check_termination("write-stall", data)
|
||||
print("PASS [write-stall]: a 300 ms write stall left the producer on pace: all %d steps, "
|
||||
"%d per 100 ticks at most, %d fire ticks, ends dark" % (total, burst, lit))
|
||||
|
||||
# --- rule 22: a jog never fires, whatever the modal spindle says ----
|
||||
# The arm flow runs on the M3 (window open), the modal spindle is on
|
||||
|
||||
@@ -25,6 +25,30 @@ no root) and drives it over TCP:
|
||||
does not free Z either
|
||||
6. X and Y are still free after both writes
|
||||
|
||||
A referenced lens opens the envelope to the window the shared settings
|
||||
hold, the same keys the daemon's wizards build their Z from. forgectrl
|
||||
leaves the lens on its hall edge and a marker in the state directory
|
||||
before the controller starts; the driver takes it as it loads its
|
||||
settings and places the edge on its step grid:
|
||||
|
||||
7. the marker with the edge alone: Z sits at the edge's grid step, the
|
||||
fallback window (10 below, 12 above) runs to its ends, and two
|
||||
half-steps past either end alarms
|
||||
8. the marker with the focus card's stops (14 below, 20 above): the
|
||||
wider window runs to its ends, two half-steps past either alarms
|
||||
9. a stop count out of range (41) falls back on its side alone
|
||||
|
||||
The X and Y soft limits are the driver's as well: off while the
|
||||
position is not trusted (the core's $20 cannot be turned on with $22
|
||||
off), on after a home, when the envelope is the bed. A scripted home
|
||||
(homing_mode = gfcloud with a runner that exits 0, the host hook) sets
|
||||
them:
|
||||
|
||||
10. after the home a program move past X or Y max raises ALARM:2
|
||||
before any motion, a jog past it is refused with error 15, and a
|
||||
move inside the bed runs; a stream fault or a controller start
|
||||
drops them again with the reference
|
||||
|
||||
The binary keeps its settings in EEPROM.DAT in the working directory, so
|
||||
each run starts from defaults in a temporary directory and leaves
|
||||
nothing behind.
|
||||
@@ -53,13 +77,24 @@ def fail(msg):
|
||||
|
||||
|
||||
class Session:
|
||||
"""One null-sink controller process with its own settings store."""
|
||||
"""One null-sink controller process with its own settings store.
|
||||
`conf` (key: value) is the shared config the driver reads; with
|
||||
`referenced` the lens marker forgectrl leaves is in the state
|
||||
directory, so the driver takes the lens reference at its start."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, conf=None, referenced=False):
|
||||
self.workdir = tempfile.mkdtemp(prefix="z-envelope-")
|
||||
env = dict(os.environ, GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
|
||||
for key in ("GFSINK", "GF_SWITCH_FILE", "GF_VERDICT_FILE"):
|
||||
env.pop(key, None)
|
||||
conf_path = os.path.join(self.workdir, "forgefirm.conf")
|
||||
with open(conf_path, "w") as f:
|
||||
for k, v in (conf or {}).items():
|
||||
f.write("%s = %s\n" % (k, v))
|
||||
env["GFHOME_CONF"] = conf_path
|
||||
if referenced:
|
||||
with open(os.path.join(self.workdir, "lens.home"), "w") as f:
|
||||
f.write("edge 0 3 3\n")
|
||||
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
|
||||
cwd=self.workdir, env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
@@ -113,6 +148,23 @@ class Session:
|
||||
m = re.search(r"<(\w+)", self.send("?"))
|
||||
return m.group(1) if m else "?"
|
||||
|
||||
def mpos_z(self):
|
||||
"""The reported machine Z (no work offset is set in a session)."""
|
||||
m = re.search(r"[MW]Pos:(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)", self.send("?"))
|
||||
if not m:
|
||||
fail("no position in the status report")
|
||||
return float(m.group(3))
|
||||
|
||||
def wait_idle(self, timeout=15.0):
|
||||
"""A move that ran plays out before the next line is judged: the
|
||||
soft limit on a moving machine holds first and alarms after."""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if self.state() == "Idle":
|
||||
return
|
||||
time.sleep(0.1)
|
||||
fail("the controller did not return to Idle")
|
||||
|
||||
def move(self, gcode):
|
||||
"""Run one move. Returns True when the soft limit blocked it.
|
||||
An alarm needs a reset and an unlock before the next move."""
|
||||
@@ -160,6 +212,105 @@ def free_in_xy(s, when):
|
||||
check(ran, "X and Y free %s" % when, "an X or Y move was blocked %s" % when)
|
||||
|
||||
|
||||
def window_holds(s, steps, below, above, what):
|
||||
"""The envelope of a referenced lens whose edge sits on grid step
|
||||
`steps`: the ends of the reach (`below` and `above` half-steps from
|
||||
the edge) run, two half-steps past either end alarms. The controller
|
||||
keeps a half-step of slack beyond the reach, so one past is not
|
||||
judged here; two is outside on every head."""
|
||||
spm = float(s.setting("$102"))
|
||||
top, bottom = (steps + above) / spm, (steps - below) / spm
|
||||
s.wait_idle()
|
||||
check(not s.move("G0 Z%.3f" % top), "%s: the top of the reach, Z %.3f, runs" % (what, top),
|
||||
"%s: Z %.3f, the top of the reach, was blocked" % (what, top))
|
||||
s.wait_idle()
|
||||
check(not s.move("G0 Z%.3f" % bottom), "%s: the bottom of the reach, Z %.3f, runs" % (what, bottom),
|
||||
"%s: Z %.3f, the bottom of the reach, was blocked" % (what, bottom))
|
||||
over, under = (steps + above + 2) / spm, (steps - below - 2) / spm
|
||||
s.wait_idle()
|
||||
check(s.move("G0 Z%.3f" % over), "%s: two half-steps over the top, Z %.3f, alarms" % (what, over),
|
||||
"%s: Z %.3f, two half-steps over the top, ran" % (what, over))
|
||||
s.wait_idle()
|
||||
check(s.move("G0 Z%.3f" % under), "%s: two half-steps under the bottom, Z %.3f, alarms" % (what, under),
|
||||
"%s: Z %.3f, two half-steps under the bottom, ran" % (what, under))
|
||||
|
||||
|
||||
def referenced_cases():
|
||||
"""The lens referenced by forgectrl's marker: the envelope is the
|
||||
window the shared settings hold, as the daemon's wizards read it."""
|
||||
edge = 3.35
|
||||
conf = {"lens_hall_edge_z_mm": "%.2f" % edge}
|
||||
s = Session(conf=conf, referenced=True)
|
||||
try:
|
||||
spm = float(s.setting("$102"))
|
||||
steps = int(round(edge * spm))
|
||||
z = s.mpos_z()
|
||||
check(abs(z - steps / spm) < 0.002,
|
||||
"referenced: Z sits at the edge's grid step, %.3f" % z,
|
||||
"referenced: Z reads %.3f, not the edge's grid step %.3f" % (z, steps / spm))
|
||||
window_holds(s, steps, 10, 12, "the fallback window")
|
||||
free_in_xy(s, "with the lens referenced")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
s = Session(conf=dict(conf, lens_stop_below_steps="14", lens_stop_above_steps="20"), referenced=True)
|
||||
try:
|
||||
window_holds(s, steps, 14, 20, "the focus card's window")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
s = Session(conf=dict(conf, lens_stop_below_steps="41", lens_stop_above_steps="20"), referenced=True)
|
||||
try:
|
||||
window_holds(s, steps, 10, 20, "a below count out of range")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def homed_cases():
|
||||
"""After a scripted gfcloud home the bed is the X/Y envelope."""
|
||||
conf = {"lens_hall_edge_z_mm": "3.35", "homing_mode": "gfcloud", "gfcloud_home_cmd": "true"}
|
||||
s = Session(conf=conf, referenced=True)
|
||||
try:
|
||||
free_in_xy(s, "before the home")
|
||||
x_travel = float(s.setting("$130"))
|
||||
y_travel = float(s.setting("$131"))
|
||||
reply = s.send("$H", 5.0)
|
||||
if "ok" not in reply:
|
||||
fail("the scripted home was refused: %r" % reply)
|
||||
s.wait_idle()
|
||||
st = s.send("?")
|
||||
m = re.search(r"MPos:(-?[\d.]+),(-?[\d.]+)", st)
|
||||
check(m and abs(float(m.group(1))) < 0.01 and abs(float(m.group(2))) < 0.01,
|
||||
"homed: X and Y sit at the origin", "homed: X/Y are not at the origin: %r" % st)
|
||||
check(s.move("G90 G0 X%.1f" % (x_travel + 5)), "homed: X past the bed alarms",
|
||||
"homed: a move past X max ran")
|
||||
s.wait_idle()
|
||||
check(s.move("G90 G0 Y%.1f" % (y_travel + 5)), "homed: Y past the bed alarms",
|
||||
"homed: a move past Y max ran")
|
||||
s.wait_idle()
|
||||
check(s.move("G90 G0 X-1"), "homed: X past the near edge alarms",
|
||||
"homed: a move past X min ran")
|
||||
s.wait_idle()
|
||||
reply = s.send("$J=G91X%.1fF1200" % (x_travel + 5), 1.5)
|
||||
check("error:15" in reply, "homed: a jog past the bed is refused with error 15",
|
||||
"homed: a jog past the bed was not refused with error 15: %r" % reply)
|
||||
# The core answers the line after an error with that error again
|
||||
# until an empty line (or a $ command) clears it: the sender's
|
||||
# acknowledgment at compatibility level 0.
|
||||
s.send("", 1.0)
|
||||
check(not s.move("G90 G0 X10 Y10"), "homed: a move inside the bed runs",
|
||||
"homed: a move inside the bed was blocked")
|
||||
s.wait_idle()
|
||||
# A settings write does not drop them: the driver re-applies the
|
||||
# mask after the core clears it.
|
||||
s.write_setting("$20=0")
|
||||
check(s.move("G90 G0 X%.1f" % (x_travel + 5)), "homed: X past the bed still alarms after a $20 write",
|
||||
"homed: a $20 write freed X")
|
||||
s.wait_idle()
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isfile(BIN):
|
||||
fail("no controller binary at %s" % BIN)
|
||||
@@ -189,6 +340,8 @@ def main():
|
||||
free_in_xy(s, "after the settings writes")
|
||||
finally:
|
||||
s.close()
|
||||
referenced_cases()
|
||||
homed_cases()
|
||||
print("PASS z_envelope_test")
|
||||
|
||||
|
||||
|
||||
@@ -52,8 +52,11 @@ CONTENT_LAYERS = {"meta-forgefirm": ("forgefirm", "meta-forgefirm"),
|
||||
"meta-glowforge-bsp": ("meta-openglow", "meta-glowforge-bsp"),
|
||||
"meta-openglow-core": ("meta-openglow", "meta-openglow-core")}
|
||||
# Left out of a layer's content, as in forgefirm-image-manifest.bbclass
|
||||
# (FORGEFIRM_MANIFEST_PIN_SUFFIX): documentation and the component pin files.
|
||||
LAYER_SKIP_SUFFIXES = (".md", "-pin.inc")
|
||||
# (FORGEFIRM_MANIFEST_PIN_SUFFIX, FORGEFIRM_MANIFEST_VERSION_SUFFIX):
|
||||
# documentation, the component pin files, and the release version file.
|
||||
# All three are metadata; hashing them would turn a pin bump or a version
|
||||
# bump into a platform change and invalidate every acceptance result.
|
||||
LAYER_SKIP_SUFFIXES = (".md", "-pin.inc", "forgefirm-release.inc")
|
||||
|
||||
|
||||
def git(args, cwd=None, input=None):
|
||||
|
||||
+22
-6
@@ -45,7 +45,7 @@
|
||||
# forgefirm-source-v<version>.tar.gz, and refuses to pack a bundle in which
|
||||
# a recipe that needs source has none.
|
||||
#
|
||||
# Version contract: <version> == FORGEFIRM_RELEASE in forgefirm-image.bb
|
||||
# Version contract: <version> == FORGEFIRM_RELEASE in forgefirm-release.inc
|
||||
# == /etc/forgefirm-version ("v<version>") in the built rootfs == .fw
|
||||
# meta-version ("v<version>") == release tag ("v<version>").
|
||||
|
||||
@@ -55,6 +55,9 @@ REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DEPLOY_ROOT="$REPO/build/tmp/deploy"
|
||||
DEPLOY="$DEPLOY_ROOT/images/glowforge"
|
||||
IMAGE_BB="$REPO/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb"
|
||||
# The release version, in its own file so a bump is not a platform change
|
||||
# (the manifest leaves it out of the layer content hash).
|
||||
RELEASE_INC="$REPO/meta-forgefirm/recipes-forgefirm/images/forgefirm-release.inc"
|
||||
INSTALLER="$REPO/scripts/install-forgefirm.sh"
|
||||
WARN_BYTES=$((170 * 1024 * 1024))
|
||||
FAIL_BYTES=$((195 * 1024 * 1024))
|
||||
@@ -134,7 +137,7 @@ if [ "$MODE" = "dev" ]; then
|
||||
EXT4="${EXT4/forgefirm-image-glowforge/forgefirm-image-dev-glowforge}"
|
||||
[ -f "$EXT4" ] || die "dev rootfs not found: $EXT4"
|
||||
check_size
|
||||
REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$IMAGE_BB")
|
||||
REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$RELEASE_INC")
|
||||
DEVVER="v${REL}-dev-$(date +%Y%m%d%H%M%S)"
|
||||
OUT="$DEPLOY/forgefirm-dev.fw"
|
||||
"$REPO/scripts/mkfw.sh" "$EXT4" "$DEVVER" "$OUT" "$KEY"
|
||||
@@ -163,9 +166,9 @@ else
|
||||
fi
|
||||
|
||||
# Version single-source check.
|
||||
BB_REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$IMAGE_BB")
|
||||
BB_REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$RELEASE_INC")
|
||||
[ "$BB_REL" = "$VERSION" ] \
|
||||
|| die "FORGEFIRM_RELEASE in forgefirm-image.bb is '$BB_REL', not '$VERSION'"
|
||||
|| die "FORGEFIRM_RELEASE in forgefirm-release.inc is '$BB_REL', not '$VERSION'"
|
||||
|
||||
# The beta rule: every release below 0.1.0 is a beta, and 0.1.0 is the
|
||||
# first release that is not. While the README carries the beta banner, a
|
||||
@@ -362,6 +365,19 @@ else
|
||||
DOCS_TAG_CMD="git -C $DOCS_DIR push origin v$VERSION"
|
||||
fi
|
||||
|
||||
# The release notes: releases/v<version>/notes.md when the release carries
|
||||
# one (staged beside the assets, not uploaded as one), GitHub's generated
|
||||
# notes otherwise.
|
||||
NOTES_OPT="--generate-notes"
|
||||
if [ -f "$REPO/releases/v$VERSION/notes.md" ]; then
|
||||
cp "$REPO/releases/v$VERSION/notes.md" "$STAGE/notes.md"
|
||||
NOTES_OPT="--notes-file notes.md"
|
||||
echo "notes: releases/v$VERSION/notes.md"
|
||||
else
|
||||
rm -f "$STAGE/notes.md"
|
||||
echo "notes: generated by GitHub"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
== release v$VERSION staged ==
|
||||
@@ -374,7 +390,7 @@ Pre-publish checklist (docs.forgefirm.org, Developers, "Release flow"):
|
||||
Publish (from a directory with an authenticated gh):
|
||||
cd "$STAGE"
|
||||
gh release create "v$VERSION" --repo openglow-org/forgefirm \\
|
||||
--title "ForgeFIRM v$VERSION" --generate-notes $PRERELEASE \\
|
||||
--title "ForgeFIRM v$VERSION" $NOTES_OPT $PRERELEASE \\
|
||||
$ASSETS
|
||||
|
||||
Push the documentation tag with it:
|
||||
@@ -384,7 +400,7 @@ EOF
|
||||
if [ "$PUBLISH" = "1" ]; then
|
||||
command -v gh >/dev/null || die "--publish requested but gh is not on PATH"
|
||||
( cd "$STAGE" && gh release create "v$VERSION" --repo openglow-org/forgefirm \
|
||||
--title "ForgeFIRM v$VERSION" --generate-notes $PRERELEASE \
|
||||
--title "ForgeFIRM v$VERSION" $NOTES_OPT $PRERELEASE \
|
||||
$ASSETS ) \
|
||||
|| die "gh release create failed"
|
||||
echo "== published v$VERSION =="
|
||||
|
||||
Reference in New Issue
Block a user