mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08b29fee01 | ||
|
|
f306c9983f | ||
|
|
dc7170d0d1 | ||
|
|
0ceb4abc4a | ||
|
|
aa628d684c | ||
|
|
29b00cc056 | ||
|
|
d482e76402 | ||
|
|
8232c8c9fe | ||
|
|
260f466400 | ||
|
|
cf7b51fb32 | ||
|
|
e2be3ca4c5 | ||
|
|
cd4c176a87 | ||
|
|
8850adcc5e | ||
|
|
f9f4de31c1 | ||
|
|
9ddab969e0 | ||
|
|
12e7d50746 | ||
|
|
b3f0cd54d9 | ||
|
|
fbac17a485 | ||
|
|
0eb764bf75 | ||
|
|
07158e5d7f | ||
|
|
091702c26f | ||
|
|
9a288b175b | ||
|
|
147393322f | ||
|
|
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
|
||||||
@@ -11,6 +11,7 @@ name: fixture-ci
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [master]
|
||||||
paths:
|
paths:
|
||||||
- 'fixture/**'
|
- 'fixture/**'
|
||||||
- '.github/workflows/fixture-ci.yml'
|
- '.github/workflows/fixture-ci.yml'
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ name: forgetest-ci
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [master]
|
||||||
paths:
|
paths:
|
||||||
- 'forgetest/**'
|
- 'forgetest/**'
|
||||||
- 'scripts/acceptance-gate.py'
|
- 'scripts/acceptance-gate.py'
|
||||||
|
|||||||
@@ -1,398 +1,407 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
Instructions for AI coding agents that work in this repository. A human
|
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.
|
contributor is welcome to read it too: it is the same set of rules.
|
||||||
|
|
||||||
## ForgeFIRM repository rules
|
## ForgeFIRM repository rules
|
||||||
|
|
||||||
### Safety on the machine
|
### Safety on the machine
|
||||||
|
|
||||||
- The laser never fires with the coolant pump deliberately off. The heater-only
|
- 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
|
flow-calibrate and flow-verify tools are the only pump-off state, and the
|
||||||
tube stays dark during them.
|
tube stays dark during them.
|
||||||
- The 40 V motor rail stays up while the machine is on. Never cycle it for a
|
- 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
|
controller handover. A rail glitch can leave the stepper drivers
|
||||||
unserviceable.
|
unserviceable.
|
||||||
|
|
||||||
### The iteration loop
|
### The iteration loop
|
||||||
|
|
||||||
- Edit, cross-build in the Yocto build environment, hot-deploy to the
|
- 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
|
machine, prove. Then one commit, one push, one pin bump for the proven
|
||||||
change.
|
change.
|
||||||
- A full image build happens once, at the end of a work item, never per fix.
|
- 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
|
- 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
|
overlay change on the image that ships it. Never hot-swap a `.ko` onto a
|
||||||
board that is about to be reflashed.
|
board that is about to be reflashed.
|
||||||
|
|
||||||
### Acceptance coverage
|
### Acceptance coverage
|
||||||
|
|
||||||
- Evaluate every change against the acceptance catalog in
|
- Evaluate every change against the acceptance catalog in
|
||||||
`forgetest/forgetest/suite/`. Does an existing test exercise the changed
|
`forgetest/forgetest/suite/`. Does an existing test exercise the changed
|
||||||
behavior? Does its `covers` map name the files you touched? If not, add or
|
behavior? Does its `covers` map name the files you touched? If not, add or
|
||||||
widen one in the same change. The coverage lint
|
widen one in the same change. The coverage lint
|
||||||
(`python3 -m forgetest.coverage --enforce`) is the floor.
|
(`python3 -m forgetest.coverage --enforce`) is the floor.
|
||||||
- The `SRCREV` of a component and the `PV` that moves with it live in
|
- 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
|
`<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
|
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
|
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
|
pin in a recipe body counts as a platform change and needs a full
|
||||||
acceptance campaign.
|
acceptance campaign.
|
||||||
- Test a harness change yourself, on the host, before you hand it to the
|
- 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 for a bench run. A harness that fails on its own defect costs the
|
||||||
operator a full re-run cycle.
|
operator a full re-run cycle.
|
||||||
- A test declares its `mode`, and the runner switches. Cloud tests stay in
|
- 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
|
cloud mode. Keep `requires` lists minimal, and never gate one test behind a
|
||||||
chain of others.
|
chain of others.
|
||||||
- Never exercise a gate or a limit through the `GFCOOL_*` environment
|
- 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.
|
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
|
||||||
### CI couplings that set the push order
|
drills, not one-off bench scripts.
|
||||||
|
|
||||||
- The CI of `grblHAL-glowforge` gets the laser harnesses from `scripts/bench/`
|
### CI couplings that set the push order
|
||||||
at the head of `master`, unpinned. Push a harness change here before the
|
|
||||||
driver change that needs it.
|
- The CI of `grblHAL-glowforge` gets the laser harnesses from `scripts/bench/`
|
||||||
- The acceptance page shares its theme and its vendored Bootstrap with the
|
at the head of `master`, unpinned. Push a harness change here before the
|
||||||
`forgectrl` panel, byte for byte. Land a UI change in `forgectrl` first.
|
driver change that needs it.
|
||||||
Push it and pin it. Only then push this repository.
|
- The acceptance page shares its theme and its vendored Bootstrap with the
|
||||||
- The CI of `kernel-module-glowforge` cross-builds against the BSP from
|
`forgectrl` panel, byte for byte. Land a UI change in `forgectrl` first.
|
||||||
`meta-openglow`. Push `meta-openglow` before a module change that needs a
|
Push it and pin it. Only then push this repository.
|
||||||
BSP change.
|
- The CI of `kernel-module-glowforge` cross-builds against the BSP from
|
||||||
|
`meta-openglow`. Push `meta-openglow` before a module change that needs a
|
||||||
### Builds and images
|
BSP change.
|
||||||
|
|
||||||
- Builds run only in the dedicated Yocto build environment that the Build page
|
### Builds and images
|
||||||
of the site defines (kas and bitbake). Never use a native host build of a
|
|
||||||
component as proof, not even for a compile check.
|
- Builds run only in the dedicated Yocto build environment that the Build page
|
||||||
- Every build makes both images: `forgefirm-image` (the release image) and
|
of the site defines (kas and bitbake). Never use a native host build of a
|
||||||
`forgefirm-image-dev` (the bench image, with `forgetest` on port 8090). The
|
component as proof, not even for a compile check.
|
||||||
dev image needs an explicit `--target`. Report both artifact names.
|
- Every build makes both images: `forgefirm-image` (the release image) and
|
||||||
- After a kernel-module pin bump, rebuild the kernel and the module in the
|
`forgefirm-image-dev` (the bench image, with `forgetest` on port 8090). The
|
||||||
same run: `bitbake -c cleansstate linux-fslc kernel-module-glowforge`, then
|
dev image needs an explicit `--target`. Report both artifact names.
|
||||||
the images. The kernel version suffix is not reproducible across
|
- After a kernel-module pin bump, rebuild the kernel and the module in the
|
||||||
re-checkouts, and a rootfs whose kernel and module disagree fails.
|
same run: `bitbake -c cleansstate linux-fslc kernel-module-glowforge`, then
|
||||||
- Read the layer HEAD lines at the top of a build log before you trust a build
|
the images. The kernel version suffix is not reproducible across
|
||||||
that moves a pin.
|
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
|
||||||
### The record of the work
|
that moves a pin.
|
||||||
|
|
||||||
This repository carries no status document and no dated log. Both were
|
### The record of the work
|
||||||
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
|
This repository carries no status document and no dated log. Both were
|
||||||
that carries it**: what was done, how it was proven, and what it replaced, in
|
retired: every fact about the machine and the firmware is on the
|
||||||
the same place as the change itself.
|
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
|
||||||
A measurement session or a bench drill is written up in the commit that
|
the same place as the change itself.
|
||||||
lands its result. Nothing accumulates in a file.
|
|
||||||
|
A measurement session or a bench drill is written up in the commit that
|
||||||
### On the bench
|
lands its result. Nothing accumulates in a file.
|
||||||
|
|
||||||
These rules apply whenever an agent works with the operator at a machine.
|
### On the bench
|
||||||
|
|
||||||
- **One armed run per turn.** Start the run, let it finish, stop, report what
|
These rules apply whenever an agent works with the operator at a machine.
|
||||||
happened against what was expected, and wait for the operator's
|
|
||||||
confirmation before any further armed run or machine command. Never chain a
|
- **One armed run per turn.** Start the run, let it finish, stop, report what
|
||||||
jog, a fix, or a re-run onto a burn in the same step. Dry runs (latch
|
happened against what was expected, and wait for the operator's
|
||||||
locked, no emission) can be batched. Say which is which before you start.
|
confirmation before any further armed run or machine command. Never chain a
|
||||||
- **Zero inference during live fire.** Report only what the combined
|
jog, a fix, or a re-run onto a burn in the same step. Dry runs (latch
|
||||||
observations confirm: the operator's eyes, the drill output, the logs, the
|
locked, no emission) can be batched. Say which is which before you start.
|
||||||
sysfs readbacks. Everything else is labeled "unconfirmed" and is not acted
|
- **Zero inference during live fire.** Report only what the combined
|
||||||
on.
|
observations confirm: the operator's eyes, the drill output, the logs, the
|
||||||
- **One Grbl connection.** Never open a second connection to TCP port 23 while
|
sysfs readbacks. Everything else is labeled "unconfirmed" and is not acted
|
||||||
a drill, an acceptance test, or a sender holds one. The last connection
|
on.
|
||||||
wins, the displaced client's cleanup never arrives, and its queued commands
|
- **One Grbl connection.** Never open a second connection to TCP port 23 while
|
||||||
wait behind the arm gate. Read state through `forgectrl` (HTTP) and sysfs
|
a drill, an acceptance test, or a sender holds one. The last connection
|
||||||
only. After a displaced or aborted drill: soft reset, confirm that the RX
|
wins, the displaced client's cleanup never arrives, and its queued commands
|
||||||
buffer reads full, `M5`, confirm that `armed` is false, before anyone
|
wait behind the arm gate. Read state through `forgectrl` (HTTP) and sysfs
|
||||||
touches the button.
|
only. After a displaced or aborted drill: soft reset, confirm that the RX
|
||||||
- **The operator touches the machine.** The operator runs the install, the
|
buffer reads full, `M5`, confirm that `armed` is false, before anyone
|
||||||
service restart, and the reflash on the machine. Never modify the software
|
touches the button.
|
||||||
of a running machine unless the operator asks for that exact step.
|
- **The operator touches the machine.** The operator runs the install, the
|
||||||
- **Bench hygiene.** Stage test files in `/tmp`. A file that must survive a
|
service restart, and the reflash on the machine. Never modify the software
|
||||||
reboot goes in `/data/bench-scratch/`, and that directory is deleted whole
|
of a running machine unless the operator asks for that exact step.
|
||||||
at the end of the session. Nothing goes loose in `/data`. Remove everything
|
- **Bench hygiene.** Stage test files in `/tmp`. A file that must survive a
|
||||||
you put on the board, in the same session, and list `/data` before you end.
|
reboot goes in `/data/bench-scratch/`, and that directory is deleted whole
|
||||||
A bench tool worth a second use goes in `scripts/bench/`. The dev image
|
at the end of the session. Nothing goes loose in `/data`. Remove everything
|
||||||
installs those tools under `/usr/share/forgetest/bench/`. Run them from
|
you put on the board, in the same session, and list `/data` before you end.
|
||||||
there.
|
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
|
||||||
## Project-wide rules
|
there.
|
||||||
|
|
||||||
The sections above are specific to this repository. The rules below apply to
|
## Project-wide rules
|
||||||
every repository of the OpenGlow ForgeFIRM project: the firmware components,
|
|
||||||
the build base, the documentation, and the hardware designs. Where a
|
The sections above are specific to this repository. The rules below apply to
|
||||||
repository rule and a rule below conflict, the safety, proof, and hygiene
|
every repository of the OpenGlow ForgeFIRM project: the firmware components,
|
||||||
rules below win. The documentation site, <https://docs.forgefirm.org/>, is
|
the build base, the documentation, and the hardware designs. Where a
|
||||||
the source of truth for every fact about the machine and the firmware. Read
|
repository rule and a rule below conflict, the safety, proof, and hygiene
|
||||||
its Developers section before you change code.
|
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
|
||||||
### What ForgeFIRM is
|
its Developers section before you change code.
|
||||||
|
|
||||||
ForgeFIRM is open firmware for stock Glowforge lasers (Basic, Plus, Pro). It
|
### What ForgeFIRM is
|
||||||
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
|
ForgeFIRM is open firmware for stock Glowforge lasers (Basic, Plus, Pro). It
|
||||||
controls the machine locally. In GRBL mode the machine is a standard grblHAL
|
runs on the factory NXP i.MX6 control board with no hardware modification. It
|
||||||
controller for LightBurn and other senders on TCP port 23. Cloud mode runs the
|
replaces the cloud-dependent factory userspace with an open Linux image that
|
||||||
factory experience through the Glowforge web service, and it is a deliberate,
|
controls the machine locally. In GRBL mode the machine is a standard grblHAL
|
||||||
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
|
controller for LightBurn and other senders on TCP port 23. Cloud mode runs the
|
||||||
and cloud mode runs end to end.
|
factory experience through the Glowforge web service, and it is a deliberate,
|
||||||
|
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
|
||||||
One controller runs at a time under `forgectrl`, the machine-services daemon,
|
and cloud mode runs end to end.
|
||||||
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
|
One controller runs at a time under `forgectrl`, the machine-services daemon,
|
||||||
owns the laser latch and the safety readbacks. The Technical section of the
|
which owns the pulse device, the cooling engine, the cameras, and the control
|
||||||
site describes the whole stack.
|
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
|
||||||
### The repositories
|
site describes the whole stack.
|
||||||
|
|
||||||
All repositories live under the OpenGlow organization on GitHub,
|
### The repositories
|
||||||
<https://github.com/openglow-org>. For development, check every repository out
|
|
||||||
as a sibling in one base directory named `openglow-forgefirm`. The build
|
All repositories live under the OpenGlow organization on GitHub,
|
||||||
scripts expect that layout. Each repository is its own git repository. The
|
<https://github.com/openglow-org>. For development, check every repository out
|
||||||
base directory is not.
|
as a sibling in one base directory named `openglow-forgefirm`. The build
|
||||||
|
scripts expect that layout. Each repository is its own git repository. The
|
||||||
| Repository | Role | Default branch | License |
|
base directory is not.
|
||||||
|---|---|---|---|
|
|
||||||
| `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) |
|
| Repository | Role | Default branch | License |
|
||||||
| `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 |
|
| `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) |
|
||||||
| `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 |
|
| `meta-openglow` | The BSP layers `meta-glowforge-bsp` and `meta-openglow-core`: kernel, device tree, U-Boot, board recipes. | `scarthgap` | MIT (layer metadata) |
|
||||||
| `grblHAL-core` | The fork of the grblHAL core that the driver uses. Minimum change, no rationale comments. | `forgefirm` | The upstream grblHAL license |
|
| `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 |
|
||||||
| `kernel-module-glowforge` | `glowforge.ko`: the SDMA + EPIT pulse engine, the laser latch, the safety readbacks, the sensors. | `master` | GPL-2.0-or-later |
|
| `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 |
|
||||||
| `python3-gfhardware` | The `gfhardware` hardware library and the cloud-mode applications in `forgefirm-app/`. | `master` | MIT, with one LGPL-2.1-or-later component |
|
| `grblHAL-core` | The fork of the grblHAL core that the driver uses. Minimum change, no rationale comments. | `forgefirm` | The upstream grblHAL license |
|
||||||
| `Glowforge-Utilities` | `gfutilities` on PyPI: the factory protocol and service layer that cloud mode uses. | `master` | MIT |
|
| `kernel-module-glowforge` | `glowforge.ko`: the SDMA + EPIT pulse engine, the laser latch, the safety readbacks, the sensors. | `master` | GPL-2.0-or-later |
|
||||||
| `forgefirm-docs` | The documentation site. | `main` | CC BY-SA 4.0 |
|
| `python3-gfhardware` | The `gfhardware` hardware library and the cloud-mode applications in `forgefirm-app/`. | `master` | MIT, with one LGPL-2.1-or-later component |
|
||||||
| `openglow-serial-adapter` | The USB-C serial-console adapter for the control board. | `main` | CC BY-NC-SA 4.0 |
|
| `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 |
|
||||||
The full table, the license details, and the component diagram are on the
|
| `openglow-serial-adapter` | The USB-C serial-console adapter for the control board. | `main` | CC BY-NC-SA 4.0 |
|
||||||
site: <https://docs.forgefirm.org/developers/>.
|
|
||||||
|
The full table, the license details, and the component diagram are on the
|
||||||
### Safety first, and in that order
|
site: <https://docs.forgefirm.org/developers/>.
|
||||||
|
|
||||||
The machine is a laser, and the operator is the safety authority at it. These
|
### Safety first, and in that order
|
||||||
rules bind every change, in every repository, that can affect what the machine
|
|
||||||
does.
|
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
|
||||||
- The hardware chain is the safety boundary. Software only adds gates on top
|
does.
|
||||||
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.
|
- The hardware chain is the safety boundary. Software only adds gates on top
|
||||||
- Never defeat a switch, an interlock, or a readback, in hardware or in
|
of it. `LASER_ON` is never a bare GPIO. The kernel laser latch is locked by
|
||||||
software, not even for a test.
|
default, and every close of the pulse device locks it again.
|
||||||
- Never change a fire gate, a thermal limit, or a safety default to make a
|
- Never defeat a switch, an interlock, or a readback, in hardware or in
|
||||||
test pass.
|
software, not even for a test.
|
||||||
- The order of work is absolute: emission and motion first, robustness and
|
- Never change a fire gate, a thermal limit, or a safety default to make a
|
||||||
hygiene after. Review a change that touches emission or motion for that
|
test pass.
|
||||||
before anything else.
|
- The order of work is absolute: emission and motion first, robustness and
|
||||||
- A change that can put energy where it was not commanded gets a regression
|
hygiene after. Review a change that touches emission or motion for that
|
||||||
test with the fix, in the same commit, never after.
|
before anything else.
|
||||||
- Position counters, homing anchors, and a homed flag are not proof of
|
- A change that can put energy where it was not commanded gets a regression
|
||||||
physical motion. The head accelerometer is, and so are the operator's eyes.
|
test with the fix, in the same commit, never after.
|
||||||
|
- Position counters, homing anchors, and a homed flag are not proof of
|
||||||
### Proof before done
|
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,
|
||||||
A feature or fix is not complete until it is proven. The order of preference:
|
or a verdict is never treated as "still running" or "keep waiting" without
|
||||||
|
a bound.
|
||||||
1. A host test that runs in CI. The Test page of the site lists the host tests
|
|
||||||
of each repository.
|
### Proof before done
|
||||||
2. A bench drill on the bench reference, recorded in the commit message that
|
|
||||||
carries the change.
|
A feature or fix is not complete until it is proven. The order of preference:
|
||||||
3. Documented reasoning.
|
|
||||||
|
1. A host test that runs in CI. The Test page of the site lists the host tests
|
||||||
Also:
|
of each repository.
|
||||||
|
2. A bench drill on the bench reference, recorded in the commit message that
|
||||||
- Run the host tests of this repository before every commit. Where the build
|
carries the change.
|
||||||
uses `-Werror`, a warning is a failure.
|
3. Documented reasoning.
|
||||||
- A component that ships in the image is also evaluated against the release
|
|
||||||
acceptance catalog in the `forgefirm` repository (Test page, "Coverage
|
Also:
|
||||||
currency"). A behavior change with no catalog consequence gets a sentence
|
|
||||||
of justification in the commit message.
|
- Run the host tests of this repository before every commit. Where the build
|
||||||
- For a component that ships in the image, the proof build is the Yocto
|
uses `-Werror`, a warning is a failure.
|
||||||
cross-build that the Build page of the site defines, never a native host
|
- A component that ships in the image is also evaluated against the release
|
||||||
build.
|
acceptance catalog in the `forgefirm` repository (Test page, "Coverage
|
||||||
- Report outcomes faithfully. Quote failing output as it is. Name a skipped
|
currency"). A behavior change with no catalog consequence gets a sentence
|
||||||
step as skipped. Never narrate an expected result as an observed one.
|
of justification in the commit message.
|
||||||
|
- For a component that ships in the image, the proof build is the Yocto
|
||||||
### The workflow: prove locally, push when proven
|
cross-build that the Build page of the site defines, never a native host
|
||||||
|
build.
|
||||||
- Nothing goes to a public repository until it is proven: in the host tests
|
- Report outcomes faithfully. Quote failing output as it is. Name a skipped
|
||||||
for host-only changes, on the bench for changes that alter what the machine
|
step as skipped. Never narrate an expected result as an observed one.
|
||||||
does.
|
|
||||||
- Iterate locally. Then make one commit and one push for the proven change.
|
### The workflow: prove locally, push when proven
|
||||||
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
|
- Nothing goes to a public repository until it is proven: in the host tests
|
||||||
findings, working notes, plans, audits, or status files. If a conclusion is
|
for host-only changes, on the bench for changes that alter what the machine
|
||||||
still moving, it is not ready for a repository. Unpushed mistakes come out
|
does.
|
||||||
with `git reset --mixed <base>`, which keeps the working tree.
|
- Iterate locally. Then make one commit and one push for the proven change.
|
||||||
- "Commit" and "commit and build" include the push of every touched
|
No chains of fix-up pushes. Every push runs CI, and CI minutes are a budget.
|
||||||
repository, in dependency order. Never hold a push back to protect earlier
|
- Commit only finished work, and only when asked. Never commit intermediate
|
||||||
unpushed commits on the same branch. The only exception is an explicit "do
|
findings, working notes, plans, audits, or status files. If a conclusion is
|
||||||
not push" for that session.
|
still moving, it is not ready for a repository. Unpushed mistakes come out
|
||||||
- Never push, force-push, tag, cut a release, refresh a lockfile, bump a
|
with `git reset --mixed <base>`, which keeps the working tree.
|
||||||
submodule pointer, or bump a pin unless asked.
|
- "Commit" and "commit and build" include the push of every touched
|
||||||
|
repository, in dependency order. Never hold a push back to protect earlier
|
||||||
### Pins
|
unpushed commits on the same branch. The only exception is an explicit "do
|
||||||
|
not push" for that session.
|
||||||
A firmware component reaches the image only through its pin: an exact
|
- Never push, force-push, tag, cut a release, refresh a lockfile, bump a
|
||||||
`SRCREV` in `meta-forgefirm` (in the `forgefirm` repository) for ForgeFIRM
|
submodule pointer, or bump a pin unless asked.
|
||||||
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
|
### Pins
|
||||||
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
|
A firmware component reaches the image only through its pin: an exact
|
||||||
pairs of repositories have a CI coupling that fixes the push order between
|
`SRCREV` in `meta-forgefirm` (in the `forgefirm` repository) for ForgeFIRM
|
||||||
them. The Release flow page of the site lists them, and each affected
|
components, or in `meta-openglow` for BSP components. There is no `AUTOREV`.
|
||||||
repository names its own in the section above.
|
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
|
||||||
### Git conventions
|
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
|
||||||
- A commit is attributed to the human who is responsible for it, in both the
|
them. The Release flow page of the site lists them, and each affected
|
||||||
author and the committer fields. Never list an AI assistant as an author or
|
repository names its own in the section above.
|
||||||
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
|
### Git conventions
|
||||||
`user.name` and `user.email` before the first commit.
|
|
||||||
- A commit message says what changed and why, in the present tense, in
|
- A commit is attributed to the human who is responsible for it, in both the
|
||||||
Simplified Technical English. The "why" of a change to fork code lives here
|
author and the committer fields. Never list an AI assistant as an author or
|
||||||
and nowhere else.
|
a co-author. Never add an AI attribution trailer or a "Generated with" line
|
||||||
- Keep line endings LF in every file that Linux tooling consumes: patches,
|
to a commit message or a pull request. In a fresh clone, set the local
|
||||||
shell scripts, recipes, device trees. Check the bytes after an edit from a
|
`user.name` and `user.email` before the first commit.
|
||||||
Windows tool. A CRLF patch breaks `do_patch`.
|
- A commit message says what changed and why, in the present tense, in
|
||||||
- Never commit build output, `__pycache__`, virtual environments, editor
|
Simplified Technical English. The "why" of a change to fork code lives here
|
||||||
files, a local knowledge graph (`graphify-out/`), captured machine data that
|
and nowhere else.
|
||||||
carries an identity, tokens, keys, or passwords.
|
- Keep line endings LF in every file that Linux tooling consumes: patches,
|
||||||
- Never rewrite published history unless the operator asks for it.
|
shell scripts, recipes, device trees. Check the bytes after an edit from a
|
||||||
- A pull request to an upstream repository is never automatic. The developer
|
Windows tool. A CRLF patch breaks `do_patch`.
|
||||||
decides if and when to send one. You can remind them that upstream
|
- Never commit build output, `__pycache__`, virtual environments, editor
|
||||||
candidates exist, but the decision is theirs.
|
files, a local knowledge graph (`graphify-out/`), captured machine data that
|
||||||
|
carries an identity, tokens, keys, or passwords.
|
||||||
### Writing rules
|
- Never rewrite published history unless the operator asks for it.
|
||||||
|
- A pull request to an upstream repository is never automatic. The developer
|
||||||
They apply to everything: documentation, README files, code comments,
|
decides if and when to send one. You can remind them that upstream
|
||||||
docstrings, log and console text, commit messages, issue text, and replies.
|
candidates exist, but the decision is theirs.
|
||||||
|
|
||||||
- **American English.** analyze, behavior, color, center, gray, catalog,
|
### Writing rules
|
||||||
license, judgment, program, artifact, percent, toward, among, while,
|
|
||||||
learned. The style lint in `forgefirm-docs` rejects the British forms.
|
They apply to everything: documentation, README files, code comments,
|
||||||
- **ASD-STE100 Simplified Technical English.** Short sentences: 20 words in
|
docstrings, log and console text, commit messages, issue text, and replies.
|
||||||
a procedure, 25 in a description. One topic per sentence, one instruction
|
|
||||||
per sentence. Active voice, imperative in procedures, simple present in
|
- **American English.** analyze, behavior, color, center, gray, catalog,
|
||||||
descriptions. "Must" for necessity, "can" for possibility. No "should",
|
license, judgment, program, artifact, percent, toward, among, while,
|
||||||
"would", "could", "may". Prefer: do, make sure, use, start, show, occur,
|
learned. The style lint in `forgefirm-docs` rejects the British forms.
|
||||||
before, after, because, for example, that is. No "etc.", no "and/or".
|
- **ASD-STE100 Simplified Technical English.** Short sentences: 20 words in
|
||||||
Noun clusters of three words at most. Warnings and cautions come before the
|
a procedure, 25 in a description. One topic per sentence, one instruction
|
||||||
step they protect. Warm and direct is good. A joke that shades a fact is
|
per sentence. Active voice, imperative in procedures, simple present in
|
||||||
not.
|
descriptions. "Must" for necessity, "can" for possibility. No "should",
|
||||||
- **No em dashes.** Never, anywhere. Use a colon, a period, or a comma.
|
"would", "could", "may". Prefer: do, make sure, use, start, show, occur,
|
||||||
- **Present tense, present state.** Text describes the code and the machine
|
before, after, because, for example, that is. No "etc.", no "and/or".
|
||||||
as they are. No history narrative, no "the old version did", no "evolved
|
Noun clusters of three words at most. Warnings and cautions come before the
|
||||||
from", no story of how the code got here. Upstream copyright and
|
step they protect. Warm and direct is good. A joke that shades a fact is
|
||||||
derivation attribution to public projects stays: that is license credit,
|
not.
|
||||||
not narrative.
|
- **No em dashes.** Never, anywhere. Use a colon, a period, or a comma.
|
||||||
- **No workstation paths.** Nothing outside a repository appears as a path in
|
- **Present tense, present state.** Text describes the code and the machine
|
||||||
it: no drive letters, no WSL mounts, no home directories, no sibling-repo
|
as they are. No history narrative, no "the old version did", no "evolved
|
||||||
relative paths, no private reference directories. Name the artifact
|
from", no story of how the code got here. Upstream copyright and
|
||||||
instead: "the captured factory pulse files", "that bench session".
|
derivation attribution to public projects stays: that is license credit,
|
||||||
- **No bench-machine identity.** Never a hostname, a serial number, a fuse
|
not narrative.
|
||||||
value, a private IP address, or a login of the bench machine, in any file,
|
- **No workstation paths.** Nothing outside a repository appears as a path in
|
||||||
comment, commit message, sample, or test fixture. Use neutral placeholders
|
it: no drive letters, no WSL mounts, no home directories, no sibling-repo
|
||||||
of the `ABC-123` form. Record the fact of a verification, never the values.
|
relative paths, no private reference directories. Name the artifact
|
||||||
- **No factory firmware code.** Never cite the factory firmware binary or its
|
instead: "the captured factory pulse files", "that bench session".
|
||||||
code: no offsets, no addresses, no quoted or decompiled code. State the
|
- **No bench-machine identity.** Never a hostname, a serial number, a fuse
|
||||||
recovered fact (a formula, a constant, a behavior) and stop. Hardware
|
value, a private IP address, or a login of the bench machine, in any file,
|
||||||
addresses (IOMUX pad values, sensor registers, boot offsets) are fine.
|
comment, commit message, sample, or test fixture. Use neutral placeholders
|
||||||
- **No rationale comments in fork code.** Code destined upstream (for
|
of the `ABC-123` form. Record the fact of a verification, never the values.
|
||||||
example, the grblHAL core fork) carries the minimum change and no "why we
|
- **No factory firmware code.** Never cite the factory firmware binary or its
|
||||||
did this" comments. The reasoning goes in the commit message. Patches
|
code: no offsets, no addresses, no quoted or decompiled code. State the
|
||||||
carried in the Yocto layers are exempt. The `ForgeFIRM:` comment convention
|
recovered fact (a formula, a constant, a behavior) and stop. Hardware
|
||||||
there is deliberate.
|
addresses (IOMUX pad values, sensor registers, boot offsets) are fine.
|
||||||
- **Interface docs describe the lever, not the policy.** A sysfs attribute
|
- **No rationale comments in fork code.** Code destined upstream (for
|
||||||
doc says what the attribute is, its range, its units, its conversion. How
|
example, the grblHAL core fork) carries the minimum change and no "why we
|
||||||
the factory firmware drives it does not belong there.
|
did this" comments. The reasoning goes in the commit message. Patches
|
||||||
- **No definitive legal or regulatory statements.** The project is not a
|
carried in the Yocto layers are exempt. The `ForgeFIRM:` comment convention
|
||||||
lawyer. The only permitted form: modifying the machine, including
|
there is deliberate.
|
||||||
replacing its firmware, may have legal and regulatory ramifications, and it
|
- **Interface docs describe the lever, not the policy.** A sysfs attribute
|
||||||
is the end user's responsibility to adhere to the laws, regulations,
|
doc says what the attribute is, its range, its units, its conversion. How
|
||||||
certifications, and insurance terms that apply where they are. Warranty
|
the factory firmware drives it does not belong there.
|
||||||
wording is "may void your warranty", never "voids". Never cite a regulation,
|
- **No definitive legal or regulatory statements.** The project is not a
|
||||||
a standard, or a certification regime as applying or not applying. The
|
lawyer. The only permitted form: modifying the machine, including
|
||||||
affiliation disclaimer lives in the site footer only. It is also the user's
|
replacing its firmware, may have legal and regulatory ramifications, and it
|
||||||
responsibility to make sure that they stay within the vendor's terms of
|
is the end user's responsibility to adhere to the laws, regulations,
|
||||||
service when they use a feature that calls that vendor's cloud services.
|
certifications, and insurance terms that apply where they are. Warranty
|
||||||
- **Copyright.** Copyright in this project belongs to 514 LLC d/b/a
|
wording is "may void your warranty", never "voids". Never cite a regulation,
|
||||||
OpenGlow. Every new source file carries two lines in its header, in the
|
a standard, or a certification regime as applying or not applying. The
|
||||||
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
|
affiliation disclaimer lives in the site footer only. It is also the user's
|
||||||
`Written by Scott Wiederhold`. Keep the year, or the year range, that
|
responsibility to make sure that they stay within the vendor's terms of
|
||||||
the file already has. The copyright notices of other holders stay
|
service when they use a feature that calls that vendor's cloud services.
|
||||||
unchanged.
|
- **Copyright.** Copyright in this project belongs to 514 LLC d/b/a
|
||||||
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
|
OpenGlow. Every new source file carries two lines in its header, in the
|
||||||
under the license of its repository.
|
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
|
||||||
### Where documentation goes
|
the file already has. The copyright notices of other holders stay
|
||||||
|
unchanged. Those two lines are for files this project authors: an
|
||||||
- **One home.** A fact lives on the documentation site, or it does not exist.
|
outside contributor who writes a new file puts their own name there, or
|
||||||
A repository README says what the repository is, how to build and test it,
|
leaves the lines out, and keeps their copyright in what they wrote.
|
||||||
and where the documentation is. Nothing else.
|
Editing an existing file changes no header - a contribution is recorded
|
||||||
- **The currency rule.** A change carries a documentation commit when it adds,
|
by its `Signed-off-by` line and by git, not by a copyright line.
|
||||||
removes, or renames an interface (a sysfs attribute, an HTTP route, a
|
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
|
||||||
settings key, a G-code or `$` setting), or when it corrects a measured
|
under the license of its repository.
|
||||||
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,
|
### Where documentation goes
|
||||||
which the site defines.
|
|
||||||
- **A moved document is deleted.** No stub and no redirect stays at the old
|
- **One home.** A fact lives on the documentation site, or it does not exist.
|
||||||
path.
|
A repository README says what the repository is, how to build and test it,
|
||||||
- **Every diagram is Mermaid.** No ASCII art and no image of a diagram.
|
and where the documentation is. Nothing else.
|
||||||
- There is no roadmap page. Open items are tracked as GitHub issues once the
|
- **The currency rule.** A change carries a documentation commit when it adds,
|
||||||
repositories accept them.
|
removes, or renames an interface (a sysfs attribute, an HTTP route, a
|
||||||
- Do not add plan files, audit files, working notes, or status files to any
|
settings key, a G-code or `$` setting), or when it corrects a measured
|
||||||
repository. The project has **no status document and no dated log**: the
|
fact. A measured fact says how it was obtained, and on what: unless the page
|
||||||
site describes the present, and the record of what was done, how it was
|
says otherwise, a measurement on the site was taken on the bench reference,
|
||||||
proven, and what it replaced goes in the commit message that carries the
|
which the site defines.
|
||||||
change.
|
- **A moved document is deleted.** No stub and no redirect stays at the old
|
||||||
|
path.
|
||||||
### Analysis and reporting
|
- **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
|
||||||
- Rank engineering options on technical merit only: performance, correctness,
|
repositories accept them.
|
||||||
robustness, verifiability. Process cost (an acceptance campaign, an image
|
- Do not add plan files, audit files, working notes, or status files to any
|
||||||
flash, a new dependency) is a one-line footnote, never a ranking factor.
|
repository. The project has **no status document and no dated log**: the
|
||||||
The operator decides the process cost.
|
site describes the present, and the record of what was done, how it was
|
||||||
- Never infer or assume a hardware fact. Label every unverified claim as
|
proven, and what it replaced goes in the commit message that carries the
|
||||||
unverified, and say how to verify it.
|
change.
|
||||||
- Report only what the observations confirm. Everything else is labeled
|
|
||||||
"unconfirmed" and is not acted on.
|
### Analysis and reporting
|
||||||
|
|
||||||
### General practice for agents
|
- Rank engineering options on technical merit only: performance, correctness,
|
||||||
|
robustness, verifiability. Process cost (an acceptance campaign, an image
|
||||||
- Read the README of this repository, this file, and the Developers section
|
flash, a new dependency) is a one-line footnote, never a ranking factor.
|
||||||
of the site before you change code.
|
The operator decides the process cost.
|
||||||
- Orient in the code before you edit. If a local knowledge graph
|
- Never infer or assume a hardware fact. Label every unverified claim as
|
||||||
(`graphify-out/`) exists in the working tree or its parent, query it first.
|
unverified, and say how to verify it.
|
||||||
It is a local artifact and is never committed.
|
- Report only what the observations confirm. Everything else is labeled
|
||||||
- Keep a change minimal and scoped to the request. No drive-by reformatting,
|
"unconfirmed" and is not acted on.
|
||||||
no unrelated cleanups, no renames for taste. Match the style of the
|
|
||||||
surrounding code.
|
### General practice for agents
|
||||||
- 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.
|
- Read the README of this repository, this file, and the Developers section
|
||||||
- Extend an existing test or tool instead of adding a parallel one.
|
of the site before you change code.
|
||||||
- Never create a file in the repository that the request does not need.
|
- Orient in the code before you edit. If a local knowledge graph
|
||||||
- Ask before anything destructive or outward-facing: a push, a force-push, a
|
(`graphify-out/`) exists in the working tree or its parent, query it first.
|
||||||
tag, a release, a history rewrite, or any change to a machine.
|
It is a local artifact and is never committed.
|
||||||
- When a fact is unknown, say so and say how to find out. Never fill a gap
|
- Keep a change minimal and scoped to the request. No drive-by reformatting,
|
||||||
with a plausible number.
|
no unrelated cleanups, no renames for taste. Match the style of the
|
||||||
- Use the exact names of the project: kas, bitbake, pin, SRCREV, campaign,
|
surrounding code.
|
||||||
fingerprint, drill, sysfs, the latch, the armed window. Do not invent names.
|
- 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.
|
||||||
### Before you commit
|
- 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.
|
||||||
1. The host tests of this repository pass, with `-Werror` where the build
|
- Ask before anything destructive or outward-facing: a push, a force-push, a
|
||||||
uses it.
|
tag, a release, a history rewrite, or any change to a machine.
|
||||||
2. The change is proven at the highest level available: CI test, bench drill,
|
- When a fact is unknown, say so and say how to find out. Never fill a gap
|
||||||
or documented reasoning.
|
with a plausible number.
|
||||||
3. For a component that ships in the image: the acceptance catalog covers the
|
- Use the exact names of the project: kas, bitbake, pin, SRCREV, campaign,
|
||||||
changed behavior, or the commit message says why it has no catalog
|
fingerprint, drill, sysfs, the latch, the armed window. Do not invent names.
|
||||||
consequence.
|
|
||||||
4. New files carry the 514 LLC d/b/a OpenGlow copyright, the `Written by`
|
### Before you commit
|
||||||
line, and an SPDX line. Line endings are LF.
|
|
||||||
5. No em dashes, no British spellings, no workstation paths, no bench
|
1. The host tests of this repository pass, with `-Werror` where the build
|
||||||
identity, no factory firmware code, no history narrative, no AI
|
uses it.
|
||||||
attribution.
|
2. The change is proven at the highest level available: CI test, bench drill,
|
||||||
6. Documentation is current: interfaces and measured facts (the currency
|
or documented reasoning.
|
||||||
rule).
|
3. For a component that ships in the image: the acceptance catalog covers the
|
||||||
7. The commit is one settled change, attributed to the human author, and it
|
changed behavior, or the commit message says why it has no catalog
|
||||||
is pushed only when the operator asks.
|
consequence.
|
||||||
|
4. New files carry the 514 LLC d/b/a OpenGlow copyright, the `Written by`
|
||||||
|
line, and an SPDX line. Line endings are LF.
|
||||||
|
5. No em dashes, no British spellings, no workstation paths, no bench
|
||||||
|
identity, no factory firmware code, no history narrative, no AI
|
||||||
|
attribution.
|
||||||
|
6. Documentation is current: interfaces and measured facts (the currency
|
||||||
|
rule).
|
||||||
|
7. The commit is one settled change, attributed to the human author, and it
|
||||||
|
is pushed only when the operator asks.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Patches are welcome. How the project works, what proof a change needs, and the
|
||||||
|
writing rules are on the documentation site:
|
||||||
|
<https://docs.forgefirm.org/developers/contributing/>.
|
||||||
|
|
||||||
|
## Sign your work
|
||||||
|
|
||||||
|
Every commit carries a `Signed-off-by` line:
|
||||||
|
|
||||||
|
Signed-off-by: Your Name <your.email@example.com>
|
||||||
|
|
||||||
|
`git commit -s` adds it, using your `user.name` and `user.email`. Use your real
|
||||||
|
name; an anonymous or pseudonymous sign-off certifies nothing.
|
||||||
|
|
||||||
|
The line certifies the Developer Certificate of Origin, version 1.1, below. It
|
||||||
|
is the same statement the Linux kernel and grblHAL use. It is not a copyright
|
||||||
|
assignment: you keep the copyright in what you write, and your contribution
|
||||||
|
comes in under the license of the repository you are contributing to.
|
||||||
|
|
||||||
|
You do not add a copyright line for a patch. A change to an existing file
|
||||||
|
leaves its header alone. If you write a whole new file, put your own name in
|
||||||
|
its copyright line, or leave the line out; either way the file carries an
|
||||||
|
`SPDX-License-Identifier` for the repository's license.
|
||||||
|
|
||||||
|
## Developer Certificate of Origin 1.1
|
||||||
|
|
||||||
|
By making a contribution to this project, I certify that:
|
||||||
|
|
||||||
|
(a) The contribution was created in whole or in part by me and I
|
||||||
|
have the right to submit it under the open source license
|
||||||
|
indicated in the file; or
|
||||||
|
|
||||||
|
(b) The contribution is based upon previous work that, to the best
|
||||||
|
of my knowledge, is covered under an appropriate open source
|
||||||
|
license and I have the right under that license to submit that
|
||||||
|
work with modifications, whether created in whole or in part
|
||||||
|
by me, under the same open source license (unless I am
|
||||||
|
permitted to submit under a different license), as indicated
|
||||||
|
in the file; or
|
||||||
|
|
||||||
|
(c) The contribution was provided directly to me by some other
|
||||||
|
person who certified (a), (b) or (c) and I have not modified
|
||||||
|
it.
|
||||||
|
|
||||||
|
(d) I understand and agree that this project and the contribution
|
||||||
|
are public and that a record of the contribution (including all
|
||||||
|
personal information I submit with it, including my sign-off) is
|
||||||
|
maintained indefinitely and may be redistributed consistent with
|
||||||
|
this project or the open source license(s) involved.
|
||||||
|
|
||||||
|
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
|
||||||
|
This DCO text is licensed under the terms of the Creative Commons
|
||||||
|
Attribution-ShareAlike 3.0 Unported license.
|
||||||
@@ -5,5 +5,14 @@ the associated recipe (.bb file) unless otherwise stated.
|
|||||||
See:
|
See:
|
||||||
COPYING.MIT (MIT)
|
COPYING.MIT (MIT)
|
||||||
|
|
||||||
|
The code in this repository that is not layer metadata is MIT licensed:
|
||||||
|
|
||||||
|
forgetest/ the release acceptance tool (see forgetest/LICENSE)
|
||||||
|
scripts/ the release, install and bench scripts
|
||||||
|
fixture/ the bench actuator firmware, excluding
|
||||||
|
fixture/managed_components/, which carries its own licenses
|
||||||
|
|
||||||
|
Every file in those directories states MIT in its own header.
|
||||||
|
|
||||||
License information for any other files is either explicitly stated
|
License information for any other files is either explicitly stated
|
||||||
or defaults to GPL version 2.
|
or defaults to GPL version 2.
|
||||||
|
|||||||
@@ -83,4 +83,5 @@ before you install.
|
|||||||
**This is experimental software. Use of it could seriously maim or kill you or
|
**This is experimental software. Use of it could seriously maim or kill you or
|
||||||
others, and it may void your warranty. Use it at your own risk.**
|
others, and it may void your warranty. Use it at your own risk.**
|
||||||
|
|
||||||
This project is not affiliated with or endorsed by Glowforge.
|
Glowforge is a trademark of Glowforge, Inc. This project is not affiliated
|
||||||
|
with, authorized by, or endorsed by Glowforge, Inc.
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
# forgefixture: build, flash and watch the bench actuator's firmware.
|
# forgefixture: build, flash and watch the bench actuator's firmware.
|
||||||
#
|
#
|
||||||
# ./fixture.sh env fixture.env from the example, with a fresh API key
|
# ./fixture.sh env fixture.env from the example, with a fresh API key
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
/* forgefixture firmware version, reported by GET /. */
|
/* forgefixture firmware version, reported by GET /. */
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
# The host test of the fixture's policy: needs only a C compiler.
|
# The host test of the fixture's policy: needs only a C compiler.
|
||||||
set -e
|
set -e
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
ForgeFIRM release acceptance tool (forgetest/) and the bench scripts it
|
||||||
|
drives (scripts/bench/).
|
||||||
|
|
||||||
|
Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
Written by Scott Wiederhold
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished
|
||||||
|
to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
### BEGIN INIT INFO
|
### BEGIN INIT INFO
|
||||||
# Provides: forgetest
|
# Provides: forgetest
|
||||||
# Required-Start: $network forgectrl
|
# Required-Start: $network forgectrl
|
||||||
@@ -22,13 +26,13 @@ case "$1" in
|
|||||||
start)
|
start)
|
||||||
echo "Starting forgetest"
|
echo "Starting forgetest"
|
||||||
mkdir -p "$DATA"
|
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
|
# the controller gate until the next reboot (/run is tmpfs), so a
|
||||||
# campaign runs on a machine whose required wizards are not complete.
|
# campaign runs on a machine whose required wizards are not complete.
|
||||||
# Nothing lifts the agreements or the account: the bench accepts them
|
# Nothing lifts the agreements or the account: the bench accepts them
|
||||||
# once, and that stays in /data.
|
# once, and that stays in /data.
|
||||||
mkdir -p /run/forgefirm
|
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 \
|
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
|
||||||
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
|
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""forgetest - the ForgeFIRM release acceptance tool.
|
"""forgetest - the ForgeFIRM release acceptance tool.
|
||||||
|
|
||||||
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
|
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""forgetest daemon entry point.
|
"""forgetest daemon entry point.
|
||||||
|
|
||||||
python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH]
|
python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH]
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The release artifact (acceptance.json / acceptance.md) and its verification.
|
"""The release artifact (acceptance.json / acceptance.md) and its verification.
|
||||||
|
|
||||||
|
Every test record is redacted before it enters the artifact (redact.py), so
|
||||||
|
the committed file carries no identity of the machine that ran the campaign.
|
||||||
|
It happens inside build(), ahead of the self-hash, because verify() rejects a
|
||||||
|
file edited after the fact. The gate reads none of the redacted fields.
|
||||||
|
|
||||||
The exporter serializes the campaign state with, for every catalog test,
|
The exporter serializes the campaign state with, for every catalog test,
|
||||||
the winning result record (same-campaign PASS or inherited PASS, with its
|
the winning result record (same-campaign PASS or inherited PASS, with its
|
||||||
origin) and the fingerprint it was recorded under. The gate
|
origin) and the fingerprint it was recorded under. The gate
|
||||||
@@ -15,6 +25,7 @@ from . import VERSION
|
|||||||
from . import campaign as _campaign
|
from . import campaign as _campaign
|
||||||
from . import manifest as _manifest
|
from . import manifest as _manifest
|
||||||
from .log import now_ts
|
from .log import now_ts
|
||||||
|
from .redact import scrub
|
||||||
|
|
||||||
FORMAT = 1
|
FORMAT = 1
|
||||||
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
|
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
|
||||||
@@ -47,7 +58,7 @@ def build(state, tests, manifest, records, catalog_hash):
|
|||||||
"fingerprint": st["fingerprint"],
|
"fingerprint": st["fingerprint"],
|
||||||
"satisfied": st["satisfied"],
|
"satisfied": st["satisfied"],
|
||||||
"inherited": st["status"] == "inherited",
|
"inherited": st["status"] == "inherited",
|
||||||
"record": _record(rec) if rec else None,
|
"record": scrub(_record(rec)) if rec else None,
|
||||||
})
|
})
|
||||||
tests_out.append(entry)
|
tests_out.append(entry)
|
||||||
|
|
||||||
|
|||||||
+208
-34
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The fresh-boot idle state: what every test starts from and leaves behind.
|
"""The fresh-boot idle state: what every test starts from and leaves behind.
|
||||||
|
|
||||||
The runner checks the machine against this baseline before a run and
|
The runner checks the machine against this baseline before a run and
|
||||||
@@ -48,7 +53,7 @@ from .log import now_ts
|
|||||||
# in the pulse path (a job's Z moves the lens; the driver's Z soft limit
|
# in the pulse path (a job's Z moves the lens; the driver's Z soft limit
|
||||||
# guards it), step_freq its machine tick and ramp_rate the kernel stop
|
# guards it), step_freq its machine tick and ramp_rate the kernel stop
|
||||||
# ramp, both derived from the XY microstep mode (the values here are the
|
# ramp, both derived from the XY microstep mode (the values here are the
|
||||||
# x8 default's; fixed_sysfs() gives the mode's); streaming is only ever 1
|
# x8 base; fixed_sysfs() gives the mode's); streaming is only ever 1
|
||||||
# inside a live job; the head white LED is a camera lamp, off at idle; the
|
# inside a live job; the head white LED is a camera lamp, off at idle; the
|
||||||
# loop heater and TEC are the diagnostics' tools, off at idle.
|
# loop heater and TEC are the diagnostics' tools, off at idle.
|
||||||
FIXED_SYSFS = [
|
FIXED_SYSFS = [
|
||||||
@@ -65,6 +70,21 @@ FIXED_SYSFS = [
|
|||||||
("head/white_led", "0"),
|
("head/white_led", "0"),
|
||||||
("thermal/heater_pwm", "0"),
|
("thermal/heater_pwm", "0"),
|
||||||
("thermal/tec_on", "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
|
# The subset the GRBL controller writes at its start: checked and restored
|
||||||
@@ -72,9 +92,14 @@ FIXED_SYSFS = [
|
|||||||
# every pulse header (step_freq 10 kHz, the run currents), runs at the
|
# 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
|
# 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
|
# 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",
|
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
|
# Settings the baseline never hands back as bare settings: controller_mode
|
||||||
# is the persisted mirror of the live mode (the mode item restores it
|
# is the persisted mirror of the live mode (the mode item restores it
|
||||||
@@ -113,18 +138,27 @@ BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon a
|
|||||||
SETTLE_S = 150 # the supervisor's probe + rail-off ladder
|
SETTLE_S = 150 # the supervisor's probe + rail-off ladder
|
||||||
CAM_IDLE_S = 20 # camera engine idle stop is 10 s
|
CAM_IDLE_S = 20 # camera engine idle stop is 10 s
|
||||||
COOL_IDLE_S = 120 # cooldown after motion
|
COOL_IDLE_S = 120 # cooldown after motion
|
||||||
|
COOL_PUBLISH_S = 2.5 # the engine publishes once a tick (1 Hz): two ticks and a margin
|
||||||
IDLE_S = 30 # cnc/state back to idle after a job
|
IDLE_S = 30 # cnc/state back to idle after a job
|
||||||
GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running
|
GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running
|
||||||
|
|
||||||
XY_STEPS_PER_MM = 53.333 # boards/glowforge.h (x8 microstepping)
|
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
|
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 =
|
# 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,
|
# 32). The GRBL controller reads it at its start and derives its scale,
|
||||||
# its machine tick and the kernel stop ramp from it (boards/glowforge.h):
|
# its machine tick and the kernel stop ramp from it (boards/glowforge.h):
|
||||||
# the fixed values of x/y_mode, step_freq and ramp_rate are the mode's.
|
# the fixed values of x/y_mode, step_freq and ramp_rate are the mode's.
|
||||||
XY_MODES = (8, 16, 32)
|
XY_MODES = (8, 16, 32)
|
||||||
XY_MODE_DEFAULT = 8
|
XY_MODE_BASE = 8 # the factory x8 reference the tick and ramp scale from
|
||||||
|
XY_MODE_DEFAULT = 32 # the mode an unset or invalid setting reads as
|
||||||
XY_STEPS_PER_MM_OF = {8: 53.333, 16: 106.667, 32: 213.333} # $100/$101 per mode
|
XY_STEPS_PER_MM_OF = {8: 53.333, 16: 106.667, 32: 213.333} # $100/$101 per mode
|
||||||
XY_TICK_X8_HZ = 28160 # the x8 machine tick
|
XY_TICK_X8_HZ = 28160 # the x8 machine tick
|
||||||
XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it
|
XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it
|
||||||
@@ -148,7 +182,7 @@ def fixed_sysfs(mode=XY_MODE_DEFAULT):
|
|||||||
"""FIXED_SYSFS with the mode's own x/y_mode, step_freq and ramp_rate:
|
"""FIXED_SYSFS with the mode's own x/y_mode, step_freq and ramp_rate:
|
||||||
the tick and the ramp scale with the mode (x16 doubles both, x32
|
the tick and the ramp scale with the mode (x16 doubles both, x32
|
||||||
quadruples them)."""
|
quadruples them)."""
|
||||||
k = mode // XY_MODE_DEFAULT
|
k = mode // XY_MODE_BASE
|
||||||
own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode),
|
own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode),
|
||||||
"cnc/step_freq": str(XY_TICK_X8_HZ * k),
|
"cnc/step_freq": str(XY_TICK_X8_HZ * k),
|
||||||
"cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)}
|
"cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)}
|
||||||
@@ -160,12 +194,19 @@ def leds_root():
|
|||||||
return r if r.endswith("/") else r + "/"
|
return r if r.endswith("/") else r + "/"
|
||||||
|
|
||||||
|
|
||||||
def read_led(name):
|
def read_led(name, attr="target"):
|
||||||
try:
|
"""A button LED's commanded level. The trigger fades brightness toward
|
||||||
with open(leds_root() + name + "/brightness") as f:
|
target, so brightness is where the fade has reached and target is what
|
||||||
return f.read().strip()
|
the machine was told: a run that left the button lit left a target, and
|
||||||
except OSError:
|
a run that ended a moment ago may still be fading from one. Falls back
|
||||||
return None
|
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):
|
def write_led(name, value):
|
||||||
@@ -183,6 +224,27 @@ def read_position():
|
|||||||
return None
|
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():
|
def read_ring_residue():
|
||||||
"""Unplayed bytes queued in the kernel pulse ring: total written minus
|
"""Unplayed bytes queued in the kernel pulse ring: total written minus
|
||||||
processed (the cnc/position byte counters), or None when unreadable.
|
processed (the cnc/position byte counters), or None when unreadable.
|
||||||
@@ -274,7 +336,7 @@ class Baseline:
|
|||||||
def wait_settled(self, timeout=SETTLE_S, unreachable_s=10):
|
def wait_settled(self, timeout=SETTLE_S, unreachable_s=10):
|
||||||
"""Block until forgectrl reports a settled supervisor: motion
|
"""Block until forgectrl reports a settled supervisor: motion
|
||||||
verified (the probe passed), motion-fault (the ladder exhausted),
|
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
|
is closed: no controller spawns until it opens). Gives up after
|
||||||
unreachable_s without an answer. Returns the last /mode body (None
|
unreachable_s without an answer. Returns the last /mode body (None
|
||||||
if unreachable)."""
|
if unreachable)."""
|
||||||
@@ -403,6 +465,9 @@ class Baseline:
|
|||||||
preserved state to hand back (post) - None compares nothing."""
|
preserved state to hand back (post) - None compares nothing."""
|
||||||
left = []
|
left = []
|
||||||
self.mode = None
|
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._forgectrl_side(left, captured)
|
||||||
self._kernel_side(left)
|
self._kernel_side(left)
|
||||||
self._lamp_side(left)
|
self._lamp_side(left)
|
||||||
@@ -421,6 +486,37 @@ class Baseline:
|
|||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
return None
|
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):
|
def cloud_mode(self):
|
||||||
return self.mode == "cloud"
|
return self.mode == "cloud"
|
||||||
|
|
||||||
@@ -479,7 +575,14 @@ class Baseline:
|
|||||||
before = (mode.get("controller"), mode.get("motion"))
|
before = (mode.get("controller"), mode.get("motion"))
|
||||||
mode = self.wait_settled() or mode
|
mode = self.wait_settled() or mode
|
||||||
if mode.get("controller") == "running" and mode.get("motion") == "verified":
|
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:
|
else:
|
||||||
left.append(Leftover("controller", "%s/%s" % before, "running/verified",
|
left.append(Leftover("controller", "%s/%s" % before, "running/verified",
|
||||||
"failed: %s/%s" % (mode.get("controller"), mode.get("motion"))))
|
"failed: %s/%s" % (mode.get("controller"), mode.get("motion"))))
|
||||||
@@ -487,14 +590,23 @@ class Baseline:
|
|||||||
st, s = self.fc_get("/status")
|
st, s = self.fc_get("/status")
|
||||||
if st == 200 and isinstance(s, dict):
|
if st == 200 and isinstance(s, dict):
|
||||||
if s.get("state") != "idle":
|
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:
|
try:
|
||||||
hw.sysfs_write("cnc/stop", "1") # ack
|
hw.sysfs_write("cnc/stop", "1") # ack
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
w = self._wait("idle", lambda: (self.fc_get("/status")[1] or {}).get("state") == "idle", IDLE_S)
|
w = self._wait("idle", lambda: (self.fc_get("/status")[1] or {}).get("state") == "idle", IDLE_S)
|
||||||
left.append(Leftover("state", s.get("state"), "idle",
|
if w is not None and not underrun:
|
||||||
"waited" if w is not None else "failed: not idle"))
|
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:
|
if s.get("laser_locked") is False:
|
||||||
try:
|
try:
|
||||||
hw.sysfs_write("cnc/laser_latch", "1")
|
hw.sysfs_write("cnc/laser_latch", "1")
|
||||||
@@ -502,16 +614,60 @@ class Baseline:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
act = "failed: %s" % e
|
act = "failed: %s" % e
|
||||||
left.append(Leftover("laser_locked", False, True, act))
|
left.append(Leftover("laser_locked", False, True, act))
|
||||||
# cooling engine idle, unarmed
|
self._cool_side(left)
|
||||||
|
|
||||||
|
def _cool_side(self, left):
|
||||||
|
"""The cooling engine idle, unarmed, holding nothing."""
|
||||||
st, c = self.fc_get("/cool/status")
|
st, c = self.fc_get("/cool/status")
|
||||||
if st == 200 and isinstance(c, dict):
|
if st != 200 or not isinstance(c, dict):
|
||||||
if c.get("phase") != "idle" or c.get("armed") or c.get("hold"):
|
return
|
||||||
|
if c.get("phase") == "idle" and not c.get("armed") and not c.get("hold"):
|
||||||
|
return
|
||||||
|
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
|
||||||
|
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 {})
|
||||||
|
# The engine publishes once a tick (1 Hz). A read inside the tick
|
||||||
|
# after a run ended still shows the run: a diagnostic's hold until
|
||||||
|
# the engine has the hardware back, a fail tier's hold until the
|
||||||
|
# session ends with the controller. What the engine's next tick
|
||||||
|
# clears was never left behind, so an arm or a hold is read again
|
||||||
|
# past that tick before it is called the run's doing. One that a
|
||||||
|
# run did leave stands for a job that is never coming back, and it
|
||||||
|
# is still there.
|
||||||
|
if c.get("armed") or c.get("hold"):
|
||||||
|
free = lambda: (lambda x: bool(x) and not x.get("armed") # noqa: E731
|
||||||
|
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
||||||
|
if self._wait("cool publish", free, COOL_PUBLISH_S) is not None:
|
||||||
|
st, c2 = self.fc_get("/cool/status")
|
||||||
|
c = c2 if st == 200 and isinstance(c2, dict) else {}
|
||||||
|
self.log("cool: %s was the engine's last word on the run; its next tick reads "
|
||||||
|
"%s/armed=%s/hold=%s" % (found, c.get("phase"), c.get("armed"), c.get("hold")))
|
||||||
|
if c.get("phase") == "idle":
|
||||||
|
return
|
||||||
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), 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")
|
# Armed, or holding, is the run's doing. A phase alone is
|
||||||
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {}),
|
# not: the engine clears smoke at run duty after an armed
|
||||||
COOL_IDLE_S)
|
# session and cools down after a hot one, both timed and
|
||||||
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
# both ending on their own. Waiting for that is right;
|
||||||
"waited" if w is not None else "failed: still %s" % found))
|
# 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))
|
||||||
|
return
|
||||||
|
# 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):
|
def _lamp_side(self, left):
|
||||||
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
|
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
|
||||||
@@ -575,14 +731,18 @@ class Baseline:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
act = "failed: %s" % e
|
act = "failed: %s" % e
|
||||||
left.append(Leftover("leds/" + name, got, "0", act))
|
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
|
"""Jog the head back along its own path by the kernel-measured X/Y
|
||||||
delta (Z is never touched), through the GRBL controller. Bounded:
|
delta (Z is never touched), through the GRBL controller. spm is
|
||||||
beyond RETURN_MAX_MM per axis, or without a running GRBL
|
the counters' scale (the mode's). Bounded: beyond RETURN_MAX_MM
|
||||||
controller, the counters are reported and left."""
|
per axis, or without a running GRBL controller, the counters are
|
||||||
dx = (now[0] - was[0]) / XY_STEPS_PER_MM
|
reported and left."""
|
||||||
dy = (now[1] - was[1]) / XY_STEPS_PER_MM
|
dx = (now[0] - was[0]) / spm
|
||||||
|
dy = (now[1] - was[1]) / spm
|
||||||
if abs(dx) < 0.02 and abs(dy) < 0.02:
|
if abs(dx) < 0.02 and abs(dy) < 0.02:
|
||||||
return "unrestorable (Z only)" if now[2] != was[2] else "restored"
|
return "unrestorable (Z only)" if now[2] != was[2] else "restored"
|
||||||
if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM:
|
if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM:
|
||||||
@@ -590,10 +750,18 @@ class Baseline:
|
|||||||
# Never jog on top of stale bytes: a run started now would replay
|
# 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
|
# whatever the ring still holds before the jog, in a direction and
|
||||||
# for a distance nobody asked for. Report and leave the head.
|
# 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()
|
residue = read_ring_residue()
|
||||||
if residue:
|
if residue:
|
||||||
return ("unrestorable: %d unplayed bytes queued in the kernel ring - a jog would "
|
self.log("baseline: %d unplayed bytes in the kernel ring before the return" % residue)
|
||||||
"replay them; clear the ring (controller restart) before moving" % 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
|
# a controller may be inside a respawn backoff (seconds): wait for it
|
||||||
mode = None
|
mode = None
|
||||||
deadline = time.time() + 30
|
deadline = time.time() + 30
|
||||||
@@ -662,7 +830,13 @@ class Baseline:
|
|||||||
was = captured.get("position")
|
was = captured.get("position")
|
||||||
now = read_position()
|
now = read_position()
|
||||||
if was is not None and now is not None and now != was and not self.cloud_mode():
|
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")
|
was = captured.get("settings")
|
||||||
if was:
|
if was:
|
||||||
st, body = self.fc_get("/settings")
|
st, body = self.fc_get("/settings")
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The bench diagnostics page: registry of the bench tools and the
|
"""The bench diagnostics page: registry of the bench tools and the
|
||||||
subprocess runner behind the #bench tab.
|
subprocess runner behind the #bench tab.
|
||||||
|
|
||||||
@@ -247,8 +252,11 @@ TOOLS = [
|
|||||||
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
|
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
|
||||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
"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 "
|
"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 "
|
"lens stands, and neither a $20 nor a $132 write frees it. A referenced lens (forgectrl's marker "
|
||||||
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
"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",
|
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
|
||||||
"safety": "dry", "where": "board", "ported": True,
|
"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"])],
|
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Campaign rules: which results apply, what is required, and whether a
|
"""Campaign rules: which results apply, what is required, and whether a
|
||||||
release is authorized.
|
release is authorized.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The acceptance catalog: test definitions and the registry.
|
"""The acceptance catalog: test definitions and the registry.
|
||||||
|
|
||||||
A test is a function decorated with @test(...). The decorator records
|
A test is a function decorated with @test(...). The decorator records
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Coverage lint: every source path in the manifest must be selected by
|
"""Coverage lint: every source path in the manifest must be selected by
|
||||||
some test's coverage globs, except the allowlisted non-behavioral paths,
|
some test's coverage globs, except the allowlisted non-behavioral paths,
|
||||||
and every coverage glob must select at least one path of its component.
|
and every coverage glob must select at least one path of its component.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The bench actuator (forgefixture): the box on the bench network that
|
"""The bench actuator (forgefixture): the box on the bench network that
|
||||||
opens the lid loop, pulls the interlock loop and presses the button on
|
opens the lid loop, pulls the interlock loop and presses the button on
|
||||||
request, so an operator test can run with nobody in the room.
|
request, so an operator test can run with nobody in the room.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Hardware and service access for the suite: forgectrl's HTTP API, the
|
"""Hardware and service access for the suite: forgectrl's HTTP API, the
|
||||||
kernel module's sysfs, the init scripts, and the Grbl TCP port.
|
kernel module's sysfs, the init scripts, and the Grbl TCP port.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The append-only result log (JSONL) and the export helpers.
|
"""The append-only result log (JSONL) and the export helpers.
|
||||||
|
|
||||||
One JSON object per line under the data directory (default
|
One JSON object per line under the data directory (default
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The image manifest and the domain fingerprint.
|
"""The image manifest and the domain fingerprint.
|
||||||
|
|
||||||
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
|
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The single page: acceptance tab + bench tab, assembled from ui/ into
|
"""The single page: acceptance tab + bench tab, assembled from ui/ into
|
||||||
one self-contained response (no external assets). Bootstrap and the
|
one self-contained response (no external assets). Bootstrap and the
|
||||||
OpenGlow theme are inlined; theme.css and the vendor/ files are the same
|
OpenGlow theme are inlined; theme.css and the vendor/ files are the same
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Pulse files for the offline cloud service: a job the machine will run
|
"""Pulse files for the offline cloud service: a job the machine will run
|
||||||
without the service having cut it.
|
without the service having cut it.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""Redaction for the release artifact.
|
||||||
|
|
||||||
|
The artifact is committed to a public repository, so it carries no
|
||||||
|
identity of the machine that ran the campaign. This applies the same two
|
||||||
|
layers forgectrl's log export applies (forgectrl/src/sanitize.c), in the
|
||||||
|
same order, to every value the exporter writes:
|
||||||
|
|
||||||
|
1. Known keys: a value held under a key that names identifying data -
|
||||||
|
the serial, the machine id, the hostname, the wireless credentials,
|
||||||
|
a token - is replaced whole. Naming the key is far more reliable
|
||||||
|
than guessing the shape of what is under it.
|
||||||
|
2. Pattern classes over every remaining string: MAC addresses, IPv4 and
|
||||||
|
IPv6 addresses (loopback and unspecified kept), e-mail addresses,
|
||||||
|
bearer and basic credentials, JWTs, and long hex blobs. Each distinct
|
||||||
|
value in a class keeps a stable number for the life of one export, so
|
||||||
|
"<IP-2>" is the same host wherever it appears and the artifact stays
|
||||||
|
readable as evidence.
|
||||||
|
|
||||||
|
Integrity values are never touched: the self-hash, the manifest and
|
||||||
|
catalog hashes, each test's source hash and fingerprint. Those are what
|
||||||
|
the gate recomputes, and a long hex blob is exactly what they look like.
|
||||||
|
|
||||||
|
Over-redaction is accepted where the alternative is a leak.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Layer 1: the value under one of these keys is replaced whole.
|
||||||
|
REDACT_KEYS = frozenset((
|
||||||
|
"gf_serial", "machine_id", "serial", "serial_number",
|
||||||
|
"hostname", "hostname_file", "host",
|
||||||
|
"ssid", "psk", "passphrase", "password", "passwd",
|
||||||
|
"token", "panel_token", "secret", "api_key", "key",
|
||||||
|
"mac", "macaddr", "mac_suffix", "wlan0", "eth0",
|
||||||
|
"gf_username", "gf_password", "username", "user", "email",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Never touched, by key: the gate recomputes these and they look like
|
||||||
|
# exactly what layer 2 hunts for.
|
||||||
|
KEEP_KEYS = frozenset((
|
||||||
|
"sha256", "manifest_sha", "identity_sha", "catalog_hash",
|
||||||
|
"source_sha", "fingerprint", "content_sha", "srcrev",
|
||||||
|
))
|
||||||
|
|
||||||
|
_KEEP_IP = frozenset(("0.0.0.0", "255.255.255.255"))
|
||||||
|
|
||||||
|
_CLASSES = (
|
||||||
|
("SECRET", re.compile(r"(?i)\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}")),
|
||||||
|
("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
|
||||||
|
("EMAIL", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
|
||||||
|
("MAC", re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b")),
|
||||||
|
# Three or more colons, or a "::" run: two colons is a clock time, and
|
||||||
|
# tagging every timestamp would make the evidence unreadable.
|
||||||
|
("IP6", re.compile(
|
||||||
|
r"(?:[0-9A-Fa-f]{1,4}:){3,7}[0-9A-Fa-f]{1,4}"
|
||||||
|
r"|[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{1,4})*::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?")),
|
||||||
|
("IP", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")),
|
||||||
|
("HEX", re.compile(r"\b[0-9A-Fa-f]{32,}\b")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Redactor:
|
||||||
|
"""Stable per-export numbering, one instance per artifact."""
|
||||||
|
|
||||||
|
# A known value shorter than this is too generic to replace blind.
|
||||||
|
MIN_KNOWN = 4
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._n = {}
|
||||||
|
self._known = {}
|
||||||
|
|
||||||
|
def learn(self, obj, key=None):
|
||||||
|
"""Collect the values held under the layer-1 keys, so they are
|
||||||
|
replaced wherever else they appear - a hostname in a log line is
|
||||||
|
the same leak as a hostname in its own field."""
|
||||||
|
if key is not None and key in KEEP_KEYS:
|
||||||
|
return
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for k, v in obj.items():
|
||||||
|
self.learn(v, k)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for v in obj:
|
||||||
|
self.learn(v)
|
||||||
|
elif key in REDACT_KEYS and isinstance(obj, str) and len(obj) >= self.MIN_KNOWN:
|
||||||
|
if obj not in self._known:
|
||||||
|
self._known[obj] = self._tag("REDACTED", "%s=%s" % (key, obj))
|
||||||
|
|
||||||
|
def _tag(self, cls, value):
|
||||||
|
seen = self._n.setdefault(cls, {})
|
||||||
|
if value not in seen:
|
||||||
|
seen[value] = len(seen) + 1
|
||||||
|
return "<%s-%d>" % (cls, seen[value])
|
||||||
|
|
||||||
|
def text(self, s):
|
||||||
|
for known in sorted(self._known, key=len, reverse=True):
|
||||||
|
if known in s:
|
||||||
|
s = s.replace(known, self._known[known])
|
||||||
|
for cls, rx in _CLASSES:
|
||||||
|
def sub(m, cls=cls):
|
||||||
|
v = m.group(0)
|
||||||
|
if cls == "IP" and (v.startswith("127.") or v in _KEEP_IP):
|
||||||
|
return v
|
||||||
|
if cls == "IP6" and v in ("::", "::1"):
|
||||||
|
return v
|
||||||
|
return self._tag(cls, v)
|
||||||
|
s = rx.sub(sub, s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
def value(self, v, key=None):
|
||||||
|
if key is not None and key in KEEP_KEYS:
|
||||||
|
return v
|
||||||
|
if key is not None and key in REDACT_KEYS and not isinstance(v, (dict, list)):
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, str) and v in self._known:
|
||||||
|
return self._known[v]
|
||||||
|
return self._tag("REDACTED", "%s=%s" % (key, v))
|
||||||
|
if isinstance(v, dict):
|
||||||
|
return {k: self.value(x, k) for k, x in v.items()}
|
||||||
|
if isinstance(v, list):
|
||||||
|
return [self.value(x) for x in v]
|
||||||
|
if isinstance(v, str):
|
||||||
|
return self.text(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def scrub(obj):
|
||||||
|
"""Redact a value tree with one export's numbering."""
|
||||||
|
r = Redactor()
|
||||||
|
r.learn(obj)
|
||||||
|
return r.value(obj)
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The runner: executes one acceptance test or one bench tool at a time.
|
"""The runner: executes one acceptance test or one bench tool at a time.
|
||||||
|
|
||||||
Acceptance tests run in a worker thread with a Context: log lines, an
|
Acceptance tests run in a worker thread with a Context: log lines, an
|
||||||
@@ -164,7 +169,12 @@ class Run:
|
|||||||
notice = dict(self.notice) if self.notice else None
|
notice = dict(self.notice) if self.notice else None
|
||||||
return {
|
return {
|
||||||
"kind": self.kind, "id": self.id, "title": self.title,
|
"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,
|
"log": lines, "dropped": self.dropped, "prompt": prompt, "notice": notice,
|
||||||
"finished": self.finished, "aborting": self.aborted.is_set(),
|
"finished": self.finished, "aborting": self.aborted.is_set(),
|
||||||
}
|
}
|
||||||
@@ -424,6 +434,45 @@ class Context:
|
|||||||
self.log("ACT %s %s: done after %.1f s", channel, state, dt)
|
self.log("ACT %s %s: done after %.1f s", channel, state, dt)
|
||||||
return 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.",
|
def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.",
|
||||||
lit_timeout=60):
|
lit_timeout=60):
|
||||||
"""The arm cue of a live test. A person's press by default: a
|
"""The arm cue of a live test. A person's press by default: a
|
||||||
@@ -994,7 +1043,21 @@ class Runner:
|
|||||||
run.log(traceback.format_exc().rstrip())
|
run.log(traceback.format_exc().rstrip())
|
||||||
self.fixture_release(run)
|
self.fixture_release(run)
|
||||||
try:
|
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
|
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))
|
run.log("baseline: post pass errored: %s: %s" % (type(e).__name__, e))
|
||||||
duration = int(time.time() - run.started)
|
duration = int(time.time() - run.started)
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""HTTP: the page, the JSON API, and the access rules.
|
"""HTTP: the page, the JSON API, and the access rules.
|
||||||
|
|
||||||
Access follows forgectrl's panel (auth.c): the Host header must be an
|
Access follows forgectrl's panel (auth.c): the Host header must be an
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The acceptance suite: one module per subsystem, imported in display
|
"""The acceptance suite: one module per subsystem, imported in display
|
||||||
order. Each module registers its tests with @catalog.test."""
|
order. Each module registers its tests with @catalog.test."""
|
||||||
|
|
||||||
from . import image # noqa: F401,E402
|
from . import image # noqa: F401,E402
|
||||||
from . import kernel # noqa: F401,E402
|
from . import kernel # noqa: F401,E402
|
||||||
from . import forgectrl # noqa: F401,E402
|
from . import forgectrl # noqa: F401,E402
|
||||||
from . import commission # noqa: F401,E402
|
from . import setup # noqa: F401,E402
|
||||||
from . import commission_dark # noqa: F401,E402
|
from . import setup_dark # noqa: F401,E402
|
||||||
from . import commission_sheet # noqa: F401,E402
|
from . import setup_sheet # noqa: F401,E402
|
||||||
from . import logs # noqa: F401,E402
|
from . import logs # noqa: F401,E402
|
||||||
from . import motion # noqa: F401,E402
|
from . import motion # noqa: F401,E402
|
||||||
from . import cooling # noqa: F401,E402
|
from . import cooling # noqa: F401,E402
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""camera.* - the lid camera pipeline through forgectrl."""
|
"""camera.* - the lid camera pipeline through forgectrl."""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -396,7 +401,7 @@ def lid_privacy(ctx):
|
|||||||
"authorizes a write; a rotation from the panel route stops the old key and "
|
"authorizes a write; a rotation from the panel route stops the old key and "
|
||||||
"the new one reads. The setting is put back after.")
|
"the new one reads. The setting is put back after.")
|
||||||
def key_read(ctx):
|
def key_read(ctx):
|
||||||
from .commission import request
|
from .setup import request
|
||||||
from .forgectrl import lan_ip
|
from .forgectrl import lan_ip
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""cloud.* - the controller mode switch and the optional Glowforge web-service
|
"""cloud.* - the controller mode switch and the optional Glowforge web-service
|
||||||
mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the
|
mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the
|
||||||
grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and
|
grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and
|
||||||
@@ -87,6 +92,9 @@ FORGECTRL_LOG = "/data/log/forgefirm/forgectrl/forgectrl.log"
|
|||||||
LIMITS_MARK = "job limits from the header: "
|
LIMITS_MARK = "job limits from the header: "
|
||||||
EFFECTIVE_MARK = "effective limits: coolant ceiling "
|
EFFECTIVE_MARK = "effective limits: coolant ceiling "
|
||||||
SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
|
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
|
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
|
||||||
|
|
||||||
|
|
||||||
@@ -145,7 +153,7 @@ def return_head(ctx, feed=2400):
|
|||||||
"head not back at the start after the return jog: %s", pos)
|
"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()
|
t0 = time.time()
|
||||||
last = None
|
last = None
|
||||||
while time.time() - t0 < timeout:
|
while time.time() - t0 < timeout:
|
||||||
@@ -157,7 +165,7 @@ def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
|
|||||||
return m
|
return m
|
||||||
if m.get("controller") == "motion-fault":
|
if m.get("controller") == "motion-fault":
|
||||||
break
|
break
|
||||||
time.sleep(1)
|
time.sleep(poll)
|
||||||
return last
|
return last
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +235,11 @@ def gfhome_homing(ctx, ev, g):
|
|||||||
# homed: the counters are re-anchored at the corner, where the head stays
|
# homed: the counters are re-anchored at the corner, where the head stays
|
||||||
ctx.counters_rezeroed()
|
ctx.counters_rezeroed()
|
||||||
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing")
|
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 "
|
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt "
|
||||||
@@ -235,17 +248,22 @@ def gfhome_homing(ctx, ev, g):
|
|||||||
covers=_HOMING_PATH + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"),
|
covers=_HOMING_PATH + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"),
|
||||||
("grblhal-glowforge", "src/**")],
|
("grblhal-glowforge", "src/**")],
|
||||||
requires=["forgectrl.auth", "motion.pacing"], actions=["lid"],
|
requires=["forgectrl.auth", "motion.pacing"], actions=["lid"],
|
||||||
steps=["Bed clear; cloud credentials configured; the machine on the network. The test "
|
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. "
|
||||||
"turns cloud mode and the gfcloud homing on itself when they are off, and puts "
|
"The test turns cloud mode and the gfcloud homing on itself when they are off, and "
|
||||||
"the settings back at the end.",
|
"puts the settings back at the end.",
|
||||||
"Open the lid when told and leave it open through the cloud client's connect and its "
|
"Open the lid the moment you are told, with a hand ready on it: the cloud client's "
|
||||||
"hunt; close it when told. Nothing else: the switch back and the $H homing run on "
|
"first hunt begins a few seconds after its controller starts and must find the lid "
|
||||||
"their own, and the head ends parked at the home corner."],
|
"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 "
|
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 "
|
"establishes its service session (its own log lines are the evidence; the "
|
||||||
"connect-time firmware probe is recorded when configured). Its connect-time hunt "
|
"connect-time firmware probe is recorded when configured). The lid is opened the "
|
||||||
"runs with the lid OPEN, as the factory's does - it completes, nothing before its "
|
"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 "
|
"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 "
|
"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 "
|
"reads unjudged); the camera service survives the switch. The lid closed, the "
|
||||||
@@ -270,17 +288,32 @@ def mode_switch(ctx):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
lamp0 = hw.sysfs_read("pic/lid_led")
|
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)
|
log_offset = log_size(GFCLOUD_LOG)
|
||||||
st, body = fc.post("/mode", data={"controller": "cloud"})
|
st, body = fc.post("/mode", data={"controller": "cloud"})
|
||||||
ctx.log("POST /mode controller=cloud -> %s %s", st, body)
|
ctx.log("POST /mode controller=cloud -> %s %s", st, body)
|
||||||
ctx.check(st == 200, "mode switch to cloud refused: %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
|
ev["mode_cloud"] = m
|
||||||
ctx.log("mode after switch: %s", m)
|
ctx.log("mode after switch: %s", m)
|
||||||
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
|
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
|
||||||
"cloud controller did not come up: %s", m)
|
"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
|
# the client's own session lines are the evidence of a live cloud session
|
||||||
session = wait_session(ctx, log_offset)
|
session = wait_session(ctx, log_offset)
|
||||||
probe = None
|
probe = None
|
||||||
@@ -372,7 +405,13 @@ def mode_switch(ctx):
|
|||||||
gfhome_homing(ctx, ev, g)
|
gfhome_homing(ctx, ev, g)
|
||||||
finally:
|
finally:
|
||||||
if ev["homing_mode"] != "gfcloud":
|
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("restore homing_mode=%r -> %s", ev["homing_mode"], st)
|
||||||
ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> "
|
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"])
|
"grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
|
||||||
@@ -1289,42 +1328,96 @@ def lid_during_button_wait_body(ctx, ev, off, job, offset):
|
|||||||
# ---------------------------------------------------------------- the cooling verdict
|
# ---------------------------------------------------------------- the cooling verdict
|
||||||
|
|
||||||
HOLD_KEYS = ("cool_temp_min", "cool_temp_start")
|
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
|
REFUSE_KEYS = HOLD_KEYS + ("cloud_hold_max_s",)
|
||||||
WARMUP_RELEASE_S = 720 # the flow heater warms the bulk 0.4 to 0.8 C a minute
|
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_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",
|
@test("cloud.dark-print", title="A dark cloud print: the latch locked until the run, unlocked "
|
||||||
subsystem="cloud", kind="operator", est_min=14,
|
"for it, locked after it",
|
||||||
|
subsystem="cloud", kind="operator", est_min=3,
|
||||||
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
||||||
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
|
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
|
||||||
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
|
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
|
||||||
steps=[OFFLINE_STEP,
|
steps=[OFFLINE_STEP,
|
||||||
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate just above "
|
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
||||||
"the coolant and restores it. Press the button when it lights: the print then waits "
|
"when it lights: the print waits for the engine, runs, and finishes."],
|
||||||
"for the warm-up (a few minutes with the loop heater on), runs dark, and finishes."],
|
description="The cooling-engine contract in cloud mode at its ordinary end, on the engine "
|
||||||
description="The cooling-engine contract in cloud mode, on the engine itself: the start gate "
|
"itself: the print arms on the press, waits for the engine's acknowledgment (the "
|
||||||
"set just above the coolant makes the armed session open under the warm-up "
|
"fans at their floors), and runs. The laser latch is locked at the button and "
|
||||||
"(WARMUP: fire blocked, hold), and the client waits it out after the button "
|
"through the wait, unlocks only for the run (immediately before it starts), and "
|
||||||
"instead of canceling - nothing runs, the latch stays unlocked for the armed "
|
"is locked again when the job ends; the print completes. A hold the engine never "
|
||||||
"window - until the engine releases into run, and the release starts the print, "
|
"clears is cloud.verdict-refuse; the engine's own warm-up release is "
|
||||||
"which completes. The hold mid-run (the same laser-off pause as the button's, "
|
"cooling.floor-and-warm-up.")
|
||||||
"resumed on the engine's resume_ok) and the bound on a hold are host-tested: the "
|
def dark_print(ctx):
|
||||||
"gates apply at session open, so no setting can produce a hold mid-run here.")
|
ev = ctx.evidence
|
||||||
def verdict_hold(ctx):
|
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
|
ev = ctx.evidence
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
before = fc.settings()
|
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
|
ev["orig"] = orig
|
||||||
up = (fc.status().get("coolant") or {}).get("up_c")
|
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)
|
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)
|
offset = enter_offline(ctx)
|
||||||
job = offline_job(ctx, "hold.puls", seconds=30)
|
job = offline_job(ctx, "refuse.puls", seconds=30)
|
||||||
off = Offline().__enter__()
|
off = Offline().__enter__()
|
||||||
try:
|
try:
|
||||||
verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig)
|
verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig)
|
||||||
finally:
|
finally:
|
||||||
st, body = fc.post("/settings", params=orig)
|
st, body = fc.post("/settings", params=orig)
|
||||||
ctx.log("restore: POST /settings %s -> %s", orig, st)
|
ctx.log("restore: POST /settings %s -> %s", orig, st)
|
||||||
@@ -1332,46 +1425,53 @@ def verdict_hold(ctx):
|
|||||||
offline_cleanup(ctx)
|
offline_cleanup(ctx)
|
||||||
|
|
||||||
|
|
||||||
def verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig):
|
def verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig):
|
||||||
gate = round(up + WARMUP_ABOVE_C, 1)
|
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),
|
st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
|
||||||
"cool_temp_min": orig["cool_temp_min"]})
|
"cool_temp_min": orig["cool_temp_min"],
|
||||||
ctx.check(st == 200, "POST /settings cool_temp_start=%s -> %s %s", gate, st, body)
|
"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
|
ev["gate"] = gate
|
||||||
off.print_ready(9005, job)
|
off.print_ready(9006, job)
|
||||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||||
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
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)
|
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
|
||||||
got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30)
|
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")
|
ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up")
|
||||||
st, c = fc.get("/cool/status")
|
t0 = time.time()
|
||||||
ev["warmup"] = c
|
samples = []
|
||||||
ctx.check(isinstance(c, dict) and c.get("verdict") == "WARMUP" and c.get("hold") is True
|
ctx.notice("The engine holds the print; the client gives up at the bound. Do nothing.")
|
||||||
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.")
|
|
||||||
try:
|
try:
|
||||||
t0 = time.time()
|
while time.time() - t0 < HOLD_REFUSE_S + 30:
|
||||||
run = wait_print_running(ctx, offset, WARMUP_RELEASE_S)
|
ctx.checkpoint()
|
||||||
|
samples.append(latch_locked())
|
||||||
|
if log_has(offset, HOLD_REFUSE_LINE):
|
||||||
|
break
|
||||||
|
ctx.sleep(2)
|
||||||
finally:
|
finally:
|
||||||
ctx.clear_notice()
|
ctx.clear_notice()
|
||||||
ev["release_s"] = round(time.time() - t0)
|
ev["hold_s"] = round(time.time() - t0)
|
||||||
got = wait_log(ctx, offset, [WARMUP_RELEASE_LINE], 5)
|
ev["latch_samples"] = {"n": len(samples), "unlocked": samples.count(False)}
|
||||||
ev["log"] = {WARMUP_WAIT_LINE: True, WARMUP_RELEASE_LINE: bool(got[WARMUP_RELEASE_LINE])}
|
ctx.check(log_has(offset, HOLD_REFUSE_LINE), "the client did not cancel at the hold bound (%d s)",
|
||||||
ctx.check(got[WARMUP_RELEASE_LINE], "the warm-up did not release into the run within %d s",
|
ev["hold_s"])
|
||||||
WARMUP_RELEASE_S)
|
ctx.check(all(samples), "the latch unlocked during the hold (%d of %d samples)",
|
||||||
ctx.check(run is not None, "the print did not start after the warm-up")
|
samples.count(False), len(samples))
|
||||||
fin = wait_action_finished(ctx, offset, "print", 180)
|
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)
|
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")
|
message(fin) or "no finish line")
|
||||||
st, cs = fc.get("/cool/status")
|
st, cs = fc.get("/cool/status")
|
||||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
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)
|
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)",
|
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""cooling.* - the cooling engine: flow verification through forgectrl's
|
"""cooling.* - the cooling engine: flow verification through forgectrl's
|
||||||
diagnostics runner (the same check the fire gate runs), the fan profile
|
diagnostics runner (the same check the fire gate runs), the fan profile
|
||||||
returning to idle after motion, the gate settings (a value inside the
|
returning to idle after motion, the gate settings (a value inside the
|
||||||
@@ -5,6 +10,7 @@ legal range trips the gate, the far end of the range turns it off by
|
|||||||
value, and both are said out loud: the settings reply, /status, the
|
value, and both are said out loud: the settings reply, /status, the
|
||||||
engine's run-start log line), and the airflow gates (a fan under its
|
engine's run-start log line), and the airflow gates (a fan under its
|
||||||
floor past the spin-up grace is a fault for the rest of the run)."""
|
floor past the spin-up grace is a fault for the rest of the run)."""
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
@@ -746,7 +752,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",
|
@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,
|
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 "
|
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, "
|
"thresholds under the lamp's own reading and restores them; three M8/M9 sessions, "
|
||||||
"no fire."],
|
"no fire."],
|
||||||
@@ -759,6 +765,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 "
|
"read as the four flame gates off and the watch reads watch. Restored, the "
|
||||||
"watch reads armed at OK.")
|
"watch reads armed at OK.")
|
||||||
def fire_watch_tiers(ctx):
|
def fire_watch_tiers(ctx):
|
||||||
|
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
before = fc.settings()
|
before = fc.settings()
|
||||||
@@ -810,7 +817,16 @@ def fire_watch_tiers(ctx):
|
|||||||
ctx.check(c.get("verdict") == "OK", "the alert survived into a fresh session: %s", c)
|
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
|
# 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)),
|
_set_gates(ctx, fc, {"cool_fire_q1_critical": str(max(2, q1 - 5)),
|
||||||
"cool_fire_q1_alert": str(max(1, q1 - 10))})
|
"cool_fire_q1_alert": str(max(1, q1 - 10))})
|
||||||
grbl.command("M8")
|
grbl.command("M8")
|
||||||
@@ -821,19 +837,38 @@ def fire_watch_tiers(ctx):
|
|||||||
c = _cool(fc)
|
c = _cool(fc)
|
||||||
if c.get("verdict") == "FIRE":
|
if c.get("verdict") == "FIRE":
|
||||||
break
|
break
|
||||||
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
ev["critical"] = c
|
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("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"))
|
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(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 "),
|
ctx.check(_tail_wait(ctx, fc, "LID IR FIRE SIGNAL (quartiles "),
|
||||||
"the FIRE line is missing from the forgectrl log")
|
"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"],
|
_set_gates(ctx, fc, {"cool_fire_q1_critical": orig["cool_fire_q1_critical"],
|
||||||
"cool_fire_q1_alert": orig["cool_fire_q1_alert"]})
|
"cool_fire_q1_alert": orig["cool_fire_q1_alert"]})
|
||||||
grbl.command("M9")
|
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the FIRE restart")
|
||||||
_session_ended(ctx, fc, "critical leg")
|
grbl.connect() # a fresh session on the restarted controller
|
||||||
|
|
||||||
# Leg 3: all four at zero: the watch off, said so, session OK.
|
# Leg 3: all four at zero: the watch off, said so, session OK.
|
||||||
_set_gates(ctx, fc, {k: "0" for k in FIRE_KEYS})
|
_set_gates(ctx, fc, {k: "0" for k in FIRE_KEYS})
|
||||||
@@ -891,7 +926,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 "
|
"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 "
|
"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 "
|
"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):
|
def crash_watch_plumbing(ctx):
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
@@ -1212,7 +1247,6 @@ LOAD_LINES = 27 # two 30 x 4 mm fills back to back, about 35
|
|||||||
LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window
|
LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window
|
||||||
LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit
|
LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit
|
||||||
LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s
|
LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s
|
||||||
LOAD_LOG_LINES = "300"
|
|
||||||
_LOAD_LINE_RX = None
|
_LOAD_LINE_RX = None
|
||||||
|
|
||||||
|
|
||||||
@@ -1236,9 +1270,19 @@ def _load_fill():
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def _forgectrl_tail(fc):
|
def _log_since(path, offset):
|
||||||
st, body = fc.get("/logs/tail", params={"name": "forgectrl", "lines": LOAD_LOG_LINES})
|
"""The text a log gained since byte `offset`. A count of lines in a
|
||||||
return body.get("text", "") if st == 200 and isinstance(body, dict) else ""
|
tail of fixed length cannot tell a new line from an old one: the new
|
||||||
|
line comes in at the bottom as an old one leaves at the top. A file now
|
||||||
|
shorter than the offset was rotated under the test, and all of it is
|
||||||
|
newer."""
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
if os.fstat(f.fileno()).st_size >= offset:
|
||||||
|
f.seek(offset)
|
||||||
|
return f.read().decode("utf-8", "replace")
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window",
|
@test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window",
|
||||||
@@ -1256,6 +1300,7 @@ def _forgectrl_tail(fc):
|
|||||||
"the tube's share off and read its baseline as a mean.")
|
"the tube's share off and read its baseline as a mean.")
|
||||||
def flow_under_load(ctx):
|
def flow_under_load(ctx):
|
||||||
from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm
|
from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm
|
||||||
|
from .motion import FORGECTRL_LOG, _log_offset
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
state, check_s = _gate_state(fc, "cool_flow_check_s")
|
state, check_s = _gate_state(fc, "cool_flow_check_s")
|
||||||
@@ -1263,7 +1308,7 @@ def flow_under_load(ctx):
|
|||||||
ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s)
|
ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s)
|
||||||
ctx.check(limit, "cool_flow_rise unreadable")
|
ctx.check(limit, "cool_flow_rise unreadable")
|
||||||
ev.update({"check_s": check_s, "limit_c": limit})
|
ev.update({"check_s": check_s, "limit_c": limit})
|
||||||
before = _forgectrl_tail(fc).count("heater rise")
|
off = _log_offset(FORGECTRL_LOG) # the verdict is a line written after this
|
||||||
# The fill ends one line length out and (LOAD_LINES - 1) pitches up;
|
# The fill ends one line length out and (LOAD_LINES - 1) pitches up;
|
||||||
# the job brings the head back so the baseline finds it where it began.
|
# the job brings the head back so the baseline finds it where it began.
|
||||||
back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1))
|
back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1))
|
||||||
@@ -1298,12 +1343,11 @@ def flow_under_load(ctx):
|
|||||||
ctx.checkpoint()
|
ctx.checkpoint()
|
||||||
if hw.sysfs_int("thermal/heater_pwm", 0) > 0:
|
if hw.sysfs_int("thermal/heater_pwm", 0) > 0:
|
||||||
heater_seen = True
|
heater_seen = True
|
||||||
text = _forgectrl_tail(fc)
|
m = None
|
||||||
if text.count("heater rise") > before:
|
for m in _load_verdict_rx().finditer(_log_since(FORGECTRL_LOG, off)):
|
||||||
m = None
|
pass
|
||||||
for m in _load_verdict_rx().finditer(text):
|
if m:
|
||||||
pass
|
line = m.group(0)
|
||||||
line = m.group(0) if m else None
|
|
||||||
break
|
break
|
||||||
ctx.sleep(1)
|
ctx.sleep(1)
|
||||||
ev["heater_seen"] = heater_seen
|
ev["heater_seen"] = heater_seen
|
||||||
@@ -1329,3 +1373,162 @@ def flow_under_load(ctx):
|
|||||||
ctx.check(dt is not None, "the window did not close after M2")
|
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 "
|
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)
|
"(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, the crash fault ends with the run "
|
||||||
|
"session (the engine's next tick after the controller is gone), and the "
|
||||||
|
"thresholds are put back.")
|
||||||
|
def fail_tier_stop(ctx):
|
||||||
|
from .laser import prepare, stream, sample
|
||||||
|
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
before = fc.settings()
|
||||||
|
orig = {k: before.get(k, "") for k in CRASH_KEYS}
|
||||||
|
ev["orig"] = orig
|
||||||
|
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
|
||||||
|
st, m0 = fc.get("/mode")
|
||||||
|
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
|
||||||
|
pid0 = m0.get("pid")
|
||||||
|
_set_gates(ctx, fc, {k: "1" for k in CRASH_KEYS})
|
||||||
|
off = _log_offset(FORGECTRL_LOG)
|
||||||
|
t_arm = None
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
prepare(ctx, g)
|
||||||
|
ctx.ready("DARK JOB (S0, nothing fires). Bed clear with 40 mm of free +X travel, lid closed. "
|
||||||
|
"The head moves 20 mm and the controller restarts on its own.")
|
||||||
|
stream(g, ["G91", "G21", "M3 S0", "G4 P1", "G1 X20 F1200", "G1 X-20 F1200", "M5", "G90", "M2"])
|
||||||
|
ctx.arm_press()
|
||||||
|
t_arm = ctx.wait_for(lambda: bool((sample(ctx) or {}).get("armed")), 240)
|
||||||
|
ctx.clear_notice()
|
||||||
|
ctx.check(t_arm is not None, "the window never opened (arm refused, or no press)")
|
||||||
|
t0 = time.time()
|
||||||
|
tripped = ctx.wait_for(lambda: _cool(fc).get("verdict") == "CRASH"
|
||||||
|
or (fc.get("/mode")[1] or {}).get("pid") not in (None, pid0), 30)
|
||||||
|
ctx.log("the crash tier tripped after %s s", tripped)
|
||||||
|
except (hw.HwError, OSError) as e:
|
||||||
|
ctx.log("the Grbl connection ended with the restart: %s", e)
|
||||||
|
t_kill = time.time()
|
||||||
|
m1 = None
|
||||||
|
while time.time() - t_kill < 60:
|
||||||
|
st, m1 = fc.get("/mode")
|
||||||
|
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
|
||||||
|
break
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
# the daemon's lines reach the log file through syslog, a moment
|
||||||
|
# after the events they name
|
||||||
|
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL"))
|
||||||
|
and bool(_log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")), 15)
|
||||||
|
engine = _log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL")
|
||||||
|
super_ = _log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")
|
||||||
|
smp = sample(ctx)
|
||||||
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
|
ev["result"] = {"mode_after": m1, "engine_lines": [ln[-160:] for ln in engine[-2:]],
|
||||||
|
"super_lines": [ln[-160:] for ln in super_[-2:]],
|
||||||
|
"emission": smp and smp["emission"], "kernel": smp and smp["kstate"],
|
||||||
|
"latch_locked": ilk is not None and bool(ilk & (1 << 3))}
|
||||||
|
ctx.log("after the crash tier: %s", ev["result"])
|
||||||
|
ctx.check(engine, "the engine did not log the crash signal")
|
||||||
|
ctx.check(super_, "the supervisor did not log the fail-tier stop")
|
||||||
|
ctx.check(m1 and m1.get("pid") != pid0, "the controller was not stopped and started again: %s", m1)
|
||||||
|
ctx.check(ev["result"]["latch_locked"], "latch not locked after the crash tier")
|
||||||
|
ctx.check(not (smp and smp["emission"]), "emission during a dark job: %s", smp and smp["emission"])
|
||||||
|
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the restart")
|
||||||
|
# The crash fault is the run session's: the engine ends the session
|
||||||
|
# on its next 1 Hz tick after the controller is gone, and the fault
|
||||||
|
# and its hold go with it. The supervisor has the new controller up
|
||||||
|
# inside that second, so a test that returned here would hand the
|
||||||
|
# machine back with the fault it raised still standing.
|
||||||
|
cleared = ctx.wait_for(lambda: _cool(fc).get("verdict") != "CRASH", 10)
|
||||||
|
ev["result"]["fault_cleared_s"] = cleared
|
||||||
|
ctx.log("the crash fault ended with the run session after %s s: %s", cleared,
|
||||||
|
{k: _cool(fc).get(k) for k in ("phase", "verdict", "hold", "armed")})
|
||||||
|
ctx.check(cleared is not None, "the crash fault did not end with the run session: %s", _cool(fc))
|
||||||
|
finally:
|
||||||
|
fc.wait_idle(30, abort=ctx.aborted)
|
||||||
|
st, body = fc.post("/settings", params=orig)
|
||||||
|
ctx.log("restore thresholds: POST /settings %s -> %s", orig, st)
|
||||||
|
after = fc.settings()
|
||||||
|
ctx.check(all(after.get(k, "") == orig[k] for k in CRASH_KEYS),
|
||||||
|
"thresholds not restored: %s", {k: after.get(k) for k in CRASH_KEYS})
|
||||||
|
ctx.log("PASS: the crash tier stopped the job, locked the latch, and the supervisor restarted "
|
||||||
|
"the controller (pid %s -> %s)", pid0, m1 and m1.get("pid"))
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -35,8 +40,9 @@ def lan_ip():
|
|||||||
covers=_COVERS_AUTH,
|
covers=_COVERS_AUTH,
|
||||||
description="Every state-changing endpoint refuses an unauthenticated write (the factory "
|
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 "
|
"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 "
|
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused, while "
|
||||||
"cooling report channel accepts the loopback peer and refuses a non-loopback "
|
"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 "
|
"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 "
|
"answers 403 loopback only); the fuse view is two-factor "
|
||||||
"(token and the physical button) and refused without either; "
|
"(token and the physical button) and refused without either; "
|
||||||
@@ -55,6 +61,7 @@ def auth(ctx):
|
|||||||
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
|
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
|
||||||
("/diag/flow-verify", None), ("/diag/abort", None),
|
("/diag/flow-verify", None), ("/diag/abort", None),
|
||||||
("/update/apply", None), ("/boot", {"target": "a"}),
|
("/update/apply", None), ("/boot", {"target": "a"}),
|
||||||
|
("/update/check", None), ("/update/dismiss", {"version": "v0.0.0"}),
|
||||||
("/system/reboot", None), ("/restore/factory", None),
|
("/system/reboot", None), ("/restore/factory", None),
|
||||||
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
|
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
|
||||||
("/wiz/advisories/accept", None), ("/wiz/account", None),
|
("/wiz/advisories/accept", None), ("/wiz/account", None),
|
||||||
@@ -96,6 +103,17 @@ def auth(ctx):
|
|||||||
ev["host_name"] = st
|
ev["host_name"] = st
|
||||||
ctx.log("GET /status Host=evil.example.net -> %s", st)
|
ctx.log("GET /status Host=evil.example.net -> %s", st)
|
||||||
ctx.check(st == 403, "a DNS-name Host was accepted (%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"})
|
st, body = fc.get("/status", headers={"Origin": "http://evil.example.net"})
|
||||||
ev["origin_name"] = st
|
ev["origin_name"] = st
|
||||||
ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
|
ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
|
||||||
@@ -139,7 +157,7 @@ def auth(ctx):
|
|||||||
# HTTPS the route itself refuses the peer (403 loopback only). Neither
|
# HTTPS the route itself refuses the peer (403 loopback only). Neither
|
||||||
# request follows the redirect, and the self-signed certificate is
|
# request follows the redirect, and the self-signed certificate is
|
||||||
# not verified.
|
# not verified.
|
||||||
from .commission import request, decode
|
from .setup import request, decode
|
||||||
ip = lan_ip()
|
ip = lan_ip()
|
||||||
ev["lan_ip"] = ip
|
ev["lan_ip"] = ip
|
||||||
ctx.check(ip, "cannot determine the board's LAN address")
|
ctx.check(ip, "cannot determine the board's LAN address")
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""image.* - the post-flash health of the running image (always-required)."""
|
"""image.* - the post-flash health of the running image (always-required)."""
|
||||||
import glob
|
import glob
|
||||||
import gzip
|
import gzip
|
||||||
import mmap
|
import mmap
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import struct
|
import struct
|
||||||
import re
|
import re
|
||||||
import stat
|
import stat
|
||||||
@@ -28,6 +34,12 @@ def _wdog1_wcr():
|
|||||||
return None
|
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):
|
def _read(path, default=None):
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
@@ -104,7 +116,9 @@ def fds_of(pid):
|
|||||||
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
|
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
|
||||||
("linux-fslc", "**")],
|
("linux-fslc", "**")],
|
||||||
description="The image that is running is the image the manifest describes, with the "
|
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 daemon ownership, "
|
||||||
"the init ordering, the file modes the release depends on, and the mounts: the "
|
"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 "
|
"rootfs read-only, /data writable, the account files and the banner rendered "
|
||||||
@@ -113,11 +127,39 @@ def image_health(ctx):
|
|||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
manifest = ctx.runner.manifest
|
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()
|
ver = (_read("/etc/forgefirm-version", "") or "").strip()
|
||||||
ev["forgefirm_version"] = ver
|
ev["forgefirm_version"] = ver
|
||||||
ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version)
|
ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version)
|
||||||
ctx.check(ver == manifest.version, "/etc/forgefirm-version %r != manifest %r", 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
|
# 2. kernel options
|
||||||
cfg = kernel_config()
|
cfg = kernel_config()
|
||||||
@@ -251,7 +293,8 @@ def image_health(ctx):
|
|||||||
|
|
||||||
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
||||||
# the state a read-only rootfs hands off (the read-only-rootfs image
|
# 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.
|
# dev image alone mounts the factory slots under /factory.
|
||||||
mounts = {}
|
mounts = {}
|
||||||
for line in (_read("/proc/mounts", "") or "").splitlines():
|
for line in (_read("/proc/mounts", "") or "").splitlines():
|
||||||
@@ -262,7 +305,8 @@ def image_health(ctx):
|
|||||||
def mount_opts(path):
|
def mount_opts(path):
|
||||||
return (mounts.get(path) or {}).get("opts") or []
|
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)",
|
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)")
|
",".join(mount_opts("/data")) or "(absent)", ",".join(mount_opts("/var/lib")) or "(absent)")
|
||||||
ctx.check("ro" in mount_opts("/"), "the rootfs is not mounted read-only: %s", mounts.get("/"))
|
ctx.check("ro" in mount_opts("/"), "the rootfs is not mounted read-only: %s", mounts.get("/"))
|
||||||
@@ -292,6 +336,7 @@ def image_health(ctx):
|
|||||||
ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f)
|
ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f)
|
||||||
for n in names:
|
for n in names:
|
||||||
ctx.check(n in passwd_names, "record account %r is missing from /etc/passwd", n)
|
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")
|
ctx.check("/etc/issue" in mounts, "/etc/issue is not the bind-mounted banner copy")
|
||||||
if hw.pidof("sshd"):
|
if hw.pidof("sshd"):
|
||||||
key = "/data/forgefirm/ssh/ssh_host_ed25519_key"
|
key = "/data/forgefirm/ssh/ssh_host_ed25519_key"
|
||||||
@@ -356,3 +401,140 @@ def license_bundle(ctx):
|
|||||||
text = bytes(body).decode("utf-8", "replace") if body else ""
|
text = bytes(body).decode("utf-8", "replace") if body else ""
|
||||||
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
|
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
|
||||||
"the Licenses page lacks the download link or the manifest")
|
"the Licenses page lacks the download link or the manifest")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- the network boot
|
||||||
|
|
||||||
|
IFSTATE = "/var/run/ifstate" # busybox ifupdown's state file
|
||||||
|
INTERFACES = "/etc/network/interfaces"
|
||||||
|
DHCP6_CLIENTS = ("udhcpc6", "dhcp6c", "odhcp6c")
|
||||||
|
|
||||||
|
|
||||||
|
def ifstate_names(text):
|
||||||
|
"""The interfaces ifup finished configuring: one name=name line each
|
||||||
|
in busybox's state file, written when ifup returns for the name."""
|
||||||
|
return {line.split("=", 1)[0].strip() for line in (text or "").splitlines() if "=" in line}
|
||||||
|
|
||||||
|
|
||||||
|
def dhcp6_in_interfaces(text):
|
||||||
|
"""The lines of an interfaces file that start a DHCPv6 client: an
|
||||||
|
inet6 stanza with a method that runs one, or a command that names
|
||||||
|
one. A comment starts nothing."""
|
||||||
|
bad = []
|
||||||
|
for raw in (text or "").splitlines():
|
||||||
|
words = raw.split("#", 1)[0].split()
|
||||||
|
if not words:
|
||||||
|
continue
|
||||||
|
if words[0] == "iface":
|
||||||
|
if len(words) >= 4 and words[2] == "inet6" and words[3] in ("dhcp", "auto"):
|
||||||
|
bad.append(" ".join(words))
|
||||||
|
elif any(w.split("/")[-1] in DHCP6_CLIENTS for w in words[1:]):
|
||||||
|
bad.append(" ".join(words))
|
||||||
|
return bad
|
||||||
|
|
||||||
|
|
||||||
|
def parse_stat(text):
|
||||||
|
"""(pid, comm, ppid) from a /proc/<pid>/stat line, or None. The comm
|
||||||
|
sits in parentheses and may itself hold spaces and parentheses."""
|
||||||
|
try:
|
||||||
|
head, tail = text.split("(", 1)
|
||||||
|
comm, rest = tail.rsplit(")", 1)
|
||||||
|
return int(head), comm, int(rest.split()[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _procs():
|
||||||
|
"""(pid, ppid, comm, command line) of every process."""
|
||||||
|
out = []
|
||||||
|
for name in os.listdir("/proc"):
|
||||||
|
if not name.isdigit():
|
||||||
|
continue
|
||||||
|
st = parse_stat(_read("/proc/%s/stat" % name, ""))
|
||||||
|
if st is None:
|
||||||
|
continue
|
||||||
|
cmd = (_read("/proc/%s/cmdline" % name, "") or "").replace("\0", " ").strip()
|
||||||
|
out.append((st[0], st[2], st[1], cmd))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def link_addrs(text, iface):
|
||||||
|
"""(link-local, global) IPv6 addresses of one interface, as the hex
|
||||||
|
strings of /proc/net/if_inet6 (address, index, prefix length, scope,
|
||||||
|
flags, name; scope 20 is the link, 00 the world)."""
|
||||||
|
local, world = [], []
|
||||||
|
for line in (text or "").splitlines():
|
||||||
|
f = line.split()
|
||||||
|
if len(f) == 6 and f[5] == iface:
|
||||||
|
if f[3] == "20":
|
||||||
|
local.append(f[0])
|
||||||
|
elif f[3] == "00":
|
||||||
|
world.append(f[0])
|
||||||
|
return local, world
|
||||||
|
|
||||||
|
|
||||||
|
@test("image.network-boot", title="The boot does not wait on the network",
|
||||||
|
subsystem="image", kind="auto", est_min=1,
|
||||||
|
description="The network comes up without the boot waiting on a server. init starts the "
|
||||||
|
"rest of the boot, the console login last, only after the networking script "
|
||||||
|
"returns, so a client that waits in ifup's foreground on a server's answer "
|
||||||
|
"holds the whole machine: no login, no SSH, no control panel. ifup has "
|
||||||
|
"returned for wlan0 (it is in the state file and no ifup is running) and the "
|
||||||
|
"console login is up; the DHCP client is the bounded one (-b, which leaves "
|
||||||
|
"ifup after three unanswered discovers) and has left ifup; no DHCPv6 client "
|
||||||
|
"is named in the interfaces file, running, or on the image; and IPv6 is the "
|
||||||
|
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
|
||||||
|
"address up. A global address is evidence only, because a network whose "
|
||||||
|
"router advertisement offers no SLAAC prefix gives none. What a hostile "
|
||||||
|
"server does to a client is a bench drill, not a test. No component covers "
|
||||||
|
"this: the interfaces file is layer content, in the platform identity of "
|
||||||
|
"every fingerprint.")
|
||||||
|
def network_boot(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
procs = _procs()
|
||||||
|
|
||||||
|
# 1. ifup came back, and the boot went on to the console login
|
||||||
|
state = ifstate_names(_read(IFSTATE, ""))
|
||||||
|
ev["ifstate"] = sorted(state)
|
||||||
|
ctx.check("wlan0" in state, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(state))
|
||||||
|
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
|
||||||
|
ev["ifup_running"] = [p[3] for p in stuck]
|
||||||
|
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
|
||||||
|
logins = [p[3] for p in procs if p[2] == "getty"]
|
||||||
|
ev["getty"] = logins
|
||||||
|
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
|
||||||
|
|
||||||
|
# 2. the DHCP client is the bounded one, and it has left ifup
|
||||||
|
v4 = [p for p in procs if p[2] == "udhcpc"]
|
||||||
|
ev["udhcpc"] = [p[3] for p in v4]
|
||||||
|
ctx.check(v4, "no DHCP client is running")
|
||||||
|
for pid, ppid, _comm, cmd in v4:
|
||||||
|
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
|
||||||
|
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
|
||||||
|
|
||||||
|
# 3. no DHCPv6 client: not started, not running, not on the image
|
||||||
|
text = _read(INTERFACES)
|
||||||
|
ctx.check(text is not None, "%s is missing", INTERFACES)
|
||||||
|
named = dhcp6_in_interfaces(text)
|
||||||
|
ev["interfaces_dhcp6"] = named
|
||||||
|
ctx.check(not named, "%s starts a DHCPv6 client: %s", INTERFACES, named)
|
||||||
|
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
|
||||||
|
ev["dhcp6_running"] = alive
|
||||||
|
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
|
||||||
|
for name in DHCP6_CLIENTS:
|
||||||
|
ctx.check(shutil.which(name) is None, "%s is on the image", name)
|
||||||
|
ctx.check(not os.path.exists("/usr/share/udhcpc/default6.script"),
|
||||||
|
"the DHCPv6 hook script is on the image")
|
||||||
|
|
||||||
|
# 4. IPv6 is the kernel's own
|
||||||
|
conf = "/proc/sys/net/ipv6/conf/wlan0/"
|
||||||
|
ev["disable_ipv6"] = (_read(conf + "disable_ipv6", "") or "").strip()
|
||||||
|
ev["accept_ra"] = (_read(conf + "accept_ra", "") or "").strip()
|
||||||
|
ctx.check(ev["disable_ipv6"] == "0", "IPv6 is off on wlan0 (disable_ipv6=%r)", ev["disable_ipv6"])
|
||||||
|
ctx.check(ev["accept_ra"] not in ("", "0"),
|
||||||
|
"wlan0 does not accept router advertisements (accept_ra=%r)", ev["accept_ra"])
|
||||||
|
local, world = link_addrs(_read("/proc/net/if_inet6", ""), "wlan0")
|
||||||
|
ev["link_local"] = local
|
||||||
|
ev["global"] = world
|
||||||
|
ctx.check(local, "wlan0 has no link-local IPv6 address")
|
||||||
|
ctx.log("wlan0: %d link-local, %d global IPv6 address(es)", len(local), len(world))
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
|
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
|
||||||
|
|
||||||
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
|
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
|
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
|
||||||
|
|
||||||
Every test here can emit, except `laser.power-floor`, which only reads
|
Every test here can emit, except `laser.power-floor`, which only reads
|
||||||
@@ -252,6 +257,26 @@ def kill_trail(ctx, t0, seconds=5.0):
|
|||||||
return trail
|
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):
|
def judge_kill(ctx, trail, what):
|
||||||
"""(first zero, tail stayed zero, kernel stopped running) from a trail."""
|
"""(first zero, tail stayed zero, kernel stopped running) from a trail."""
|
||||||
for t in trail:
|
for t in trail:
|
||||||
@@ -566,11 +591,19 @@ def m5_rapid_dark(ctx):
|
|||||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||||
ctx.ready(ARM_CUE % "20 mm +X")
|
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",
|
job = ["G91", "G21", "M3", "S400",
|
||||||
"G1 X20 F600",
|
"G1 X20 F600",
|
||||||
"M5", "G4 P2.5",
|
"M5", "G4 P2.5",
|
||||||
"G0 X-20", "G4 P2.5",
|
"G0 X-20", "G4 P2.5",
|
||||||
"G0 X20", "G4 P2.5",
|
"G0 X20", "G4 P2.5",
|
||||||
|
"G0 X-20", "G4 P0.5",
|
||||||
"G90", "M2"]
|
"G90", "M2"]
|
||||||
ctx.arm_press()
|
ctx.arm_press()
|
||||||
try:
|
try:
|
||||||
@@ -632,6 +665,7 @@ def disarm_in_hold(ctx):
|
|||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||||
prepare(ctx, g)
|
prepare(ctx, g)
|
||||||
|
k0 = kernel_start(ctx)
|
||||||
ctx.ready(ARM_CUE % "40 mm +X")
|
ctx.ready(ARM_CUE % "40 mm +X")
|
||||||
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
|
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
|
||||||
ctx.log("armed; waiting for motion to start (arm + your button press)...")
|
ctx.log("armed; waiting for motion to start (arm + your button press)...")
|
||||||
@@ -674,6 +708,20 @@ def disarm_in_hold(ctx):
|
|||||||
ctx.sleep(1)
|
ctx.sleep(1)
|
||||||
if "Alarm" in g.status_report()["state"]:
|
if "Alarm" in g.status_report()["state"]:
|
||||||
g.command("$X")
|
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(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)
|
ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold)
|
||||||
check_button_dark(ctx, ev)
|
check_button_dark(ctx, ev)
|
||||||
@@ -692,7 +740,9 @@ def disarm_in_hold(ctx):
|
|||||||
"SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, "
|
"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 "
|
"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 "
|
"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):
|
def armed_kill(ctx):
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
@@ -746,13 +796,20 @@ def armed_kill(ctx):
|
|||||||
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
|
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
|
||||||
t_kill = time.time()
|
t_kill = time.time()
|
||||||
_os.kill(pid, _signal.SIGKILL)
|
_os.kill(pid, _signal.SIGKILL)
|
||||||
|
fast = kill_fast_trail(t_kill)
|
||||||
trail = kill_trail(ctx, 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")
|
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "kill")
|
||||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
locked = ilk is not None and bool(ilk & (1 << 3))
|
locked = ilk is not None and bool(ilk & (1 << 3))
|
||||||
ev["sigkill"] = {"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero,
|
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.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,
|
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)
|
"emission did not drop within 2.5 s of the kill (first 0 at %s)", zero_at)
|
||||||
ctx.check(tail_zero, "emission returned after the kill")
|
ctx.check(tail_zero, "emission returned after the kill")
|
||||||
@@ -769,10 +826,187 @@ def armed_kill(ctx):
|
|||||||
ctx.log("/mode after the kill: %s", m1)
|
ctx.log("/mode after the kill: %s", m1)
|
||||||
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
|
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
|
||||||
"supervisor did not respawn the controller: %s", m1)
|
"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")
|
ev["beam_at_fire_kill"] = smp.get("beam")
|
||||||
check_button_dark(ctx, ev)
|
check_button_dark(ctx, ev)
|
||||||
ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller "
|
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",
|
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
|
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
|
||||||
the routing path (emitters, relays, rendered rules); the level settings."""
|
the routing path (emitters, relays, rendered rules); the level settings."""
|
||||||
import gzip
|
import gzip
|
||||||
@@ -22,14 +27,46 @@ def _read(path, default=None):
|
|||||||
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
|
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
|
||||||
("forgectrl", "src/main.c")]
|
("forgectrl", "src/main.c")]
|
||||||
|
|
||||||
|
LOGS_ROOT = "/data/log/forgefirm"
|
||||||
|
# What the export test plants in the installer's directory: a line to find
|
||||||
|
# again, and two addresses (documentation ranges) the sanitizer must take.
|
||||||
|
_PROBE_MARK = "forgetest export probe"
|
||||||
|
_PROBE_MAC = "02:00:5e:10:20:30"
|
||||||
|
_PROBE_IP = "192.0.2.77"
|
||||||
|
|
||||||
|
|
||||||
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
|
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
|
||||||
kind="auto", est_min=1,
|
kind="auto", est_min=1,
|
||||||
covers=_LOG_COVERS, requires=["forgectrl.auth"],
|
covers=_LOG_COVERS, requires=["forgectrl.auth"],
|
||||||
description="/logs lists the loggers with their levels and files, /logs/tail returns the "
|
description="/logs lists the loggers with their levels and files, /logs/tail returns the "
|
||||||
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
|
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
|
||||||
"bundle that contains neither the panel token nor the camera key.")
|
"bundle that contains neither the panel token nor the camera key. The bundle "
|
||||||
|
"also carries the installer's directory of the tree (logs/install/), which no "
|
||||||
|
"logger feeds: a probe file planted there for the export comes back in the "
|
||||||
|
"bundle with its addresses redacted, and an install log that the installer "
|
||||||
|
"left is in the bundle too.")
|
||||||
def tree_tail_export(ctx):
|
def tree_tail_export(ctx):
|
||||||
|
install_dir = os.path.join(LOGS_ROOT, "install")
|
||||||
|
probe = os.path.join(install_dir, "forgetest-probe.txt")
|
||||||
|
made_dir = not os.path.isdir(install_dir)
|
||||||
|
try:
|
||||||
|
os.makedirs(install_dir, exist_ok=True)
|
||||||
|
with open(probe, "w") as f:
|
||||||
|
f.write("%s from %s at %s\n" % (_PROBE_MARK, _PROBE_MAC, _PROBE_IP))
|
||||||
|
_tree_tail_export(ctx, probe)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(probe)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if made_dir:
|
||||||
|
try:
|
||||||
|
os.rmdir(install_dir)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _tree_tail_export(ctx, probe):
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
st, body = fc.get("/logs")
|
st, body = fc.get("/logs")
|
||||||
@@ -82,6 +119,21 @@ def tree_tail_export(ctx):
|
|||||||
ev[what.replace(" ", "_") + "_leaks"] = leaked
|
ev[what.replace(" ", "_") + "_leaks"] = leaked
|
||||||
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
|
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
|
||||||
|
|
||||||
|
# the installer's directory: no logger feeds it, the export carries it
|
||||||
|
by_name = dict(contents)
|
||||||
|
want = "logs/install/" + os.path.basename(probe)
|
||||||
|
got = [name for name in by_name if name.endswith(want)]
|
||||||
|
ev["install_members"] = sorted(name for name in by_name if "/logs/install/" in name)
|
||||||
|
ctx.check(got, "the bundle lacks %s: %s", want, ev["install_members"])
|
||||||
|
if got:
|
||||||
|
text = by_name[got[0]].decode("utf-8", "replace")
|
||||||
|
ctx.check(_PROBE_MARK in text, "the probe file came back without its line: %r", text)
|
||||||
|
for what, value in (("MAC address", _PROBE_MAC), ("IPv4 address", _PROBE_IP)):
|
||||||
|
ctx.check(value not in text, "the install directory left the sanitizer with its %s", what)
|
||||||
|
if os.path.isfile(os.path.join(os.path.dirname(probe), "install.log")):
|
||||||
|
ctx.check(any(name.endswith("logs/install/install.log") for name in by_name),
|
||||||
|
"the machine has an install log and the bundle does not")
|
||||||
|
|
||||||
|
|
||||||
# The routing test proves the whole path every logger takes: emitter (or
|
# The routing test proves the whole path every logger takes: emitter (or
|
||||||
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
|
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
|
||||||
@@ -95,8 +147,7 @@ _ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("
|
|||||||
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
|
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
|
||||||
("forgefirm-app", "forgefirm-app/gfhome.py")]
|
("forgefirm-app", "forgefirm-app/gfhome.py")]
|
||||||
|
|
||||||
LOGS_ROOT = "/data/log/forgefirm"
|
LOGGERS =("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
|
||||||
LOGGERS = ("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
|
|
||||||
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
|
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
|
||||||
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
|
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
|
||||||
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
|
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""motion.* - the motion controller under grblHAL: dry motion, no emission.
|
"""motion.* - the motion controller under grblHAL: dry motion, no emission.
|
||||||
|
|
||||||
Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
|
Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
|
||||||
@@ -136,9 +141,68 @@ def clean_slate(ctx, g):
|
|||||||
ctx.log("unlock: %s", g.command("$X"))
|
ctx.log("unlock: %s", g.command("$X"))
|
||||||
st = g.status_report()
|
st = g.status_report()
|
||||||
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
|
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
|
||||||
|
clear_error(ctx, g)
|
||||||
return st
|
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",
|
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
|
||||||
subsystem="motion", kind="auto", mode="grbl", est_min=1,
|
subsystem="motion", kind="auto", mode="grbl", est_min=1,
|
||||||
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
|
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
|
||||||
@@ -753,21 +817,30 @@ def _return_x(ctx, delta_mm):
|
|||||||
machine_idle(ctx)
|
machine_idle(ctx)
|
||||||
|
|
||||||
|
|
||||||
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move",
|
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move, "
|
||||||
subsystem="motion", kind="auto", mode="grbl", est_min=4,
|
"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/**")],
|
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
|
||||||
requires=["motion.cancel-abort", "kernel.k1-k2"],
|
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, "
|
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 "
|
"latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a "
|
||||||
"hang) mid-move: the ring drains into a kernel underrun (fast halt, latch "
|
"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 "
|
"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 "
|
"(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 "
|
"fault to the stream and clears the stale ring) and moves again without a "
|
||||||
"restart. forgectrl "
|
"restart. A short SIGSTOP (100 ms, inside the kernel's 200 ms queue) mid-move: "
|
||||||
"restart mid-move: the busy controller finishes the move unmanaged and the new "
|
"no underrun, but the step producer comes back later than its lead and its "
|
||||||
"daemon retakes supervision at idle. After each drill the head is jogged back "
|
"late events clamp; unarmed that is a warning in the controller's log and the "
|
||||||
"by the kernel-measured distance.")
|
"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):
|
def deadman(ctx):
|
||||||
import os as _os
|
import os as _os
|
||||||
import signal as _signal
|
import signal as _signal
|
||||||
@@ -897,6 +970,47 @@ def deadman(ctx):
|
|||||||
x1 = _kernel_x_mm(ctx)
|
x1 = _kernel_x_mm(ctx)
|
||||||
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
_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
|
# ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle
|
||||||
m2 = wait_running(10)
|
m2 = wait_running(10)
|
||||||
pid2 = m2["pid"]
|
pid2 = m2["pid"]
|
||||||
@@ -934,8 +1048,226 @@ def deadman(ctx):
|
|||||||
x1 = _kernel_x_mm(ctx)
|
x1 = _kernel_x_mm(ctx)
|
||||||
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||||
machine_idle(ctx)
|
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)
|
# ------------------------------------------------- lid / button (the factory's)
|
||||||
@@ -1066,9 +1398,7 @@ def button_hold_resume(ctx):
|
|||||||
"moves; the job holds and the test sees it.")
|
"moves; the job holds and the test sees it.")
|
||||||
g.command("M5")
|
g.command("M5")
|
||||||
g.command("G91")
|
g.command("G91")
|
||||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain() # the message window opens here
|
g.drain() # the message window opens here
|
||||||
w = Watch(g)
|
w = Watch(g)
|
||||||
ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
|
ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
|
||||||
@@ -1144,9 +1474,7 @@ def lid_cancel_home(ctx):
|
|||||||
"leave it open until the head has come back on its own.")
|
"leave it open until the head has come back on its own.")
|
||||||
g.command("M5")
|
g.command("M5")
|
||||||
g.command("G91")
|
g.command("G91")
|
||||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain() # the message window opens here
|
g.drain() # the message window opens here
|
||||||
ctx.act("lid", "open", text="Leave it open until the head has come back.", timeout=12)
|
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")
|
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "lid opened", "running")
|
||||||
@@ -1239,9 +1567,7 @@ def interlock_cancel_home(ctx):
|
|||||||
"moves and leave it open until the head has come back on its own.")
|
"moves and leave it open until the head has come back on its own.")
|
||||||
g.command("M5")
|
g.command("M5")
|
||||||
g.command("G91")
|
g.command("G91")
|
||||||
g.command("G1X60F300", timeout=0.5) # a 12 s move
|
start_move(ctx, g, "G1X60F300") # a 12 s move
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain()
|
g.drain()
|
||||||
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
|
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
|
||||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
sw = (ctx.forgectrl.status().get("switches") or {})
|
||||||
@@ -1285,8 +1611,13 @@ def interlock_cancel_home(ctx):
|
|||||||
def lid_policy_hold(ctx):
|
def lid_policy_hold(ctx):
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
fc = ctx.forgectrl
|
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_before"] = was
|
||||||
|
ev["lid_policy_in_force"] = was or "cancel"
|
||||||
st, _b = fc.post("/settings", data={"lid_policy": "hold"})
|
st, _b = fc.post("/settings", data={"lid_policy": "hold"})
|
||||||
ctx.check(st == 200, "could not set lid_policy=hold (%s)", st)
|
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")
|
ctx.check(((fc.settings() or {}).get("lid_policy")) == "hold", "lid_policy did not take")
|
||||||
@@ -1298,9 +1629,7 @@ def lid_policy_hold(ctx):
|
|||||||
"the job parks in the door state and waits.")
|
"the job parks in the door state and waits.")
|
||||||
g.command("M5")
|
g.command("M5")
|
||||||
g.command("G91")
|
g.command("G91")
|
||||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain()
|
g.drain()
|
||||||
ctx.act("lid", "open", timeout=12)
|
ctx.act("lid", "open", timeout=12)
|
||||||
st, text = wait_state_text(ctx, g, "Door", 8)
|
st, text = wait_state_text(ctx, g, "Door", 8)
|
||||||
@@ -1332,8 +1661,12 @@ def lid_policy_hold(ctx):
|
|||||||
g.command("G90")
|
g.command("G90")
|
||||||
machine_idle(ctx)
|
machine_idle(ctx)
|
||||||
finally:
|
finally:
|
||||||
st, _b = fc.post("/settings", data={"lid_policy": was})
|
# An empty value clears the key, and only the query-string form
|
||||||
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy")
|
# 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.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.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)",
|
ctx.log("PASS: lid_policy=hold parked the job in Door and the cycle start finished it (%.3f mm)",
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
"""commission.* - the first-run commissioning: the record and the
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
controller gate, the advisories, the account and its login, the HTTPS
|
# Written by Scott Wiederhold
|
||||||
boundary, SSH, the cloud switch, the mDNS name, and the factory return.
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
The daemon reads the commissioning record (commissioning.json in 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 machine's name, and the factory
|
||||||
|
return.
|
||||||
|
|
||||||
|
The daemon reads the setup record (setup.json in the
|
||||||
data directory) and the account record (users) once, at its start, and
|
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
|
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
|
a different record installs the file under a forgectrl restart (a
|
||||||
@@ -11,7 +17,7 @@ never saves a test record over the real one, and a restore is complete
|
|||||||
when the daemon is up again.
|
when the daemon is up again.
|
||||||
|
|
||||||
The layer content of this work (the account replay init script, the
|
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.
|
platform identity that every fingerprint carries, not of a component.
|
||||||
No coverage map names it: a change there makes every test necessary
|
No coverage map names it: a change there makes every test necessary
|
||||||
again.
|
again.
|
||||||
@@ -23,10 +29,9 @@ import http.client
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import select
|
import re
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import struct
|
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
@@ -34,21 +39,18 @@ from ..catalog import test
|
|||||||
from .. import hw
|
from .. import hw
|
||||||
from .forgectrl import lan_ip
|
from .forgectrl import lan_ip
|
||||||
|
|
||||||
RECORD = "commissioning.json"
|
RECORD = "setup.json"
|
||||||
USERS = "users"
|
USERS = "users"
|
||||||
OVERRIDE = "commissioning-override"
|
OVERRIDE = "setup-override"
|
||||||
SSH_FLAG = "ssh-enabled"
|
SSH_FLAG = "ssh-enabled"
|
||||||
TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests)
|
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")
|
REQUIRED_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
|
||||||
ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service")
|
ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service")
|
||||||
SAFETY_PHRASE = "I UNDERSTAND"
|
SAFETY_PHRASE = "I UNDERSTAND"
|
||||||
LOGIN_FAILS = 5
|
LOGIN_FAILS = 5
|
||||||
LOGIN_LOCK_S = 30
|
LOGIN_LOCK_S = 30
|
||||||
MDNS_NAME = "forgefirm.local"
|
|
||||||
MDNS_GROUP = "224.0.0.251"
|
|
||||||
MDNS_PORT = 5353
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------- files
|
# ----------------------------------------------------------------- files
|
||||||
@@ -179,8 +181,8 @@ def record_without_wizards(record):
|
|||||||
|
|
||||||
|
|
||||||
def complete_record(status, base=None, account_name="bench"):
|
def complete_record(status, base=None, account_name="bench"):
|
||||||
"""A complete commissioning record for a machine that must count as
|
"""A complete setup record for a machine that must count as
|
||||||
commissioned: every advisory at the hash GET /wiz reports, the
|
set up: every advisory at the hash GET /wiz reports, the
|
||||||
press recorded, an account (the one of `base` when it has one),
|
press recorded, an account (the one of `base` when it has one),
|
||||||
every wizard of the catalog at its version, and the run complete.
|
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
|
`status` is a GET /wiz body; `base` is an existing record to keep
|
||||||
@@ -355,12 +357,12 @@ def gate_is_open():
|
|||||||
|
|
||||||
# -------------------------------------------------------------- the gate
|
# -------------------------------------------------------------- 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")]
|
("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")]
|
||||||
|
|
||||||
|
|
||||||
@test("commission.gate-blocks-controllers", title="The commissioning gate spawns no controller",
|
@test("setup.gate-blocks-controllers", title="The setup gate spawns no controller",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", mode="grbl", est_min=5,
|
subsystem="setup", kind="auto", hardware="takeover", mode="grbl", est_min=5,
|
||||||
covers=_GATE_COVERS, requires=["forgectrl.auth"], precheck=gate_is_open,
|
covers=_GATE_COVERS, requires=["forgectrl.auth"], precheck=gate_is_open,
|
||||||
description="With the override removed and a record that lacks every required wizard "
|
description="With the override removed and a record that lacks every required wizard "
|
||||||
"(installed under a forgectrl restart, the consent and the account kept), "
|
"(installed under a forgectrl restart, the consent and the account kept), "
|
||||||
@@ -377,11 +379,11 @@ def gate_blocks_controllers(ctx):
|
|||||||
ctx.log("before: gate %s, override %s, required %s", before.get("gate"), before.get("override"),
|
ctx.log("before: gate %s, override %s, required %s", before.get("gate"), before.get("override"),
|
||||||
before.get("required"))
|
before.get("required"))
|
||||||
raw = read_file(record_path())
|
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:
|
try:
|
||||||
record = json.loads(raw.decode("utf-8"))
|
record = json.loads(raw.decode("utf-8"))
|
||||||
except ValueError as e:
|
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"
|
lacking = json.dumps(record_without_wizards(record), indent=1, sort_keys=True).encode() + b"\n"
|
||||||
had_override = override_present()
|
had_override = override_present()
|
||||||
ev["override_before"] = had_override
|
ev["override_before"] = had_override
|
||||||
@@ -394,7 +396,7 @@ def gate_blocks_controllers(ctx):
|
|||||||
ctx.log("/mode after the restart: %s (after %s s)", m, took)
|
ctx.log("/mode after the restart: %s (after %s s)", m, took)
|
||||||
ctx.check(m.get("controller") == "gated" and m.get("gated") is True,
|
ctx.check(m.get("controller") == "gated" and m.get("gated") is True,
|
||||||
"the supervisor did not report the gate: %s", m)
|
"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"))
|
"the reason does not name the missing wizards: %r", m.get("why"))
|
||||||
w = wiz(fc)
|
w = wiz(fc)
|
||||||
ev["gated_wiz"] = wiz_summary(w)
|
ev["gated_wiz"] = wiz_summary(w)
|
||||||
@@ -442,11 +444,11 @@ def gate_blocks_controllers(ctx):
|
|||||||
ctx.log("restored: gate %s, controller %s", after.get("gate"), m.get("controller"))
|
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",
|
@test("setup.override-until-reboot", title="The override stands until the next reboot",
|
||||||
subsystem="commission", kind="auto", est_min=1,
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
covers=_GATE_COVERS, requires=["forgectrl.auth"],
|
covers=_GATE_COVERS, requires=["forgectrl.auth"],
|
||||||
description="The bench seed: the dev image's forgetest init script writes "
|
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 "
|
"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 "
|
"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 "
|
"gated. The file lives on tmpfs (/proc/mounts), so a reboot removes it by "
|
||||||
@@ -489,9 +491,9 @@ def override_until_reboot(ctx):
|
|||||||
|
|
||||||
# ------------------------------------------------------------ advisories
|
# ------------------------------------------------------------ advisories
|
||||||
|
|
||||||
@test("commission.advisories-rehash", title="An advisory is accepted at its current hash only",
|
@test("setup.advisories-rehash", title="An advisory is accepted at its current hash only",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||||
covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/commission.*"),
|
covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"),
|
||||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/sha256.*"), ("forgectrl", "src/main.c"),
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/sha256.*"), ("forgectrl", "src/main.c"),
|
||||||
("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake")],
|
("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
@@ -507,7 +509,7 @@ def advisories_rehash(ctx):
|
|||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
raw = read_file(record_path())
|
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)
|
before = wiz(fc)
|
||||||
ev["before"] = wiz_summary(before)
|
ev["before"] = wiz_summary(before)
|
||||||
docs = {d.get("id"): d for d in before.get("documents") or []}
|
docs = {d.get("id"): d for d in before.get("documents") or []}
|
||||||
@@ -582,7 +584,7 @@ def advisories_rehash(ctx):
|
|||||||
|
|
||||||
# -------------------------------------------------------------- the login
|
# -------------------------------------------------------------- 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,
|
kind="auto", est_min=2,
|
||||||
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
||||||
("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
||||||
@@ -705,7 +707,7 @@ def login_checks(ctx, name, pw):
|
|||||||
|
|
||||||
# ------------------------------------------------------- HTTPS boundary
|
# ------------------------------------------------------- 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,
|
kind="auto", est_min=1,
|
||||||
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"),
|
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"),
|
||||||
("forgectrl", "src/main.c")],
|
("forgectrl", "src/main.c")],
|
||||||
@@ -777,7 +779,7 @@ def https_only_writes(ctx):
|
|||||||
# ------------------------------------------------------------------ ssh
|
# ------------------------------------------------------------------ ssh
|
||||||
|
|
||||||
def sshd_policy():
|
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."""
|
lowercase keys), or {"error": ...} when sshd cannot report it."""
|
||||||
for exe in ("/usr/sbin/sshd", "sshd"):
|
for exe in ("/usr/sbin/sshd", "sshd"):
|
||||||
rc, out = hw.run([exe, "-T"], timeout=15)
|
rc, out = hw.run([exe, "-T"], timeout=15)
|
||||||
@@ -789,7 +791,7 @@ def sshd_policy():
|
|||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
parts = line.split(None, 1)
|
parts = line.split(None, 1)
|
||||||
if len(parts) == 2 and parts[0] in ("permitrootlogin", "permitemptypasswords",
|
if len(parts) == 2 and parts[0] in ("permitrootlogin", "permitemptypasswords",
|
||||||
"passwordauthentication"):
|
"passwordauthentication", "banner"):
|
||||||
policy[parts[0]] = parts[1].strip()
|
policy[parts[0]] = parts[1].strip()
|
||||||
return policy
|
return policy
|
||||||
return {"error": "no sshd binary"}
|
return {"error": "no sshd binary"}
|
||||||
@@ -805,8 +807,8 @@ def restore_ssh(fc, before):
|
|||||||
write_file(flag, b"" if before.get("enabled") else None)
|
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",
|
@test("setup.ssh-until-reboot", title="SSH is switched from the panel until the next reboot",
|
||||||
subsystem="commission", kind="auto", est_min=1,
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")],
|
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
description="GET /system/ssh reports enabled, running, and dev_image, and agrees with the "
|
description="GET /system/ssh reports enabled, running, and dev_image, and agrees with the "
|
||||||
@@ -814,7 +816,9 @@ def restore_ssh(fc, before):
|
|||||||
"sshd listens on 22, with the effective policy (sshd -T) at PasswordAuthentication "
|
"sshd listens on 22, with the effective policy (sshd -T) at PasswordAuthentication "
|
||||||
"yes, and on a release image PermitRootLogin no and PermitEmptyPasswords no (the "
|
"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 "
|
"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 "
|
"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 "
|
"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 "
|
"by hand (as a reboot does, the flag is on tmpfs) and sshd stays. The prior "
|
||||||
@@ -848,6 +852,13 @@ def ssh_until_reboot(ctx):
|
|||||||
ctx.log("sshd -T: %s", policy)
|
ctx.log("sshd -T: %s", policy)
|
||||||
ctx.check(policy.get("passwordauthentication") == "yes",
|
ctx.check(policy.get("passwordauthentication") == "yes",
|
||||||
"PasswordAuthentication is %r: the account cannot log in", policy.get("passwordauthentication"))
|
"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"]:
|
if not before["dev_image"]:
|
||||||
ctx.check(policy.get("permitrootlogin") == "no" and policy.get("permitemptypasswords") == "no",
|
ctx.check(policy.get("permitrootlogin") == "no" and policy.get("permitemptypasswords") == "no",
|
||||||
"a release image runs sshd with PermitRootLogin %r, PermitEmptyPasswords %r",
|
"a release image runs sshd with PermitRootLogin %r, PermitEmptyPasswords %r",
|
||||||
@@ -882,8 +893,8 @@ def ssh_until_reboot(ctx):
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- cloud
|
# ---------------------------------------------------------------- cloud
|
||||||
|
|
||||||
@test("commission.cloud-disabled-surface", title="Nothing points at the cloud while it is off",
|
@test("setup.cloud-disabled-surface", title="Nothing points at the cloud while it is off",
|
||||||
subsystem="commission", kind="auto", mode="grbl", est_min=1,
|
subsystem="setup", kind="auto", mode="grbl", est_min=1,
|
||||||
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/settings.*"),
|
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/settings.*"),
|
||||||
("forgectrl", "src/wiz.c"), ("forgectrl", "src/hooks.h")],
|
("forgectrl", "src/wiz.c"), ("forgectrl", "src/hooks.h")],
|
||||||
requires=["forgectrl.settings-bounds"],
|
requires=["forgectrl.settings-bounds"],
|
||||||
@@ -965,8 +976,8 @@ def cloud_disabled_surface(ctx):
|
|||||||
|
|
||||||
# ---------------------------------------------------------- the operator
|
# ---------------------------------------------------------- the operator
|
||||||
|
|
||||||
@test("commission.factory-return", title="The return to the factory firmware is guarded",
|
@test("setup.factory-return", title="The return to the factory firmware is guarded",
|
||||||
subsystem="commission", kind="auto", est_min=1,
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.js")],
|
covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.js")],
|
||||||
requires=["update.slots-and-signature"],
|
requires=["update.slots-and-signature"],
|
||||||
description="The return itself reboots the machine as a Glowforge, so it never runs from the "
|
description="The return itself reboots the machine as a Glowforge, so it never runs from the "
|
||||||
@@ -1002,165 +1013,89 @@ def factory_return(ctx):
|
|||||||
ctx.log("PASS: the return is refused without confirm=1; the page confirms before it calls")
|
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):
|
def mac_suffix():
|
||||||
"""A DNS query packet for the A record of name with the unicast-
|
"""The last four hex digits of the MAC address the hostname is built
|
||||||
response bit set (RFC 6762 5.4), so the responder answers this
|
from: wlan0, or eth0 on a machine with no WiFi."""
|
||||||
socket directly."""
|
for dev in ("wlan0", "eth0"):
|
||||||
qid = random.randrange(1, 65536) if qid is None else qid
|
raw = read_file("/sys/class/net/%s/address" % dev)
|
||||||
labels = b"".join(struct.pack("B", len(p)) + p.encode("ascii") for p in name.strip(".").split("."))
|
mac = (raw or b"").decode("ascii", "replace").strip().replace(":", "").lower()
|
||||||
return struct.pack(">HHHHHH", qid, 0, 1, 0, 0, 0) + labels + b"\x00" + struct.pack(">HH", 1, 0x8001)
|
if mac and mac != "0" * 12:
|
||||||
|
return mac[-4:]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _dns_name(pkt, off):
|
def cmdlines():
|
||||||
"""(name, offset after the name); follows compression pointers."""
|
"""The command line of every process on the machine, one string
|
||||||
parts = []
|
each."""
|
||||||
jumped = False
|
out = []
|
||||||
end = off
|
for name in os.listdir("/proc"):
|
||||||
hops = 0
|
if not name.isdigit():
|
||||||
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")
|
|
||||||
continue
|
continue
|
||||||
off += 1
|
raw = read_file("/proc/%s/cmdline" % name)
|
||||||
parts.append(pkt[off:off + n].decode("ascii", "replace"))
|
if raw:
|
||||||
off += n
|
out.append(raw.decode("utf-8", "replace").replace("\0", " ").strip())
|
||||||
if not jumped:
|
return out
|
||||||
end = off
|
|
||||||
return ".".join(parts), end
|
|
||||||
|
|
||||||
|
|
||||||
def mdns_answers(pkt, qid=None):
|
@test("setup.machine-name", title="The machine names itself from its MAC address",
|
||||||
"""The A records of a DNS response: [(name, address)]. A qid, when
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
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,
|
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
description="avahi-daemon runs, its configuration names the host forgefirm on the WiFi and "
|
description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex "
|
||||||
"wired links, and the service file advertises the panel on 443 and 80. An mDNS "
|
"digits of its WiFi MAC address, so two machines on one network answer to "
|
||||||
"query for the A record of forgefirm.local, sent to the multicast group from "
|
"different names and no name carries a serial number. The live name, "
|
||||||
"the board's own LAN interface, is answered with the board's LAN address; that "
|
"/etc/hostname (a bind-mounted copy, because the rootfs is read-only) and the "
|
||||||
"is what `ping forgefirm.local` on a workstation resolves. No component "
|
"MAC address agree; the DHCP client sends the name as the hostname option, "
|
||||||
"covers this: the avahi files are layer content, in the platform identity of "
|
"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.")
|
"every fingerprint.")
|
||||||
def mdns_announce(ctx):
|
def machine_name(ctx):
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
pids = hw.pidof("avahi-daemon")
|
|
||||||
ev["avahi_pids"] = pids
|
# 1. the name, the file and the MAC address agree
|
||||||
ctx.check(pids, "avahi-daemon is not running")
|
suffix = mac_suffix()
|
||||||
conf = read_file("/etc/avahi/avahi-daemon.conf") or b""
|
ev["mac_suffix"] = suffix
|
||||||
ctx.check(b"host-name=forgefirm" in conf, "avahi-daemon.conf does not name the host forgefirm")
|
ctx.check(suffix, "no MAC address to build a name from")
|
||||||
ctx.check(b"allow-interfaces=wlan0,eth0" in conf, "avahi-daemon.conf does not restrict the interfaces")
|
live = socket.gethostname()
|
||||||
svc = read_file("/etc/avahi/services/forgefirm.service") or b""
|
on_file = ((read_file("/etc/hostname") or b"").decode("utf-8", "replace")).strip()
|
||||||
ev["service_file_bytes"] = len(svc)
|
ev["hostname"] = live
|
||||||
ctx.check(b"_https._tcp" in svc and b"<port>443</port>" in svc, "the service file lacks HTTPS 443")
|
ev["hostname_file"] = on_file
|
||||||
ctx.check(b"_http._tcp" in svc and b"<port>80</port>" in svc, "the service file lacks HTTP 80")
|
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()
|
ip = lan_ip()
|
||||||
ev["lan_ip"] = ip
|
ev["lan_ip"] = ip
|
||||||
ctx.check(ip, "cannot determine the board's LAN address")
|
ctx.check(ip, "cannot determine the board's LAN address")
|
||||||
t0 = time.time()
|
ctx.check("Control panel:" in issue, "the console banner names no control panel")
|
||||||
found = mdns_resolve(ip)
|
ctx.check("https://%s/" % ip in issue,
|
||||||
ev["answers"] = sorted(found)
|
"the console banner does not carry the LAN address %s", ip)
|
||||||
ev["resolve_s"] = round(time.time() - t0, 2)
|
for junk in ("# end", "# ForgeFIRM addresses", ".local"):
|
||||||
ctx.log("%s -> %s (%.1f s)", MDNS_NAME, sorted(found) or "no answer", ev["resolve_s"])
|
ctx.check(junk not in issue, "the console banner shows %r", junk)
|
||||||
ctx.check(found, "no mDNS answer for %s from the LAN interface (%s)", MDNS_NAME, ip)
|
urls = re.findall(r"https://(\S+)/", issue)
|
||||||
ctx.check(ip in found, "%s resolves to %s, not the LAN address %s", MDNS_NAME, sorted(found), ip)
|
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
|
# --------------------------------------------------------- the first run
|
||||||
@@ -1283,12 +1218,12 @@ def finished_first_run(ctx):
|
|||||||
"the controller did not come back after the setup: %s", m)
|
"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,
|
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/session.*"), ("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"),
|
||||||
("forgectrl", "src/sheetid.*"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/hooks.h"),
|
("forgectrl", "src/sheetid.*"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/hooks.h"),
|
||||||
("forgectrl", "src/main.c")],
|
("forgectrl", "src/main.c")],
|
||||||
@@ -1379,11 +1314,11 @@ def first_run_flow(ctx):
|
|||||||
ctx.log("the run completed: gate open, the controller back")
|
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,
|
kind="operator", hardware="takeover", est_min=6,
|
||||||
covers=[("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/forms.js"), ("forgectrl", "src/ui/md.js"),
|
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.*")],
|
("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"],
|
actions=["button"], hands=["workstation"],
|
||||||
steps=["From the workstation open https://<address>/ and accept the self-signed certificate.",
|
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 "
|
"Click through the setup: scroll each document to its end and accept it (type I "
|
||||||
@@ -1398,7 +1333,7 @@ def first_run_flow(ctx):
|
|||||||
"clicks through (the documents accepted, the press step with the LEDs "
|
"clicks through (the documents accepted, the press step with the LEDs "
|
||||||
"breathing teal, the account created, the run complete, the gate open, the "
|
"breathing teal, the account created, the run complete, the gate open, the "
|
||||||
"controller back). The operator confirms the panel opened. Everything the run "
|
"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):
|
def first_run_page(ctx):
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
@@ -1434,8 +1369,8 @@ def first_run_page(ctx):
|
|||||||
ctx.confirm("Did the control panel open after Finish?")
|
ctx.confirm("Did the control panel open after Finish?")
|
||||||
|
|
||||||
|
|
||||||
@test("commission.cert-page", title="The certificate is checkable before the warning",
|
@test("setup.cert-page", title="The certificate is checkable before the warning",
|
||||||
subsystem="commission", kind="auto", est_min=1,
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c"),
|
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c"),
|
||||||
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.js")],
|
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.js")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
@@ -1458,7 +1393,8 @@ def cert_page(ctx):
|
|||||||
ctx.check(st == 200, "GET %s/cert -> %s, expected 200", base, st)
|
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("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(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")
|
st, body, hdrs = request(base, "GET", "/cert.pem")
|
||||||
ctx.check(st == 200 and body.startswith(b"-----BEGIN CERTIFICATE-----"),
|
ctx.check(st == 200 and body.startswith(b"-----BEGIN CERTIFICATE-----"),
|
||||||
"GET %s/cert.pem -> %s, not a PEM certificate", base, st)
|
"GET %s/cert.pem -> %s, not a PEM certificate", base, st)
|
||||||
@@ -1492,9 +1428,9 @@ def flag_of(w, level, wid):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@test("commission.what-changed", title="A replaced part asks for its checks again",
|
@test("setup.what-changed", title="A replaced part asks for its checks again",
|
||||||
subsystem="commission", kind="auto", est_min=2, hardware="takeover",
|
subsystem="setup", kind="auto", est_min=2, hardware="takeover",
|
||||||
covers=[("forgectrl", "src/commission.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"),
|
covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"),
|
||||||
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html"),
|
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html"),
|
||||||
("forgectrl", "src/ui/help.js")],
|
("forgectrl", "src/ui/help.js")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
@@ -1562,9 +1498,9 @@ def what_changed(ctx):
|
|||||||
ctx.check(bool(mode(fc).get("gated")) == gated_before, "the gate does not read as before")
|
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",
|
@test("setup.record-export", title="The record: JSON, the printable page, and the log bundle",
|
||||||
subsystem="commission", kind="auto", est_min=2,
|
subsystem="setup", kind="auto", est_min=2,
|
||||||
covers=[("forgectrl", "src/commission.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"),
|
covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"),
|
||||||
("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
|
("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
|
||||||
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/wizard.html")],
|
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/wizard.html")],
|
||||||
requires=["forgectrl.auth", "logs.tree-tail-export"],
|
requires=["forgectrl.auth", "logs.tree-tail-export"],
|
||||||
@@ -1573,7 +1509,7 @@ def what_changed(ctx):
|
|||||||
"named after the sheet id; GET /wiz/record.html is a page with no script that "
|
"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 "
|
"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 "
|
"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):
|
def record_export(ctx):
|
||||||
import gzip
|
import gzip
|
||||||
import io
|
import io
|
||||||
@@ -1613,7 +1549,7 @@ def record_export(ctx):
|
|||||||
ctx.check(st == 200 and "text/html" in (hdrs.get("content-type") or ""), "GET /wiz/record.html -> %s %s",
|
ctx.check(st == 200 and "text/html" in (hdrs.get("content-type") or ""), "GET /wiz/record.html -> %s %s",
|
||||||
st, hdrs.get("content-type"))
|
st, hdrs.get("content-type"))
|
||||||
ctx.check("<script" not in page.lower(), "the printable page carries a script")
|
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 []}
|
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]
|
missing = [d for d in done if titles.get(d) and ("<h3>" + titles[d]) not in page]
|
||||||
ev["missing_on_page"] = missing
|
ev["missing_on_page"] = missing
|
||||||
@@ -1634,11 +1570,11 @@ def record_export(ctx):
|
|||||||
try:
|
try:
|
||||||
tf = tarfile.open(fileobj=io.BytesIO(gzip.decompress(data)))
|
tf = tarfile.open(fileobj=io.BytesIO(gzip.decompress(data)))
|
||||||
for m in tf.getmembers():
|
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()
|
member = tf.extractfile(m).read()
|
||||||
except (OSError, tarfile.TarError, EOFError) as e:
|
except (OSError, tarfile.TarError, EOFError) as e:
|
||||||
ctx.fail("export is not a readable tar.gz: %s", 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:
|
if member is not None:
|
||||||
try:
|
try:
|
||||||
inside = json.loads(member.decode("utf-8"))
|
inside = json.loads(member.decode("utf-8"))
|
||||||
@@ -1656,12 +1592,12 @@ def record_export(ctx):
|
|||||||
ctx.log("record %d wizards; page %d bytes; bundle carries the record", len(done), ev["page_bytes"])
|
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",
|
@test("setup.mirror", title="One browser drives a check; a second one follows",
|
||||||
subsystem="commission", kind="auto", est_min=2, hardware="takeover",
|
subsystem="setup", kind="auto", est_min=2, hardware="takeover",
|
||||||
covers=[("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), ("forgectrl", "src/wiz.*"),
|
covers=[("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), ("forgectrl", "src/wiz.*"),
|
||||||
("forgectrl", "src/session.*"), ("forgectrl", "src/ui/wizard.js"),
|
("forgectrl", "src/session.*"), ("forgectrl", "src/ui/wizard.js"),
|
||||||
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.css")],
|
("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 "
|
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 "
|
"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, "
|
"reports the run as owned and its own to the first session, not to the second, "
|
||||||
+87
-33
@@ -1,4 +1,9 @@
|
|||||||
"""commission.check-* - the setup's checks (the dark wizards), driven the
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""setup.check-* - the setup's checks (the dark wizards), driven the
|
||||||
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
|
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
|
the prompts answered from here (the bench fixture works the lid and the
|
||||||
button; a confirmation is answered yes once the snapshot exists), the
|
button; a confirmation is answered yes once the snapshot exists), the
|
||||||
@@ -12,11 +17,17 @@ import time
|
|||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
from .. import hw
|
from .. import hw
|
||||||
from ..baseline import read_position
|
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
|
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.*"),
|
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.*")]
|
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
||||||
|
|
||||||
|
|
||||||
@@ -76,6 +87,12 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
|||||||
if value is not None:
|
if value is not None:
|
||||||
st, body = answer(fc, wid, p, value)
|
st, body = answer(fc, wid, p, value)
|
||||||
ctx.check(st == 200, "the answer to %s -> %s %s", p.get("id"), st, body)
|
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"):
|
if not d.get("running"):
|
||||||
last = d
|
last = d
|
||||||
break
|
break
|
||||||
@@ -101,8 +118,8 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
|||||||
return last
|
return last
|
||||||
|
|
||||||
|
|
||||||
@test("commission.check-switches", title="The switches check follows the lid and the button",
|
@test("setup.check-switches", title="The switches check follows the lid and the button",
|
||||||
subsystem="commission", kind="operator", est_min=3,
|
subsystem="setup", kind="operator", est_min=3,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
||||||
requires=["forgectrl.auth"], actions=["lid", "button"],
|
requires=["forgectrl.auth"], actions=["lid", "button"],
|
||||||
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
||||||
@@ -136,30 +153,40 @@ def check_switches(ctx):
|
|||||||
ctx.log("switches: %s", json.dumps(r))
|
ctx.log("switches: %s", json.dumps(r))
|
||||||
|
|
||||||
|
|
||||||
@test("commission.check-sensors", title="The sensors check reads a plausible machine at rest",
|
@test("setup.check-sensors", title="The sensors check reads a plausible machine at rest",
|
||||||
subsystem="commission", kind="auto", est_min=2,
|
subsystem="setup", kind="auto", est_min=2,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
|
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
|
||||||
("forgectrl", "src/cool.c")],
|
("forgectrl", "src/cool.c")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
description="POST /wiz/sensors/start; ten seconds of readings, then the room-temperature "
|
description="POST /wiz/sensors/start; ten seconds of readings and no question asked. The "
|
||||||
"prompt, answered Skip so no offset is written. The result carries both coolant "
|
"result carries both coolant temperatures, the chassis and SoC, the lid IR "
|
||||||
"temperatures, the chassis and SoC, the lid IR maxima, the accelerometer event "
|
"maxima, the accelerometer event count, the supply power-good, the HV current, "
|
||||||
"count, the supply power-good, the HV current, and the idle fan speeds; the "
|
"and the idle fan speeds; the record carries sensors at version 1 and the "
|
||||||
"record carries sensors at version 1 and cool_temp_offset_c reads as before.")
|
"settings read as before (the check writes none).")
|
||||||
def check_sensors(ctx):
|
def check_sensors(ctx):
|
||||||
with Restore(ctx, ["cool_temp_offset_c"]):
|
fc = ctx.forgectrl
|
||||||
last = run_check(ctx, "sensors", lambda p: "Skip" if p.get("id") == "room-temp" else None, 120)
|
ev = ctx.evidence
|
||||||
r = last.get("result") or {}
|
before = fc.settings() or {}
|
||||||
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
ev["settings_before"] = dict(before)
|
||||||
"exhaust_rpm_idle", "intake_rpm_idle"):
|
|
||||||
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
def on_prompt(p):
|
||||||
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
|
ctx.fail("the sensors check asked a question: %s", p)
|
||||||
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
|
last = run_check(ctx, "sensors", on_prompt, 120)
|
||||||
r.get("lid_ir_max"), r.get("hv_current_max"))
|
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",
|
@test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
||||||
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
||||||
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
||||||
@@ -167,7 +194,8 @@ def check_sensors(ctx):
|
|||||||
"for 35 s, the purge is measured on and off, and five settings are written: the "
|
"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 "
|
"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 "
|
"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):
|
def check_airflow(ctx):
|
||||||
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
|
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"]
|
"cool_purge_min_current", "cool_fan_grace_s"]
|
||||||
@@ -186,12 +214,38 @@ def check_airflow(ctx):
|
|||||||
for k in keys:
|
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.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))
|
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)
|
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")
|
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",
|
@test("setup.check-cameras", title="The cameras check captures both cameras",
|
||||||
subsystem="commission", kind="operator", est_min=2,
|
subsystem="setup", kind="operator", est_min=2,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
|
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
|
||||||
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
|
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
|
||||||
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
|
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
|
||||||
@@ -225,10 +279,10 @@ def check_cameras(ctx):
|
|||||||
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
|
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",
|
@test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
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")],
|
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
|
||||||
requires=["forgectrl.auth", "motion.pacing"],
|
requires=["forgectrl.auth", "motion.pacing"],
|
||||||
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
|
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
|
||||||
@@ -266,8 +320,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()})
|
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",
|
@test("setup.check-flow-verify", title="The flow check runs as a setup check",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
||||||
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
||||||
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
||||||
@@ -284,8 +338,8 @@ def check_flow_verify(ctx):
|
|||||||
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
|
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",
|
@test("setup.cloud-header-capture", title="The cloud header check takes one print's envelope",
|
||||||
subsystem="commission", kind="operator", hardware="takeover", mode="grbl", est_min=8,
|
subsystem="setup", kind="operator", hardware="takeover", mode="grbl", est_min=8,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
|
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
|
||||||
("python3-gfhardware", "gfhardware/machine.py"),
|
("python3-gfhardware", "gfhardware/machine.py"),
|
||||||
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
|
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
|
||||||
+116
-30
@@ -1,4 +1,9 @@
|
|||||||
"""commission.sheet - the setup's sheet (the live wizards) in one run,
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""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
|
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
|
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
|
result judged, and every setting a wizard wrote put back as found. One
|
||||||
@@ -18,13 +23,13 @@ restored. The page is where the real numbers go in.
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from ..catalog import test
|
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"),
|
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/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
||||||
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
("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/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
||||||
("forgectrl", "src/super.c"),
|
("forgectrl", "src/super.c"),
|
||||||
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
||||||
@@ -36,19 +41,18 @@ SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
|||||||
|
|
||||||
# The live wizards in the order the sheet burns them: the wizard id, the
|
# 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
|
# 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
|
# sheet), and the keys its result must carry. The arm press is not timed
|
||||||
# waits for the button to light before the arm press goes to a person.
|
# from here: each card opens a press prompt of its own when the button is
|
||||||
# The flow-load card settles the coolant loop before it lights the button
|
# lit and the tube still dark, and run_check presses on that.
|
||||||
# (60 to 240 s); the others light within seconds of the lens reference.
|
|
||||||
CARDS = [
|
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"},
|
("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",
|
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
|
||||||
"focus_range_mm", "stops"], 120),
|
"focus_range_mm", "stops"]),
|
||||||
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"], 120),
|
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"]),
|
||||||
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"], 120),
|
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"]),
|
||||||
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"], 120),
|
("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"], 420),
|
("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.
|
# 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",
|
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
|
||||||
@@ -72,6 +76,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,
|
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)
|
"no program for %s (%s)", wid, st)
|
||||||
ctx.evidence["program_lines"] = body.count(b"\n")
|
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):
|
def emission_ok(ctx, last):
|
||||||
@@ -90,6 +110,61 @@ def file_card(ctx, wid):
|
|||||||
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
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):
|
def thickness_answer(ctx):
|
||||||
"""The thickness the placement is answered with, in the machine's
|
"""The thickness the placement is answered with, in the machine's
|
||||||
units (the number prompts read ui_units), and the millimeters the
|
units (the number prompts read ui_units), and the millimeters the
|
||||||
@@ -130,12 +205,12 @@ def place(ctx):
|
|||||||
file_card(ctx, "sheet.place")
|
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
|
"""One live card: the preview, the burn after the press with the
|
||||||
prompts answered from `answers`, the witnesses, the keys of 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)
|
preview_ok(ctx, wid)
|
||||||
ctx.arm_press(lit_timeout=lit_s)
|
|
||||||
|
|
||||||
def on_prompt(p):
|
def on_prompt(p):
|
||||||
return answers.get(p.get("id"))
|
return answers.get(p.get("id"))
|
||||||
@@ -152,30 +227,38 @@ def burn(ctx, wid, answers, want, lit_s):
|
|||||||
file_card(ctx, wid)
|
file_card(ctx, wid)
|
||||||
|
|
||||||
|
|
||||||
@test("commission.sheet", title="The commissioning sheet: the placement, the frame, and the five cards",
|
@test("setup.sheet", title="The setup sheet: the placement, the frame, and the five cards",
|
||||||
subsystem="commission", kind="live", hardware="takeover", mode="grbl", est_min=35,
|
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=35,
|
||||||
covers=SHEET_COVERS,
|
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"],
|
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 "
|
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); "
|
"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.",
|
"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 "
|
"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 "
|
"machine, and the bench actuator makes every arm press after it, each one when the "
|
||||||
"the start is the Ready answer instead, and you press the button each time it lights "
|
"card itself asks for it. With no actuator the start is the Ready answer instead, "
|
||||||
"white: six times, the frame and the five cards.",
|
"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 "
|
"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 "
|
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
|
||||||
"tails."],
|
"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 "
|
"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 "
|
"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 "
|
"(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 "
|
"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 "
|
"(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 "
|
"lens on its hall edge, the stops found by the head accelerometer and written "
|
||||||
"travel; the pick is line 11, the thickness kept); the floor card (twelve "
|
"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 "
|
"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 "
|
"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 "
|
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
|
||||||
@@ -191,6 +274,9 @@ def sheet(ctx):
|
|||||||
"each. The bench presses when the button lights.")
|
"each. The bench presses when the button lights.")
|
||||||
place(ctx)
|
place(ctx)
|
||||||
with Restore(ctx, CARD_SETTINGS):
|
with Restore(ctx, CARD_SETTINGS):
|
||||||
for wid, answers, want, lit_s in CARDS:
|
fresh_lens(ctx)
|
||||||
burn(ctx, wid, answers, want, lit_s)
|
for wid, answers, want in CARDS:
|
||||||
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
|
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,11 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""update.* - the A/B slot inventory and the firmware verification path."""
|
"""update.* - the A/B slot inventory and the firmware verification path."""
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import shutil
|
import shutil
|
||||||
@@ -8,6 +14,7 @@ from ..catalog import test
|
|||||||
from .. import hw
|
from .. import hw
|
||||||
|
|
||||||
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
|
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
|
||||||
|
("forgectrl", "src/relcheck.c"), ("forgectrl", "src/relcheck.h"),
|
||||||
("ffboot", "**")]
|
("ffboot", "**")]
|
||||||
|
|
||||||
|
|
||||||
@@ -117,3 +124,76 @@ def slots_and_signature(ctx):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
shutil.rmtree(work, ignore_errors=True)
|
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);
|
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 ---------------------------------- */
|
/* ---- Chrome: header, tabs, footer ---------------------------------- */
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -238,6 +332,7 @@ header {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
#themebtn,
|
#themebtn,
|
||||||
|
#logsbtn,
|
||||||
#logoutbtn {
|
#logoutbtn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgba(255, 255, 255, 0.75);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
@@ -249,6 +344,7 @@ header {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
#themebtn:hover,
|
#themebtn:hover,
|
||||||
|
#logsbtn:hover,
|
||||||
#logoutbtn:hover {
|
#logoutbtn:hover {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: rgba(255, 255, 255, 0.6);
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
@@ -692,3 +788,13 @@ footer a {
|
|||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The setup's log-bundle line, under the header: the result of the
|
||||||
|
header's Download logs, or why the machine refused it. */
|
||||||
|
.wz-logs-msg {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 6px auto 0;
|
||||||
|
padding: 0 16px;
|
||||||
|
min-height: 16px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Shared fixtures for the forgetest unit tests: synthetic manifests and
|
"""Shared fixtures for the forgetest unit tests: synthetic manifests and
|
||||||
catalog entries that never touch hardware."""
|
catalog entries that never touch hardware."""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The baseline: fixed resting values are restored, preserved values are
|
"""The baseline: fixed resting values are restored, preserved values are
|
||||||
handed back, every deviation is a recorded leftover. Runs against a fake
|
handed back, every deviation is a recorded leftover. Runs against a fake
|
||||||
sysfs tree; forgectrl is unreachable (service-side checks skip)."""
|
sysfs tree; forgectrl is unreachable (service-side checks skip)."""
|
||||||
@@ -10,6 +15,11 @@ import unittest
|
|||||||
|
|
||||||
from forgetest import baseline
|
from forgetest import baseline
|
||||||
|
|
||||||
|
# The fixed machine tick at the mode an unset xy_microsteps reads as.
|
||||||
|
# Taken from baseline rather than typed, so a change to the default mode
|
||||||
|
# moves the expectations with it.
|
||||||
|
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
|
||||||
|
|
||||||
|
|
||||||
class BaselineTests(unittest.TestCase):
|
class BaselineTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -21,8 +31,11 @@ class BaselineTests(unittest.TestCase):
|
|||||||
for name in baseline.BUTTON_LEDS + ("lid_led",):
|
for name in baseline.BUTTON_LEDS + ("lid_led",):
|
||||||
os.makedirs(self.leds + name)
|
os.makedirs(self.leds + name)
|
||||||
self._led(name, "0")
|
self._led(name, "0")
|
||||||
# a clean machine
|
# A clean machine at the mode an unset xy_microsteps reads as:
|
||||||
for attr, val in baseline.FIXED_SYSFS + baseline.IDLE_READBACKS:
|
# x/y_mode, step_freq and ramp_rate are the mode's, not the x8
|
||||||
|
# literals in FIXED_SYSFS, and that is what enforce() compares
|
||||||
|
# against.
|
||||||
|
for attr, val in baseline.fixed_sysfs() + baseline.IDLE_READBACKS:
|
||||||
self._attr(attr, val)
|
self._attr(attr, val)
|
||||||
self._attr("cnc/interlock_circuit", "45")
|
self._attr("cnc/interlock_circuit", "45")
|
||||||
self._attr("pic/lid_led", "0")
|
self._attr("pic/lid_led", "0")
|
||||||
@@ -52,6 +65,13 @@ class BaselineTests(unittest.TestCase):
|
|||||||
# the class interface writes 'target'; the fake mirrors it into brightness
|
# the class interface writes 'target'; the fake mirrors it into brightness
|
||||||
# only when the test asks (see _sync_leds)
|
# 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):
|
def _sync_leds(self):
|
||||||
for name in baseline.BUTTON_LEDS:
|
for name in baseline.BUTTON_LEDS:
|
||||||
p = self.leds + name + "/target"
|
p = self.leds + name + "/target"
|
||||||
@@ -83,7 +103,7 @@ class BaselineTests(unittest.TestCase):
|
|||||||
for x in left:
|
for x in left:
|
||||||
self.assertEqual(x.action, "restored", str(x))
|
self.assertEqual(x.action, "restored", str(x))
|
||||||
self.assertEqual(self._read("cnc/motor_lock"), "0")
|
self.assertEqual(self._read("cnc/motor_lock"), "0")
|
||||||
self.assertEqual(self._read("cnc/step_freq"), "28160")
|
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
|
||||||
self.assertEqual(self._read("cnc/streaming"), "0")
|
self.assertEqual(self._read("cnc/streaming"), "0")
|
||||||
self.assertEqual(items["cnc/motor_lock"].found, "15")
|
self.assertEqual(items["cnc/motor_lock"].found, "15")
|
||||||
self.assertEqual(items["cnc/motor_lock"].expected, "0")
|
self.assertEqual(items["cnc/motor_lock"].expected, "0")
|
||||||
@@ -119,6 +139,21 @@ class BaselineTests(unittest.TestCase):
|
|||||||
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
|
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
|
||||||
self.assertEqual(items["position"].found, [1000, 0, 0])
|
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):
|
def test_a_held_controller_is_reset_before_the_return_jog(self):
|
||||||
# a pause test that failed while held leaves the controller in
|
# a pause test that failed while held leaves the controller in
|
||||||
# Hold, which refuses a jog: the baseline resets out of it first
|
# Hold, which refuses a jog: the baseline resets out of it first
|
||||||
@@ -131,14 +166,16 @@ class BaselineTests(unittest.TestCase):
|
|||||||
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
|
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
|
||||||
b = self.bl()
|
b = self.bl()
|
||||||
cap = b.capture()
|
cap = b.capture()
|
||||||
self._pos(221, 0, 0) # 4.144 mm into the held move
|
# 4.144 mm into the held move, in the steps of the mode in force
|
||||||
|
held_mm = 4.144
|
||||||
|
self._pos(round(held_mm * baseline.xy_steps_per_mm(baseline.XY_MODE_DEFAULT)), 0, 0)
|
||||||
left = b.enforce("post", captured=cap)
|
left = b.enforce("post", captured=cap)
|
||||||
items = {x.item: x for x in left}
|
items = {x.item: x for x in left}
|
||||||
self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
|
self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
|
||||||
self.assertEqual(dev.sent[0], "^X")
|
self.assertEqual(dev.sent[0], "^X")
|
||||||
jogs = [l for l in dev.sent if l.startswith("$J=")]
|
jogs = [l for l in dev.sent if l.startswith("$J=")]
|
||||||
self.assertEqual(len(jogs), 1)
|
self.assertEqual(len(jogs), 1)
|
||||||
self.assertIn("X-4.144", jogs[0])
|
self.assertIn("X-%.3f" % held_mm, jogs[0])
|
||||||
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
|
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
|
||||||
finally:
|
finally:
|
||||||
dev.stop()
|
dev.stop()
|
||||||
@@ -154,16 +191,32 @@ class BaselineTests(unittest.TestCase):
|
|||||||
b = self.bl()
|
b = self.bl()
|
||||||
cap = b.capture()
|
cap = b.capture()
|
||||||
# the run left 40 unplayed bytes queued in the kernel ring and the
|
# 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
|
# head 1000 counts out: the hand-back stands the machine down to
|
||||||
# is refused (it would replay the residue first)
|
# 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)
|
self._pos_bytes(1000, 0, 0, 100, 140)
|
||||||
left = b.enforce("post", captured=cap)
|
left = b.enforce("post", captured=cap)
|
||||||
items = {x.item: x for x in left}
|
items = {x.item: x for x in left}
|
||||||
self.assertIn("pulse ring", items)
|
self.assertIn("pulse ring", items)
|
||||||
self.assertEqual(items["pulse ring"].found, "40 unplayed bytes")
|
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)
|
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):
|
def test_clean_ring_reads_zero_residue(self):
|
||||||
self.assertEqual(baseline.read_ring_residue(), 0)
|
self.assertEqual(baseline.read_ring_residue(), 0)
|
||||||
|
|
||||||
@@ -207,7 +260,7 @@ class BaselineTests(unittest.TestCase):
|
|||||||
def test_fixed_constants_checked_against_a_dump(self):
|
def test_fixed_constants_checked_against_a_dump(self):
|
||||||
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
|
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
|
||||||
diffs = baseline.check_fixed_against(ref, self.lines.append)
|
diffs = baseline.check_fixed_against(ref, self.lines.append)
|
||||||
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=28160"])
|
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=%s" % DEFAULT_TICK])
|
||||||
|
|
||||||
|
|
||||||
# -- the reference is taken after the controller applied its config -----
|
# -- the reference is taken after the controller applied its config -----
|
||||||
@@ -226,7 +279,8 @@ class BaselineTests(unittest.TestCase):
|
|||||||
def sleep(_s):
|
def sleep(_s):
|
||||||
calls["n"] += 1
|
calls["n"] += 1
|
||||||
if calls["n"] == 3: # the controller's init writes land
|
if calls["n"] == 3: # the controller's init writes land
|
||||||
for attr, val in baseline.CONFIGURED_MARKERS:
|
# at the mode wait_controller_configured watches by default
|
||||||
|
for attr, val in baseline.configured_markers():
|
||||||
self._attr(attr, val)
|
self._attr(attr, val)
|
||||||
ok = baseline.wait_controller_configured(
|
ok = baseline.wait_controller_configured(
|
||||||
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
|
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
|
||||||
@@ -264,11 +318,15 @@ class BaselineTests(unittest.TestCase):
|
|||||||
def test_preconfig_reference_is_recognized(self):
|
def test_preconfig_reference_is_recognized(self):
|
||||||
self.assertTrue(baseline.reference_preconfig(
|
self.assertTrue(baseline.reference_preconfig(
|
||||||
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
|
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
|
||||||
|
# The markers are read at the reference's own mode, so a dump taken
|
||||||
|
# on an x8 machine carries the setting that says so.
|
||||||
self.assertFalse(baseline.reference_preconfig(
|
self.assertFalse(baseline.reference_preconfig(
|
||||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"}}))
|
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"},
|
||||||
|
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
|
||||||
# a genuinely different single constant is a machine fact, not pre-config
|
# a genuinely different single constant is a machine fact, not pre-config
|
||||||
self.assertFalse(baseline.reference_preconfig(
|
self.assertFalse(baseline.reference_preconfig(
|
||||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"}}))
|
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"},
|
||||||
|
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
|
||||||
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
|
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
|
||||||
|
|
||||||
# -- the XY microstep mode drives the fixed values ------------------------
|
# -- the XY microstep mode drives the fixed values ------------------------
|
||||||
@@ -287,16 +345,18 @@ class BaselineTests(unittest.TestCase):
|
|||||||
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
|
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
|
||||||
|
|
||||||
def test_the_mode_is_read_from_the_settings_with_a_default(self):
|
def test_the_mode_is_read_from_the_settings_with_a_default(self):
|
||||||
|
# An unset or unusable key reads as the driver's default, x32.
|
||||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
|
||||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32)
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32)
|
||||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 8)
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "8"}), 8)
|
||||||
self.assertEqual(baseline.xy_mode_of({}), 8)
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 32)
|
||||||
self.assertEqual(baseline.xy_mode_of(None), 8)
|
self.assertEqual(baseline.xy_mode_of({}), 32)
|
||||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 8)
|
self.assertEqual(baseline.xy_mode_of(None), 32)
|
||||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 8)
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 32)
|
||||||
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 32)
|
||||||
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
|
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
|
||||||
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 8)
|
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 32)
|
||||||
self.assertEqual(baseline.ref_xy_mode({}), 8)
|
self.assertEqual(baseline.ref_xy_mode({}), 32)
|
||||||
|
|
||||||
def test_fixed_constants_are_checked_at_the_reference_mode(self):
|
def test_fixed_constants_are_checked_at_the_reference_mode(self):
|
||||||
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
|
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
|
||||||
@@ -354,6 +414,153 @@ if __name__ == "__main__":
|
|||||||
unittest.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 CoolPublishTests(unittest.TestCase):
|
||||||
|
"""The cooling engine publishes once a tick. A status read inside the
|
||||||
|
tick after a run ended is the engine's last word on the run, not the
|
||||||
|
machine as the run left it. Found on the bench reference: a diagnostic
|
||||||
|
that finished clean (cooling.aa-offset-calibrate) and a fail tier that
|
||||||
|
did its work (cooling.fail-tier-stop) both failed their hand-back on a
|
||||||
|
hold the engine's next tick cleared."""
|
||||||
|
|
||||||
|
IDLE = {"phase": "idle", "armed": False, "hold": False}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.lines = []
|
||||||
|
|
||||||
|
def bl(self, statuses):
|
||||||
|
"""A baseline whose /cool/status answers play in order (the last
|
||||||
|
one repeats) and whose waits count reads, not seconds: three for
|
||||||
|
the engine's next tick, as the real wait makes, and enough for the
|
||||||
|
cooldown to let a slow script run out."""
|
||||||
|
b = baseline.Baseline(self.lines.append)
|
||||||
|
seq = list(statuses)
|
||||||
|
b.fc_get = lambda path: (200, dict(seq.pop(0) if len(seq) > 1 else seq[0]))
|
||||||
|
|
||||||
|
def wait(what, pred, timeout):
|
||||||
|
for i in range(3 if what == "cool publish" else 50):
|
||||||
|
if pred():
|
||||||
|
return float(i)
|
||||||
|
return None
|
||||||
|
b._wait = wait
|
||||||
|
b.stood_down = []
|
||||||
|
b.stand_down = b.stood_down.append
|
||||||
|
return b
|
||||||
|
|
||||||
|
def test_a_diagnostic_hold_the_next_tick_clears_is_not_a_leftover(self):
|
||||||
|
left = []
|
||||||
|
b = self.bl([{"phase": "diag", "armed": False, "hold": True}, self.IDLE])
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual(left, [])
|
||||||
|
self.assertTrue(any("last word on the run" in ln for ln in self.lines), self.lines)
|
||||||
|
|
||||||
|
def test_a_fail_tier_hold_clears_into_the_engines_own_post_job_phase(self):
|
||||||
|
left = []
|
||||||
|
b = self.bl([{"phase": "run", "armed": False, "hold": True},
|
||||||
|
{"phase": "smoke", "armed": False, "hold": False},
|
||||||
|
{"phase": "smoke", "armed": False, "hold": False}, self.IDLE])
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual(left, [])
|
||||||
|
self.assertTrue(any("not a leftover" in ln for ln in self.lines), self.lines)
|
||||||
|
|
||||||
|
def test_a_hold_that_outlives_the_tick_is_the_runs_doing(self):
|
||||||
|
left = []
|
||||||
|
b = self.bl([{"phase": "run", "armed": True, "hold": True}])
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual([x.item for x in left], ["cool"])
|
||||||
|
self.assertEqual(left[0].found, "run/armed=True/hold=True")
|
||||||
|
self.assertTrue(left[0].action.startswith("failed: still"), left[0].action)
|
||||||
|
self.assertEqual(len(b.stood_down), 1)
|
||||||
|
|
||||||
|
def test_a_hold_that_clears_only_later_is_still_recorded(self):
|
||||||
|
left = []
|
||||||
|
held = {"phase": "run", "armed": False, "hold": True}
|
||||||
|
b = self.bl([held] * 10 + [self.IDLE])
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual([x.item for x in left], ["cool"])
|
||||||
|
self.assertEqual(left[0].action, "waited")
|
||||||
|
self.assertEqual(b.stood_down, [])
|
||||||
|
|
||||||
|
def test_an_idle_engine_and_a_silent_daemon_leave_nothing(self):
|
||||||
|
left = []
|
||||||
|
self.bl([self.IDLE])._cool_side(left)
|
||||||
|
b = baseline.Baseline(self.lines.append)
|
||||||
|
b.fc_get = lambda path: (None, None)
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual(left, [])
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class BaselineModeTests(BaselineTests):
|
||||||
"""The baseline against a fake forgectrl: what the mode in force
|
"""The baseline against a fake forgectrl: what the mode in force
|
||||||
owns. Reuses the fake sysfs tree of BaselineTests; only the new
|
owns. Reuses the fake sysfs tree of BaselineTests; only the new
|
||||||
@@ -461,7 +668,7 @@ class BaselineModeTests(BaselineTests):
|
|||||||
self._attr("pic/lid_led", "77")
|
self._attr("pic/lid_led", "77")
|
||||||
left = self.bl().enforce("pre", captured=None)
|
left = self.bl().enforce("pre", captured=None)
|
||||||
self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"])
|
self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"])
|
||||||
self.assertEqual(self._read("cnc/step_freq"), "28160")
|
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
|
||||||
self.assertEqual(self._read("pic/lid_led"), "236")
|
self.assertEqual(self._read("pic/lid_led"), "236")
|
||||||
|
|
||||||
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
|
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The bench registry against scripts/bench: every python tool is
|
"""The bench registry against scripts/bench: every python tool is
|
||||||
registered (or named as not-a-tool), every registered script exists,
|
registered (or named as not-a-tool), every registered script exists,
|
||||||
every argument spec builds a command line, every script compiles, and
|
every argument spec builds a command line, every script compiles, and
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import helpers
|
import helpers
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Every phrase the cloud suite greps for in the gfcloud log must be a
|
"""Every phrase the cloud suite greps for in the gfcloud log must be a
|
||||||
phrase the pinned cloud app can actually log.
|
phrase the pinned cloud app can actually log.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The cloud.* job-behavior tests against a fake forgectrl and a replayed
|
"""The cloud.* job-behavior tests against a fake forgectrl and a replayed
|
||||||
gfcloud log: the real test functions run under the real runner Context,
|
gfcloud log: the real test functions run under the real runner Context,
|
||||||
the operator prompts answered - and the standing notices of the machine
|
the operator prompts answered - and the standing notices of the machine
|
||||||
@@ -558,12 +563,16 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
|
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
|
||||||
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
|
"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
|
"""The fakes a mode-switch run needs: grbl to answer $H, the lid
|
||||||
lamp attr, homing_mode = gfcloud, the service lines landing on
|
lamp attr, homing_mode = gfcloud, the service lines landing on
|
||||||
the switch to cloud (the hunt reads as a run to the cooling
|
the switch to cloud - the client's start at once, its session and
|
||||||
engine while it lasts), the re-hunt on the lid close, and gfhome
|
hunt once the lid reads open, as on the bench where the lid opens
|
||||||
finishing the homing after $H."""
|
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()
|
self.grbl = helpers.FakeGrbl().start()
|
||||||
os.makedirs(self.sysfs + "pic", exist_ok=True)
|
os.makedirs(self.sysfs + "pic", exist_ok=True)
|
||||||
self._attr("pic/lid_led", "236")
|
self._attr("pic/lid_led", "236")
|
||||||
@@ -575,22 +584,35 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
hunt_part = hunt_lines(hunt_part)
|
hunt_part = hunt_lines(hunt_part)
|
||||||
fc = self.fc
|
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):
|
def on_post(path, form):
|
||||||
if path == "/mode" and form.get("controller") == "cloud":
|
if path == "/mode" and form.get("controller") == "cloud":
|
||||||
fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE)
|
fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE)
|
||||||
|
|
||||||
def land():
|
def land():
|
||||||
self.append(pre, delay=0.0)
|
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)
|
time.sleep(0.3)
|
||||||
self.append(hunt_part, delay=0.0)
|
hunt()
|
||||||
time.sleep(4.0) # the hunt outlasts the session wait on the bench
|
|
||||||
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
|
|
||||||
threading.Thread(target=land, daemon=True).start()
|
threading.Thread(target=land, daemon=True).start()
|
||||||
elif path == "/mode" and form.get("controller") == "grbl":
|
elif path == "/mode" and form.get("controller") == "grbl":
|
||||||
self.grbl.state = "Idle"
|
self.grbl.state = "Idle"
|
||||||
return None
|
return None
|
||||||
self.fc.on_post = on_post
|
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():
|
def lid_closed():
|
||||||
self.lid(True)
|
self.lid(True)
|
||||||
self.append(close_part, delay=0.2)
|
self.append(close_part, delay=0.2)
|
||||||
@@ -605,7 +627,7 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete "
|
f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete "
|
||||||
b"(service quiet 8s, 3 motion windows)\n")
|
b"(service quiet 8s, 3 motion windows)\n")
|
||||||
self.home_hook = homing
|
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):
|
def wait_home_command(self):
|
||||||
while "$H" not in self.grbl.sent:
|
while "$H" not in self.grbl.sent:
|
||||||
@@ -633,6 +655,29 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.script.asked, [])
|
self.assertEqual(self.script.asked, [])
|
||||||
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
|
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 test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self):
|
||||||
def refused(hunt_part):
|
def refused(hunt_part):
|
||||||
i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)
|
i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)
|
||||||
@@ -914,22 +959,63 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.fc.posts, [])
|
self.assertEqual(self.fc.posts, [])
|
||||||
self.assertTrue(any("PASS: lid open at the button prompt" in l for l in run.lines))
|
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 ------------
|
# -- the cooling verdict: locked until the run, a hold that never clears --
|
||||||
def test_verdict_hold_on_the_bench_excerpt(self):
|
def dark_parts(self):
|
||||||
self.in_offline()
|
"""(the print prologue through the button prompt, the run and the
|
||||||
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
print's end with no pause in it)."""
|
||||||
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})
|
|
||||||
lines = fixture("pause")
|
lines = fixture("pause")
|
||||||
pre, rest = cut(lines, "waiting for button")
|
pre, rest = cut(lines, "waiting for button")
|
||||||
pre = pre + [rest[0]]
|
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 = cut(rest, "machine:_run_loop starting run")
|
||||||
run = [l for l in run if "paus" not in l and "resum" not in l]
|
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 "
|
return pre, run
|
||||||
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)"]
|
def test_dark_print_on_the_bench_excerpt(self):
|
||||||
release = [stamp % 200000 + "cooling verdict clean after WARMUP; starting the run"]
|
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):
|
def on_post(path, form):
|
||||||
if path == "/settings" and form.get("cool_temp_start") not in (None, "0", "16"):
|
if path == "/settings" and form.get("cool_temp_start") not in (None, "0", "16"):
|
||||||
@@ -942,37 +1028,44 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
def press():
|
def press():
|
||||||
self.append(wait, delay=0.05)
|
self.append(wait, delay=0.05)
|
||||||
|
|
||||||
def released():
|
def refused():
|
||||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True,
|
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True,
|
||||||
"armed": False, "phase": "run"})
|
"armed": False, "phase": "idle"})
|
||||||
self.append(release + run, delay=0.0)
|
self.append(refuse, delay=0.0)
|
||||||
threading.Timer(3.0, released).start()
|
threading.Timer(2.5, refused).start()
|
||||||
hooks = {"press it. The print then waits": press}
|
hooks = {"press it. Nothing runs": press}
|
||||||
run_ = self.run_test(cloud.verdict_hold, hooks=hooks, test_id="cloud.verdict-hold")
|
run_ = self.run_test(cloud.verdict_refuse, hooks=hooks, test_id="cloud.verdict-refuse")
|
||||||
ev = run_.evidence
|
ev = run_.evidence
|
||||||
self.assertEqual(ev["gate"], 22.5)
|
self.assertEqual(ev["gate"], 29.5)
|
||||||
self.assertEqual(ev["warmup"]["verdict"], "WARMUP")
|
self.assertTrue(ev["latch_locked_at_button"])
|
||||||
self.assertTrue(ev["log"]["cooling verdict clean after WARMUP; starting the run"])
|
self.assertEqual(ev["latch_samples"]["unlocked"], 0)
|
||||||
self.assertIn(":completed", ev["print finished"])
|
self.assertGreaterEqual(ev["latch_samples"]["n"], 1)
|
||||||
posted = [f for p, f in self.fc.posts if p == "/settings"]
|
self.assertIn(":cancelled", ev["print finished"])
|
||||||
self.assertEqual(posted[0]["cool_temp_start"], "22.5")
|
posted = [f for p_, f in self.fc.posts if p_ == "/settings"]
|
||||||
self.assertEqual(posted[-1], {"cool_temp_min": "5", "cool_temp_start": "16"})
|
self.assertEqual(posted[0]["cool_temp_start"], "29.5")
|
||||||
self.assertTrue(any("PASS: the armed print waited" in l for l in run_.lines))
|
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.in_offline()
|
||||||
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
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["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"})
|
||||||
self.fc.state["cool"].update({"verdict": "WARMUP", "hold": True, "fire_ok": False, "armed": True})
|
self.fc.state["cool"].update({"verdict": "WARMUP", "hold": True, "fire_ok": False, "armed": True})
|
||||||
lines = fixture("pause")
|
pre, run = self.dark_parts()
|
||||||
pre, rest = cut(lines, "waiting for button")
|
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] %s machine:_verdict_wait "
|
||||||
pre = pre + [rest[0]]
|
wait = [stamp % (100000, "INFO") + "waiting on the cooling engine: WARMUP (no reason given)"]
|
||||||
_, run = cut(rest, "machine:_run_loop starting run")
|
refuse = [stamp % (200000, "WARNING") + "cooling hold held 60 s - relocking the laser",
|
||||||
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] INFO machine:_verdict_wait "
|
"2026-08-17T09:44:15.000000+00:00 gfcloud[1927] INFO basemachine:_finish_action print "
|
||||||
wait = [stamp % 100000 + "waiting on the cooling engine: WARMUP (no reason given)"]
|
"[9006]: finished with event \":cancelled\""]
|
||||||
self.offline_print_hooks(pre)
|
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 --------
|
# -- a paused print canceled by the lid, a running one by the app --------
|
||||||
def cancel_parts(self):
|
def cancel_parts(self):
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""cooling.aa-offset-calibrate is registered before the heater tools, so
|
"""cooling.aa-offset-calibrate is registered before the heater tools, so
|
||||||
a queue runs it first: a heater trial leaves a warm slug circulating past
|
a queue runs it first: a heater trial leaves a warm slug circulating past
|
||||||
the coolant sensors for minutes, and the tool's edges read it as
|
the coolant sensors for minutes, and the tool's edges read it as
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
|
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
|
||||||
under the real runner Context against a scripted machine: a fake
|
under the real runner Context against a scripted machine: a fake
|
||||||
forgectrl (/status fans, /cool/status phase and verdict, /settings with
|
forgectrl (/status fans, /cool/status phase and verdict, /settings with
|
||||||
@@ -729,3 +734,51 @@ class FanGateTests(unittest.TestCase):
|
|||||||
self.run_test()
|
self.run_test()
|
||||||
self.assertIn("lacks exhaust", str(cm.exception))
|
self.assertIn("lacks exhaust", str(cm.exception))
|
||||||
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
|
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
|
||||||
|
|
||||||
|
|
||||||
|
class FlowVerdictLogTests(unittest.TestCase):
|
||||||
|
"""cooling.flow-under-load finds the engine's verdict in what the log
|
||||||
|
gained since the job began, not in a count over a tail of fixed length:
|
||||||
|
there a new verdict line comes in at the bottom as an old one leaves at
|
||||||
|
the top, the count does not move, and a check that verified reads as
|
||||||
|
one that never judged."""
|
||||||
|
|
||||||
|
VERDICT = ("2026-09-19T21:37:46.869674+00:00 forgectrl[2796] INFO cool: coolant flow verified "
|
||||||
|
"(heater rise 11.5 C, dT 9.5 C; laser 1.6 off 13.1)\n")
|
||||||
|
OLD = ("2026-09-19T21:17:27.200840+00:00 forgectrl[965] INFO cool: coolant flow verified "
|
||||||
|
"(heater rise 9.5 C, dT 8.7 C)\n")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.mkdtemp(prefix="flowlog.")
|
||||||
|
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||||
|
self.path = os.path.join(self.dir, "forgectrl.log")
|
||||||
|
|
||||||
|
def write(self, text, mode="w"):
|
||||||
|
with open(self.path, mode, newline="\n") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
def test_only_the_text_after_the_offset_comes_back(self):
|
||||||
|
self.write(self.OLD + "filler\n" * 400)
|
||||||
|
off = os.path.getsize(self.path)
|
||||||
|
self.assertEqual(cooling._log_since(self.path, off), "")
|
||||||
|
self.write(self.VERDICT, mode="a")
|
||||||
|
text = cooling._log_since(self.path, off)
|
||||||
|
self.assertEqual(text, self.VERDICT)
|
||||||
|
m = cooling._load_verdict_rx().search(text)
|
||||||
|
self.assertEqual(m.group(1), "coolant flow verified")
|
||||||
|
self.assertEqual((m.group(2), m.group(5)), ("11.5", "1.6"))
|
||||||
|
|
||||||
|
def test_an_old_verdict_before_the_offset_is_not_the_new_one(self):
|
||||||
|
self.write(self.OLD)
|
||||||
|
off = os.path.getsize(self.path)
|
||||||
|
self.write("2026-09-19T21:36:40+00:00 forgectrl[2796] INFO cool: crash watch armed\n", mode="a")
|
||||||
|
self.assertIsNone(cooling._load_verdict_rx().search(cooling._log_since(self.path, off)))
|
||||||
|
|
||||||
|
def test_a_log_rotated_under_the_test_is_read_whole(self):
|
||||||
|
self.write(self.OLD + "filler\n" * 400)
|
||||||
|
off = os.path.getsize(self.path)
|
||||||
|
self.write(self.VERDICT) # the rotation: a new, shorter file
|
||||||
|
self.assertEqual(cooling._log_since(self.path, off), self.VERDICT)
|
||||||
|
|
||||||
|
def test_a_missing_log_reads_as_nothing(self):
|
||||||
|
self.assertEqual(cooling._log_since(os.path.join(self.dir, "none.log"), 0), "")
|
||||||
|
|||||||
+717
-677
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,11 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from forgetest import catalog
|
||||||
from forgetest.suite import image
|
from forgetest.suite import image
|
||||||
|
|
||||||
|
|
||||||
@@ -20,5 +26,61 @@ class KernelIdentTests(unittest.TestCase):
|
|||||||
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
|
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkBootTests(unittest.TestCase):
|
||||||
|
STANZA = ("auto wlan0\n"
|
||||||
|
"iface wlan0 inet dhcp\n"
|
||||||
|
"\twpa-driver nl80211\n"
|
||||||
|
"\tudhcpc_opts -x hostname:$(hostname)\n")
|
||||||
|
|
||||||
|
def test_the_test_is_registered_and_covers_nothing_by_design(self):
|
||||||
|
t = catalog.load_suite()["image.network-boot"]
|
||||||
|
self.assertEqual(t.kind, "auto")
|
||||||
|
self.assertEqual(t.covers, ()) # the interfaces file is layer content
|
||||||
|
|
||||||
|
def test_ifstate_names_the_interfaces_ifup_finished(self):
|
||||||
|
self.assertEqual(image.ifstate_names("lo=lo\nwlan0=wlan0\n"), {"lo", "wlan0"})
|
||||||
|
self.assertEqual(image.ifstate_names("lo=lo\n"), {"lo"})
|
||||||
|
self.assertEqual(image.ifstate_names(""), set())
|
||||||
|
self.assertEqual(image.ifstate_names(None), set())
|
||||||
|
|
||||||
|
def test_an_interfaces_file_without_a_dhcpv6_client_is_clean(self):
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces(self.STANZA), [])
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces(None), [])
|
||||||
|
|
||||||
|
def test_a_command_that_starts_a_dhcpv6_client_is_named(self):
|
||||||
|
text = self.STANZA + ("iface wlan0 inet6 manual\n"
|
||||||
|
"\tup udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid\n")
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces(text),
|
||||||
|
["up udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid"])
|
||||||
|
detached = "\tup start-stop-daemon -S -b -x /usr/bin/udhcpc6 -- -f -i wlan0\n"
|
||||||
|
self.assertEqual(len(image.dhcp6_in_interfaces(self.STANZA + detached)), 1)
|
||||||
|
|
||||||
|
def test_an_inet6_method_that_runs_a_client_is_named(self):
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 dhcp\n"),
|
||||||
|
["iface wlan0 inet6 dhcp"])
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 static\n"), [])
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet dhcp\n"), [])
|
||||||
|
|
||||||
|
def test_a_comment_starts_nothing(self):
|
||||||
|
text = self.STANZA + "# No DHCPv6 client runs; udhcpc6 is not on the image.\n"
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces(text), [])
|
||||||
|
|
||||||
|
def test_parse_stat_reads_the_parent_past_an_awkward_comm(self):
|
||||||
|
self.assertEqual(image.parse_stat("371 (udhcpc) S 1 371 371 0 -1 4194624"),
|
||||||
|
(371, "udhcpc", 1))
|
||||||
|
self.assertEqual(image.parse_stat("9 (a (b) c) S 7 9 9 0"), (9, "a (b) c", 7))
|
||||||
|
self.assertIsNone(image.parse_stat(""))
|
||||||
|
self.assertIsNone(image.parse_stat("12 (x) S"))
|
||||||
|
|
||||||
|
def test_link_addrs_sorts_one_interface_by_scope(self):
|
||||||
|
text = ("fe800000000000002e6b7dfffe0db00a 02 40 20 80 wlan0\n"
|
||||||
|
"26001700000000000000000000001234 02 80 00 00 wlan0\n"
|
||||||
|
"00000000000000000000000000000001 01 80 10 80 lo\n")
|
||||||
|
local, world = image.link_addrs(text, "wlan0")
|
||||||
|
self.assertEqual(local, ["fe800000000000002e6b7dfffe0db00a"])
|
||||||
|
self.assertEqual(world, ["26001700000000000000000000001234"])
|
||||||
|
self.assertEqual(image.link_addrs(text, "eth0"), ([], []))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""scripts/install-forgefirm.sh - the download that retries, and the log.
|
||||||
|
|
||||||
|
The installer runs once per machine, on factory firmware, over whatever
|
||||||
|
network the owner has. These tests run its own functions under sh with a
|
||||||
|
scripted curl in front of them: a transfer that fails resumes and is
|
||||||
|
tried again, the failures that waiting cannot fix end the tries at once,
|
||||||
|
and every step leaves a line in the install log in the log tree's own
|
||||||
|
format."""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers # noqa: F401 (sys.path)
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
SCRIPT = os.path.join(REPO, "scripts", "install-forgefirm.sh")
|
||||||
|
MARK = "# The install log, before anything can fail."
|
||||||
|
|
||||||
|
# The log tree's line format (forgetest/suite/logs.py keeps the same one).
|
||||||
|
LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d install\[\d+\] "
|
||||||
|
r"(EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) .+$")
|
||||||
|
|
||||||
|
# A curl that plays a script: each call takes the next "rc http bytes"
|
||||||
|
# line of $CURL_PLAN, appends that many bytes to the --output file the way
|
||||||
|
# a resumed transfer does, prints the HTTP code as -w '%{http_code}'
|
||||||
|
# would, records its arguments, and exits rc.
|
||||||
|
FAKE_CURL = r"""#!/bin/sh
|
||||||
|
echo "$*" >> "$CURL_CALLS"
|
||||||
|
OUT=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
[ "$1" = "--output" ] && OUT="$2"
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
LINE=$(sed -n 1p "$CURL_PLAN")
|
||||||
|
sed -i 1d "$CURL_PLAN"
|
||||||
|
set -- $LINE
|
||||||
|
[ "${3:-0}" -gt 0 ] && dd if=/dev/zero bs=1 count="$3" 2>/dev/null | tr '\0' 'x' >> "$OUT"
|
||||||
|
printf '%s' "$2"
|
||||||
|
exit "$1"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def functions():
|
||||||
|
"""The installer up to its first action: constants and functions."""
|
||||||
|
with open(SCRIPT, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
head, mark, _ = text.partition(MARK)
|
||||||
|
assert mark, "the installer lost the line the tests cut it at"
|
||||||
|
return head
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("sh") and os.name == "posix", "needs a POSIX sh")
|
||||||
|
class InstallerTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix="ffinstall-test.")
|
||||||
|
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
|
||||||
|
self.bin = os.path.join(self.tmp, "bin")
|
||||||
|
os.mkdir(self.bin)
|
||||||
|
for name, body in (("curl", FAKE_CURL), ("nslookup", "#!/bin/sh\nexit 1\n"),
|
||||||
|
("ip", "#!/bin/sh\nexit 0\n"), ("sleep", "#!/bin/sh\nexit 0\n")):
|
||||||
|
path = os.path.join(self.bin, name)
|
||||||
|
with open(path, "w", newline="\n") as f:
|
||||||
|
f.write(body)
|
||||||
|
os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR)
|
||||||
|
self.fw = os.path.join(self.tmp, "forgefirm.fw")
|
||||||
|
self.log = os.path.join(self.tmp, "log", "install.log")
|
||||||
|
self.calls = os.path.join(self.tmp, "curl.calls")
|
||||||
|
self.plan = os.path.join(self.tmp, "curl.plan")
|
||||||
|
|
||||||
|
def run_sh(self, body, plan=()):
|
||||||
|
with open(self.plan, "w", newline="\n") as f:
|
||||||
|
f.write("".join("%s\n" % line for line in plan))
|
||||||
|
open(self.calls, "w").close()
|
||||||
|
script = "%s\nFW_FILE='%s'\nLOG_DIR='%s'\nLOG_FILE='%s'\nmkdir -p \"$LOG_DIR\"\nLOG_OK=yes\n%s\n" % (
|
||||||
|
functions(), self.fw, os.path.dirname(self.log), self.log, body)
|
||||||
|
env = dict(os.environ, PATH=self.bin + os.pathsep + os.environ["PATH"],
|
||||||
|
CURL_PLAN=self.plan, CURL_CALLS=self.calls)
|
||||||
|
return subprocess.run(["sh", "-c", script], env=env, capture_output=True, text=True, timeout=60)
|
||||||
|
|
||||||
|
def download(self, plan):
|
||||||
|
r = self.run_sh('download_fw; echo "rc=$? why=$DL_WHY"', plan)
|
||||||
|
with open(self.calls) as f:
|
||||||
|
calls = f.read().splitlines()
|
||||||
|
return r.stdout, calls, self.log_lines()
|
||||||
|
|
||||||
|
def log_lines(self):
|
||||||
|
try:
|
||||||
|
with open(self.log) as f:
|
||||||
|
return f.read().splitlines()
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def test_a_clean_download_takes_one_try_and_its_name(self):
|
||||||
|
out, calls, log = self.download(["0 200 5000"])
|
||||||
|
self.assertIn("rc=0", out)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
self.assertEqual(os.path.getsize(self.fw), 5000)
|
||||||
|
self.assertFalse(os.path.exists(self.fw + ".part"))
|
||||||
|
self.assertTrue(any("complete on try 1, 5000 bytes" in line for line in log), log)
|
||||||
|
|
||||||
|
def test_a_failed_transfer_resumes_and_is_tried_again(self):
|
||||||
|
out, calls, log = self.download(["6 000 0", "56 200 3000", "0 206 2000"])
|
||||||
|
self.assertIn("rc=0", out)
|
||||||
|
self.assertEqual(len(calls), 3)
|
||||||
|
for c in calls: # every try resumes, bounded in time
|
||||||
|
self.assertIn("-C -", c)
|
||||||
|
self.assertIn("--connect-timeout", c)
|
||||||
|
self.assertIn("--speed-time", c)
|
||||||
|
self.assertEqual(os.path.getsize(self.fw), 5000) # 3000 kept, 2000 resumed
|
||||||
|
warns = [line for line in log if " WARNING download: try " in line]
|
||||||
|
self.assertEqual(len(warns), 2, log)
|
||||||
|
self.assertIn("did not resolve", warns[0])
|
||||||
|
self.assertIn("dropped mid-transfer (3000 bytes so far)", warns[1])
|
||||||
|
self.assertIn("Trying again in 5s (try 2 of 5)", out)
|
||||||
|
self.assertIn("Trying again in 15s (try 3 of 5)", out)
|
||||||
|
|
||||||
|
def test_the_tries_run_out(self):
|
||||||
|
out, calls, log = self.download(["28 000 0"] * 5)
|
||||||
|
self.assertIn("rc=1", out)
|
||||||
|
self.assertIn("timed out or stalled", out)
|
||||||
|
self.assertEqual(len(calls), 5)
|
||||||
|
self.assertFalse(os.path.exists(self.fw))
|
||||||
|
self.assertFalse(os.path.exists(self.fw + ".part"))
|
||||||
|
# each failed try leaves what the network looked like
|
||||||
|
self.assertEqual(sum("github.com does not resolve" in line for line in log), 5, log)
|
||||||
|
|
||||||
|
def test_a_missing_release_and_a_full_disk_end_the_tries_at_once(self):
|
||||||
|
out, calls, _ = self.download(["22 404 0"])
|
||||||
|
self.assertIn("rc=1", out)
|
||||||
|
self.assertIn("HTTP 404", out)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
out, calls, _ = self.download(["23 200 100"])
|
||||||
|
self.assertIn("rc=1", out)
|
||||||
|
self.assertIn("could not be written", out)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
|
||||||
|
def test_a_partial_file_the_server_will_not_resume_starts_over(self):
|
||||||
|
out, calls, _ = self.download(["56 200 4000", "22 416 0", "0 200 5000"])
|
||||||
|
self.assertIn("rc=0", out)
|
||||||
|
self.assertEqual(len(calls), 3)
|
||||||
|
self.assertEqual(os.path.getsize(self.fw), 5000) # not 9000: the stale part went
|
||||||
|
|
||||||
|
def test_a_tls_failure_names_the_clock(self):
|
||||||
|
out, _, _ = self.download(["60 000 0"] * 5)
|
||||||
|
self.assertRegex(out, r"TLS handshake failed \(a wrong clock does this: \d{4}-\d\d-\d\d")
|
||||||
|
|
||||||
|
def test_every_log_line_is_in_the_tree_format(self):
|
||||||
|
self.download(["7 000 0", "0 200 10"])
|
||||||
|
self.run_sh('log NOTICE "operator declined to continue; no changes made"')
|
||||||
|
lines = self.log_lines()
|
||||||
|
self.assertGreater(len(lines), 5)
|
||||||
|
for line in lines:
|
||||||
|
self.assertRegex(line, LINE_RE)
|
||||||
|
|
||||||
|
def test_die_leaves_the_reason_in_the_log(self):
|
||||||
|
r = self.run_sh('die "archiving slot 1 failed"; echo not-reached')
|
||||||
|
self.assertEqual(r.returncode, 1)
|
||||||
|
self.assertNotIn("not-reached", r.stdout)
|
||||||
|
self.assertTrue(any(" ERR install failed: archiving slot 1 failed" in line
|
||||||
|
for line in self.log_lines()))
|
||||||
|
|
||||||
|
def test_no_writable_log_never_fails_the_install(self):
|
||||||
|
r = self.run_sh('LOG_OK=""; log INFO "x"; LOG_OK=yes; LOG_FILE=/nonexistent/dir/x.log; '
|
||||||
|
'log INFO "y"; echo "still-running rc=$?"')
|
||||||
|
self.assertIn("still-running rc=0", r.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class InstallerShapeTests(unittest.TestCase):
|
||||||
|
"""What holds on any host, with no shell to run."""
|
||||||
|
|
||||||
|
def test_the_log_lives_in_the_tree_the_export_carries(self):
|
||||||
|
head = functions()
|
||||||
|
self.assertIn('LOG_DIR="/data/log/forgefirm/install"', head)
|
||||||
|
self.assertIn('LOG_FILE="$LOG_DIR/install.log"', head)
|
||||||
|
|
||||||
|
def test_the_download_is_the_retrying_one(self):
|
||||||
|
with open(SCRIPT, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
body = text.partition(MARK)[2]
|
||||||
|
self.assertIn("download_fw", body)
|
||||||
|
self.assertNotRegex(body, r"(?m)^\s*curl ") # no bare curl past the functions
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""wait_hv_off against a scripted charge-pump readback: a chain that
|
"""wait_hv_off against a scripted charge-pump readback: a chain that
|
||||||
releases inside the window passes, a chain held past it is the refusal,
|
releases inside the window passes, a chain held past it is the refusal,
|
||||||
and a chain already off costs no wait."""
|
and a chain already off costs no wait."""
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The dwell-gap verdict of laser.emission-witness over synthetic trails:
|
"""The dwell-gap verdict of laser.emission-witness over synthetic trails:
|
||||||
the button latch judged from the first emission through every sample
|
the button latch judged from the first emission through every sample
|
||||||
whose readback word shows the laser latch unlocked, both bits from that
|
whose readback word shows the laser latch unlocked, both bits from that
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The controller mode a test declares: the runner puts the machine there
|
"""The controller mode a test declares: the runner puts the machine there
|
||||||
before the test and the baseline keeps it there afterward.
|
before the test and the baseline keeps it there afterward.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The operator channel: notices, `ready`, `act`, and the precheck.
|
"""The operator channel: notices, `ready`, `act`, and the precheck.
|
||||||
|
|
||||||
A test asks for the operator's part in a way the page can show before
|
A test asks for the operator's part in a way the page can show before
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The page assembly: the ui/ files inlined in order, the gzipped install
|
"""The page assembly: the ui/ files inlined in order, the gzipped install
|
||||||
(what the dev image carries) assembling to the same bytes as a checkout,
|
(what the dev image carries) assembling to the same bytes as a checkout,
|
||||||
the token placeholder carried exactly once, and a missing marker refused
|
the token placeholder carried exactly once, and a missing marker refused
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The two queues: what they select, the order they run in, and where
|
"""The two queues: what they select, the order they run in, and where
|
||||||
they stop.
|
they stop.
|
||||||
|
|
||||||
@@ -84,6 +89,32 @@ class OrderTests(unittest.TestCase):
|
|||||||
self.assertEqual(sorted(out), ["s.a", "s.b"])
|
self.assertEqual(sorted(out), ["s.a", "s.b"])
|
||||||
|
|
||||||
|
|
||||||
|
class RunClockTests(unittest.TestCase):
|
||||||
|
"""The page shows the last run until the next one starts, so a
|
||||||
|
finished run's elapsed figure must be the time it took, not the time
|
||||||
|
since. Found on the bench reference, where a failed test went on
|
||||||
|
counting past 5000 s and read as still running."""
|
||||||
|
|
||||||
|
def test_a_finished_runs_clock_stops(self):
|
||||||
|
from forgetest.runner import Run
|
||||||
|
run = Run("test", "t.x", "t.x")
|
||||||
|
run.started = time.time() - 30
|
||||||
|
self.assertGreaterEqual(run.snapshot()["elapsed_s"], 30)
|
||||||
|
run.finished = {"result": "FAIL", "message": "", "duration_s": 31}
|
||||||
|
first = run.snapshot()["elapsed_s"]
|
||||||
|
time.sleep(1.1)
|
||||||
|
self.assertEqual(first, 31)
|
||||||
|
self.assertEqual(run.snapshot()["elapsed_s"], 31)
|
||||||
|
|
||||||
|
def test_a_running_runs_clock_ticks(self):
|
||||||
|
from forgetest.runner import Run
|
||||||
|
run = Run("test", "t.x", "t.x")
|
||||||
|
run.started = time.time() - 5
|
||||||
|
first = run.snapshot()["elapsed_s"]
|
||||||
|
time.sleep(1.1)
|
||||||
|
self.assertGreater(run.snapshot()["elapsed_s"], first)
|
||||||
|
|
||||||
|
|
||||||
class QueueTests(unittest.TestCase):
|
class QueueTests(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""The release artifact carries no machine identity (redact.py).
|
||||||
|
|
||||||
|
The artifact is committed to a public repository. These hold the exporter
|
||||||
|
to that, and to not redacting what the gate recomputes.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from forgetest.redact import scrub
|
||||||
|
|
||||||
|
|
||||||
|
class RedactTests(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_keyed_identity_is_replaced(self):
|
||||||
|
out = scrub({"gf_serial": "15232020", "machine_id": "JTY-876",
|
||||||
|
"hostname": "forgefirm-b00a"})
|
||||||
|
self.assertNotIn("15232020", json.dumps(out))
|
||||||
|
self.assertNotIn("JTY-876", json.dumps(out))
|
||||||
|
self.assertNotIn("forgefirm-b00a", json.dumps(out))
|
||||||
|
|
||||||
|
def test_a_known_value_is_replaced_in_free_text_too(self):
|
||||||
|
# A hostname in a log line is the same leak as one in its own field.
|
||||||
|
out = scrub({"evidence": {"hostname": "forgefirm-b00a"},
|
||||||
|
"log": ["set hostname 'forgefirm-b00a' ok"]})
|
||||||
|
self.assertNotIn("forgefirm-b00a", json.dumps(out))
|
||||||
|
|
||||||
|
def test_addresses_and_macs_go(self):
|
||||||
|
out = scrub(["ping 172.16.1.97", "wlan0 2C:6B:7D:0D:B0:0A up"])
|
||||||
|
t = json.dumps(out)
|
||||||
|
self.assertNotIn("172.16.1.97", t)
|
||||||
|
self.assertNotIn("2C:6B:7D:0D:B0:0A", t)
|
||||||
|
|
||||||
|
def test_loopback_is_kept(self):
|
||||||
|
self.assertIn("127.0.0.1", json.dumps(scrub(["curl http://127.0.0.1:8090/"])))
|
||||||
|
|
||||||
|
def test_numbering_is_stable_within_an_export(self):
|
||||||
|
out = scrub(["a 10.0.0.5", "b 10.0.0.6", "c 10.0.0.5"])
|
||||||
|
self.assertEqual(out[0].split()[1], out[2].split()[1])
|
||||||
|
self.assertNotEqual(out[0].split()[1], out[1].split()[1])
|
||||||
|
|
||||||
|
def test_integrity_values_survive(self):
|
||||||
|
# The gate recomputes these; a long hex blob is what they look like.
|
||||||
|
rec = {"fingerprint": "a" * 64, "manifest_sha": "b" * 64,
|
||||||
|
"source_sha": "c" * 64, "sha256": "d" * 64,
|
||||||
|
"catalog_hash": "e" * 64, "identity_sha": "f" * 64}
|
||||||
|
self.assertEqual(scrub(rec), rec)
|
||||||
|
|
||||||
|
def test_a_clock_time_is_not_an_address(self):
|
||||||
|
out = scrub(["2026-09-18T18:50:11Z baseline: pre: clean"])
|
||||||
|
self.assertIn("18:50:11", out[0])
|
||||||
|
|
||||||
|
def test_result_fields_the_gate_reads_are_untouched(self):
|
||||||
|
rec = {"result": "PASS", "ts": "2026-09-18T18:50:11Z",
|
||||||
|
"campaign": "c-1", "fingerprint": "a" * 64}
|
||||||
|
self.assertEqual(scrub(rec), rec)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""What keeps the page answering the operator instead of the timer.
|
"""What keeps the page answering the operator instead of the timer.
|
||||||
|
|
||||||
Two things went wrong on the bench and are pinned here:
|
Two things went wrong on the bench and are pinned here:
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Runner + HTTP API end to end on localhost with a fake catalog and a
|
"""Runner + HTTP API end to end on localhost with a fake catalog and a
|
||||||
fake bench tool. No hardware, no forgectrl."""
|
fake bench tool. No hardware, no forgectrl."""
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The setup's checks in the catalog: registered as automatic tests with
|
"""The setup's checks in the catalog: registered as automatic tests with
|
||||||
no precheck, the takeover ones marked so, every covers map naming the
|
no precheck, the takeover ones marked so, every covers map naming the
|
||||||
check runner; and the check driver itself against the fake daemon: a
|
check runner; and the check driver itself against the fake daemon: a
|
||||||
start, a prompt answered, the result taken, the record read at version
|
start, the run polled to its end with no question asked, the result
|
||||||
1, and the settings a check may write put back as found."""
|
taken, the record read at version 1, and the settings read as before."""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
@@ -12,8 +17,8 @@ import helpers # noqa: E402
|
|||||||
from forgetest import catalog # noqa: E402
|
from forgetest import catalog # noqa: E402
|
||||||
from forgetest.runner import Context, Run # noqa: E402
|
from forgetest.runner import Context, Run # noqa: E402
|
||||||
|
|
||||||
IDS = ["commission.check-switches", "commission.check-sensors", "commission.check-airflow",
|
IDS = ["setup.check-switches", "setup.check-sensors", "setup.check-airflow",
|
||||||
"commission.check-cameras", "commission.check-motion", "commission.check-flow-verify"]
|
"setup.check-cameras", "setup.check-motion", "setup.check-flow-verify"]
|
||||||
|
|
||||||
|
|
||||||
class RegistrationTests(unittest.TestCase):
|
class RegistrationTests(unittest.TestCase):
|
||||||
@@ -25,11 +30,11 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
# bench fixture performs; the rest need no hands at all.
|
# bench fixture performs; the rest need no hands at all.
|
||||||
for tid in IDS:
|
for tid in IDS:
|
||||||
self.assertIn(tid, self.reg)
|
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.assertEqual(self.reg[tid].kind, "operator", tid)
|
||||||
self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid)
|
self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid)
|
||||||
for tid in ("commission.check-sensors", "commission.check-airflow", "commission.check-motion",
|
for tid in ("setup.check-sensors", "setup.check-airflow", "setup.check-motion",
|
||||||
"commission.check-flow-verify"):
|
"setup.check-flow-verify"):
|
||||||
self.assertEqual(self.reg[tid].kind, "auto", tid)
|
self.assertEqual(self.reg[tid].kind, "auto", tid)
|
||||||
|
|
||||||
def test_no_check_asks_the_operator_to_prepare_anything(self):
|
def test_no_check_asks_the_operator_to_prepare_anything(self):
|
||||||
@@ -37,7 +42,7 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
|
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
|
||||||
|
|
||||||
def test_the_checks_that_take_the_machine_say_so(self):
|
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)
|
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||||
|
|
||||||
def test_every_check_covers_the_runner_and_the_page(self):
|
def test_every_check_covers_the_runner_and_the_page(self):
|
||||||
@@ -62,9 +67,17 @@ class SensorsCheckTests(unittest.TestCase):
|
|||||||
"shots": {"lid": False, "head": False}}
|
"shots": {"lid": False, "head": False}}
|
||||||
self.versions = {}
|
self.versions = {}
|
||||||
self.answers = []
|
self.answers = []
|
||||||
|
self.polls = 0
|
||||||
|
|
||||||
def on_get(path, q):
|
def on_get(path, q):
|
||||||
if path == "/wiz/dark":
|
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)
|
return 200, dict(self.dark)
|
||||||
if path == "/wiz":
|
if path == "/wiz":
|
||||||
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
|
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
|
||||||
@@ -74,19 +87,11 @@ class SensorsCheckTests(unittest.TestCase):
|
|||||||
s = self.fake.state["settings"]
|
s = self.fake.state["settings"]
|
||||||
if path == "/wiz/sensors/start":
|
if path == "/wiz/sensors/start":
|
||||||
self.dark.update({"id": "sensors", "running": True, "phase": "sampling",
|
self.dark.update({"id": "sensors", "running": True, "phase": "sampling",
|
||||||
"prompt": {"seq": 7, "id": "room-temp", "kind": "number",
|
"prompt": None})
|
||||||
"text": "Optional: the room temperature.",
|
|
||||||
"options": ["Set", "Skip"]}})
|
|
||||||
return 200, {"started": True, "id": "sensors"}
|
return 200, {"started": True, "id": "sensors"}
|
||||||
if path == "/wiz/sensors/answer":
|
if path == "/wiz/sensors/answer":
|
||||||
self.answers.append(dict(form))
|
self.answers.append(dict(form))
|
||||||
if form.get("seq") != "7":
|
return 409, {"error": "no such prompt is open"}
|
||||||
return 409, {"error": "no such prompt is open"}
|
|
||||||
if form.get("value") == "Skip":
|
|
||||||
self.dark.update({"running": False, "prompt": None, "progress": 100,
|
|
||||||
"result": dict(self.RESULT)})
|
|
||||||
self.versions["sensors"] = 1
|
|
||||||
return 200, {"ok": True}
|
|
||||||
if path == "/settings":
|
if path == "/settings":
|
||||||
for k, v in form.items():
|
for k, v in form.items():
|
||||||
if v == "":
|
if v == "":
|
||||||
@@ -101,15 +106,35 @@ class SensorsCheckTests(unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.fake.stop()
|
self.fake.stop()
|
||||||
|
|
||||||
def test_start_prompt_answer_result_and_restore(self):
|
def test_start_no_question_result_and_settings_untouched(self):
|
||||||
t = catalog.load_suite()["commission.check-sensors"]
|
t = catalog.load_suite()["setup.check-sensors"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
t.fn(Context(run, None, t))
|
t.fn(Context(run, None, t))
|
||||||
self.assertIn(("/wiz/sensors/start", {}), [(p, f) for p, f in self.fake.posts])
|
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["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.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__":
|
if __name__ == "__main__":
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The setup's sheet in the catalog: one live test on the button with a
|
"""The setup's sheet in the catalog: one live test on the button with a
|
||||||
piece of wood at hand, its covers map naming the live runner, the
|
piece of wood at hand, its covers map naming the live runner, the
|
||||||
renderer, the sender, the page, and the driver's laser module; and the
|
renderer, the sender, the page, and the driver's laser module; and the
|
||||||
@@ -14,10 +19,47 @@ sys.path.insert(0, os.path.dirname(__file__))
|
|||||||
import helpers # noqa: E402
|
import helpers # noqa: E402
|
||||||
from forgetest import catalog # noqa: E402
|
from forgetest import catalog # noqa: E402
|
||||||
from forgetest.runner import Context, Run # 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"]
|
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}
|
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,
|
# 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
|
# options) and the result it ends with. A "jog" prompt repeats until the
|
||||||
@@ -37,7 +79,9 @@ SCRIPT = {
|
|||||||
"steps_per_mm": 2.922, "max_height_mm": 13.81,
|
"steps_per_mm": 2.922, "max_height_mm": 13.81,
|
||||||
"focus_range_mm": {"min": 2.17, "max": 13.81},
|
"focus_range_mm": {"min": 2.17, "max": 13.81},
|
||||||
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
|
"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"]),
|
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
|
||||||
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
|
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
|
||||||
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
|
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
|
||||||
@@ -50,10 +94,11 @@ SCRIPT = {
|
|||||||
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
|
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
|
||||||
"k_cw": 2.78e-5, "emission": EMISSION}),
|
"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 = {
|
WRITES = {
|
||||||
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14",
|
"laser.focus": {"lens_hall_edge_z_mm": "6.96"},
|
||||||
"lens_stop_above_steps": "20"},
|
|
||||||
"laser.floor": {"laser_floor_density": "10"},
|
"laser.floor": {"laser_floor_density": "10"},
|
||||||
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
|
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
|
||||||
"laser.corner": {"laser_corner_gamma": "1.5"},
|
"laser.corner": {"laser_corner_gamma": "1.5"},
|
||||||
@@ -67,7 +112,7 @@ FOUND = {"lens_hall_edge_z_mm": "3.35", "lens_stop_below_steps": "10", "lens_sto
|
|||||||
|
|
||||||
class RegistrationTests(unittest.TestCase):
|
class RegistrationTests(unittest.TestCase):
|
||||||
def setUp(self):
|
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):
|
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
|
||||||
t = self.t
|
t = self.t
|
||||||
@@ -78,12 +123,12 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
self.assertTrue(t.hands)
|
self.assertTrue(t.hands)
|
||||||
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
|
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
|
||||||
self.assertIsNone(getattr(t, "precheck", None))
|
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)
|
self.assertIn(want, t.requires)
|
||||||
|
|
||||||
def test_no_other_sheet_test_remains(self):
|
def test_no_other_sheet_test_remains(self):
|
||||||
ids = [i for i in catalog.load_suite() if i.startswith("commission.sheet")]
|
ids = [i for i in catalog.load_suite() if i.startswith("setup.sheet")]
|
||||||
self.assertEqual(ids, ["commission.sheet"])
|
self.assertEqual(ids, ["setup.sheet"])
|
||||||
|
|
||||||
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
|
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
|
||||||
covers = set(self.t.covers)
|
covers = set(self.t.covers)
|
||||||
@@ -97,7 +142,7 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
text = " ".join(self.t.steps).lower()
|
text = " ".join(self.t.steps).lower()
|
||||||
self.assertIn("press the machine's button once", text)
|
self.assertIn("press the machine's button once", text)
|
||||||
self.assertNotIn("when the page", text)
|
self.assertNotIn("when the page", text)
|
||||||
self.assertEqual(len(commission_sheet.CARDS), 6)
|
self.assertEqual(len(setup_sheet.CARDS), 6)
|
||||||
|
|
||||||
|
|
||||||
class ScriptedDaemon:
|
class ScriptedDaemon:
|
||||||
@@ -115,9 +160,11 @@ class ScriptedDaemon:
|
|||||||
self.versions = {}
|
self.versions = {}
|
||||||
self.answers = {} # wid -> [values]
|
self.answers = {} # wid -> [values]
|
||||||
self.order = [] # the wizards as started
|
self.order = [] # the wizards as started
|
||||||
|
self.settings_at_start = {} # wid -> the settings as the wizard found them
|
||||||
self.previews = []
|
self.previews = []
|
||||||
self.queue = []
|
self.queue = []
|
||||||
self.seq = 0
|
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_get = self.on_get
|
||||||
fake.on_post = self.on_post
|
fake.on_post = self.on_post
|
||||||
|
|
||||||
@@ -130,6 +177,10 @@ class ScriptedDaemon:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def on_get(self, path, q):
|
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":
|
if path == "/wiz/dark":
|
||||||
return 200, dict(self.dark)
|
return 200, dict(self.dark)
|
||||||
if path == "/wiz":
|
if path == "/wiz":
|
||||||
@@ -140,8 +191,18 @@ class ScriptedDaemon:
|
|||||||
return 404, {"error": "no such card"}
|
return 404, {"error": "no such card"}
|
||||||
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
|
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
|
||||||
if path == "/wiz/sheet.gcode":
|
if path == "/wiz/sheet.gcode":
|
||||||
# the frame and the text are M4 at the mark dose, as the daemon serves them
|
# the frame and the text are M4 at the mark dose, as the daemon
|
||||||
return 200, b"; mock\nM4 S400\nG0 X60 Y42\nG1 X120 Y42 F3000\nM5\n", "text/plain"
|
# 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
|
return None
|
||||||
|
|
||||||
def on_post(self, path, form):
|
def on_post(self, path, form):
|
||||||
@@ -151,6 +212,8 @@ class ScriptedDaemon:
|
|||||||
if wid not in SCRIPT:
|
if wid not in SCRIPT:
|
||||||
return 404, {"error": "no such wizard"}
|
return 404, {"error": "no such wizard"}
|
||||||
self.order.append(wid)
|
self.order.append(wid)
|
||||||
|
self.settings_at_start[wid] = dict(s)
|
||||||
|
s.update(START_WRITES.get(wid, {}))
|
||||||
self.queue = list(SCRIPT[wid][0])
|
self.queue = list(SCRIPT[wid][0])
|
||||||
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
|
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
|
||||||
self.open_next()
|
self.open_next()
|
||||||
@@ -188,7 +251,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
self.fake.stop()
|
self.fake.stop()
|
||||||
|
|
||||||
def test_the_whole_sheet_on_one_piece(self):
|
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 = Run("test", t.id, t.title)
|
||||||
run.unattended = True # the ready gate passes; the arm cue is a notice
|
run.unattended = True # the ready gate passes; the arm cue is a notice
|
||||||
t.fn(Context(run, None, t))
|
t.fn(Context(run, None, t))
|
||||||
@@ -199,7 +262,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
|
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
|
||||||
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
|
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
|
||||||
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
|
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
|
||||||
self.assertEqual(d.previews, LIVE)
|
self.assertEqual(d.previews, PREVIEWS)
|
||||||
cards = run.evidence["cards"]
|
cards = run.evidence["cards"]
|
||||||
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
|
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
|
||||||
for wid in LIVE:
|
for wid in LIVE:
|
||||||
@@ -208,6 +271,24 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
self.assertNotIn("emission", run.evidence)
|
self.assertNotIn("emission", run.evidence)
|
||||||
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
|
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
|
||||||
self.assertEqual(run.evidence["settings_before"], FOUND)
|
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():
|
for k, v in FOUND.items():
|
||||||
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
|
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
|
||||||
|
|
||||||
@@ -218,7 +299,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
place_prompts, place_result = SCRIPT["sheet.place"]
|
place_prompts, place_result = SCRIPT["sheet.place"]
|
||||||
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=3.175))
|
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=3.175))
|
||||||
try:
|
try:
|
||||||
t = catalog.load_suite()["commission.sheet"]
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
run.unattended = True
|
run.unattended = True
|
||||||
t.fn(Context(run, None, t))
|
t.fn(Context(run, None, t))
|
||||||
@@ -234,7 +315,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
place_prompts, place_result = SCRIPT["sheet.place"]
|
place_prompts, place_result = SCRIPT["sheet.place"]
|
||||||
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=0.0))
|
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=0.0))
|
||||||
try:
|
try:
|
||||||
t = catalog.load_suite()["commission.sheet"]
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
run.unattended = True
|
run.unattended = True
|
||||||
with self.assertRaises(Failed) as cm:
|
with self.assertRaises(Failed) as cm:
|
||||||
@@ -250,7 +331,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
SCRIPT_DARK["emission"] = {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0}
|
SCRIPT_DARK["emission"] = {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0}
|
||||||
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], SCRIPT_DARK)
|
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], SCRIPT_DARK)
|
||||||
try:
|
try:
|
||||||
t = catalog.load_suite()["commission.sheet"]
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
run.unattended = True
|
run.unattended = True
|
||||||
with self.assertRaises(Failed):
|
with self.assertRaises(Failed):
|
||||||
@@ -261,6 +342,44 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -1,28 +1,31 @@
|
|||||||
"""The commission.* suite on the host: the registration (ids, kinds, the
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""The setup.* suite on the host: the registration (ids, kinds, the
|
||||||
takeover tests, the operator tests' hands), the record builders, 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
|
gated supervisor, and the cloud-off surface test driven end to end
|
||||||
against the fake daemon."""
|
against the fake daemon."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import helpers
|
import helpers
|
||||||
from forgetest import baseline, catalog
|
from forgetest import baseline, catalog
|
||||||
from forgetest.runner import Context, Run
|
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",
|
IDS = ("setup.gate-blocks-controllers", "setup.override-until-reboot",
|
||||||
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes",
|
"setup.advisories-rehash", "setup.account-login", "setup.https-only-writes",
|
||||||
"commission.ssh-until-reboot", "commission.cloud-disabled-surface",
|
"setup.ssh-until-reboot", "setup.cloud-disabled-surface",
|
||||||
"commission.factory-return", "commission.mdns-announce", "commission.first-run-flow",
|
"setup.factory-return", "setup.machine-name", "setup.first-run-flow",
|
||||||
"commission.first-run-page", "commission.what-changed", "commission.record-export",
|
"setup.first-run-page", "setup.what-changed", "setup.record-export",
|
||||||
"commission.mirror")
|
"setup.mirror")
|
||||||
OPERATOR = ("commission.first-run-flow", "commission.first-run-page")
|
OPERATOR = ("setup.first-run-flow", "setup.first-run-page")
|
||||||
|
|
||||||
|
|
||||||
class RegistrationTests(unittest.TestCase):
|
class RegistrationTests(unittest.TestCase):
|
||||||
@@ -37,49 +40,49 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
for tid in IDS:
|
for tid in IDS:
|
||||||
if tid not in OPERATOR:
|
if tid not in OPERATOR:
|
||||||
self.assertEqual(kinds[tid], "auto", tid)
|
self.assertEqual(kinds[tid], "auto", tid)
|
||||||
self.assertNotIn("commission.root-ssh-refused", self.reg) # folded into ssh-until-reboot
|
self.assertNotIn("setup.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.wizard-first-run", self.reg) # split into the flow and the page
|
||||||
|
|
||||||
def test_the_record_swapping_tests_are_takeovers(self):
|
def test_the_record_swapping_tests_are_takeovers(self):
|
||||||
for tid in ("commission.gate-blocks-controllers", "commission.advisories-rehash",
|
for tid in ("setup.gate-blocks-controllers", "setup.advisories-rehash",
|
||||||
"commission.first-run-flow", "commission.first-run-page",
|
"setup.first-run-flow", "setup.first-run-page",
|
||||||
"commission.what-changed", "commission.mirror"):
|
"setup.what-changed", "setup.mirror"):
|
||||||
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
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):
|
def test_the_lifecycle_tests_cover_their_files(self):
|
||||||
changed = set(self.reg["commission.what-changed"].covers)
|
changed = set(self.reg["setup.what-changed"].covers)
|
||||||
export = set(self.reg["commission.record-export"].covers)
|
export = set(self.reg["setup.record-export"].covers)
|
||||||
mirror = set(self.reg["commission.mirror"].covers)
|
mirror = set(self.reg["setup.mirror"].covers)
|
||||||
self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
|
self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
|
||||||
self.assertIn(("forgectrl", "src/recordhtml.*"), export)
|
self.assertIn(("forgectrl", "src/recordhtml.*"), export)
|
||||||
self.assertIn(("forgectrl", "src/logs.*"), export)
|
self.assertIn(("forgectrl", "src/logs.*"), export)
|
||||||
self.assertIn(("forgectrl", "src/wizdark.*"), mirror)
|
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):
|
def test_flag_of_reads_the_status_document(self):
|
||||||
w = {"required": ["airflow", {"id": "laser.floor", "reason": "the tube was replaced"}],
|
w = {"required": ["airflow", {"id": "laser.floor", "reason": "the tube was replaced"}],
|
||||||
"recommended": [{"id": "laser.focus", "reason": "the tray 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(setup.flag_of(w, "required", "laser.floor"), "the tube was replaced")
|
||||||
self.assertEqual(commission.flag_of(w, "recommended", "laser.focus"), "the tray was replaced")
|
self.assertEqual(setup.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(setup.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(setup.flag_of(w, "required", "laser.focus"))
|
||||||
self.assertIsNone(commission.flag_of({}, "recommended", "x"))
|
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):
|
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.assertEqual(list(flow.actions), ["button"])
|
||||||
self.assertFalse(flow.hands)
|
self.assertFalse(flow.hands)
|
||||||
self.assertTrue(flow.fixture_runnable(("button", "lid", "interlock")))
|
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.assertTrue(page.hands)
|
||||||
self.assertFalse(page.fixture_runnable(("button", "lid", "interlock")))
|
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):
|
def test_the_first_run_split_keeps_the_backend_and_the_page_apart(self):
|
||||||
flow = set(self.reg["commission.first-run-flow"].covers)
|
flow = set(self.reg["setup.first-run-flow"].covers)
|
||||||
page = set(self.reg["commission.first-run-page"].covers)
|
page = set(self.reg["setup.first-run-page"].covers)
|
||||||
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"),
|
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
|
||||||
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
|
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
|
||||||
self.assertIn(want, flow, want)
|
self.assertIn(want, flow, want)
|
||||||
self.assertNotIn(want, page, want)
|
self.assertNotIn(want, page, want)
|
||||||
@@ -87,33 +90,33 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
self.assertIn(want, page, want)
|
self.assertIn(want, page, want)
|
||||||
|
|
||||||
def test_the_only_attended_tests_need_a_workstation_or_the_sheet(self):
|
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
|
# person: the page walk, the print from the Glowforge app, and
|
||||||
# the sheet. Everything else runs from the queue.
|
# 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")))
|
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
|
||||||
self.assertEqual(attended, ["commission.cloud-header-capture", "commission.first-run-page",
|
self.assertEqual(attended, ["setup.cloud-header-capture", "setup.first-run-page",
|
||||||
"commission.sheet"])
|
"setup.sheet"])
|
||||||
|
|
||||||
def test_the_factory_return_never_runs_the_return(self):
|
def test_the_factory_return_never_runs_the_return(self):
|
||||||
import inspect
|
import inspect
|
||||||
t = self.reg["commission.factory-return"]
|
t = self.reg["setup.factory-return"]
|
||||||
src = inspect.getsource(t.fn)
|
src = inspect.getsource(t.fn)
|
||||||
self.assertNotIn('"confirm": "1"', src) # the only argument that starts the return
|
self.assertNotIn('"confirm": "1"', src) # the only argument that starts the return
|
||||||
self.assertIn('"confirm": "0"', src)
|
self.assertIn('"confirm": "0"', src)
|
||||||
self.assertEqual(t.kind, "auto") # the return itself is a bench drill, not a test
|
self.assertEqual(t.kind, "auto") # the return itself is a bench drill, not a test
|
||||||
self.assertFalse(t.hands)
|
self.assertFalse(t.hands)
|
||||||
|
|
||||||
def test_mdns_covers_nothing_by_design(self):
|
def test_the_machine_name_covers_nothing_by_design(self):
|
||||||
self.assertEqual(self.reg["commission.mdns-announce"].covers, ())
|
self.assertEqual(self.reg["setup.machine-name"].covers, ())
|
||||||
|
|
||||||
def test_the_login_test_makes_its_own_account(self):
|
def test_the_login_test_makes_its_own_account(self):
|
||||||
# No bench credentials, no precheck: the test installs a temporary
|
# No bench credentials, no precheck: the test installs a temporary
|
||||||
# account under a takeover and restores the real one.
|
# 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.assertIsNone(getattr(t, "precheck", None))
|
||||||
self.assertEqual(t.hardware, "takeover")
|
self.assertEqual(t.hardware, "takeover")
|
||||||
with open(commission.__file__) as f:
|
with open(setup.__file__) as f:
|
||||||
src = f.read()
|
src = f.read()
|
||||||
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
|
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
|
||||||
self.assertNotIn(k, src)
|
self.assertNotIn(k, src)
|
||||||
@@ -126,8 +129,8 @@ class RecordTests(unittest.TestCase):
|
|||||||
"wizards": [{"id": "advisories", "version": 1}, {"id": "account", "version": 1},
|
"wizards": [{"id": "advisories", "version": 1}, {"id": "account", "version": 1},
|
||||||
{"id": "machine", "version": 2}]}
|
{"id": "machine", "version": 2}]}
|
||||||
|
|
||||||
def test_complete_record_counts_as_commissioned(self):
|
def test_complete_record_counts_as_set_up(self):
|
||||||
rec = commission.complete_record(self.STATUS)
|
rec = setup.complete_record(self.STATUS)
|
||||||
self.assertEqual(rec["schema"], 1)
|
self.assertEqual(rec["schema"], 1)
|
||||||
self.assertEqual(rec["advisories"]["safety-and-risk"]["hash"], "a" * 64)
|
self.assertEqual(rec["advisories"]["safety-and-risk"]["hash"], "a" * 64)
|
||||||
self.assertEqual(rec["advisories"]["safety-and-risk"]["method"], "typed")
|
self.assertEqual(rec["advisories"]["safety-and-risk"]["method"], "typed")
|
||||||
@@ -141,16 +144,16 @@ class RecordTests(unittest.TestCase):
|
|||||||
def test_complete_record_keeps_the_base_account_and_machine(self):
|
def test_complete_record_keeps_the_base_account_and_machine(self):
|
||||||
base = {"account": {"name": "owner", "uid": 1000, "created": "x"}, "machine": {"model": "pro"},
|
base = {"account": {"name": "owner", "uid": 1000, "created": "x"}, "machine": {"model": "pro"},
|
||||||
"sheet_id": "KKKKK-LLLLL", "created": "then"}
|
"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["account"]["name"], "owner")
|
||||||
self.assertEqual(rec["machine"], {"model": "pro"})
|
self.assertEqual(rec["machine"], {"model": "pro"})
|
||||||
self.assertEqual(rec["sheet_id"], "KKKKK-LLLLL")
|
self.assertEqual(rec["sheet_id"], "KKKKK-LLLLL")
|
||||||
self.assertEqual(rec["created"], "then")
|
self.assertEqual(rec["created"], "then")
|
||||||
|
|
||||||
def test_without_wizards_keeps_consent_and_account(self):
|
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"}}
|
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["wizards"], {})
|
||||||
self.assertEqual(out["flags"], {})
|
self.assertEqual(out["flags"], {})
|
||||||
self.assertEqual(out["advisories"], rec["advisories"])
|
self.assertEqual(out["advisories"], rec["advisories"])
|
||||||
@@ -160,14 +163,14 @@ class RecordTests(unittest.TestCase):
|
|||||||
def test_write_file_and_remove(self):
|
def test_write_file_and_remove(self):
|
||||||
tmp = tempfile.mkdtemp(prefix="forgetest-comm-")
|
tmp = tempfile.mkdtemp(prefix="forgetest-comm-")
|
||||||
try:
|
try:
|
||||||
p = os.path.join(tmp, "sub", "commissioning.json")
|
p = os.path.join(tmp, "sub", "setup.json")
|
||||||
commission.write_file(p, b"{}\n")
|
setup.write_file(p, b"{}\n")
|
||||||
self.assertEqual(commission.read_file(p), b"{}\n")
|
self.assertEqual(setup.read_file(p), b"{}\n")
|
||||||
if os.name == "posix": # a mode means nothing on a Windows host
|
if os.name == "posix": # a mode means nothing on a Windows host
|
||||||
self.assertEqual(oct(os.stat(p).st_mode & 0o777), oct(0o600))
|
self.assertEqual(oct(os.stat(p).st_mode & 0o777), oct(0o600))
|
||||||
commission.write_file(p, None)
|
setup.write_file(p, None)
|
||||||
self.assertIsNone(commission.read_file(p))
|
self.assertIsNone(setup.read_file(p))
|
||||||
commission.write_file(p, None) # a second remove is silent
|
setup.write_file(p, None) # a second remove is silent
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmp, ignore_errors=True)
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
@@ -175,72 +178,82 @@ class RecordTests(unittest.TestCase):
|
|||||||
os.environ["FORGECTRL_DATA_DIR"] = "/tmp/ffdata"
|
os.environ["FORGECTRL_DATA_DIR"] = "/tmp/ffdata"
|
||||||
os.environ["GF_RUN_DIR"] = "/tmp/ffrun"
|
os.environ["GF_RUN_DIR"] = "/tmp/ffrun"
|
||||||
try:
|
try:
|
||||||
self.assertEqual(commission.record_path(), "/tmp/ffdata/commissioning.json")
|
self.assertEqual(setup.record_path(), "/tmp/ffdata/setup.json")
|
||||||
self.assertEqual(commission.users_path(), "/tmp/ffdata/users")
|
self.assertEqual(setup.users_path(), "/tmp/ffdata/users")
|
||||||
self.assertEqual(commission.override_path(), "/tmp/ffrun/commissioning-override")
|
self.assertEqual(setup.override_path(), "/tmp/ffrun/setup-override")
|
||||||
self.assertEqual(commission.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
|
self.assertEqual(setup.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
|
||||||
finally:
|
finally:
|
||||||
os.environ.pop("FORGECTRL_DATA_DIR", None)
|
os.environ.pop("FORGECTRL_DATA_DIR", None)
|
||||||
os.environ.pop("GF_RUN_DIR", None)
|
os.environ.pop("GF_RUN_DIR", None)
|
||||||
|
|
||||||
|
|
||||||
class MdnsTests(unittest.TestCase):
|
class MachineNameTests(unittest.TestCase):
|
||||||
def test_query_is_a_unicast_response_question(self):
|
def setUp(self):
|
||||||
q = commission.mdns_query("forgefirm.local", qid=0x1234)
|
self.root = tempfile.mkdtemp()
|
||||||
qid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", q[:12])
|
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||||
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))
|
|
||||||
|
|
||||||
def _response(self, qid, name_bytes, addr, flags=0x8400, extra=b""):
|
def _net(self, **devs):
|
||||||
q = b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 1)
|
"""A /sys/class/net tree; setup.mac_suffix reads it through
|
||||||
rr = name_bytes + struct.pack(">HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(addr)
|
setup.read_file, which takes an absolute path."""
|
||||||
return struct.pack(">HHHHHH", qid, flags, 1, 1, 0, 0) + q + rr + extra
|
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):
|
def read(path):
|
||||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
head = "/sys/class/net/"
|
||||||
self.assertEqual(commission.mdns_answers(pkt, 7), [("forgefirm.local", "192.168.1.9")])
|
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):
|
setup.read_file = read
|
||||||
pkt = self._response(0, b"\x09forgefirm\x05local\x00", "10.0.0.5")
|
self.addCleanup(setattr, setup, "read_file", real)
|
||||||
self.assertEqual(commission.mdns_answers(pkt), [("forgefirm.local", "10.0.0.5")])
|
|
||||||
|
|
||||||
def test_wrong_id_or_a_query_yields_nothing(self):
|
def test_the_wifi_address_names_the_machine(self):
|
||||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
|
||||||
self.assertEqual(commission.mdns_answers(pkt, 8), [])
|
self.assertEqual(setup.mac_suffix(), "b00a")
|
||||||
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_a_truncated_packet_yields_nothing(self):
|
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
|
||||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
self._net(eth0="00:11:22:33:44:55")
|
||||||
self.assertEqual(commission.mdns_answers(pkt[:20], 7), [])
|
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):
|
class SmallHelpersTests(unittest.TestCase):
|
||||||
def test_cookie_parsing(self):
|
def test_cookie_parsing(self):
|
||||||
sid = "ab" * 32
|
sid = "ab" * 32
|
||||||
value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid
|
value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid
|
||||||
self.assertEqual(commission.session_from_cookie(value), sid)
|
self.assertEqual(setup.session_from_cookie(value), sid)
|
||||||
self.assertEqual(commission.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
|
self.assertEqual(setup.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
|
||||||
self.assertIsNone(commission.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
|
self.assertIsNone(setup.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
|
||||||
self.assertIsNone(commission.session_from_cookie(""))
|
self.assertIsNone(setup.session_from_cookie(""))
|
||||||
|
|
||||||
def test_the_teal_breathe_cue(self):
|
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]}))
|
"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
|
"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
|
"pulse_off": [1800] * 3})) # white
|
||||||
self.assertFalse(commission.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
|
self.assertFalse(setup.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
|
||||||
self.assertFalse(commission.breathes_teal({}))
|
self.assertFalse(setup.breathes_teal({}))
|
||||||
|
|
||||||
def test_decode(self):
|
def test_decode(self):
|
||||||
self.assertEqual(commission.decode(b'{"a": 1}'), {"a": 1})
|
self.assertEqual(setup.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"cloud mode is not enabled"), "cloud mode is not enabled")
|
||||||
|
|
||||||
|
|
||||||
class GatedSettleTests(unittest.TestCase):
|
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
|
it spawns nothing until the gate opens, so a takeover that installs
|
||||||
a gating record must not wait the whole settle timeout for it."""
|
a gating record must not wait the whole settle timeout for it."""
|
||||||
|
|
||||||
@@ -253,7 +266,7 @@ class GatedSettleTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_gated_returns_at_once(self):
|
def test_gated_returns_at_once(self):
|
||||||
self.fake.state["mode"] = {"mode": "grbl", "controller": "gated", "pid": 0, "motion": "unverified",
|
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 = []
|
lines = []
|
||||||
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
|
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
|
||||||
self.assertEqual(body["controller"], "gated")
|
self.assertEqual(body["controller"], "gated")
|
||||||
@@ -276,7 +289,7 @@ class FirstRunSeedTests(unittest.TestCase):
|
|||||||
"advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"},
|
"advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"},
|
||||||
"account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True},
|
"account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True},
|
||||||
"wizards": {"advisories": {"version": 1}, "switches": {"version": 1, "result": {"lid": 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("completed", rec)
|
||||||
self.assertNotIn("acceptance", rec)
|
self.assertNotIn("acceptance", rec)
|
||||||
self.assertNotIn("account", rec)
|
self.assertNotIn("account", rec)
|
||||||
@@ -288,7 +301,7 @@ class FirstRunSeedTests(unittest.TestCase):
|
|||||||
self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version
|
self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version
|
||||||
|
|
||||||
def test_without_a_base_record(self):
|
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(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
|
||||||
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
|
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
|
||||||
|
|
||||||
@@ -304,7 +317,7 @@ class SshdPolicyTests(unittest.TestCase):
|
|||||||
real = hw.run
|
real = hw.run
|
||||||
hw.run = fake_run
|
hw.run = fake_run
|
||||||
try:
|
try:
|
||||||
self.assertEqual(commission.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
|
self.assertEqual(setup.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
|
||||||
"permitemptypasswords": "no"})
|
"permitemptypasswords": "no"})
|
||||||
finally:
|
finally:
|
||||||
hw.run = real
|
hw.run = real
|
||||||
@@ -315,7 +328,7 @@ class SshdPolicyTests(unittest.TestCase):
|
|||||||
real = hw.run
|
real = hw.run
|
||||||
hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available")
|
hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available")
|
||||||
try:
|
try:
|
||||||
self.assertEqual(commission.sshd_policy(), {"error": "sshd: no hostkeys available"})
|
self.assertEqual(setup.sshd_policy(), {"error": "sshd: no hostkeys available"})
|
||||||
finally:
|
finally:
|
||||||
hw.run = real
|
hw.run = real
|
||||||
|
|
||||||
@@ -367,7 +380,7 @@ class CloudDisabledSurfaceTests(unittest.TestCase):
|
|||||||
self.fake.stop()
|
self.fake.stop()
|
||||||
|
|
||||||
def run_test(self):
|
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)
|
run = Run("test", t.id, t.title)
|
||||||
t.fn(Context(run, None, t))
|
t.fn(Context(run, None, t))
|
||||||
return run
|
return run
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""scripts/source-bundle.py - the source that a release publishes.
|
"""scripts/source-bundle.py - the source that a release publishes.
|
||||||
|
|
||||||
The bundle must hold the source of every recipe of the image whose license
|
The bundle must hold the source of every recipe of the image whose license
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""scripts/manifest-from-tree.py - the workstation/CI mirror of the image
|
"""scripts/manifest-from-tree.py - the workstation/CI mirror of the image
|
||||||
manifest: recipe pins are read through their pin files, and a layer's
|
manifest: recipe pins are read through their pin files, and a layer's
|
||||||
content hash leaves the pin files out (a component bump is not a platform
|
content hash leaves the pin files out (a component bump is not a platform
|
||||||
@@ -94,13 +99,28 @@ class TreeManifestTests(unittest.TestCase):
|
|||||||
self.assertNotEqual(self.mod.layer_content(self.layer), base)
|
self.assertNotEqual(self.mod.layer_content(self.layer), base)
|
||||||
|
|
||||||
def test_layer_hash_matches_the_bbclass_rule(self):
|
def test_layer_hash_matches_the_bbclass_rule(self):
|
||||||
# The same skip list as FORGEFIRM_MANIFEST_PIN_SUFFIX + *.md in
|
# The same skip list as *.md plus FORGEFIRM_MANIFEST_PIN_SUFFIX and
|
||||||
# forgefirm-image-manifest.bbclass.
|
# 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")
|
bbclass = os.path.join(REPO, "meta-forgefirm", "classes", "forgefirm-image-manifest.bbclass")
|
||||||
with open(bbclass, encoding="utf-8") as f:
|
with open(bbclass, encoding="utf-8") as f:
|
||||||
text = f.read()
|
text = f.read()
|
||||||
self.assertIn('FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"', text)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ overrides:
|
|||||||
meta-freescale-distro:
|
meta-freescale-distro:
|
||||||
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
||||||
meta-openglow:
|
meta-openglow:
|
||||||
commit: b7ad6d9377fbb4215dc4d898837906b06b47e5e9
|
commit: 71fad52e8ff5695bce3b9c6bc8530a1468426819
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# forgefirm-image-manifest.bbclass - assemble /etc/forgefirm-manifest.json
|
# forgefirm-image-manifest.bbclass - assemble /etc/forgefirm-manifest.json
|
||||||
#
|
#
|
||||||
# Inherited by the ForgeFIRM images. At rootfs postprocess it collects the
|
# Inherited by the ForgeFIRM images. At rootfs postprocess it collects the
|
||||||
@@ -44,6 +46,13 @@
|
|||||||
FORGEFIRM_MANIFEST_DIR ?= "${sysconfdir}/forgefirm-manifest.d"
|
FORGEFIRM_MANIFEST_DIR ?= "${sysconfdir}/forgefirm-manifest.d"
|
||||||
FORGEFIRM_MANIFEST_CONTENT_LAYERS ?= "meta-forgefirm meta-glowforge-bsp meta-openglow-core"
|
FORGEFIRM_MANIFEST_CONTENT_LAYERS ?= "meta-forgefirm meta-glowforge-bsp meta-openglow-core"
|
||||||
FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"
|
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"
|
do_rootfs[depends] += "virtual/kernel:do_deploy kernel-module-glowforge:do_deploy"
|
||||||
|
|
||||||
@@ -88,7 +97,8 @@ def forgefirm_manifest_layers(d):
|
|||||||
and dirty flag of every layer checkout (informational)."""
|
and dirty flag of every layer checkout (informational)."""
|
||||||
import os, subprocess
|
import os, subprocess
|
||||||
content_layers = (d.getVar('FORGEFIRM_MANIFEST_CONTENT_LAYERS') or '').split()
|
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 = {}, {}
|
identity, build = {}, {}
|
||||||
for layer in (d.getVar('BBLAYERS') or '').split():
|
for layer in (d.getVar('BBLAYERS') or '').split():
|
||||||
name = os.path.basename(layer.rstrip('/'))
|
name = os.path.basename(layer.rstrip('/'))
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# forgefirm-manifest.bbclass - source fingerprint of one ForgeFIRM component
|
# forgefirm-manifest.bbclass - source fingerprint of one ForgeFIRM component
|
||||||
#
|
#
|
||||||
# A component recipe that inherits this class records what source it was
|
# A component recipe that inherits this class records what source it was
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
require conf/distro/include/fslc-base.inc
|
require conf/distro/include/fslc-base.inc
|
||||||
|
|
||||||
DISTRO = "forgefirm"
|
DISTRO = "forgefirm"
|
||||||
@@ -8,19 +10,19 @@ DISTRO_FEATURES:remove = " \
|
|||||||
3g alsa avahi bluetooth bluez5 ext2 irda nfc nfs pci pcmcia \
|
3g alsa avahi bluetooth bluez5 ext2 irda nfc nfs pci pcmcia \
|
||||||
pulseaudio vulkan wayland x11 zeroconf "
|
pulseaudio vulkan wayland x11 zeroconf "
|
||||||
|
|
||||||
# mDNS: the image installs avahi-daemon by name (forgefirm-image.bb), so
|
# The name the image ships in /etc/hostname. forgefirm-hostname replaces
|
||||||
# the control panel answers at forgefirm.local. The avahi and zeroconf
|
# it at S38 in rcS with forgefirm-<xxxx>, from the machine's MAC address;
|
||||||
# features stay removed above: zeroconf would install
|
# this is what the few seconds before that show, and the fallback on a
|
||||||
# packagegroup-base-zeroconf (the daemon plus libnss-mdns), and avahi
|
# machine whose MAC address cannot be read.
|
||||||
# would switch other recipes' avahi options on. The build is trimmed to
|
hostname:pn-base-files = "forgefirm"
|
||||||
# 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
|
# No mDNS. The panel is reached at the machine's address, which every
|
||||||
# front ends, no libdns_sd compatibility library, no libevent binding.
|
# network resolves; the machine's own name (forgefirm-<xxxx>, from
|
||||||
# Python bindings are off in the recipe itself.
|
# forgefirm-hostname) goes out in the DHCP request, so a network with
|
||||||
PACKAGECONFIG:pn-avahi = ""
|
# dynamic DNS publishes it as well. The avahi and zeroconf features stay
|
||||||
# libnss-mdns is what avahi-daemon recommends: an NSS module so the
|
# removed above: zeroconf would install packagegroup-base-zeroconf (the
|
||||||
# machine itself resolves .local names. Nothing on the machine does.
|
# daemon plus libnss-mdns), and avahi would switch other recipes' avahi
|
||||||
BAD_RECOMMENDATIONS += "libnss-mdns"
|
# options on.
|
||||||
|
|
||||||
# TLS: forgectrl serves HTTPS on 443 with a self-signed certificate
|
# TLS: forgectrl serves HTTPS on 443 with a self-signed certificate
|
||||||
# through libmicrohttpd and ulfius, and links GnuTLS itself. GnuTLS is
|
# through libmicrohttpd and ulfius, and links GnuTLS itself. GnuTLS is
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# We have a conf and classes directory, add to BBPATH
|
# We have a conf and classes directory, add to BBPATH
|
||||||
BBPATH .= ":${LAYERDIR}"
|
BBPATH .= ":${LAYERDIR}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# NXP distributes the i.MX VPU/EPDC firmware blobs under its firmware EULA
|
# NXP distributes the i.MX VPU/EPDC firmware blobs under its firmware EULA
|
||||||
# (accepted with ACCEPT_FSL_EULA), so an image carrying those blobs has to
|
# (accepted with ACCEPT_FSL_EULA), so an image carrying those blobs has to
|
||||||
# carry the license text with them.
|
# carry the license text with them.
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# ForgeFIRM SSH policy, set in the installed files so the release image
|
# ForgeFIRM SSH policy, set in the installed files so the release image
|
||||||
# carries it as built:
|
# carries it as built:
|
||||||
# PermitRootLogin no root logs in at the serial console only
|
# PermitRootLogin no root logs in at the serial console only
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# ForgeFIRM image manifest: source fingerprint of the hardware library
|
# ForgeFIRM image manifest: source fingerprint of the hardware library
|
||||||
# (forgefirm-manifest.bbclass in this layer).
|
# (forgefirm-manifest.bbclass in this layer).
|
||||||
inherit forgefirm-manifest
|
inherit forgefirm-manifest
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# ForgeFIRM image manifest: source fingerprint of the cloud-mode service
|
# ForgeFIRM image manifest: source fingerprint of the cloud-mode service
|
||||||
# layer (forgefirm-manifest.bbclass in this layer).
|
# layer (forgefirm-manifest.bbclass in this layer).
|
||||||
inherit forgefirm-manifest
|
inherit forgefirm-manifest
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# urllib3's optional pyOpenSSL/cryptography backend is not used here: the cloud
|
# urllib3's optional pyOpenSSL/cryptography backend is not used here: the cloud
|
||||||
# client and the machine glue talk TLS through the standard library's ssl
|
# client and the machine glue talk TLS through the standard library's ssl
|
||||||
# module (nothing imports OpenSSL or cryptography). Dropping the two runtime
|
# module (nothing imports OpenSSL or cryptography). Dropping the two runtime
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
SUMMARY = "Configurable embedded Linux firmware update creator and runner"
|
SUMMARY = "Configurable embedded Linux firmware update creator and runner"
|
||||||
DESCRIPTION = "Applies and creates signed .fw firmware archives. ForgeFIRM \
|
DESCRIPTION = "Applies and creates signed .fw firmware archives. ForgeFIRM \
|
||||||
uses the factory's own update format: fwup applies ForgeFIRM upgrades and \
|
uses the factory's own update format: fwup applies ForgeFIRM upgrades and \
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Potluck with different functions for C programs (babelouest base library)"
|
DESCRIPTION = "Potluck with different functions for C programs (babelouest base library)"
|
||||||
HOMEPAGE = "https://github.com/babelouest/orcania"
|
HOMEPAGE = "https://github.com/babelouest/orcania"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# /etc/rsyslog.conf - ForgeFIRM
|
# /etc/rsyslog.conf - ForgeFIRM
|
||||||
#
|
#
|
||||||
# rsyslog is the only log writer on the machine. Every ForgeFIRM process
|
# rsyslog is the only log writer on the machine. Every ForgeFIRM process
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# rsyslog is the ForgeFIRM system logger: the only log writer, one
|
# rsyslog is the ForgeFIRM system logger: the only log writer, one
|
||||||
# directory per logger under /data/log/forgefirm. This appends the
|
# directory per logger under /data/log/forgefirm. This appends the
|
||||||
# ForgeFIRM /etc/rsyslog.conf (inputs, line format, and the include of
|
# ForgeFIRM /etc/rsyslog.conf (inputs, line format, and the include of
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Web Framework to build REST APIs, Webservices or any HTTP endpoint in C"
|
DESCRIPTION = "Web Framework to build REST APIs, Webservices or any HTTP endpoint in C"
|
||||||
HOMEPAGE = "https://github.com/babelouest/ulfius"
|
HOMEPAGE = "https://github.com/babelouest/ulfius"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Logging library for C applications (babelouest)"
|
DESCRIPTION = "Logging library for C applications (babelouest)"
|
||||||
HOMEPAGE = "https://github.com/babelouest/yder"
|
HOMEPAGE = "https://github.com/babelouest/yder"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
SUMMARY = "Boot-slot selection and inventory for Glowforge factory hardware"
|
SUMMARY = "Boot-slot selection and inventory for Glowforge factory hardware"
|
||||||
DESCRIPTION = "Inventories the bootable partitions (ffboot -l) and switches \
|
DESCRIPTION = "Inventories the bootable partitions (ffboot -l) and switches \
|
||||||
the boot target by rewriting the saved U-Boot environment with read-back \
|
the boot target by rewriting the saved U-Boot environment with read-back \
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# forgectrl pin. Bump deliberately after pushing forgectrl changes; keep
|
# forgectrl pin. Bump deliberately after pushing forgectrl changes; keep
|
||||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||||
# layer content hash because the component entry already identifies the
|
# layer content hash because the component entry already identifies the
|
||||||
# pinned source (forgefirm-image-manifest.bbclass).
|
# pinned source (forgefirm-image-manifest.bbclass).
|
||||||
SRCREV = "468ee215dd1148b92c979decacecc41aa6a75ccf"
|
SRCREV = "0dae758cb93e1461034aaa58f4b9fc22edc68367"
|
||||||
PV = "0.1.12"
|
PV = "0.1.27"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "System Control Daemon for ForgeFIRM powered Glowforge"
|
DESCRIPTION = "System Control Daemon for ForgeFIRM powered Glowforge"
|
||||||
HOMEPAGE = "https://github.com/openglow-org/forgectrl"
|
HOMEPAGE = "https://github.com/openglow-org/forgectrl"
|
||||||
|
|
||||||
LICENSE = "MIT"
|
LICENSE = "MIT"
|
||||||
LIC_FILES_CHKSUM = "file://LICENSE;md5=fe9dfc6caad62544b4b1d0fa55c4b92f"
|
LIC_FILES_CHKSUM = "file://LICENSE;md5=785c97b59b518e7ad943cd9e8b15fc97"
|
||||||
|
|
||||||
PE = "1"
|
PE = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# forgefirm-app pin (python3-gfhardware repository, forgefirm-app/ tree).
|
# forgefirm-app pin (python3-gfhardware repository, forgefirm-app/ tree).
|
||||||
# Bump deliberately (AUTOREV is not reproducible); keep only SRCREV and PV
|
# Bump deliberately (AUTOREV is not reproducible); keep only SRCREV and PV
|
||||||
# here - the image manifest leaves *-pin.inc out of the layer content hash
|
# here - the image manifest leaves *-pin.inc out of the layer content hash
|
||||||
# because the component entry already identifies the pinned source
|
# because the component entry already identifies the pinned source
|
||||||
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
||||||
# meta-glowforge-bsp pins the same repository; move both together.
|
# meta-glowforge-bsp pins the same repository; move both together.
|
||||||
SRCREV = "56f134a1e6393a2096c16e8226027fbbd38d79c7"
|
SRCREV = "6cc4f45d311ab451c7d72dee009103e55a749675"
|
||||||
|
|
||||||
# Bump PV with every SRCREV move: the hash-derived package version is not
|
# 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
|
# monotonic on its own and buildhistory QA fails the build when it sorts
|
||||||
# backwards.
|
# backwards.
|
||||||
PV = "0.1.28+git"
|
PV = "0.1.30+git"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# Shared fetch for the ForgeFIRM web-service apps (gfhome, python3-ffmachine,
|
# Shared fetch for the ForgeFIRM web-service apps (gfhome, python3-ffmachine,
|
||||||
# gfcloud). Their sources live in the forgefirm-app/ directory of the
|
# gfcloud). Their sources live in the forgefirm-app/ directory of the
|
||||||
# python3-gfhardware repository.
|
# python3-gfhardware repository.
|
||||||
@@ -7,7 +9,7 @@ HOMEPAGE = "https://github.com/openglow-org/python3-gfhardware"
|
|||||||
# repository's LGPL component (the Bayer decoder compiled into
|
# repository's LGPL component (the Bayer decoder compiled into
|
||||||
# gfhardware._cam) is not part of them; python3-gfhardware declares it.
|
# gfhardware._cam) is not part of them; python3-gfhardware declares it.
|
||||||
LICENSE = "MIT"
|
LICENSE = "MIT"
|
||||||
LIC_FILES_CHKSUM = "file://LICENSE;md5=7029670f29302d9352647c6c89614bc2"
|
LIC_FILES_CHKSUM = "file://LICENSE;md5=314d21c23b626bdadbb4801c47b16266"
|
||||||
|
|
||||||
SRC_URI = "git://github.com/openglow-org/python3-gfhardware.git;protocol=https;branch=master"
|
SRC_URI = "git://github.com/openglow-org/python3-gfhardware.git;protocol=https;branch=master"
|
||||||
# SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass).
|
# SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass).
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Full Glowforge web-service controller daemon for ForgeFIRM (cloud mode)"
|
DESCRIPTION = "Full Glowforge web-service controller daemon for ForgeFIRM (cloud mode)"
|
||||||
|
|
||||||
require forgefirm-app.inc
|
require forgefirm-app.inc
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "One-shot Glowforge web-service homing for ForgeFIRM"
|
DESCRIPTION = "One-shot Glowforge web-service homing for ForgeFIRM"
|
||||||
|
|
||||||
require forgefirm-app.inc
|
require forgefirm-app.inc
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Shared ForgeFIRM web-service hardware-machine glue (gfhome + gfcloud)"
|
DESCRIPTION = "Shared ForgeFIRM web-service hardware-machine glue (gfhome + gfcloud)"
|
||||||
|
|
||||||
require forgefirm-app.inc
|
require forgefirm-app.inc
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Rewrites the address block of /etc/issue, the serial-console login
|
# Rewrites /etc/issue, the serial-console login banner: the lines the
|
||||||
# banner: the control panel by mDNS name, then one https:// URL per
|
# image build wrote, then one https:// URL per global address of wlan0
|
||||||
# global address of wlan0 (and eth0 when the machine has one). Every
|
# (and eth0 when the machine has one). The address is the only way in
|
||||||
# other line of /etc/issue stays as the image build wrote it. The block
|
# that the banner gives, because it is the one that works on every
|
||||||
# sits between the marker lines "# ForgeFIRM addresses" and "# end" and
|
# network. Called by the init script at boot and by the udhcpc hook on
|
||||||
# is appended when absent. Called by the init script at boot and by the
|
# every lease event. Idempotent: the file is written only when the text
|
||||||
# udhcpc hook on every lease event. Idempotent: the file is written only
|
# changes.
|
||||||
# when the block changes.
|
|
||||||
#
|
#
|
||||||
# The rootfs is read-only: at the first change after boot the file is
|
# 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
|
# bind-mounted from a copy under /run/forgefirm (tmpfs) and the banner
|
||||||
# written through the mount. Before that the image's own file shows.
|
# 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
|
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
||||||
|
|
||||||
ISSUE=/etc/issue
|
ISSUE=/etc/issue
|
||||||
STATE=/run/forgefirm/issue
|
STATE=/run/forgefirm/issue
|
||||||
MARK_BEGIN='# ForgeFIRM addresses'
|
BASE=/run/forgefirm/issue.base
|
||||||
MARK_END='# end'
|
|
||||||
PANEL='Control panel: https://forgefirm.local/'
|
|
||||||
|
|
||||||
# One URL per global address; an IPv6 address gets its URL brackets.
|
# One URL per global address; an IPv6 address gets its URL brackets.
|
||||||
# Tentative, deprecated and temporary addresses are left out.
|
# Tentative, deprecated and temporary addresses are left out.
|
||||||
@@ -32,45 +31,40 @@ addresses () {
|
|||||||
a = $2
|
a = $2
|
||||||
sub(/\/.*/, "", a)
|
sub(/\/.*/, "", a)
|
||||||
if ($1 == "inet6") a = "[" a "]"
|
if ($1 == "inet6") a = "[" a "]"
|
||||||
print "https://" a "/"
|
print " https://" a "/"
|
||||||
}'
|
}'
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
block () {
|
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
|
||||||
echo "$MARK_BEGIN"
|
# file by its device numbers alone).
|
||||||
echo "$PANEL"
|
is_mounted () {
|
||||||
addrs=$(addresses)
|
awk -v t="$1" '$2 == t { f = 1 } END { exit !f }' /proc/mounts
|
||||||
if [ -n "$addrs" ]; then
|
|
||||||
echo "$addrs"
|
|
||||||
else
|
|
||||||
echo "no network address yet"
|
|
||||||
fi
|
|
||||||
echo "$MARK_END"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[ -f "$ISSUE" ] || exit 0
|
[ -f "$ISSUE" ] || exit 0
|
||||||
|
|
||||||
new=$(block)
|
# The image's own text: the file as it is before the first write of the
|
||||||
old=$(awk -v b="$MARK_BEGIN" -v e="$MARK_END" \
|
# boot. /run is a tmpfs, so the copy is made once per boot, and the
|
||||||
'$0 == b { p = 1 } p { print } $0 == e { p = 0 }' "$ISSUE")
|
# first caller is the one that finds the file untouched.
|
||||||
[ "$new" = "$old" ] && exit 0
|
mkdir -p "${STATE%/*}" || exit 1
|
||||||
|
[ -f "$BASE" ] || cp -p "$ISSUE" "$BASE" || exit 1
|
||||||
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
|
if ! is_mounted "$ISSUE"; then
|
||||||
# file by its device numbers alone).
|
cp -p "$BASE" "$STATE" \
|
||||||
if ! awk -v t="$ISSUE" '$2 == t { f = 1 } END { exit !f }' /proc/mounts; then
|
|
||||||
mkdir -p "${STATE%/*}" \
|
|
||||||
&& cp -p "$ISSUE" "$STATE" \
|
|
||||||
&& mount --bind "$STATE" "$ISSUE" || exit 1
|
&& mount --bind "$STATE" "$ISSUE" || exit 1
|
||||||
fi
|
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.$$"
|
tmp="$STATE.tmp.$$"
|
||||||
awk -v b="$MARK_BEGIN" -v e="$MARK_END" -v blk="$new" '
|
printf '%s\n\n' "$new" > "$tmp" || { rm -f "$tmp"; exit 1; }
|
||||||
$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; }
|
|
||||||
cat "$tmp" > "$ISSUE"
|
cat "$tmp" > "$ISSUE"
|
||||||
rm -f "$tmp"
|
rm -f "$tmp"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
### BEGIN INIT INFO
|
### BEGIN INIT INFO
|
||||||
# Provides: forgefirm-banner
|
# Provides: forgefirm-banner
|
||||||
# Required-Start: $network
|
# Required-Start: $network
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user