mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfd32d5fd1 | ||
|
|
35221de791 | ||
|
|
46b6df1c2d | ||
|
|
16a8ae9ba3 | ||
|
|
72907f311a | ||
|
|
3873013383 | ||
|
|
2ea501f975 | ||
|
|
1bc15fe70a | ||
|
|
66b39c6f2b | ||
|
|
83686fc712 | ||
|
|
f47ddad839 | ||
|
|
eb9e61d3c6 | ||
|
|
55e789bc34 | ||
|
|
11031ad87f | ||
|
|
cd84786fc5 | ||
|
|
6e71ac86f0 | ||
|
|
1727b819cb | ||
|
|
b494bbecb5 | ||
|
|
b2e0f91342 | ||
|
|
a05a226305 | ||
|
|
9f7744b459 | ||
|
|
8dc7c0bc56 | ||
|
|
0eb0a16452 | ||
|
|
bfcfd7a376 | ||
|
|
d2307a899e | ||
|
|
8570c0141e | ||
|
|
800863fb09 | ||
|
|
cfef6a1b84 | ||
|
|
d40ebb090e | ||
|
|
ef2390f7d4 | ||
|
|
82869cc45c | ||
|
|
b7dd23d539 | ||
|
|
308a033146 | ||
|
|
dc9bede034 | ||
|
|
56af6eaab2 | ||
|
|
c95b5aa771 | ||
|
|
d45520c218 | ||
|
|
2870daa2aa | ||
|
|
82c354d70e | ||
|
|
853f862f03 | ||
|
|
ac2456261e | ||
|
|
8fc07c47ee | ||
|
|
69b56ec1ff | ||
|
|
99d9830991 | ||
|
|
4e47b7fbf1 | ||
|
|
675fc11cb1 | ||
|
|
71b44598eb | ||
|
|
fab8920cd9 | ||
|
|
861f5597e6 | ||
|
|
b7e7c0fb2c | ||
|
|
2f51e38178 | ||
|
|
d9cc61f198 | ||
|
|
1bdc11f6fe | ||
|
|
625d0acec7 | ||
|
|
d410c6f696 | ||
|
|
a552541acd | ||
|
|
5915408147 | ||
|
|
59887ac742 | ||
|
|
4388d69816 | ||
|
|
77e4c03369 | ||
|
|
0ba0fb112b | ||
|
|
f74382984c | ||
|
|
75ef8bbc95 | ||
|
|
2216e5d3f2 | ||
|
|
bb28c7855a | ||
|
|
447f90205d | ||
|
|
952e7dc32d | ||
|
|
eb0a1e7838 | ||
|
|
963bded9b3 | ||
|
|
9c36df9ee9 | ||
|
|
121d7d3f81 | ||
|
|
68fd35b8b7 | ||
|
|
ee89b31c07 | ||
|
|
9ee277f977 | ||
|
|
800d6890a4 | ||
|
|
1a306d5d8d | ||
|
|
1e98b37e45 | ||
|
|
2894269115 | ||
|
|
5ad7ee6faa | ||
|
|
9128f0e98e | ||
|
|
619414ed2b | ||
|
|
474bf9db09 | ||
|
|
d627ee32f1 | ||
|
|
965f7c3ca9 | ||
|
|
19ce4d78d2 | ||
|
|
ebc4222182 | ||
|
|
f0ba4b9249 | ||
|
|
888b47abe8 | ||
|
|
520bf6023d | ||
|
|
25cc42b879 | ||
|
|
8856fb0099 | ||
|
|
b29bb9e023 | ||
|
|
11f4c42705 | ||
|
|
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'
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# The extension sandbox's deny rules, proven on real traffic.
|
||||||
|
#
|
||||||
|
# scripts/sandbox-rules-test.py loads meta-forgefirm's ffx.nft into a network
|
||||||
|
# namespace of its own, joins a second namespace to it with a veth pair (a
|
||||||
|
# peer that is not the machine), and sends from real uids: a pool uid is
|
||||||
|
# refused on loopback, at its own LAN address, and at the peer (TCP at once,
|
||||||
|
# UDP with EPERM), the uids on either side of the pool and root are not
|
||||||
|
# touched, an allowlist chain opens one port of the peer to one uid, the
|
||||||
|
# machine's own addresses stay shut even on an allowlist, and loading the
|
||||||
|
# file again takes the allowlist away. The
|
||||||
|
# runner's nft is the version the image carries or newer; the kernel side is
|
||||||
|
# the image's own business and is proven on the machine (exthost.platform).
|
||||||
|
|
||||||
|
name: sandbox-ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- 'meta-forgefirm/recipes-forgefirm/forgefirm-sandbox/**'
|
||||||
|
- 'scripts/sandbox-rules-test.py'
|
||||||
|
- '.github/workflows/sandbox-ci.yml'
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rules:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: nft
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y nftables
|
||||||
|
|
||||||
|
- name: The deny rules on real traffic
|
||||||
|
run: |
|
||||||
|
# 77 is the script's "could not run here": on this runner that is a failure.
|
||||||
|
sudo python3 -B scripts/sandbox-rules-test.py
|
||||||
@@ -45,6 +45,8 @@ contributor is welcome to read it too: it is the same set of rules.
|
|||||||
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
|
||||||
|
drills, not one-off bench scripts.
|
||||||
|
|
||||||
### CI couplings that set the push order
|
### CI couplings that set the push order
|
||||||
|
|
||||||
@@ -186,6 +188,9 @@ does.
|
|||||||
test with the fix, in the same commit, never after.
|
test with the fix, in the same commit, never after.
|
||||||
- Position counters, homing anchors, and a homed flag are not proof of
|
- Position counters, homing anchors, and a homed flag are not proof of
|
||||||
physical motion. The head accelerometer is, and so are the operator's eyes.
|
physical motion. The head accelerometer is, and so are the operator's eyes.
|
||||||
|
- Unreadable is fail-closed. A failed read of a safety input, a state file,
|
||||||
|
or a verdict is never treated as "still running" or "keep waiting" without
|
||||||
|
a bound.
|
||||||
|
|
||||||
### Proof before done
|
### Proof before done
|
||||||
|
|
||||||
@@ -320,7 +325,11 @@ docstrings, log and console text, commit messages, issue text, and replies.
|
|||||||
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
|
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
|
`Written by Scott Wiederhold`. Keep the year, or the year range, that
|
||||||
the file already has. The copyright notices of other holders stay
|
the file already has. The copyright notices of other holders stay
|
||||||
unchanged.
|
unchanged. Those two lines are for files this project authors: an
|
||||||
|
outside contributor who writes a new file puts their own name there, or
|
||||||
|
leaves the lines out, and keeps their copyright in what they wrote.
|
||||||
|
Editing an existing file changes no header - a contribution is recorded
|
||||||
|
by its `Signed-off-by` line and by git, not by a copyright line.
|
||||||
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
|
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
|
||||||
under the license of its repository.
|
under the license of its repository.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> ### BETA
|
> ### BETA
|
||||||
>
|
>
|
||||||
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
|
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
|
||||||
> Expect problems, and expect frequent updates. Upgrade whenever a newer
|
> It's stable, but you may still hit the occasional bug, and expect frequent updates. Upgrade whenever a newer
|
||||||
> release is available, and report what you find on the
|
> release is available, and report what you find on the
|
||||||
> [community forum](https://community.openglow.org).
|
> [community forum](https://community.openglow.org).
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"seq":268,"ts_mono":1268.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":true,"reason":"","down_c":22.30,"up_c":22.30}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
255
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
idle
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1023
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
300
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
629
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
700
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
700
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
65535
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
26214
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
43278
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
300
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
300
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
300
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"seq":1517,"ts_mono":3127.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":false,"reason":"HEAD CRASH SIGNAL (axes x) - motion stopped, laser locked","down_c":22.30,"up_c":22.30}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
idle
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
204
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
300
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
629
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
100
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
100
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
100
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
100
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
700
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
700
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
300
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
300
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
300
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+292
-29
@@ -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
|
||||||
@@ -36,6 +41,7 @@ that made it, not a reason to hand the dirt on.
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from . import hw
|
from . import hw
|
||||||
@@ -48,7 +54,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 +71,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 +93,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
|
||||||
@@ -91,6 +117,12 @@ UNPRESERVED_SETTINGS = ("controller_mode",)
|
|||||||
# service starts its own client with the hunt.
|
# service starts its own client with the hunt.
|
||||||
NOHUNT_MARKER = os.environ.get("FORGETEST_NOHUNT_MARKER", "/run/gfcloud-nohunt")
|
NOHUNT_MARKER = os.environ.get("FORGETEST_NOHUNT_MARKER", "/run/gfcloud-nohunt")
|
||||||
|
|
||||||
|
# The offline service: gfcloud's one-start offline marker and the socket
|
||||||
|
# the service listens on while it runs. A takeover that finds it running
|
||||||
|
# makes forgectrl's start at its end under the marker again (runner.Takeover).
|
||||||
|
OFFLINE_MARKER = os.environ.get("FORGETEST_OFFLINE_MARKER", "/run/gfcloud-offline")
|
||||||
|
OFFLINE_SOCKET = os.environ.get("FORGETEST_OFFLINE_SOCKET", "/run/gfcloud-offline.sock")
|
||||||
|
|
||||||
# Read-only readbacks with their idle values (no direct restore: the state
|
# Read-only readbacks with their idle values (no direct restore: the state
|
||||||
# comes right through forgectrl - see restore_forgectrl - or is fatal).
|
# comes right through forgectrl - see restore_forgectrl - or is fatal).
|
||||||
IDLE_READBACKS = [
|
IDLE_READBACKS = [
|
||||||
@@ -113,18 +145,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 +189,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,11 +201,18 @@ 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"):
|
||||||
|
"""A button LED's commanded level. The trigger fades brightness toward
|
||||||
|
target, so brightness is where the fade has reached and target is what
|
||||||
|
the machine was told: a run that left the button lit left a target, and
|
||||||
|
a run that ended a moment ago may still be fading from one. Falls back
|
||||||
|
to brightness where no target exists."""
|
||||||
|
for a in (attr, "brightness"):
|
||||||
try:
|
try:
|
||||||
with open(leds_root() + name + "/brightness") as f:
|
with open(leds_root() + name + "/" + a) as f:
|
||||||
return f.read().strip()
|
return f.read().strip()
|
||||||
except OSError:
|
except OSError:
|
||||||
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -173,6 +221,23 @@ def write_led(name, value):
|
|||||||
f.write(str(value))
|
f.write(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
# The GRBL controller zeroes the kernel's step counters at every start (the
|
||||||
|
# lens reference) and at every home, and rewrites its home anchor each time.
|
||||||
|
# The anchor's identity is therefore the counters' frame: if it changed
|
||||||
|
# during a run, the counters at the end are not in the frame of the counters
|
||||||
|
# at the start, and their difference is not a distance the head traveled.
|
||||||
|
ANCHOR_PATH = os.environ.get("FORGETEST_ANCHOR", "/run/grblhal.homed")
|
||||||
|
|
||||||
|
|
||||||
|
def counter_frame():
|
||||||
|
"""What identifies the frame the step counters are in, or None."""
|
||||||
|
try:
|
||||||
|
st = os.stat(ANCHOR_PATH)
|
||||||
|
return [st.st_ino, st.st_mtime_ns]
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def read_position():
|
def read_position():
|
||||||
"""(x, y, z) step counters, or None when unreadable."""
|
"""(x, y, z) step counters, or None when unreadable."""
|
||||||
try:
|
try:
|
||||||
@@ -183,6 +248,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.
|
||||||
@@ -210,6 +296,11 @@ def read_program_total():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# The extension host stops a removed package's service on its next
|
||||||
|
# one-second turn; its group is killed and waited for inside that turn.
|
||||||
|
EXT_STOP_S = 8
|
||||||
|
|
||||||
|
|
||||||
class Leftover:
|
class Leftover:
|
||||||
def __init__(self, item, found, expected, action):
|
def __init__(self, item, found, expected, action):
|
||||||
self.item = item
|
self.item = item
|
||||||
@@ -274,7 +365,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)."""
|
||||||
@@ -383,7 +474,8 @@ class Baseline:
|
|||||||
# -- capture -------------------------------------------------------
|
# -- capture -------------------------------------------------------
|
||||||
def capture(self):
|
def capture(self):
|
||||||
"""Record the preserved state before a run."""
|
"""Record the preserved state before a run."""
|
||||||
cap = {"sysfs": {}, "position": read_position(), "settings": None, "mode": None}
|
cap = {"sysfs": {}, "position": read_position(), "frame": counter_frame(),
|
||||||
|
"settings": None, "mode": None}
|
||||||
for attr in PRESERVED_SYSFS:
|
for attr in PRESERVED_SYSFS:
|
||||||
cap["sysfs"][attr] = hw.sysfs_read(attr)
|
cap["sysfs"][attr] = hw.sysfs_read(attr)
|
||||||
st, body = self.fc_get("/settings")
|
st, body = self.fc_get("/settings")
|
||||||
@@ -403,9 +495,13 @@ 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)
|
||||||
|
self._ext_side(left)
|
||||||
self._preserved(left, captured)
|
self._preserved(left, captured)
|
||||||
if left:
|
if left:
|
||||||
self.log("%s: %d leftover(s): %s" % (phase, len(left), "; ".join(str(x) for x in left)))
|
self.log("%s: %d leftover(s): %s" % (phase, len(left), "; ".join(str(x) for x in left)))
|
||||||
@@ -421,6 +517,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 +606,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,12 +621,21 @@ 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)
|
||||||
|
if w is not None and not underrun:
|
||||||
|
self.log("state: %s reached idle on its own after %.0f s; the machine "
|
||||||
|
"finishing, not a leftover" % (s.get("state"), w))
|
||||||
|
else:
|
||||||
left.append(Leftover("state", s.get("state"), "idle",
|
left.append(Leftover("state", s.get("state"), "idle",
|
||||||
"waited" if w is not None else "failed: not 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:
|
||||||
@@ -502,16 +645,91 @@ 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"))
|
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")
|
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 {}),
|
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
||||||
COOL_IDLE_S)
|
# 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"))
|
||||||
|
# Armed, or holding, is the run's doing. A phase alone is
|
||||||
|
# not: the engine clears smoke at run duty after an armed
|
||||||
|
# session and cools down after a hot one, both timed and
|
||||||
|
# both ending on their own. Waiting for that is right;
|
||||||
|
# calling it a leftover is not, because nothing was left -
|
||||||
|
# the machine was still finishing.
|
||||||
|
dirt = bool(c.get("armed") or c.get("hold"))
|
||||||
|
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
||||||
|
if w is not None and not dirt:
|
||||||
|
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
|
||||||
|
"work, not a leftover" % (found, w))
|
||||||
|
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",
|
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
||||||
"waited" if w is not None else "failed: still %s" % found))
|
act if w is not None else "failed: still %s" % found))
|
||||||
|
|
||||||
|
def _ext_side(self, left):
|
||||||
|
"""A campaign's machine is extension-free. What a test made and
|
||||||
|
left behind (a package under the tests' own prefix, the owner key
|
||||||
|
it was signed with) is removed, and the host stops its service.
|
||||||
|
The operator's own packages are theirs: running ones are reported
|
||||||
|
and not touched."""
|
||||||
|
for id_ in [p for p in hw.ext_packages() if p.startswith(hw.EXT_TEST_PREFIX)]:
|
||||||
|
try:
|
||||||
|
p = subprocess.run([hw.FORGEEXT, "remove", id_], capture_output=True, text=True, timeout=120)
|
||||||
|
ok = p.returncode == 0 and id_ not in hw.ext_packages()
|
||||||
|
act = "restored" if ok else "failed: %s" % (p.stdout.strip()[-120:] or p.returncode)
|
||||||
|
except (OSError, subprocess.SubprocessError) as e:
|
||||||
|
act = "failed: %s" % e
|
||||||
|
left.append(Leftover("ext.package", id_, "not installed", act))
|
||||||
|
keys = hw.EXT_ROOT + "/keys"
|
||||||
|
for name in sorted(os.listdir(keys)) if os.path.isdir(keys) else []:
|
||||||
|
if not name.startswith("forgetest-"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.remove(os.path.join(keys, name))
|
||||||
|
act = "restored"
|
||||||
|
except OSError as e:
|
||||||
|
act = "failed: %s" % e
|
||||||
|
left.append(Leftover("ext.owner_key", name, "absent", act))
|
||||||
|
# the host stops a removed package's service on its next turn
|
||||||
|
running = self._wait("no test package running", lambda: not hw.pool_pids() or None, EXT_STOP_S) is None \
|
||||||
|
and hw.pool_pids()
|
||||||
|
if running:
|
||||||
|
left.append(Leftover("ext.running", "%d process(es) under pool accounts, packages %s"
|
||||||
|
% (len(running), hw.ext_packages()), "an extension-free machine", "unrestorable"))
|
||||||
|
|
||||||
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 +793,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 +812,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
|
||||||
@@ -661,8 +891,41 @@ class Baseline:
|
|||||||
# action re-zeroes them at its start, so they preserve nothing.
|
# action re-zeroes them at its start, so they preserve nothing.
|
||||||
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():
|
# A controller start or a home during the run re-zeroed the counters
|
||||||
left.append(Leftover("position", now, was, self._return_head(was, now)))
|
# wherever the head then stood. Unless the test said where that was
|
||||||
|
# (ctx.counters_rezeroed), the two readings share no frame: on the
|
||||||
|
# bench reference a head that had not moved was "returned" 30 mm into
|
||||||
|
# the gantry stops this way, twice. Nothing is moved on a guess.
|
||||||
|
frame_lost = ("frame" in captured and captured["frame"] != counter_frame()
|
||||||
|
and not captured.get("rezero_declared"))
|
||||||
|
if was is not None and now is not None and now != was and frame_lost and not self.cloud_mode():
|
||||||
|
self.log("position: %s, and %s when the run began, but the controller re-zeroed its "
|
||||||
|
"counters during the run (a controller start or a home): the two are not in "
|
||||||
|
"one frame, so the head's position cannot be judged and the head is not moved"
|
||||||
|
% (now, was))
|
||||||
|
elif was is not None and now is not None and now != was and not self.cloud_mode():
|
||||||
|
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))
|
||||||
|
# A controller restart during the run (a takeover's) re-zeroed the
|
||||||
|
# counters wherever the head then stood, so a head left out before
|
||||||
|
# it reads as home from here. The takeover judged it on the spot
|
||||||
|
# (runner.Takeover); what it recorded is a leftover, never moved.
|
||||||
|
for r in captured.get("restart_positions") or []:
|
||||||
|
left.append(Leftover("position at the %s" % r["where"], r["found"], r["expected"],
|
||||||
|
"unrestorable: a controller restart re-zeroed the counters there, "
|
||||||
|
"so the head is not moved"))
|
||||||
|
# The cloud client forgectrl's start brought back at the end of a
|
||||||
|
# takeover, when it is not the one the takeover found (the same
|
||||||
|
# judge, runner.Takeover). The client that came up is left as it
|
||||||
|
# is: the cloud test that needs a client starts its own.
|
||||||
|
for r in captured.get("restart_clients") or []:
|
||||||
|
left.append(Leftover("cloud client at the %s" % r["where"], r["found"], r["expected"],
|
||||||
|
"not restored: a cloud test starts the client it needs"))
|
||||||
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,69 @@ 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 "
|
||||||
|
"and the lens settings) opens it to the window the settings hold, the fallback or the focus "
|
||||||
|
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
|
||||||
|
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
|
||||||
|
"bench-page tool."},
|
||||||
|
{"id": "ctlport-test", "title": "Controller port and line multiplexer harness", "script": "ctlport_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "A scripted sender that counts every response, beside the controller port's one client, on the "
|
||||||
|
"null-sink controller: a port jog's status goes to the port and never to the sender, a port "
|
||||||
|
"error does not reach the sender's parser, a sender line cancels a port jog and gets its own ok "
|
||||||
|
"(a line queued right behind the port's included), the refusals, the single client, a CR LF "
|
||||||
|
"sender, a soft reset, and the client as the dead-man. A CI harness (the grblHAL repo): needs "
|
||||||
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||||
|
{"id": "cool-report-test", "title": "Cooling report secret harness", "script": "cool_report_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "The controller's cooling reports on the null-sink controller, read by a stand-in for "
|
||||||
|
"forgectrl's listener: every report carries the secret the supervisor hands the controller at "
|
||||||
|
"its spawn (GF_REPORT_SECRET), none does when there is none, a value that is not 32 hex digits "
|
||||||
|
"(a CR LF with a header behind it included) never reaches the wire, and the homing runner the "
|
||||||
|
"controller starts is handed it (it reports in the controller's place during a gfcloud $H), "
|
||||||
|
"and none when the controller has none. A CI harness (the grblHAL repo): needs the host-built "
|
||||||
|
"null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||||
|
{"id": "mcode-test", "title": "Package M-code barrier harness", "script": "mcode_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "The M-codes extension packages answer (M160 to M179), on the null-sink controller with a "
|
||||||
|
"scripted sender and a stand-in for the machine daemon on the port: the table and its refusals, "
|
||||||
|
"a number nothing answers is error:20 where it is parsed, an answered one waits with the head "
|
||||||
|
"still and a port jog refused while the port's state names it, the answer's words, an answer "
|
||||||
|
"that the work was not done and no answer in time each hold the job, a soft reset ends the "
|
||||||
|
"wait, and a wait under an open armed window with M3 S500 ships no FIRE tick. Imports its "
|
||||||
|
"sender and port client from ctlport_test.py. A CI harness (the grblHAL repo): needs the "
|
||||||
|
"host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||||
|
{"id": "envelope-test", "title": "Measured envelope harness", "script": "envelope_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "The measured work envelope and the bed check's port op, on the null-sink controller with a "
|
||||||
|
"scripted sender and a port client: unset keys are the axis travel, envelope_x_mm and _y are the "
|
||||||
|
"far edges at a home and are held to 50 mm up to the travel plus 30 mm, the port's envelope open "
|
||||||
|
"is refused before a home and under an open armed window and lets the port's jogs reach the "
|
||||||
|
"travel plus 30 mm, envelope apply puts the keys' edges back, and the sender's first line (never "
|
||||||
|
"a status poll or an empty line), a soft reset, and the port client going away each close an "
|
||||||
|
"open envelope, the sender's line only after it closed. Imports its sender and port client from "
|
||||||
|
"ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink controller, "
|
||||||
|
"not the machine, so it is not a bench-page tool."},
|
||||||
|
{"id": "manual-home-test", "title": "Manual home and motor release harness", "script": "manual_home_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "The manual homing provider and the motor release on the null-sink controller: $H under manual "
|
||||||
|
"ships nothing and declares X0 Y0 with the soft limits on and Z kept; while the motors are "
|
||||||
|
"released every motion source is refused and ships nothing, $X and a soft reset do not unlock "
|
||||||
|
"it, and only $ME and a manual $H write the energize, once each; the port's panel operations "
|
||||||
|
"do the same with the sender's count exact. Imports its sender and port client from "
|
||||||
|
"ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink controller, "
|
||||||
|
"not the machine, so it is not a bench-page tool."},
|
||||||
|
{"id": "tray-test", "title": "Crumb tray mode harness", "script": "tray_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "The crumb tray's mode on the null-sink controller: M103 P1 and P0 move Z and its envelope by "
|
||||||
|
"tray_offset_mm on the lens step grid, tell the sender, write and remove the marker beside the "
|
||||||
|
"shared config, and ship no step; a missing P, a P other than 0 or 1 and an axis word are "
|
||||||
|
"errors; the switch waits for the move before it; the port's tray op, and its refusals during "
|
||||||
|
"a program and at a package's M-code; the mode survives a soft reset and a restart, and a start "
|
||||||
|
"over the marker references Z in the tray-out frame; the offset's range; an unreferenced lens; "
|
||||||
|
"and a camera home in both modes hands the runner the same park. Imports its sender and port "
|
||||||
|
"client from ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink "
|
||||||
|
"controller, not the machine, so it is not a bench-page tool."},
|
||||||
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
|
{"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
|
||||||
@@ -69,6 +74,10 @@ class Test:
|
|||||||
that test's earlier passes and no other; a change to a helper
|
that test's earlier passes and no other; a change to a helper
|
||||||
invalidates the tests of that module. A test the module does not
|
invalidates the tests of that module. A test the module does not
|
||||||
define in the @test form hashes its whole file."""
|
define in the @test form hashes its whole file."""
|
||||||
|
if self._source_sha is None:
|
||||||
|
# One filler at a time: a second poll that arrives while the
|
||||||
|
# first computes waits for it rather than repeating the work.
|
||||||
|
with _IMPL_LOCK:
|
||||||
if self._source_sha is None:
|
if self._source_sha is None:
|
||||||
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
|
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
|
||||||
self._source_sha = implementation_sha(path, self.id)
|
self._source_sha = implementation_sha(path, self.id)
|
||||||
@@ -135,8 +144,38 @@ def source_file_sha(path):
|
|||||||
return hashlib.sha256(data).hexdigest()
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
_PARTS = {} # path -> (shared sha, {test id: own sha}); the files never change under a run
|
# The files never change under a run, so each is read and parsed once:
|
||||||
_PARTS_LOCK = threading.Lock()
|
# path -> (text, tree or None), path -> (shared sha, {test id: own sha}),
|
||||||
|
# path -> the sibling modules it imports itself.
|
||||||
|
_PARSED = {}
|
||||||
|
_PARTS = {}
|
||||||
|
_SIBLINGS = {}
|
||||||
|
_PARTS_LOCK = threading.RLock()
|
||||||
|
_IMPL_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def forget(path):
|
||||||
|
"""Drop what is known about a module: its file changed (the unit tests
|
||||||
|
edit their modules between readings)."""
|
||||||
|
with _PARTS_LOCK:
|
||||||
|
for cache in (_PARSED, _PARTS, _SIBLINGS):
|
||||||
|
cache.pop(path, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _parsed(path):
|
||||||
|
"""(text, tree) of a suite module, line endings normalized; the tree
|
||||||
|
is None when the file does not parse."""
|
||||||
|
with _PARTS_LOCK:
|
||||||
|
hit = _PARSED.get(path)
|
||||||
|
if hit is None:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
||||||
|
try:
|
||||||
|
tree = ast.parse(text)
|
||||||
|
except SyntaxError:
|
||||||
|
tree = None
|
||||||
|
hit = _PARSED[path] = (text, tree)
|
||||||
|
return hit
|
||||||
|
|
||||||
|
|
||||||
def module_parts(path):
|
def module_parts(path):
|
||||||
@@ -149,14 +188,9 @@ def module_parts(path):
|
|||||||
hit = _PARTS.get(path)
|
hit = _PARTS.get(path)
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
return hit
|
return hit
|
||||||
with open(path, "rb") as f:
|
text, tree = _parsed(path)
|
||||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
spans = {}
|
spans = {}
|
||||||
try:
|
|
||||||
tree = ast.parse(text)
|
|
||||||
except SyntaxError:
|
|
||||||
tree = None
|
|
||||||
for node in (tree.body if tree is not None else []):
|
for node in (tree.body if tree is not None else []):
|
||||||
if not isinstance(node, ast.FunctionDef):
|
if not isinstance(node, ast.FunctionDef):
|
||||||
continue
|
continue
|
||||||
@@ -183,22 +217,30 @@ def sibling_imports(path, seen=None):
|
|||||||
test's fingerprint."""
|
test's fingerprint."""
|
||||||
if seen is None:
|
if seen is None:
|
||||||
seen = []
|
seen = []
|
||||||
with open(path, "rb") as f:
|
for sib in _direct_siblings(path):
|
||||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
if sib not in seen and sib != path:
|
||||||
try:
|
|
||||||
tree = ast.parse(text)
|
|
||||||
except SyntaxError:
|
|
||||||
return seen
|
|
||||||
here = os.path.dirname(path)
|
|
||||||
for node in tree.body:
|
|
||||||
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
|
|
||||||
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
|
|
||||||
if os.path.isfile(sib) and sib not in seen and sib != path:
|
|
||||||
seen.append(sib)
|
seen.append(sib)
|
||||||
sibling_imports(sib, seen)
|
sibling_imports(sib, seen)
|
||||||
return seen
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_siblings(path):
|
||||||
|
"""The suite modules one module imports itself, in import order."""
|
||||||
|
with _PARTS_LOCK:
|
||||||
|
hit = _SIBLINGS.get(path)
|
||||||
|
if hit is None:
|
||||||
|
_text, tree = _parsed(path)
|
||||||
|
here = os.path.dirname(path)
|
||||||
|
hit = []
|
||||||
|
for node in (tree.body if tree is not None else []):
|
||||||
|
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
|
||||||
|
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
|
||||||
|
if os.path.isfile(sib):
|
||||||
|
hit.append(sib)
|
||||||
|
_SIBLINGS[path] = hit
|
||||||
|
return hit
|
||||||
|
|
||||||
|
|
||||||
def implementation_sha(path, test_id):
|
def implementation_sha(path, test_id):
|
||||||
"""The implementation hash of one test (see Test.source_sha): the
|
"""The implementation hash of one test (see Test.source_sha): the
|
||||||
test's own text, the shared text of its module, and the shared text
|
test's own text, the shared text of its module, and the shared text
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -292,11 +297,11 @@ class Fixture:
|
|||||||
return bool(st and st.get("button_enabled"))
|
return bool(st and st.get("button_enabled"))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def act(self, channel, state):
|
def act(self, channel, state, ms=None):
|
||||||
if channel == "button":
|
if channel == "button":
|
||||||
if state != "press":
|
if state != "press":
|
||||||
raise FixtureError("the button is only ever pressed")
|
raise FixtureError("the button is only ever pressed")
|
||||||
st, body = self._press()
|
st, body = self._press(ms)
|
||||||
else:
|
else:
|
||||||
if state not in ("open", "close"):
|
if state not in ("open", "close"):
|
||||||
raise FixtureError("%s: unknown state %r" % (channel, state))
|
raise FixtureError("%s: unknown state %r" % (channel, state))
|
||||||
@@ -307,8 +312,11 @@ class Fixture:
|
|||||||
self.last_state = body
|
self.last_state = body
|
||||||
return body
|
return body
|
||||||
|
|
||||||
def _press(self):
|
def _press(self, ms=None):
|
||||||
"""One press, spaced from the last one. The controller detects
|
"""One press, spaced from the last one. `ms` is how long the box
|
||||||
|
holds it (the firmware clamps it into 20 to 500, and takes 200 when
|
||||||
|
it is not said): a check that must reach the machine *while* the
|
||||||
|
button is down asks for the longest press the box can make. The controller detects
|
||||||
the button's rising edge, so a press must follow a release it has
|
the button's rising edge, so a press must follow a release it has
|
||||||
seen: the next press waits for the last pulse to end (the
|
seen: the next press waits for the last pulse to end (the
|
||||||
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
|
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
|
||||||
@@ -317,7 +325,7 @@ class Fixture:
|
|||||||
wait = self._pulse_end + BUTTON_GAP_S - time.time()
|
wait = self._pulse_end + BUTTON_GAP_S - time.time()
|
||||||
if wait > 0:
|
if wait > 0:
|
||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
st, body = self._request("POST", "/button", {})
|
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
|
||||||
if st == 409 and "in progress" in str(body.get("error", "")):
|
if st == 409 and "in progress" in str(body.get("error", "")):
|
||||||
deadline = time.time() + PULSE_WAIT_S
|
deadline = time.time() + PULSE_WAIT_S
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
@@ -326,7 +334,7 @@ class Fixture:
|
|||||||
if s == 200 and not b.get("button_pulsing"):
|
if s == 200 and not b.get("button_pulsing"):
|
||||||
break
|
break
|
||||||
time.sleep(BUTTON_GAP_S)
|
time.sleep(BUTTON_GAP_S)
|
||||||
st, body = self._request("POST", "/button", {})
|
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
|
||||||
if st == 200:
|
if st == 200:
|
||||||
self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0
|
self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0
|
||||||
return st, body
|
return st, body
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -319,6 +324,40 @@ def initd(service, action, timeout=60):
|
|||||||
return p.returncode, p.stdout.decode("utf-8", "replace")
|
return p.returncode, p.stdout.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
# The extension host's side of the machine (forgefirm-sandbox, forgeext).
|
||||||
|
EXT_POOL_FIRST, EXT_POOL_LAST = 800, 831
|
||||||
|
EXT_ROOT = "/data/forgefirm/ext"
|
||||||
|
EXT_TEST_PREFIX = "org.forgetest." # a package a test made; every other one is the operator's
|
||||||
|
FORGEEXT = "/usr/bin/forgeext"
|
||||||
|
|
||||||
|
|
||||||
|
def pool_pids():
|
||||||
|
"""{pid: uid} of every process that runs under an extension pool account."""
|
||||||
|
out = {}
|
||||||
|
try:
|
||||||
|
pids = [p for p in os.listdir("/proc") if p.isdigit()]
|
||||||
|
except OSError:
|
||||||
|
return out
|
||||||
|
for pid in pids:
|
||||||
|
try:
|
||||||
|
with open("/proc/%s/status" % pid) as f:
|
||||||
|
line = next((x for x in f if x.startswith("Uid:")), "")
|
||||||
|
uid = int(line.split()[1]) # the real uid; a service has all four alike
|
||||||
|
except (OSError, IndexError, ValueError):
|
||||||
|
continue
|
||||||
|
if EXT_POOL_FIRST <= uid <= EXT_POOL_LAST:
|
||||||
|
out[int(pid)] = uid
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def ext_packages():
|
||||||
|
"""The ids of the installed extension packages."""
|
||||||
|
try:
|
||||||
|
return sorted(d for d in os.listdir(EXT_ROOT + "/pkg") if os.path.isdir(os.path.join(EXT_ROOT, "pkg", d)))
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def pidof(comm):
|
def pidof(comm):
|
||||||
"""PIDs whose /proc/<pid>/comm equals comm (15-char kernel limit applies)."""
|
"""PIDs whose /proc/<pid>/comm equals comm (15-char kernel limit applies)."""
|
||||||
out = []
|
out = []
|
||||||
@@ -356,6 +395,44 @@ def pgrep_f(needle):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def listens_on(pid, path):
|
||||||
|
"""Process pid holds a listening UNIX socket bound at path: one of its
|
||||||
|
own descriptors is that socket, so a socket file another process left
|
||||||
|
behind does not count. Reads /proc (FORGETEST_PROC_ROOT for tests)."""
|
||||||
|
if not pid:
|
||||||
|
return False
|
||||||
|
proc = os.environ.get("FORGETEST_PROC_ROOT") or "/proc"
|
||||||
|
inodes = set()
|
||||||
|
try:
|
||||||
|
with open(os.path.join(proc, "net", "unix")) as f:
|
||||||
|
next(f, None) # the header line
|
||||||
|
for ln in f:
|
||||||
|
# Num RefCount Protocol Flags Type St Inode Path
|
||||||
|
parts = ln.split()
|
||||||
|
try:
|
||||||
|
if len(parts) >= 8 and parts[7] == path and int(parts[3], 16) & 0x10000:
|
||||||
|
inodes.add(parts[6]) # __SO_ACCEPTCON: a listener
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
if not inodes:
|
||||||
|
return False
|
||||||
|
fd_dir = os.path.join(proc, str(pid), "fd")
|
||||||
|
try:
|
||||||
|
fds = os.listdir(fd_dir)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
for fd in fds:
|
||||||
|
try:
|
||||||
|
link = os.readlink(os.path.join(fd_dir, fd))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if link.startswith("socket:[") and link[8:-1] in inodes:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def run(cmd, timeout=60):
|
def run(cmd, timeout=60):
|
||||||
"""Run a command list; returns (rc, combined output)."""
|
"""Run a command list; returns (rc, combined output)."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -88,10 +93,27 @@ NON_BEHAVIORAL = [
|
|||||||
("*", ".vscode/**"),
|
("*", ".vscode/**"),
|
||||||
("*", ".env.example"),
|
("*", ".env.example"),
|
||||||
("forgectrl", "tools/**"), # host-side dev tools (panel dev server)
|
("forgectrl", "tools/**"), # host-side dev tools (panel dev server)
|
||||||
|
("forgectrl", "examples/**"), # clients of the remote API, run on another computer
|
||||||
|
# forgeext's recipe installs the binary and its init script, and nothing of these: the author's kit
|
||||||
|
# and the host tools run off the image (OpenGlow's own packages have repositories of their own).
|
||||||
|
("forgeext", "sdk/**"),
|
||||||
|
("forgeext", "template/**"),
|
||||||
|
("forgeext", "tools/**"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Paths the list above would take out that are behavior all the same: forgectrl embeds its advisory
|
||||||
|
# documents in the binary, serves them, and records the operator's consent to a document by its hash, so
|
||||||
|
# an edited advisory is a changed consent and must move every fingerprint that covers it.
|
||||||
|
BEHAVIORAL = [
|
||||||
|
("forgectrl", "docs/advisories/**"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def non_behavioral(comp, path, allow=NON_BEHAVIORAL):
|
def non_behavioral(comp, path, allow=NON_BEHAVIORAL):
|
||||||
|
for c, pat in BEHAVIORAL:
|
||||||
|
if c == comp and glob_to_regex(pat).match(path):
|
||||||
|
return False
|
||||||
for c, pat in allow:
|
for c, pat in allow:
|
||||||
if c in ("*", comp) and glob_to_regex(pat).match(path):
|
if c in ("*", comp) and glob_to_regex(pat).match(path):
|
||||||
return True
|
return True
|
||||||
@@ -198,7 +220,8 @@ def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
|
|||||||
"""Which manifest paths no test covers.
|
"""Which manifest paths no test covers.
|
||||||
|
|
||||||
tests: iterable with .covers. allow: iterable of (component, glob)
|
tests: iterable with .covers. allow: iterable of (component, glob)
|
||||||
that need no coverage (docs, CI, licenses...). Returns
|
that need no coverage (docs, CI, licenses...); a BEHAVIORAL path is
|
||||||
|
never allowed away, as it is never taken out of a fingerprint. Returns
|
||||||
{component: [uncovered paths]} for the non-dev-only components.
|
{component: [uncovered paths]} for the non-dev-only components.
|
||||||
"""
|
"""
|
||||||
covered = {}
|
covered = {}
|
||||||
@@ -212,11 +235,14 @@ def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
|
|||||||
for comp in manifest.component_names():
|
for comp in manifest.component_names():
|
||||||
if comp in DEV_ONLY_COMPONENTS:
|
if comp in DEV_ONLY_COMPONENTS:
|
||||||
continue
|
continue
|
||||||
rxs = covered.get(comp, []) + allowed.get(comp, [])
|
rxs = covered.get(comp, [])
|
||||||
star = allowed.get("*", [])
|
free = allowed.get(comp, []) + allowed.get("*", [])
|
||||||
|
behavior = [glob_to_regex(pat) for c, pat in BEHAVIORAL if c == comp]
|
||||||
missing = []
|
missing = []
|
||||||
for p, _b in manifest.files(comp):
|
for p, _b in manifest.files(comp):
|
||||||
if any(rx.match(p) for rx in rxs) or any(rx.match(p) for rx in star):
|
if any(rx.match(p) for rx in rxs):
|
||||||
|
continue
|
||||||
|
if any(rx.match(p) for rx in free) and not any(rx.match(p) for rx in behavior):
|
||||||
continue
|
continue
|
||||||
missing.append(p)
|
missing.append(p)
|
||||||
if missing:
|
if missing:
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
@@ -356,7 +366,7 @@ class Context:
|
|||||||
"""One of forgectrl's switch readings (lid, interlock_ok, ...)."""
|
"""One of forgectrl's switch readings (lid, interlock_ok, ...)."""
|
||||||
return (self.forgectrl.status().get("switches") or {}).get(name)
|
return (self.forgectrl.status().get("switches") or {}).get(name)
|
||||||
|
|
||||||
def act(self, channel, state, until=None, timeout=ACT_TIMEOUT_S, text="", fail=True):
|
def act(self, channel, state, until=None, timeout=ACT_TIMEOUT_S, text="", fail=True, ms=None):
|
||||||
"""A machine action by name: ("lid", "open"|"close"),
|
"""A machine action by name: ("lid", "open"|"close"),
|
||||||
("interlock", "open"|"close"), ("button", "press"). The bench's
|
("interlock", "open"|"close"), ("button", "press"). The bench's
|
||||||
actuator performs it when one covers the channel (the runner's
|
actuator performs it when one covers the channel (the runner's
|
||||||
@@ -395,7 +405,7 @@ class Context:
|
|||||||
self.log("ACT %s %s (fixture)", channel, state)
|
self.log("ACT %s %s (fixture)", channel, state)
|
||||||
rec["by"] = "fixture"
|
rec["by"] = "fixture"
|
||||||
try:
|
try:
|
||||||
fixture.act(channel, state)
|
fixture.act(channel, state, ms)
|
||||||
except _fixture.FixtureError as e:
|
except _fixture.FixtureError as e:
|
||||||
rec["fixture_error"] = str(e)
|
rec["fixture_error"] = str(e)
|
||||||
if self.run.unattended:
|
if self.run.unattended:
|
||||||
@@ -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
|
||||||
@@ -490,7 +539,7 @@ class Context:
|
|||||||
return hw.Grbl()
|
return hw.Grbl()
|
||||||
|
|
||||||
def takeover(self):
|
def takeover(self):
|
||||||
return Takeover(self.run.log, self.test.id)
|
return Takeover(self.run.log, self.test.id, run=self.run)
|
||||||
|
|
||||||
def counters_rezeroed(self):
|
def counters_rezeroed(self):
|
||||||
"""Tell the baseline the kernel position counters were re-zeroed
|
"""Tell the baseline the kernel position counters were re-zeroed
|
||||||
@@ -500,6 +549,10 @@ class Context:
|
|||||||
cap = self.run.baseline_captured
|
cap = self.run.baseline_captured
|
||||||
if cap and cap.get("position") is not None:
|
if cap and cap.get("position") is not None:
|
||||||
cap["position"] = [0, 0, 0]
|
cap["position"] = [0, 0, 0]
|
||||||
|
cap["rezero_declared"] = True # the test vouches for the new frame
|
||||||
|
# ... and it is the frame now in force, so a takeover later in
|
||||||
|
# the run can still judge where the head stands against it
|
||||||
|
cap["frame"] = _baseline.counter_frame()
|
||||||
self.log("position counters re-zeroed at the starting position; the baseline "
|
self.log("position counters re-zeroed at the starting position; the baseline "
|
||||||
"expects (0,0,0) at the end")
|
"expects (0,0,0) at the end")
|
||||||
|
|
||||||
@@ -529,11 +582,16 @@ class Takeover:
|
|||||||
"cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
|
"cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
|
||||||
"pic/x_step_current", "pic/y_step_current")
|
"pic/x_step_current", "pic/y_step_current")
|
||||||
|
|
||||||
def __init__(self, log, who):
|
def __init__(self, log, who, run=None):
|
||||||
self.log = log # callable(str)
|
self.log = log # callable(str)
|
||||||
self.who = who
|
self.who = who
|
||||||
self.marker = marker_path()
|
self.marker = marker_path()
|
||||||
self.saved = {}
|
self.saved = {}
|
||||||
|
self.run = run # the test's run, whose baseline the head is judged against
|
||||||
|
self.cloud = False
|
||||||
|
self.offline = False # the cloud client found is the offline service
|
||||||
|
self.frame = None # the counters' frame, read before the controller is stopped
|
||||||
|
self.found = None # (counters, microstep mode) once the controller is gone
|
||||||
|
|
||||||
def wait_settled(self):
|
def wait_settled(self):
|
||||||
return _baseline.Baseline(self.log).wait_settled()
|
return _baseline.Baseline(self.log).wait_settled()
|
||||||
@@ -560,6 +618,18 @@ class Takeover:
|
|||||||
self.saved[attr] = v
|
self.saved[attr] = v
|
||||||
if self.saved:
|
if self.saved:
|
||||||
log("takeover: preserving %s" % ", ".join("%s=%s" % kv for kv in self.saved.items()))
|
log("takeover: preserving %s" % ", ".join("%s=%s" % kv for kv in self.saved.items()))
|
||||||
|
try:
|
||||||
|
st, mode = hw.Forgectrl().get("/mode")
|
||||||
|
self.cloud = st == 200 and isinstance(mode, dict) and mode.get("mode") == "cloud"
|
||||||
|
self.offline = self.cloud and hw.listens_on(mode.get("pid"), _baseline.OFFLINE_SOCKET)
|
||||||
|
except hw.HwError:
|
||||||
|
pass
|
||||||
|
if self.offline:
|
||||||
|
log("takeover: the cloud client is the offline service (pid %s); forgectrl's start "
|
||||||
|
"brings it back offline" % mode.get("pid"))
|
||||||
|
# The frame is read while the controller runs: its exit takes the
|
||||||
|
# anchor with it (forgectrl unlinks it), though not the counters.
|
||||||
|
self.frame = _baseline.counter_frame()
|
||||||
log("takeover: stopping the controller through forgectrl")
|
log("takeover: stopping the controller through forgectrl")
|
||||||
try:
|
try:
|
||||||
st, body = hw.Forgectrl().post("/controller/stop")
|
st, body = hw.Forgectrl().post("/controller/stop")
|
||||||
@@ -582,10 +652,146 @@ class Takeover:
|
|||||||
self.__exit__(None, None, None)
|
self.__exit__(None, None, None)
|
||||||
raise Failed("takeover: processes still alive after stop: %s" % left)
|
raise Failed("takeover: processes still alive after stop: %s" % left)
|
||||||
log("takeover: pulse device free")
|
log("takeover: pulse device free")
|
||||||
|
try:
|
||||||
|
self.position_at_start()
|
||||||
|
except Exception as e: # noqa: BLE001 - a reading never keeps forgectrl down
|
||||||
|
log("takeover: WARNING the head's position could not be judged: %s: %s" % (type(e).__name__, e))
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
# A controller start re-zeroes the step counters where the head then
|
||||||
|
# stands, and forgectrl's start after a takeover is one, so the
|
||||||
|
# baseline's check after the run cannot see a head left out before it.
|
||||||
|
# The takeover judges it at the two moments it can: as the controller
|
||||||
|
# goes away (against where the run expects the head) and before it
|
||||||
|
# comes back (against where the takeover found it). A miss is recorded
|
||||||
|
# for the baseline's post pass, which fails the run; nothing is moved,
|
||||||
|
# because a jog sized from these counters is only as good as the frame
|
||||||
|
# they are in. Cloud mode is not judged: its counters are the cloud
|
||||||
|
# client's, as in the baseline.
|
||||||
|
|
||||||
|
def _record(self, where, found, expected):
|
||||||
|
cap = self.run.baseline_captured if self.run is not None else None
|
||||||
|
if cap is not None:
|
||||||
|
cap.setdefault("restart_positions", []).append(
|
||||||
|
{"where": where, "found": found, "expected": expected})
|
||||||
|
self.log("takeover: the head is not where it belongs at the %s: counters %s, expected %s; "
|
||||||
|
"recorded as a leftover, nothing moved" % (where, found, expected))
|
||||||
|
|
||||||
|
def position_at_start(self):
|
||||||
|
cap = self.run.baseline_captured if self.run is not None else None
|
||||||
|
if not cap or self.cloud:
|
||||||
|
return
|
||||||
|
now = _baseline.read_position()
|
||||||
|
self.found = (now, hw.sysfs_read("cnc/x_mode"))
|
||||||
|
was = cap.get("position")
|
||||||
|
if now is None or was is None:
|
||||||
|
return
|
||||||
|
if self.frame is None or cap.get("frame") != self.frame:
|
||||||
|
self.log("takeover: the counters are not in the frame the run began in (a controller "
|
||||||
|
"start or a home the test did not declare), so the head is not judged here")
|
||||||
|
return
|
||||||
|
if now != was and not _baseline.position_quantized(was, now, _baseline.counter_steps_per_mm()):
|
||||||
|
self._record("takeover start", now, was)
|
||||||
|
|
||||||
|
def position_at_end(self):
|
||||||
|
if self.found is None:
|
||||||
|
return
|
||||||
|
was, mode = self.found
|
||||||
|
now = _baseline.read_position()
|
||||||
|
if was is None or now is None:
|
||||||
|
return
|
||||||
|
if hw.sysfs_read("cnc/x_mode") != mode:
|
||||||
|
self.log("takeover: the microstep mode changed under the takeover, so the counters "
|
||||||
|
"before and after it are not one scale; the head is not judged here")
|
||||||
|
return
|
||||||
|
if now != was and not _baseline.position_quantized(was, now, _baseline.counter_steps_per_mm()):
|
||||||
|
self._record("takeover end", now, was)
|
||||||
|
|
||||||
|
# In cloud mode forgectrl's start at the end starts a cloud client, and
|
||||||
|
# what that client is comes only from the one-start markers gfcloud
|
||||||
|
# reads and takes down as it starts. Started bare, it is the online
|
||||||
|
# client with the service's connect-time hunt, and the moves the
|
||||||
|
# service sends after the hunt are still running as the run ends and
|
||||||
|
# the next one begins, whatever the takeover found. The start is made
|
||||||
|
# under the no-hunt marker, as every client start the runner makes is,
|
||||||
|
# and under the offline marker as well when the takeover found the
|
||||||
|
# offline service; that client is then proven to be the offline
|
||||||
|
# service, and a miss is recorded for the baseline's post pass, which
|
||||||
|
# fails the run.
|
||||||
|
OFFLINE_BACK_S = 60 # the start to the offline service's listener
|
||||||
|
|
||||||
|
def client_markers(self):
|
||||||
|
"""(path, name) of the markers forgectrl's start is made under."""
|
||||||
|
if not self.cloud:
|
||||||
|
return []
|
||||||
|
marks = [(_baseline.NOHUNT_MARKER, "no-hunt")]
|
||||||
|
if self.offline:
|
||||||
|
marks.append((_baseline.OFFLINE_MARKER, "offline"))
|
||||||
|
return marks
|
||||||
|
|
||||||
|
def markers_on(self):
|
||||||
|
for path, name in self.client_markers():
|
||||||
|
try:
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write("forgetest takeover %s\n" % self.who)
|
||||||
|
except OSError as e:
|
||||||
|
self.log("takeover: WARNING the %s marker could not be written: %s" % (name, e))
|
||||||
|
|
||||||
|
def wait_offline(self):
|
||||||
|
"""The cloud client's pid once it listens on the offline socket, or
|
||||||
|
None after OFFLINE_BACK_S."""
|
||||||
|
deadline = time.time() + self.OFFLINE_BACK_S
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
st, m = hw.Forgectrl().get("/mode")
|
||||||
|
except hw.HwError:
|
||||||
|
st, m = None, None
|
||||||
|
pid = m.get("pid") if st == 200 and isinstance(m, dict) else None
|
||||||
|
if hw.listens_on(pid, _baseline.OFFLINE_SOCKET):
|
||||||
|
return pid
|
||||||
|
if time.time() >= deadline:
|
||||||
|
return None
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
def client_back(self, mode):
|
||||||
|
"""After forgectrl's start: a marker no client read is taken down
|
||||||
|
(no client started - the gate, standby, a fault - and a later
|
||||||
|
start must not come up under it), and the offline service found is
|
||||||
|
judged against the client that came up."""
|
||||||
|
if not self.cloud:
|
||||||
|
return
|
||||||
|
mode = mode if isinstance(mode, dict) else {}
|
||||||
|
running = mode.get("mode") == "cloud" and mode.get("controller") == "running"
|
||||||
|
pid = self.wait_offline() if self.offline and running else None
|
||||||
|
for path, name in self.client_markers():
|
||||||
|
if (not running or name == "offline") and os.path.exists(path):
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
self.log("takeover: the %s marker was never read - taken down" % name)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if not self.offline:
|
||||||
|
return
|
||||||
|
if pid:
|
||||||
|
self.log("takeover: the cloud client is back as the offline service (pid %s)" % pid)
|
||||||
|
return
|
||||||
|
found = ("a client that is not the offline service (pid %s)" % mode.get("pid") if running
|
||||||
|
else "no cloud client running (mode %s, controller %s)"
|
||||||
|
% (mode.get("mode"), mode.get("controller")))
|
||||||
|
cap = self.run.baseline_captured if self.run is not None else None
|
||||||
|
if cap is not None:
|
||||||
|
cap.setdefault("restart_clients", []).append(
|
||||||
|
{"where": "takeover end", "found": found, "expected": "the offline service"})
|
||||||
|
self.log("takeover: the offline service did not come back: %s; recorded as a leftover" % found)
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc, tb):
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
try:
|
||||||
|
self.position_at_end()
|
||||||
|
except Exception as e: # noqa: BLE001 - a reading never keeps forgectrl down
|
||||||
|
self.log("takeover: WARNING the head's position could not be judged: %s: %s"
|
||||||
|
% (type(e).__name__, e))
|
||||||
self.restore_attrs()
|
self.restore_attrs()
|
||||||
|
self.markers_on()
|
||||||
rc, out = hw.initd("forgectrl", "start")
|
rc, out = hw.initd("forgectrl", "start")
|
||||||
self.log("takeover: forgectrl start -> rc %s" % rc)
|
self.log("takeover: forgectrl start -> rc %s" % rc)
|
||||||
try:
|
try:
|
||||||
@@ -594,7 +800,12 @@ class Takeover:
|
|||||||
pass
|
pass
|
||||||
# leave the machine settled for whatever runs next: the probe
|
# leave the machine settled for whatever runs next: the probe
|
||||||
# move done, the controller back (or the ladder's verdict logged)
|
# move done, the controller back (or the ladder's verdict logged)
|
||||||
self.wait_settled()
|
mode = self.wait_settled()
|
||||||
|
try:
|
||||||
|
self.client_back(mode)
|
||||||
|
except Exception as e: # noqa: BLE001 - the judge never breaks the exit path
|
||||||
|
self.log("takeover: WARNING the cloud client could not be judged: %s: %s"
|
||||||
|
% (type(e).__name__, e))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -994,7 +1205,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,16 +1,38 @@
|
|||||||
|
# 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
|
||||||
|
from . import cooling_intake # noqa: F401,E402
|
||||||
from . import laser # noqa: F401,E402
|
from . import laser # noqa: F401,E402
|
||||||
from . import camera # noqa: F401,E402
|
from . import camera # noqa: F401,E402
|
||||||
from . import update # noqa: F401,E402
|
from . import update # noqa: F401,E402
|
||||||
from . import cloud # noqa: F401,E402
|
from . import cloud # noqa: F401,E402
|
||||||
|
# Last: it stands on the setup record's consent and on the cloud suite's dark print.
|
||||||
|
from . import exthost # noqa: F401,E402
|
||||||
|
from . import extcore # noqa: F401,E402
|
||||||
|
from . import extcall # noqa: F401,E402
|
||||||
|
from . import extdest # noqa: F401,E402
|
||||||
|
from . import extlife # noqa: F401,E402
|
||||||
|
from . import evmore # noqa: F401,E402
|
||||||
|
from . import extcat # noqa: F401,E402
|
||||||
|
from . import extmcode # noqa: F401,E402
|
||||||
|
from . import extwizard # noqa: F401,E402
|
||||||
|
from . import updlock # noqa: F401,E402
|
||||||
|
from . import homeoff # noqa: F401,E402
|
||||||
|
from . import bedsize # noqa: F401,E402
|
||||||
|
from . import extsender # noqa: F401,E402
|
||||||
|
from . import tray # noqa: F401,E402
|
||||||
|
from . import tlsrec # noqa: F401,E402
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""The Setup page's bed check, on the machine.
|
||||||
|
|
||||||
|
Its own module, so that no other test's fingerprint moves. The check is
|
||||||
|
driven from here inside the travel the machine already knows: a few jogs
|
||||||
|
out from the camera home, never toward an end, so it needs no operator.
|
||||||
|
Measuring the real ends is the operator's own run of the check. The
|
||||||
|
homing is the web-service session homing.cloud-offsets runs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .cloud import _HOMING_PATH, gfhome_homing
|
||||||
|
from .homeoff import camera_home_return, judge_whole_motions, session_mark
|
||||||
|
from .setup import read_file, record_path, write_file
|
||||||
|
from .setup_dark import DARK_COVERS, answer, dark, start
|
||||||
|
|
||||||
|
WID = "motion.envelope"
|
||||||
|
END = "This is the end"
|
||||||
|
# The operator's answers, as the page sends them: out 60.1 mm in X and
|
||||||
|
# 61.1 mm in Y from the home, the fine steps included. A far edge is 50 mm
|
||||||
|
# at the least, so the ends are well past that and well inside the bed.
|
||||||
|
X_STEPS = ["X+10"] * 6 + ["X+1", "X+0.1", "X-1"]
|
||||||
|
Y_STEPS = ["Y+10"] * 6 + ["Y+1", "Y+0.1"]
|
||||||
|
X_OUT, Y_OUT = 60.1, 61.1
|
||||||
|
MARGIN_MM = 1.0
|
||||||
|
# The head is back on the home's step: half a step (213.3 steps/mm) and
|
||||||
|
# the port's three decimals.
|
||||||
|
HOME_TOL_MM = 0.003
|
||||||
|
KEYS =("envelope_x_mm", "envelope_y_mm", "homing_mode")
|
||||||
|
CLOSED = "closed the check's envelope"
|
||||||
|
CLOSED_MSG = "Bed check envelope closed: a sender line"
|
||||||
|
|
||||||
|
|
||||||
|
def _port(fc):
|
||||||
|
st, body = fc.get("/motion/state")
|
||||||
|
return body if st == 200 and isinstance(body, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _drive(ctx, fc, x_steps, y_steps, hook=None, timeout=300):
|
||||||
|
"""Start the check and answer each prompt from the steps in turn, then
|
||||||
|
This is the end; hook(prompt_id, n) runs before the answer to the n-th
|
||||||
|
prompt. The final status document, and the answers given."""
|
||||||
|
# The daemon calls the machine busy for a moment after the last jog.
|
||||||
|
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "the machine is not idle: %s", fc.status().get("state"))
|
||||||
|
start(ctx, fc, WID)
|
||||||
|
queue = {"x-end": list(x_steps) + [END], "y-end": list(y_steps) + [END]}
|
||||||
|
seen, given = set(), []
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
while time.time() - t0 < timeout:
|
||||||
|
ctx.checkpoint()
|
||||||
|
d = dark(fc)
|
||||||
|
p = d.get("prompt")
|
||||||
|
if d.get("running") and p and p.get("seq") not in seen:
|
||||||
|
seen.add(p["seq"])
|
||||||
|
pid = p.get("id")
|
||||||
|
ctx.check(pid in queue and queue[pid], "the check asked %s: %s (the lid must be closed)",
|
||||||
|
pid, p.get("text"))
|
||||||
|
ctx.check(p.get("kind") == "jog" and END in (p.get("options") or []),
|
||||||
|
"the %s prompt is not a jog with %r: %s", pid, END, p)
|
||||||
|
if hook:
|
||||||
|
hook(pid, len(given))
|
||||||
|
value = queue[pid].pop(0)
|
||||||
|
st, body = answer(fc, WID, p, value)
|
||||||
|
ctx.check(st == 200, "the answer %s to %s -> %s %s", value, pid, st, body)
|
||||||
|
given.append(value)
|
||||||
|
if not d.get("running"):
|
||||||
|
return d, given
|
||||||
|
time.sleep(0.3)
|
||||||
|
except BaseException:
|
||||||
|
fc.post("/wiz/%s/abort" % WID)
|
||||||
|
raise
|
||||||
|
fc.post("/wiz/%s/abort" % WID)
|
||||||
|
ctx.fail("the bed check did not end within %d s", timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _jog(fc, dx=0.0, dy=0.0):
|
||||||
|
params = {"feed": "1200"}
|
||||||
|
if dx:
|
||||||
|
params["x"] = "%.3f" % dx
|
||||||
|
if dy:
|
||||||
|
params["y"] = "%.3f" % dy
|
||||||
|
return fc.post("/motion/jog", params=params)
|
||||||
|
|
||||||
|
|
||||||
|
def _settled(ctx, fc):
|
||||||
|
"""After the suite's Grbl client closes, the daemon calls the machine
|
||||||
|
busy for a moment: the check and a settings write each wait for it."""
|
||||||
|
ctx.wait_for(lambda: _port(fc).get("sender") is False, 20, poll=0.5)
|
||||||
|
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "the machine is not idle: %s", fc.status().get("state"))
|
||||||
|
|
||||||
|
|
||||||
|
def _put_back(ctx, fc, found):
|
||||||
|
for k in KEYS:
|
||||||
|
v = found.get(k) or ""
|
||||||
|
|
||||||
|
def put():
|
||||||
|
st_, body_ = fc.post("/settings", params={k: ""}) if v == "" else fc.post("/settings", data={k: v})
|
||||||
|
if st_ != 200:
|
||||||
|
ctx.log("restore %s=%r -> %s %s", k, v, st_, body_)
|
||||||
|
return st_ == 200 or None
|
||||||
|
took = ctx.wait_for(put, 20, poll=0.5)
|
||||||
|
ctx.log("restore %s=%r -> %s", k, v, "taken" if took is not None else "not taken")
|
||||||
|
|
||||||
|
|
||||||
|
def _back_home(ctx, fc, home):
|
||||||
|
"""The head back where the home put it: one jog to the home in machine
|
||||||
|
coordinates, from a Grbl client. A relative jog aimed at the home from
|
||||||
|
the port's position can be refused whole: the port reports the step
|
||||||
|
counters, the core adds a relative jog to its own position, which the
|
||||||
|
check's jogs leave up to half a step off the counters, and the envelope
|
||||||
|
begins at the home, so the target can land microns behind it (error:15).
|
||||||
|
Where the head stands after, from the port; None when unread."""
|
||||||
|
# 0.5 um inside an envelope that begins at the home (a camera home
|
||||||
|
# behind the origin), far under half a step: the head lands on the
|
||||||
|
# home's own step.
|
||||||
|
line = "$J=G90 G53 X%.4f Y%.4f F1200" % (home[0] + 0.0005, home[1] + 0.0005)
|
||||||
|
try:
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
reply = g.command(line, timeout=10)
|
||||||
|
ctx.log("the jog back to the home: %s -> %s", line, " | ".join(reply))
|
||||||
|
if reply[-1:] == ["ok"]:
|
||||||
|
t0 = time.time()
|
||||||
|
ctx.wait_for(lambda: time.time() - t0 > 0.5 and g.status_report()["state"].startswith("Idle"),
|
||||||
|
60, poll=0.2)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
ctx.log("the jog back to the home: %s", e)
|
||||||
|
ctx.wait_for(lambda: _port(fc).get("sender") is False and _port(fc).get("state") == "Idle", 20, poll=0.5)
|
||||||
|
at = _port(fc).get("mpos")
|
||||||
|
ctx.log("the head after the jog back: %s (the home %s)", at, home)
|
||||||
|
return at
|
||||||
|
|
||||||
|
|
||||||
|
def _port_idle(ctx, fc):
|
||||||
|
ok = ctx.wait_for(lambda: (lambda s: s.get("state") == "Idle" and not s.get("port_jog"))(_port(fc)), 30,
|
||||||
|
poll=0.1)
|
||||||
|
ctx.check(ok is not None, "the port jog did not end: %s", _port(fc))
|
||||||
|
|
||||||
|
|
||||||
|
def _edge(ctx, fc, ev, axis, edge):
|
||||||
|
"""From where the head stands, a jog 0.5 mm short of the far edge runs
|
||||||
|
and comes back, and one 0.5 mm past it is refused with nothing moved."""
|
||||||
|
s = _port(fc)
|
||||||
|
at = s["mpos"]["XY".index(axis)]
|
||||||
|
short, past = edge - 0.5 - at, edge + 0.5 - at
|
||||||
|
kw = (lambda d: {"dx": d}) if axis == "X" else (lambda d: {"dy": d})
|
||||||
|
st1, b1 = _jog(fc, **kw(short))
|
||||||
|
_port_idle(ctx, fc)
|
||||||
|
st2, b2 = _jog(fc, **kw(-short))
|
||||||
|
_port_idle(ctx, fc)
|
||||||
|
st3, b3 = _jog(fc, **kw(past))
|
||||||
|
_port_idle(ctx, fc)
|
||||||
|
back = _port(fc)["mpos"]["XY".index(axis)]
|
||||||
|
ev["edge_" + axis] = {"edge": edge, "short": [st1, st2], "past": [st3, b3], "at": [at, back]}
|
||||||
|
ctx.check(st1 == 200 and st2 == 200, "a jog 0.5 mm short of the %s edge %.2f and back -> %s %s, %s %s",
|
||||||
|
axis, edge, st1, b1, st2, b2)
|
||||||
|
ctx.check(st3 == 409 and "envelope" in json.dumps(b3) and abs(back - at) < 0.02,
|
||||||
|
"a jog 0.5 mm past the %s edge %.2f -> %s %s, the head at %.3f from %.3f", axis, edge, st3, b3, back, at)
|
||||||
|
|
||||||
|
|
||||||
|
@test("setup.check-envelope", title="The bed check measures the far edges from the operator's jogs, and a Grbl "
|
||||||
|
"client's line ends it with nothing written",
|
||||||
|
subsystem="setup", kind="auto", mode="grbl", est_min=10,
|
||||||
|
covers=DARK_COVERS + _HOMING_PATH + [("forgectrl", "src/grblport.*"),
|
||||||
|
("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||||
|
("grblhal-glowforge", "src/ctlport.*"),
|
||||||
|
("grblhal-glowforge", "src/driver.c")],
|
||||||
|
requires=["forgectrl.auth", "motion.pacing"],
|
||||||
|
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. The head moves "
|
||||||
|
"at most about 62 mm out from the camera home in X and in Y, and ends where it was found."],
|
||||||
|
description="With cloud mode on, the machine is homed with the camera (the web-service session), every "
|
||||||
|
"service motion of it run whole. "
|
||||||
|
"POST /wiz/motion.envelope/start runs the Bed size check; its prompts are jogs with This is the "
|
||||||
|
"end among the options, and are answered as the page would, out 60.1 mm in X and 61.1 mm in Y "
|
||||||
|
"(steps of 10, 1, and 0.1 mm). The check ends complete: the ends it reports are the home plus "
|
||||||
|
"those, the far edges 1 mm short of them are written as envelope_x_mm and envelope_y_mm, and the "
|
||||||
|
"controller holds them at once, without a home: a panel jog 0.5 mm short of each edge runs, "
|
||||||
|
"and one 0.5 mm past it is refused with nothing moved. A second run is opened, and while its "
|
||||||
|
"first prompt waits a Grbl client sends one line, which draws the controller's word that it "
|
||||||
|
"closed the envelope and then ok; the port's state then says the envelope is closed, and the "
|
||||||
|
"next answer ends the check in words, with the keys as the "
|
||||||
|
"first run left them and the edges still in force. The keys, the homing mode, and the setup "
|
||||||
|
"record are put back as found, the record under a restart, and the head goes back to the "
|
||||||
|
"home by a Grbl client's jog in machine coordinates; the run fails unless the port reads "
|
||||||
|
"the head on the home's step before the restart. Then the camera home is dropped and the "
|
||||||
|
"head goes back to where the test found it, by the travel the session's motions logged.")
|
||||||
|
def check_envelope(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
s0 = fc.settings() or {}
|
||||||
|
ctx.check(s0.get("cloud_enabled") == "1", "cloud mode is off: a camera home needs it on")
|
||||||
|
raw = read_file(record_path())
|
||||||
|
found = {k: s0.get(k) or "" for k in KEYS}
|
||||||
|
ev["found"] = found
|
||||||
|
home = back = session_at = None
|
||||||
|
try:
|
||||||
|
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
|
||||||
|
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
session_at = session_mark()
|
||||||
|
gfhome_homing(ctx, ev, g)
|
||||||
|
home = g.status_report().get("MPos")
|
||||||
|
judge_whole_motions(ctx, ev, session_at)
|
||||||
|
ev["home"] = home
|
||||||
|
ctx.log("the camera home declared %s", home)
|
||||||
|
ctx.check(home and home[0] is not None, "no position after the home: %s", home)
|
||||||
|
_settled(ctx, fc)
|
||||||
|
|
||||||
|
last, given = _drive(ctx, fc, X_STEPS, Y_STEPS)
|
||||||
|
r = last.get("result") or {}
|
||||||
|
ev["run1"] = {"answers": given, "error": last.get("error"), "result": r, "log": last.get("log", [])[-8:]}
|
||||||
|
ctx.log("the bed check ended: %s", json.dumps(r))
|
||||||
|
ctx.check(not last.get("error"), "the bed check failed: %s", last.get("error"))
|
||||||
|
want_x, want_y = home[0] + X_OUT, home[1] + Y_OUT
|
||||||
|
ctx.check(abs((r.get("x_end_mm") or -1) - want_x) < 0.02 and abs((r.get("y_end_mm") or -1) - want_y) < 0.02,
|
||||||
|
"the ends are not the home plus the jogs (%.2f, %.2f): %s", want_x, want_y, r)
|
||||||
|
edge_x, edge_y = round(want_x - MARGIN_MM, 2), round(want_y - MARGIN_MM, 2)
|
||||||
|
s1 = fc.settings() or {}
|
||||||
|
ev["keys_after_run1"] = {k: s1.get(k) for k in ("envelope_x_mm", "envelope_y_mm")}
|
||||||
|
ctx.check(abs(float(s1.get("envelope_x_mm") or 0) - edge_x) < 0.011 and
|
||||||
|
abs(float(s1.get("envelope_y_mm") or 0) - edge_y) < 0.011,
|
||||||
|
"the keys are not the ends less %.1f mm (%.2f, %.2f): %s", MARGIN_MM, edge_x, edge_y,
|
||||||
|
ev["keys_after_run1"])
|
||||||
|
ctx.check(_port(fc).get("envelope_open") is False, "the envelope is still open after the check: %s",
|
||||||
|
_port(fc))
|
||||||
|
_edge(ctx, fc, ev, "X", edge_x)
|
||||||
|
_edge(ctx, fc, ev, "Y", edge_y)
|
||||||
|
|
||||||
|
# A Grbl client's line while the envelope is open ends the check.
|
||||||
|
said = {}
|
||||||
|
|
||||||
|
def client_line(pid, n):
|
||||||
|
if n:
|
||||||
|
return
|
||||||
|
ctx.check(_port(fc).get("envelope_open") is True, "the second run did not open the envelope: %s",
|
||||||
|
_port(fc))
|
||||||
|
with ctx.grbl() as g2:
|
||||||
|
said["reply"] = g2.command("G90", timeout=10)
|
||||||
|
said["text"] = g2.drain()
|
||||||
|
said["port"] = _port(fc)
|
||||||
|
|
||||||
|
last2, given2 = _drive(ctx, fc, ["X+1"], [], hook=client_line)
|
||||||
|
ev["run2"] = {"answers": given2, "said": said, "error": last2.get("error"), "log": last2.get("log", [])[-6:]}
|
||||||
|
ctx.log("the second run: %s", json.dumps(ev["run2"]))
|
||||||
|
ctx.check((said.get("reply") or [])[-1:] == ["ok"], "the client's line did not draw ok: %s", said)
|
||||||
|
ctx.check(any(CLOSED_MSG in l for l in (said.get("reply") or [])) or CLOSED_MSG in (said.get("text") or ""),
|
||||||
|
"the client was not told its line closed the envelope: %s", said)
|
||||||
|
ctx.check((said.get("port") or {}).get("envelope_open") is False,
|
||||||
|
"the client's line did not close the envelope: %s", said.get("port"))
|
||||||
|
ctx.check(CLOSED in (last2.get("error") or ""), "the second run did not end on the closed envelope: %s",
|
||||||
|
last2.get("error"))
|
||||||
|
s2 = fc.settings() or {}
|
||||||
|
ctx.check({k: s2.get(k) for k in ("envelope_x_mm", "envelope_y_mm")} == ev["keys_after_run1"],
|
||||||
|
"the second run wrote the keys: %s", s2)
|
||||||
|
_settled(ctx, fc)
|
||||||
|
_edge(ctx, fc, ev, "X", edge_x)
|
||||||
|
finally:
|
||||||
|
fc.post("/wiz/%s/abort" % WID)
|
||||||
|
if home and home[0] is not None:
|
||||||
|
ctx.wait_for(lambda: not dark(fc).get("running"), 30, poll=0.5)
|
||||||
|
back = _back_home(ctx, fc, home)
|
||||||
|
_put_back(ctx, fc, found)
|
||||||
|
try:
|
||||||
|
# Before the record's restart: the counters since the home say
|
||||||
|
# where the head stands until a controller start zeroes them.
|
||||||
|
if session_at is not None:
|
||||||
|
camera_home_return(ctx, ev, session_at)
|
||||||
|
finally:
|
||||||
|
# The check records itself when it completes; the record goes back
|
||||||
|
# as found under a restart, which also ends the envelope it set.
|
||||||
|
if read_file(record_path()) != raw:
|
||||||
|
with ctx.takeover():
|
||||||
|
write_file(record_path(), raw)
|
||||||
|
ctx.log("the previous setup record is back under a restart")
|
||||||
|
# Judged from the reading before the restart: the restart zeroes the
|
||||||
|
# counters where the head stands, so the baseline's position check
|
||||||
|
# after it cannot see a head left out.
|
||||||
|
ev["back"] = back
|
||||||
|
ctx.check(back and all(abs(back[i] - home[i]) < HOME_TOL_MM for i in (0, 1)),
|
||||||
|
"the head is not back at the home %s: %s", home, back)
|
||||||
@@ -1,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
|
||||||
|
|
||||||
@@ -58,7 +63,10 @@ def _jpeg_size(data):
|
|||||||
"far smaller), a second snapshot with the lid lamp switched off differs from it "
|
"far smaller), a second snapshot with the lid lamp switched off differs from it "
|
||||||
"and is smaller (the capture is live, not a frame the pipeline is re-serving, "
|
"and is smaller (the capture is live, not a frame the pipeline is re-serving, "
|
||||||
"and the lamp lights what the camera sees), and the MJPEG stream starts and "
|
"and the lamp lights what the camera sees), and the MJPEG stream starts and "
|
||||||
"stops.")
|
"stops. A capture marked background=1 yields to a viewer: while the stream is "
|
||||||
|
"open it is refused in words, the operator's own unmarked capture is still "
|
||||||
|
"served, and once the viewer stops the background capture is served again. A "
|
||||||
|
"background value that is neither 0 nor 1 is refused.")
|
||||||
def snapshot(ctx):
|
def snapshot(ctx):
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
@@ -111,6 +119,48 @@ def snapshot(ctx):
|
|||||||
ev["stream_ctype"] = ctype
|
ev["stream_ctype"] = ctype
|
||||||
ctx.log("stream: %s, first %d bytes", ctype, len(chunk))
|
ctx.log("stream: %s, first %d bytes", ctype, len(chunk))
|
||||||
ctx.check("multipart" in ctype and b"\xff\xd8" in chunk, "stream is not an MJPEG multipart")
|
ctx.check("multipart" in ctype and b"\xff\xd8" in chunk, "stream is not an MJPEG multipart")
|
||||||
|
|
||||||
|
# A capture nobody is waiting for yields to one somebody is: a
|
||||||
|
# snapshot borrows the mux and stutters a running stream, which is a
|
||||||
|
# price worth paying for an operator and not for a program. The
|
||||||
|
# stream above is closed by now, so one is opened and held here.
|
||||||
|
bad = fc.get("/cam/snapshot", params={"cam": "lid", "background": "maybe"})
|
||||||
|
ev["background_bad_value"] = bad
|
||||||
|
ctx.check(bad[0] == 400, "background=maybe -> %s", bad)
|
||||||
|
|
||||||
|
held = urllib.request.urlopen(
|
||||||
|
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}), timeout=10)
|
||||||
|
try:
|
||||||
|
held.read(8192)
|
||||||
|
watching = None
|
||||||
|
for _ in range(40):
|
||||||
|
st_, body_ = fc.get("/cam/status")
|
||||||
|
if isinstance(body_, dict) and body_.get("clients"):
|
||||||
|
watching = body_
|
||||||
|
break
|
||||||
|
ctx.sleep(0.25)
|
||||||
|
ev["clients_while_watching"] = (watching or {}).get("clients")
|
||||||
|
ctx.check(watching, "no viewer was counted while the stream was held: %s", body_)
|
||||||
|
st_, why = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half", "background": "1"})
|
||||||
|
ev["background_while_watched"] = [st_, why]
|
||||||
|
ctx.log("background capture while a viewer watches -> %s %s", st_, why)
|
||||||
|
ctx.check(st_ == 409, "a background capture did not yield to a viewer: %s %s", st_, why)
|
||||||
|
st_, data_ = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
|
||||||
|
ev["operator_while_watched"] = [st_, len(data_ or b"")]
|
||||||
|
ctx.check(st_ == 200 and data_ and data_[:2] == b"\xff\xd8",
|
||||||
|
"the operator's own capture was refused while watching: %s", st_)
|
||||||
|
finally:
|
||||||
|
held.close()
|
||||||
|
for _ in range(40):
|
||||||
|
st_, body_ = fc.get("/cam/status")
|
||||||
|
if isinstance(body_, dict) and not body_.get("clients"):
|
||||||
|
break
|
||||||
|
ctx.sleep(0.25)
|
||||||
|
st_, data_ = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half", "background": "1"}, raw=True)
|
||||||
|
ev["background_after_watching"] = [st_, len(data_ or b"")]
|
||||||
|
ctx.check(st_ == 200 and data_ and data_[:2] == b"\xff\xd8",
|
||||||
|
"a background capture was still refused after the viewer stopped: %s", st_)
|
||||||
|
|
||||||
ctx.sleep(2)
|
ctx.sleep(2)
|
||||||
st, body = fc.get("/cam/status")
|
st, body = fc.get("/cam/status")
|
||||||
ev["cam_status_after"] = body
|
ev["cam_status_after"] = body
|
||||||
@@ -229,10 +279,12 @@ def h264_stream(ctx):
|
|||||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
||||||
description="The capture queue flags a frame errored when it is short, torn, or arrived "
|
description="The capture queue flags a frame errored when it is short, torn, or arrived "
|
||||||
"after the CSI-2 receiver lost sync; forgectrl drops those rather than "
|
"after the CSI-2 receiver lost sync; forgectrl drops those rather than "
|
||||||
"demosaicing them, and restarts the stream if they persist. A healthy machine "
|
"demosaicing them, restarts the stream if they persist, and withholds the "
|
||||||
"captures a burst with none of them. A nonzero corrupt count here is a real "
|
"stream frames on either side of one. A healthy machine captures a burst of "
|
||||||
"signal - a marginal camera ribbon, a mistimed D-PHY - even though the frames "
|
"snapshots and a few seconds of stream with none of them flagged and none "
|
||||||
"themselves never reach a client.")
|
"withheld, and reports the stream's per-stage timing while a viewer watches. "
|
||||||
|
"A nonzero corrupt count here is a real signal - a marginal camera ribbon, a "
|
||||||
|
"mistimed D-PHY - even though the frames themselves never reach a client.")
|
||||||
def frame_health(ctx):
|
def frame_health(ctx):
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
@@ -269,18 +321,63 @@ def frame_health(ctx):
|
|||||||
ctx.check(corrupt == 0, "%d of %d captured frames came back errored", corrupt, captured)
|
ctx.check(corrupt == 0, "%d of %d captured frames came back errored", corrupt, captured)
|
||||||
ctx.check(restarts == 0, "the capture stream was restarted %d time(s)", restarts)
|
ctx.check(restarts == 0, "the capture stream was restarted %d time(s)", restarts)
|
||||||
|
|
||||||
|
# The stream path: five seconds of MJPEG, so the frame gate judges a
|
||||||
|
# few dozen frames and the timing covers two whole windows. A frame is
|
||||||
|
# withheld only next to one the receiver flagged, so a healthy machine
|
||||||
|
# withholds none, and the per-stage timing is reported while the
|
||||||
|
# viewer watches.
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
s0 = health()
|
||||||
|
stream = urllib.request.urlopen(
|
||||||
|
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}),
|
||||||
|
timeout=10)
|
||||||
|
try:
|
||||||
|
got = 0
|
||||||
|
t_end = time.monotonic() + 5.0
|
||||||
|
while time.monotonic() < t_end:
|
||||||
|
got += len(stream.read(65536))
|
||||||
|
st, body = fc.get("/cam/status")
|
||||||
|
timing = body.get("timing") if isinstance(body, dict) else None
|
||||||
|
finally:
|
||||||
|
stream.close()
|
||||||
|
s1 = health()
|
||||||
|
ev["stream_bytes"] = got
|
||||||
|
ev["stream_timing"] = timing
|
||||||
|
ev["stream_health"] = [s0, s1]
|
||||||
|
ctx.log("stream: %d bytes in 5 s, timing %s", got, timing)
|
||||||
|
s_captured = (s1.get("captured") or 0) - (s0.get("captured") or 0)
|
||||||
|
s_corrupt = (s1.get("corrupt") or 0) - (s0.get("corrupt") or 0)
|
||||||
|
s_withheld = (s1.get("withheld") or 0) - (s0.get("withheld") or 0)
|
||||||
|
ev["stream_captured"] = s_captured
|
||||||
|
ev["stream_withheld"] = s_withheld
|
||||||
|
ctx.check(s_captured >= 30, "only %d frames were dequeued in 5 s of stream", s_captured)
|
||||||
|
ctx.check(s_corrupt == 0, "%d stream frames came back errored", s_corrupt)
|
||||||
|
ctx.check("withheld" in s1, "/cam/status health carries no withheld count: %s", s1)
|
||||||
|
ctx.check(s_withheld == 0, "%d stream frames were withheld with none flagged", s_withheld)
|
||||||
|
ctx.check(isinstance(timing, dict) and
|
||||||
|
all(k in timing for k in ("latency_ms", "convert_ms", "copy_ms", "encode_ms")),
|
||||||
|
"/cam/status carries no timing block while a viewer watches: %s", body)
|
||||||
|
latency = (timing or {}).get("latency_ms") or 0
|
||||||
|
ctx.check(0 < latency < 250, "capture to publication took %.1f ms while streaming", latency)
|
||||||
|
|
||||||
@test("camera.lid-privacy", title="Cameras capture only with the lid closed", subsystem="camera",
|
|
||||||
|
@test("camera.lid-privacy", title="Cameras see the room only with the lid closed", subsystem="camera",
|
||||||
kind="operator", est_min=3,
|
kind="operator", est_min=3,
|
||||||
covers=_PRIVACY_COVERS, requires=["forgectrl.panel-serves"], actions=["lid"],
|
covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")], requires=["forgectrl.panel-serves"],
|
||||||
|
actions=["lid"],
|
||||||
steps=["Start with the lid closed; you will be told to open it, then close it again.",
|
steps=["Start with the lid closed; you will be told to open it, then close it again.",
|
||||||
"Nothing moves and the laser is not involved."],
|
"Nothing moves and the laser is not involved."],
|
||||||
description="The privacy gate: with the lid open neither camera captures. A running stream "
|
description="The privacy gate: with the lid open the lid camera does not capture, and the head "
|
||||||
"stops within a frame or so of the lid opening, /cam/status reports capture as "
|
"camera captures only for a local viewer. A running lid-camera stream stops within a "
|
||||||
"not allowed, and both the snapshot and the stream are refused with 409 and a "
|
"frame or so of the lid opening, /cam/status reports capture as not allowed, and the "
|
||||||
"reason naming the lid. Closing the lid restores all of it. This is what stops "
|
"lid camera's snapshot and stream are refused with 409 and a reason naming the lid, "
|
||||||
"the machine - and in cloud mode the Glowforge service - from imaging the room "
|
"to the panel too. The head camera, which looks down at the bed, still answers the "
|
||||||
"through an open lid.")
|
"panel (its token) and the extension host (its client header from this host), while "
|
||||||
|
"the same snapshot and stream asked without either - the cloud client's form - are "
|
||||||
|
"refused for the lid. Closing the lid restores all of it. This is what stops the "
|
||||||
|
"machine - and in cloud mode the Glowforge service - from imaging the room through "
|
||||||
|
"an open lid.")
|
||||||
def lid_privacy(ctx):
|
def lid_privacy(ctx):
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -361,9 +458,26 @@ def lid_privacy(ctx):
|
|||||||
ctx.check(b"lid" in data.lower(), "the refusal should name the lid: %r", data[:120])
|
ctx.check(b"lid" in data.lower(), "the refusal should name the lid: %r", data[:120])
|
||||||
ctx.check(data[:2] != b"\xff\xd8", "a JPEG was returned with the lid open")
|
ctx.check(data[:2] != b"\xff\xd8", "a JPEG was returned with the lid open")
|
||||||
|
|
||||||
|
# The head camera looks at the bed: a local viewer still has it, the cloud client's form does not.
|
||||||
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True)
|
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True)
|
||||||
ctx.log("lid open: head snapshot -> %s", st)
|
ctx.log("lid open: the panel's head snapshot -> %s", st)
|
||||||
ctx.check(st == 409, "the head camera should be refused too, got %s", st)
|
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "the panel's head snapshot with the lid open -> %s", st)
|
||||||
|
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True, auth=False,
|
||||||
|
headers={"X-ForgeFIRM-Client": "extension-host"})
|
||||||
|
ctx.log("lid open: the extension host's head snapshot -> %s", st)
|
||||||
|
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "the extension host's head snapshot with the lid open -> %s", st)
|
||||||
|
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True, auth=False)
|
||||||
|
ev["head_cloud_form_lid_open"] = [st, (data or b"")[:120].decode("utf-8", "replace")]
|
||||||
|
ctx.log("lid open: head snapshot without a token or the header -> %s %s", st, (data or b"")[:120])
|
||||||
|
ctx.check(st == 409 and b"lid" in (data or b"").lower(),
|
||||||
|
"the head camera without a token or the header should be refused for the lid, got %s", st)
|
||||||
|
try:
|
||||||
|
hreq = urllib.request.Request(fc.base + "/cam/stream?cam=head", headers={"Host": fc.host_header()})
|
||||||
|
with urllib.request.urlopen(hreq, timeout=10) as r:
|
||||||
|
ctx.fail("the head stream opened with the lid open, for no local viewer (%s)", r.status)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
ctx.log("lid open: head stream for no local viewer -> %s", e.code)
|
||||||
|
ctx.check(e.code == 409, "the head stream for no local viewer should be refused with 409, got %s", e.code)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=10) as r:
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
@@ -396,7 +510,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
|
||||||
|
|||||||
+226
-111
@@ -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,7 +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")
|
||||||
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
|
# 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"
|
||||||
|
|
||||||
|
|
||||||
def log_size(path):
|
def log_size(path):
|
||||||
@@ -112,40 +119,7 @@ def session_established(lines):
|
|||||||
return all(any(m in ln for ln in lines) for m in SESSION_MARKS)
|
return all(any(m in ln for ln in lines) for m in SESSION_MARKS)
|
||||||
|
|
||||||
|
|
||||||
def return_head(ctx, feed=2400):
|
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90, poll=1.0):
|
||||||
"""Jog the head back to where the run found it: cloud mode re-zeroed the
|
|
||||||
kernel counters at the starting position, so the counters now read the
|
|
||||||
displacement (the home corner). Ends on the machine idle."""
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
pos = fc.status().get("pos") or {}
|
|
||||||
x, y = float(pos.get("x", 0.0)), float(pos.get("y", 0.0))
|
|
||||||
ctx.log("head displacement since the switch: X %.3f Y %.3f mm", x, y)
|
|
||||||
ctx.check(abs(x) <= RETURN_MAX_MM and abs(y) <= RETURN_MAX_MM,
|
|
||||||
"displacement %.1f/%.1f mm exceeds %.0f mm - not jogging back", x, y, RETURN_MAX_MM)
|
|
||||||
if abs(x) < 0.05 and abs(y) < 0.05:
|
|
||||||
return
|
|
||||||
with ctx.grbl() as g:
|
|
||||||
st = g.status_report()["state"]
|
|
||||||
if st.startswith("Alarm"):
|
|
||||||
g.command("$X")
|
|
||||||
r = g.command("$J=G91X%.3fY%.3fF%d" % (-x, -y, feed))
|
|
||||||
ctx.check(not any(k.startswith("error") for k in r), "return jog refused: %s", r)
|
|
||||||
t0 = time.time()
|
|
||||||
while time.time() - t0 < 120:
|
|
||||||
ctx.checkpoint()
|
|
||||||
st = g.status_report()["state"]
|
|
||||||
if st.startswith("Idle") and time.time() - t0 > 0.5:
|
|
||||||
break
|
|
||||||
time.sleep(0.2)
|
|
||||||
g.command("G90")
|
|
||||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the return jog")
|
|
||||||
pos = fc.status().get("pos") or {}
|
|
||||||
ctx.log("head returned: counters X %.3f Y %.3f mm", float(pos.get("x", 0)), float(pos.get("y", 0)))
|
|
||||||
ctx.check(abs(float(pos.get("x", 0))) < 0.1 and abs(float(pos.get("y", 0))) < 0.1,
|
|
||||||
"head not back at the start after the return jog: %s", pos)
|
|
||||||
|
|
||||||
|
|
||||||
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
last = None
|
last = None
|
||||||
while time.time() - t0 < timeout:
|
while time.time() - t0 < timeout:
|
||||||
@@ -157,7 +131,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 +201,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,26 +214,38 @@ 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 where the "
|
||||||
|
"test found it."],
|
||||||
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 "
|
||||||
"service's re-hunt is waited out; switching back brings grblHAL up with the Grbl "
|
"service's re-hunt is waited out; switching back brings grblHAL up with the Grbl "
|
||||||
"port open and Idle, and the head returns to its start. Then $H with "
|
"port open and Idle, and the head returns to its start by the travel the "
|
||||||
|
"client logged for the service's motions. Then $H with "
|
||||||
"homing_mode = gfcloud runs gfhome, the web-service homing session with the "
|
"homing_mode = gfcloud runs gfhome, the web-service homing session with the "
|
||||||
"head-accelerometer motion witness: the controller returns to Idle with "
|
"head-accelerometer motion witness: the controller returns to Idle with "
|
||||||
"homed:true and gfhome reports the homing complete with the motion it saw.")
|
"homed:true, gfhome reports the homing complete with the motion it saw, and "
|
||||||
|
"every service motion of the session ran whole. The camera home is then "
|
||||||
|
"dropped and the head goes back to where the test found it, by the travel the "
|
||||||
|
"session's motions logged.")
|
||||||
def mode_switch(ctx):
|
def mode_switch(ctx):
|
||||||
|
# The hand-back helpers, imported here so that no other cloud test's
|
||||||
|
# fingerprint moves.
|
||||||
|
from .homeoff import camera_home_return, cloud_mode_return, judge_whole_motions, session_mark
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
st, m0 = fc.get("/mode")
|
st, m0 = fc.get("/mode")
|
||||||
@@ -270,17 +261,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
|
||||||
@@ -350,10 +356,9 @@ def mode_switch(ctx):
|
|||||||
st, cam2 = fc.get("/cam/status")
|
st, cam2 = fc.get("/cam/status")
|
||||||
ev["cam_after"] = cam2
|
ev["cam_after"] = cam2
|
||||||
ctx.check(st == 200, "camera status lost after the switch back")
|
ctx.check(st == 200, "camera status lost after the switch back")
|
||||||
# cloud mode's connect cleared the kernel counters at the starting position
|
# The service moved the head in cloud mode, and the controller's start
|
||||||
# and its hunt homed the head: bring it back
|
# zeroed the counters where it left it: the client's record brings it back.
|
||||||
ctx.counters_rezeroed()
|
cloud_mode_return(ctx, ev, log_offset)
|
||||||
return_head(ctx)
|
|
||||||
# cloud mode sets its own lid-lamp level (LLvl) and leaves it: hand back the level found
|
# cloud mode sets its own lid-lamp level (LLvl) and leaves it: hand back the level found
|
||||||
lamp1 = hw.sysfs_read("pic/lid_led")
|
lamp1 = hw.sysfs_read("pic/lid_led")
|
||||||
ev["lid_lamp"] = {"before": lamp0, "after_cloud": lamp1}
|
ev["lid_lamp"] = {"before": lamp0, "after_cloud": lamp1}
|
||||||
@@ -367,13 +372,22 @@ def mode_switch(ctx):
|
|||||||
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
|
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
|
||||||
ctx.log("homing_mode=gfcloud for the homing -> %s %s", st, body if isinstance(body, str) else "")
|
ctx.log("homing_mode=gfcloud for the homing -> %s %s", st, body if isinstance(body, str) else "")
|
||||||
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
|
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
|
||||||
|
session_at = session_mark()
|
||||||
try:
|
try:
|
||||||
with ctx.grbl() as g:
|
with ctx.grbl() as g:
|
||||||
gfhome_homing(ctx, ev, g)
|
gfhome_homing(ctx, ev, g)
|
||||||
|
judge_whole_motions(ctx, ev, session_at)
|
||||||
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)
|
||||||
|
camera_home_return(ctx, ev, session_at)
|
||||||
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"])
|
||||||
|
|
||||||
@@ -488,9 +502,18 @@ NOHUNT_MARK = "NO-HUNT:"
|
|||||||
NOHUNT_MARKER = "/run/gfcloud-nohunt"
|
NOHUNT_MARKER = "/run/gfcloud-nohunt"
|
||||||
HUNT_DONE = "hunt ["
|
HUNT_DONE = "hunt ["
|
||||||
WS_MARKS = ("RX-EVENT: ready", "RX-EVENT: closed", "RECONNECTING", "CLOSING", OFFLINE_MARK)
|
WS_MARKS = ("RX-EVENT: ready", "RX-EVENT: closed", "RECONNECTING", "CLOSING", OFFLINE_MARK)
|
||||||
ACTIVITY_MARKS = ("start motion", "start return home", "starting run", "starting z homing cycle")
|
# The service at work: every line of a service action, the requests, the image
|
||||||
|
# uploads and the ends included. Between a lid image and its next move the
|
||||||
|
# service is thinking and the log is silent, so the ends count: the quiet is
|
||||||
|
# measured from the last line of any action.
|
||||||
|
ACTIVITY_MARKS = ("service action request:", "img_upload COMPLETE", "finished with event",
|
||||||
|
"start motion", "end motion", "start return home", "return home complete",
|
||||||
|
"starting run", "finished run", "starting z homing cycle")
|
||||||
LOG_TAIL_BYTES = 4 << 20
|
LOG_TAIL_BYTES = 4 << 20
|
||||||
QUIET_S = 8 # the re-hunt's motions are ~4 s apart (a lid image between them)
|
# The service's think time after an image, on the bench reference: over 1901
|
||||||
|
# motion, lid-image and hunt requests, half came within 1.6 s of the line before,
|
||||||
|
# 99 percent within 12.8 s, and two after more than 30 s (32.8 and 62.2 s).
|
||||||
|
QUIET_S = 30
|
||||||
QUIET_TIMEOUT_S = 180
|
QUIET_TIMEOUT_S = 180
|
||||||
HUNT_TIMEOUT_S = 180
|
HUNT_TIMEOUT_S = 180
|
||||||
|
|
||||||
@@ -547,6 +570,42 @@ def wait_log(ctx, offset, needles, timeout, poll=0.5):
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
# What the client logs when it turns a print away before the button wait
|
||||||
|
# (machine._safe_to_move): the lid or the interlock, a machine that is not
|
||||||
|
# idle, the coolant above the start ceiling of its own configuration
|
||||||
|
# (THERMAL.max_start_temp), a coolant sensor that reads nothing.
|
||||||
|
REFUSED_BEFORE_THE_BUTTON = ("unsafe to move", "machine is not idle", "machine temp is too high",
|
||||||
|
"coolant sensor reads invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_button_wait(ctx, offset, timeout, also=()):
|
||||||
|
"""Wait for the client's button wait (and the `also` needles). A print
|
||||||
|
the client turns away never gets there: it logs why and finishes at
|
||||||
|
once, so the wait ends on that finish line too and the failure carries
|
||||||
|
the client's own reason, not two minutes of silence. Returns wait_log's
|
||||||
|
result."""
|
||||||
|
needles = list(also) + ["waiting for button"]
|
||||||
|
found = {n: None for n in needles}
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < timeout:
|
||||||
|
ctx.checkpoint()
|
||||||
|
lines = log_lines_since(GFCLOUD_LOG, offset)
|
||||||
|
for ln in lines:
|
||||||
|
for n in needles:
|
||||||
|
if found[n] is None and n in ln:
|
||||||
|
found[n] = ln
|
||||||
|
if all(found.values()):
|
||||||
|
break
|
||||||
|
if found["waiting for button"] is None and action_finish_index(lines, "print") is not None:
|
||||||
|
why = [message(ln) for ln in lines if any(r in ln for r in REFUSED_BEFORE_THE_BUTTON)]
|
||||||
|
fin = lines[action_finish_index(lines, "print")]
|
||||||
|
ctx.fail("the client turned the print away before the button wait: %s (%s)",
|
||||||
|
"; ".join(why[-3:]) or "it logged no reason", message(fin))
|
||||||
|
time.sleep(0.5)
|
||||||
|
ctx.check(found["waiting for button"], "the print never reached the button wait")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
def log_has(offset, needle):
|
def log_has(offset, needle):
|
||||||
"""True when the gfcloud log carries needle since offset (one read;
|
"""True when the gfcloud log carries needle since offset (one read;
|
||||||
the condition an `act` waits on)."""
|
the condition an `act` waits on)."""
|
||||||
@@ -679,9 +738,9 @@ def client_offline(pid):
|
|||||||
|
|
||||||
|
|
||||||
def wait_quiet(ctx, offset, quiet_s=None, timeout=None):
|
def wait_quiet(ctx, offset, quiet_s=None, timeout=None):
|
||||||
"""The service's moves are over: the machine idle and no new service
|
"""The service's moves are over: the machine idle and no new line of any
|
||||||
activity in the log (a motion, a park, a run, a lens homing) for
|
service action in the log (ACTIVITY_MARKS) for quiet_s (default QUIET_S;
|
||||||
quiet_s (default QUIET_S; timeout QUIET_TIMEOUT_S). False on timeout."""
|
timeout QUIET_TIMEOUT_S). False on timeout."""
|
||||||
quiet_s = QUIET_S if quiet_s is None else quiet_s
|
quiet_s = QUIET_S if quiet_s is None else quiet_s
|
||||||
timeout = QUIET_TIMEOUT_S if timeout is None else timeout
|
timeout = QUIET_TIMEOUT_S if timeout is None else timeout
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
@@ -1004,9 +1063,7 @@ def offline_start_print(ctx, off, action_id, path, offset, settings=None):
|
|||||||
client loads the job, lights the button and waits; the operator's
|
client loads the job, lights the button and waits; the operator's
|
||||||
press arms it and the run starts. Returns the 'starting run' line."""
|
press arms it and the run starts. Returns the 'starting run' line."""
|
||||||
off.print_ready(action_id, path, settings)
|
off.print_ready(action_id, path, settings)
|
||||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
wait_button_wait(ctx, offset, 120)
|
||||||
ctx.check(got["waiting for button"], "the offline print never reached the button wait (refused, "
|
|
||||||
"or the job did not load)")
|
|
||||||
ctx.act("button", "press", text="The button is lit white: the press arms the print and the "
|
ctx.act("button", "press", text="The button is lit white: the press arms the print and the "
|
||||||
"head starts to move. Nothing fires: the job commands no laser.",
|
"head starts to move. Nothing fires: the job commands no laser.",
|
||||||
until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=180)
|
until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=180)
|
||||||
@@ -1233,10 +1290,9 @@ def lid_during_button_wait(ctx):
|
|||||||
|
|
||||||
def lid_during_button_wait_body(ctx, ev, off, job, offset):
|
def lid_during_button_wait_body(ctx, ev, off, job, offset):
|
||||||
off.print_ready(9003, job)
|
off.print_ready(9003, job)
|
||||||
got = wait_log(ctx, offset, ["job is longer than the ring", "waiting for button"], 180)
|
got = wait_button_wait(ctx, offset, 180, also=["job is longer than the ring"])
|
||||||
ctx.check(got["job is longer than the ring"],
|
ctx.check(got["job is longer than the ring"],
|
||||||
"the job fit the ring: this test needs a job the ring cannot hold")
|
"the job fit the ring: this test needs a job the ring cannot hold")
|
||||||
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
|
||||||
ctx.act("lid", "open", text="The button is lit: do NOT press it.", timeout=120)
|
ctx.act("lid", "open", text="The button is lit: do NOT press it.", timeout=120)
|
||||||
relock = "button wait lid opened - relocking the laser"
|
relock = "button wait lid opened - relocking the laser"
|
||||||
got = wait_log(ctx, offset, [relock], 60)
|
got = wait_log(ctx, offset, [relock], 60)
|
||||||
@@ -1289,42 +1345,95 @@ 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)
|
||||||
|
wait_button_wait(ctx, offset, 120)
|
||||||
|
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 +1441,52 @@ 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)
|
wait_button_wait(ctx, offset, 120)
|
||||||
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
ev["latch_locked_at_button"] = latch_locked()
|
||||||
ctx.act("button", "press", text="The button is lit: press it. The print then waits for the warm-up.",
|
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")
|
|
||||||
ev["warmup"] = c
|
|
||||||
ctx.check(isinstance(c, dict) and c.get("verdict") == "WARMUP" and c.get("hold") is True
|
|
||||||
and c.get("fire_ok") is False,
|
|
||||||
"the engine is not holding the armed session on the warm-up: %s", c)
|
|
||||||
ctx.check(wait_print_running(ctx, offset, 2) is None, "the run started under the warm-up hold")
|
|
||||||
ctx.notice("Warm-up: the loop heater brings the coolant to the gate; a few minutes. Do nothing.")
|
|
||||||
try:
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
run = wait_print_running(ctx, offset, WARMUP_RELEASE_S)
|
samples = []
|
||||||
|
ctx.notice("The engine holds the print; the client gives up at the bound. Do nothing.")
|
||||||
|
try:
|
||||||
|
while time.time() - t0 < HOLD_REFUSE_S + 30:
|
||||||
|
ctx.checkpoint()
|
||||||
|
samples.append(latch_locked())
|
||||||
|
if log_has(offset, HOLD_REFUSE_LINE):
|
||||||
|
break
|
||||||
|
ctx.sleep(2)
|
||||||
finally:
|
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
|
||||||
@@ -369,6 +375,7 @@ def _tail_has(fc, needle):
|
|||||||
"board temperatures (chassis and SoC die in degrees, supply as a raw count, the "
|
"board temperatures (chassis and SoC die in degrees, supply as a raw count, the "
|
||||||
"CPU unthrottled) and every run session ends with them ranged into one log line.")
|
"CPU unthrottled) and every run session ends with them ranged into one log line.")
|
||||||
def gate_off(ctx):
|
def gate_off(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()
|
||||||
@@ -422,6 +429,7 @@ def gate_off(ctx):
|
|||||||
|
|
||||||
# Leg 2: the ceiling at its top. Off by value: no gate, verdict
|
# Leg 2: the ceiling at its top. Off by value: no gate, verdict
|
||||||
# back to OK at the next run start, and said out loud.
|
# back to OK at the next run start, and said out loud.
|
||||||
|
at_off = _log_offset(FORGECTRL_LOG) # the off gate's line is one written after this
|
||||||
_set_gates(ctx, fc, {"cool_temp_max": str(top), "cool_temp_resume": orig["cool_temp_resume"]})
|
_set_gates(ctx, fc, {"cool_temp_max": str(top), "cool_temp_resume": orig["cool_temp_resume"]})
|
||||||
state, val = _gate_state(fc, "cool_temp_max")
|
state, val = _gate_state(fc, "cool_temp_max")
|
||||||
ev["off_state"] = state
|
ev["off_state"] = state
|
||||||
@@ -433,13 +441,17 @@ def gate_off(ctx):
|
|||||||
c.get("gates_off"))
|
c.get("gates_off"))
|
||||||
s_off = fc.status().get("gates_off")
|
s_off = fc.status().get("gates_off")
|
||||||
ctx.check(s_off == ["coolant_max"], "/status gates_off %s, expected [coolant_max]", s_off)
|
ctx.check(s_off == ["coolant_max"], "/status gates_off %s, expected [coolant_max]", s_off)
|
||||||
ctx.check(_tail_has(fc, "gate coolant_max OFF: cool_temp_max = %g" % top),
|
# A log line reaches the file a moment after the engine acts.
|
||||||
|
line = "gate coolant_max OFF: cool_temp_max = %g" % top
|
||||||
|
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_off, line)), 5, poll=0.25)
|
||||||
|
ctx.check(_log_lines(FORGECTRL_LOG, at_off, line),
|
||||||
"the run-start log line for the off gate is missing from the forgectrl log")
|
"the run-start log line for the off gate is missing from the forgectrl log")
|
||||||
|
|
||||||
# Restore, and prove the restore: the next run start reloads.
|
# Restore, and prove the restore: the next run start reloads.
|
||||||
_set_gates(ctx, fc, orig)
|
_set_gates(ctx, fc, orig)
|
||||||
restored = True
|
restored = True
|
||||||
state, val = _gate_state(fc, "cool_temp_max")
|
state, val = _gate_state(fc, "cool_temp_max")
|
||||||
|
at_restore = _log_offset(FORGECTRL_LOG) # the session's run-end line is one written after this
|
||||||
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and not c.get("gates_off"),
|
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and not c.get("gates_off"),
|
||||||
"restored")
|
"restored")
|
||||||
ev["restored"] = c
|
ev["restored"] = c
|
||||||
@@ -448,7 +460,10 @@ def gate_off(ctx):
|
|||||||
ctx.log("restored ceiling %s reports state %s", val, state)
|
ctx.log("restored ceiling %s reports state %s", val, state)
|
||||||
# Every run session ends with the board temperatures ranged
|
# Every run session ends with the board temperatures ranged
|
||||||
# into one line.
|
# into one line.
|
||||||
ctx.check(_tail_has(fc, "temps this job: chassis ") and _tail_has(fc, ", soc "),
|
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_restore, "temps this job: chassis ")), 5, poll=0.25)
|
||||||
|
temps = _log_lines(FORGECTRL_LOG, at_restore, "temps this job: chassis ")
|
||||||
|
ev["temps_line"] = temps[-1] if temps else None
|
||||||
|
ctx.check(temps and ", soc " in temps[-1],
|
||||||
"the run-end board-temperature line (chassis, soc, supply) is missing from the "
|
"the run-end board-temperature line (chassis, soc, supply) is missing from the "
|
||||||
"forgectrl log")
|
"forgectrl log")
|
||||||
finally:
|
finally:
|
||||||
@@ -513,6 +528,7 @@ def _hold_session(ctx, g, fc, until, what, wait):
|
|||||||
"run-start log line, and the session reads OK. Restored, everything reads as "
|
"run-start log line, and the session reads OK. Restored, everything reads as "
|
||||||
"before.")
|
"before.")
|
||||||
def floor_and_warm_up(ctx):
|
def floor_and_warm_up(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()
|
||||||
@@ -538,6 +554,7 @@ def floor_and_warm_up(ctx):
|
|||||||
# Leg 1: the warm-up hold and its release. The gate sits just
|
# Leg 1: the warm-up hold and its release. The gate sits just
|
||||||
# above the loop, so the heater reaches it in a few minutes.
|
# above the loop, so the heater reaches it in a few minutes.
|
||||||
gate = round(up + WARMUP_ABOVE_C, 1)
|
gate = round(up + WARMUP_ABOVE_C, 1)
|
||||||
|
at_warmup = _log_offset(FORGECTRL_LOG) # the release line is one written after this
|
||||||
_set_gates(ctx, fc, {"cool_temp_start": str(gate), "cool_temp_min": orig["cool_temp_min"]})
|
_set_gates(ctx, fc, {"cool_temp_start": str(gate), "cool_temp_min": orig["cool_temp_min"]})
|
||||||
c = _hold_session(ctx, grbl, fc, lambda c: c.get("verdict") == "WARMUP", "warm-up hold", VERDICT_WAIT_S)
|
c = _hold_session(ctx, grbl, fc, lambda c: c.get("verdict") == "WARMUP", "warm-up hold", VERDICT_WAIT_S)
|
||||||
ev["warmup_hold"] = c
|
ev["warmup_hold"] = c
|
||||||
@@ -576,7 +593,11 @@ def floor_and_warm_up(ctx):
|
|||||||
ev["run_duty"] = duty
|
ev["run_duty"] = duty
|
||||||
ctx.check(heater == 0, "the loop heater stayed on after the release (%s=%s)", HEATER_PWM, heater)
|
ctx.check(heater == 0, "the loop heater stayed on after the release (%s=%s)", HEATER_PWM, heater)
|
||||||
ctx.check(duty != IDLE_DUTY, "the run fans did not come up at the release: %s", duty)
|
ctx.check(duty != IDLE_DUTY, "the run fans did not come up at the release: %s", duty)
|
||||||
ctx.check(_tail_has(fc, "warm-up complete: coolant "), "the warm-up release line is missing from the forgectrl log")
|
# A log line reaches the file a moment after the engine acts.
|
||||||
|
line = "warm-up complete: coolant "
|
||||||
|
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_warmup, line)), 5, poll=0.25)
|
||||||
|
ctx.check(_log_lines(FORGECTRL_LOG, at_warmup, line),
|
||||||
|
"the warm-up release line is missing from the forgectrl log")
|
||||||
grbl.command("M9")
|
grbl.command("M9")
|
||||||
_session_ended(ctx, fc, "warm-up")
|
_session_ended(ctx, fc, "warm-up")
|
||||||
|
|
||||||
@@ -594,6 +615,7 @@ def floor_and_warm_up(ctx):
|
|||||||
c.get("gates_off"))
|
c.get("gates_off"))
|
||||||
|
|
||||||
# Leg 3: both at zero: off, said so, and the session reads OK.
|
# Leg 3: both at zero: off, said so, and the session reads OK.
|
||||||
|
at_min_off = _log_offset(FORGECTRL_LOG) # the floor's off line is one written after this
|
||||||
_set_gates(ctx, fc, {"cool_temp_start": "0", "cool_temp_min": "0"})
|
_set_gates(ctx, fc, {"cool_temp_start": "0", "cool_temp_min": "0"})
|
||||||
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and len(c.get("gates_off") or []) == 2,
|
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and len(c.get("gates_off") or []) == 2,
|
||||||
"both off")
|
"both off")
|
||||||
@@ -601,7 +623,9 @@ def floor_and_warm_up(ctx):
|
|||||||
ctx.check(c.get("verdict") == "OK", "gates at 0 did not clear the hold: %s", c)
|
ctx.check(c.get("verdict") == "OK", "gates at 0 did not clear the hold: %s", c)
|
||||||
ctx.check(sorted(c.get("gates_off") or []) == ["coolant_min", "warm_up"],
|
ctx.check(sorted(c.get("gates_off") or []) == ["coolant_min", "warm_up"],
|
||||||
"gates_off %s, expected [coolant_min, warm_up]", c.get("gates_off"))
|
"gates_off %s, expected [coolant_min, warm_up]", c.get("gates_off"))
|
||||||
ctx.check(_tail_has(fc, "gate coolant_min OFF: cool_temp_min = 0"),
|
line = "gate coolant_min OFF: cool_temp_min = 0"
|
||||||
|
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_min_off, line)), 5, poll=0.25)
|
||||||
|
ctx.check(_log_lines(FORGECTRL_LOG, at_min_off, line),
|
||||||
"the run-start log line for the floor off is missing from the forgectrl log")
|
"the run-start log line for the floor off is missing from the forgectrl log")
|
||||||
|
|
||||||
# Restore, and prove the restore at the next run start.
|
# Restore, and prove the restore at the next run start.
|
||||||
@@ -650,6 +674,7 @@ TEC_WAIT_S = 20 # settings re-read at run start, 1 Hz ticks
|
|||||||
"The cross-check keeps off under on. The line has no readback, so the last written value is "
|
"The cross-check keeps off under on. The line has no readback, so the last written value is "
|
||||||
"what the test reads; the drive rule is the engine's, never the cloud's.")
|
"what the test reads; the drive rule is the engine's, never the cloud's.")
|
||||||
def tec_drive(ctx):
|
def tec_drive(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()
|
||||||
@@ -675,6 +700,7 @@ def tec_drive(ctx):
|
|||||||
_set_gates(ctx, fc, {"cool_tec_present": "1", "cool_tec_on_c": str(on_c),
|
_set_gates(ctx, fc, {"cool_tec_present": "1", "cool_tec_on_c": str(on_c),
|
||||||
"cool_tec_off_c": str(off_c)})
|
"cool_tec_off_c": str(off_c)})
|
||||||
ctx.check(_cool(fc).get("phase") != "run", "a run session is already open")
|
ctx.check(_cool(fc).get("phase") != "run", "a run session is already open")
|
||||||
|
off = _log_offset(FORGECTRL_LOG) # the TEC-on line is one written after this
|
||||||
grbl.command("M8")
|
grbl.command("M8")
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
tec = 0
|
tec = 0
|
||||||
@@ -686,7 +712,11 @@ def tec_drive(ctx):
|
|||||||
ev["tec_on_after_s"] = round(time.time() - t0)
|
ev["tec_on_after_s"] = round(time.time() - t0)
|
||||||
ctx.log("fitted, in session: %s=%s after %s s", TEC_ATTR, tec, ev["tec_on_after_s"])
|
ctx.log("fitted, in session: %s=%s after %s s", TEC_ATTR, tec, ev["tec_on_after_s"])
|
||||||
ctx.check(tec == 1, "the TEC did not come on in a run session with its threshold under the loop")
|
ctx.check(tec == 1, "the TEC did not come on in a run session with its threshold under the loop")
|
||||||
ctx.check(_tail_has(fc, "TEC on: coolant "), "the TEC-on line is missing from the forgectrl log")
|
# The engine writes the line first and logs it after, and the log
|
||||||
|
# line reaches the file a moment later still.
|
||||||
|
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, off, "TEC on: coolant ")), 5, poll=0.25)
|
||||||
|
ev["tec_on_line"] = (_log_lines(FORGECTRL_LOG, off, "TEC on: coolant ") or [None])[0]
|
||||||
|
ctx.check(ev["tec_on_line"], "the TEC-on line is missing from the forgectrl log")
|
||||||
grbl.command("M9")
|
grbl.command("M9")
|
||||||
_session_ended(ctx, fc, "tec leg 1")
|
_session_ended(ctx, fc, "tec leg 1")
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
@@ -746,7 +776,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 +789,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 +841,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 +861,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 +950,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
|
||||||
@@ -967,6 +1026,7 @@ def crash_watch_plumbing(ctx):
|
|||||||
"value (gates_off names it) and the ceiling alone pauses; restored, the next "
|
"value (gates_off names it) and the ceiling alone pauses; restored, the next "
|
||||||
"session runs OK with nothing off.")
|
"session runs OK with nothing off.")
|
||||||
def critical_tier(ctx):
|
def critical_tier(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()
|
||||||
@@ -1043,6 +1103,7 @@ def critical_tier(ctx):
|
|||||||
|
|
||||||
# Leg 2: the critical line at its top is the gate off; the
|
# Leg 2: the critical line at its top is the gate off; the
|
||||||
# ceiling alone pauses, and gates_off says so.
|
# ceiling alone pauses, and gates_off says so.
|
||||||
|
at_off = _log_offset(FORGECTRL_LOG) # the off gate's line is one written after this
|
||||||
_set_gates(ctx, fc, {"cool_temp_critical_c": str(top)})
|
_set_gates(ctx, fc, {"cool_temp_critical_c": str(top)})
|
||||||
c = _run_session(ctx, grbl, fc,
|
c = _run_session(ctx, grbl, fc,
|
||||||
lambda c: c.get("verdict") == "OVERTEMP" and "coolant_critical" in (c.get("gates_off") or []),
|
lambda c: c.get("verdict") == "OVERTEMP" and "coolant_critical" in (c.get("gates_off") or []),
|
||||||
@@ -1052,7 +1113,10 @@ def critical_tier(ctx):
|
|||||||
"with the critical line off the ceiling did not pause (verdict %s): %s", c.get("verdict"), c)
|
"with the critical line off the ceiling did not pause (verdict %s): %s", c.get("verdict"), c)
|
||||||
ctx.check("coolant_critical" in (c.get("gates_off") or []),
|
ctx.check("coolant_critical" in (c.get("gates_off") or []),
|
||||||
"gates_off %s lacks coolant_critical", c.get("gates_off"))
|
"gates_off %s lacks coolant_critical", c.get("gates_off"))
|
||||||
ctx.check(_tail_has(fc, "gate coolant_critical OFF: cool_temp_critical_c = %g" % top),
|
# A log line reaches the file a moment after the engine acts.
|
||||||
|
line = "gate coolant_critical OFF: cool_temp_critical_c = %g" % top
|
||||||
|
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_off, line)), 5, poll=0.25)
|
||||||
|
ctx.check(_log_lines(FORGECTRL_LOG, at_off, line),
|
||||||
"the run-start log line for the off gate is missing from the forgectrl log")
|
"the run-start log line for the off gate is missing from the forgectrl log")
|
||||||
|
|
||||||
# Restore, and prove it: OK, nothing off.
|
# Restore, and prove it: OK, nothing off.
|
||||||
@@ -1212,7 +1276,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 +1299,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 +1329,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 +1337,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 +1372,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)
|
|
||||||
if text.count("heater rise") > before:
|
|
||||||
m = None
|
m = None
|
||||||
for m in _load_verdict_rx().finditer(text):
|
for m in _load_verdict_rx().finditer(_log_since(FORGECTRL_LOG, off)):
|
||||||
pass
|
pass
|
||||||
line = m.group(0) if m else None
|
if m:
|
||||||
|
line = m.group(0)
|
||||||
break
|
break
|
||||||
ctx.sleep(1)
|
ctx.sleep(1)
|
||||||
ev["heater_seen"] = heater_seen
|
ev["heater_seen"] = heater_seen
|
||||||
@@ -1329,3 +1402,225 @@ 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):
|
||||||
|
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||||
|
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)
|
||||||
|
at_loss = _log_offset(FORGECTRL_LOG) # the put-back lines are ones written after this
|
||||||
|
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)
|
||||||
|
line = "head/air_assist_pwm read 204 with 1023 commanded: put back"
|
||||||
|
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_loss, line)), 5, poll=0.25)
|
||||||
|
ctx.check(_log_lines(FORGECTRL_LOG, at_loss, line),
|
||||||
|
"the air-assist loss is not named in the log")
|
||||||
|
line = "thermal/exhaust_pwm read 0 with 65535 commanded: put back"
|
||||||
|
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_loss, line)), 5, poll=0.25)
|
||||||
|
ctx.check(_log_lines(FORGECTRL_LOG, at_loss, line),
|
||||||
|
"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"))
|
||||||
|
|
||||||
|
|
||||||
|
@test("cooling.report-channel", title="A forged cooling report inside a run session is refused and changes nothing",
|
||||||
|
subsystem="cooling", kind="auto", mode="grbl", est_min=2,
|
||||||
|
covers=_COOL_COVERS + [("forgectrl", "src/super.*"), ("forgectrl", "src/main.c"),
|
||||||
|
("forgectrl", "src/auth.*")],
|
||||||
|
requires=["forgectrl.auth"],
|
||||||
|
description="The report channel (POST /cool/state) is what tells the engine a job is running: a "
|
||||||
|
"forged idle report would stand the fans down under a cut. M8 opens a run session "
|
||||||
|
"from the suite's Grbl client, and once the engine is in phase run with its run fan "
|
||||||
|
"profile commanded, a process on this host that is not the controller reports "
|
||||||
|
"mode=idle, armed=0: with no secret, with a made-up secret, and with a made-up "
|
||||||
|
"secret and a forged Host header. Each is refused (403). Over the next five seconds "
|
||||||
|
"the engine stays in phase run and no commanded fan duty drops. M9 "
|
||||||
|
"ends the session, and the engine leaves phase run on the controller's own report.")
|
||||||
|
def report_channel(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
forged = {"mode": "idle", "armed": "0"}
|
||||||
|
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
def in_run(c):
|
||||||
|
return c.get("phase") == "run"
|
||||||
|
ctx.check(_cool(fc).get("phase") != "run", "a run session is already open")
|
||||||
|
g.command("M8")
|
||||||
|
try:
|
||||||
|
ok = ctx.wait_for(lambda: in_run(_cool(fc)), VERDICT_WAIT_S, poll=0.5)
|
||||||
|
ctx.check(ok is not None, "M8 did not open a run session: %s", _cool(fc).get("phase"))
|
||||||
|
ctx.sleep(2) # the run fan profile is commanded
|
||||||
|
before = _cool(fc)
|
||||||
|
duties0 = _duties()
|
||||||
|
ev["before"] = {"phase": before.get("phase"), "duties": duties0}
|
||||||
|
refused = {}
|
||||||
|
for name, headers in (("no secret", {}),
|
||||||
|
("a made-up secret", {"X-ForgeFIRM-Report": "0123456789abcdef" * 2}),
|
||||||
|
("a made-up secret and a forged Host",
|
||||||
|
{"X-ForgeFIRM-Report": "f" * 32, "Host": "localhost"})):
|
||||||
|
st, body = fc.post("/cool/state", params=forged, headers=headers, auth=False)
|
||||||
|
refused[name] = [st, str(body)[:100]]
|
||||||
|
ctx.check(st == 403, "a forged report (%s) -> %s %s", name, st, str(body)[:100])
|
||||||
|
ev["refused"] = refused
|
||||||
|
held = []
|
||||||
|
for _ in range(5):
|
||||||
|
ctx.sleep(1)
|
||||||
|
c = _cool(fc)
|
||||||
|
held.append([c.get("phase"), _duties()])
|
||||||
|
ev["after"] = held
|
||||||
|
ctx.log("phase and duties over 5 s after three forged idle reports: %s", held)
|
||||||
|
ctx.check(all(p == "run" for p, _d in held), "the engine left phase run: %s", [p for p, _d in held])
|
||||||
|
ctx.check(all(d[k] >= duties0[k] for _p, d in held for k in duties0),
|
||||||
|
"a commanded fan duty dropped under the forged reports: %s -> %s", duties0, held)
|
||||||
|
finally:
|
||||||
|
g.command("M9")
|
||||||
|
_session_ended(ctx, fc, "report channel")
|
||||||
|
ctx.check(_cool(fc).get("phase") != "run", "the session did not end on the controller's own report")
|
||||||
|
ctx.log("PASS: three forged idle reports from this host were refused inside a run session, and the "
|
||||||
|
"engine held its phase and its fan duties")
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""cooling.intake-one-fan - a machine built with one intake fan
|
||||||
|
(cool_intake_fans 1, which the setup's airflow check writes): the second
|
||||||
|
intake tach is published as not fitted and never judged, and intake 1
|
||||||
|
still holds the intake floor. On a machine with two intake fans the
|
||||||
|
setting alone proves both halves: intake 2 turns and is not judged, and
|
||||||
|
an intake floor no fan can meet trips with intake 2 still out of it."""
|
||||||
|
from ..catalog import test
|
||||||
|
from .cooling import _COOL_COVERS, _after_session, _cool, _fan_gate, _run_session, _set_gates
|
||||||
|
|
||||||
|
INTAKE_KEYS = ("cool_intake_fans", "cool_tach_intake_min_rpm", "cool_fan_grace_s")
|
||||||
|
INTAKE_GRACE_S = "8" # past the intakes' 7 s to 90 percent, so only the leg's floor trips
|
||||||
|
INTAKE_TRIP_WAIT_S = 20 # grace + three ticks, with slack for the 1 Hz pipeline
|
||||||
|
|
||||||
|
|
||||||
|
@test("cooling.intake-one-fan", title="With one intake fan set, intake 2 is not judged and intake 1 still is",
|
||||||
|
subsystem="cooling", kind="auto", mode="grbl", est_min=3,
|
||||||
|
covers=_COOL_COVERS + [("forgectrl", "src/main.c")], requires=["cooling.fan-gate-trips"],
|
||||||
|
steps=["Machine idle, fans quiet. The test sets one intake fan, the intake floor and the spin-up "
|
||||||
|
"grace and restores all three; three short M8/M9 cycles spin the fans."],
|
||||||
|
description="cool_intake_fans 1 must take intake 2 out of the airflow gates and leave intake 1 in "
|
||||||
|
"them. With it set, a session at the cut fan profile must publish intake_2 as not fitted "
|
||||||
|
"with no floor while it reads its real speed, run OK, and judge intake_1 ok at the intake "
|
||||||
|
"floor; an intake floor at its legal maximum must then trip AIRFLOW (hold, fire blocked) "
|
||||||
|
"with intake_1 TRIPPED and intake_2 still not fitted, and the fault must end with the "
|
||||||
|
"session; restored, the next session judges intake_2 again and every fan reads at or "
|
||||||
|
"above its floor.")
|
||||||
|
def intake_one_fan(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
before = fc.settings()
|
||||||
|
orig = {k: before.get(k, "") for k in INTAKE_KEYS}
|
||||||
|
ev["orig"] = orig
|
||||||
|
ctx.log("original: %s", orig)
|
||||||
|
inl = (before.get("gates") or {}).get("cool_tach_intake_min_rpm") or {}
|
||||||
|
ctx.check(inl.get("gate") == "intake" and inl.get("off") == "low",
|
||||||
|
"settings reply does not describe the intake floor as the intake gate, off at zero: %s", inl)
|
||||||
|
ctx.check(orig["cool_intake_fans"] != "1",
|
||||||
|
"cool_intake_fans is already 1: the test starts from a machine with both intakes judged")
|
||||||
|
c0 = _cool(fc)
|
||||||
|
ctx.check(c0.get("verdict") == "OK" and c0.get("gates_off") == [],
|
||||||
|
"engine is not at OK with every gate on before the test: %s", c0)
|
||||||
|
|
||||||
|
restored = False
|
||||||
|
with ctx.grbl() as grbl:
|
||||||
|
st = grbl.status_report()
|
||||||
|
ctx.check(st["state"].startswith("Idle"), "controller is %s", st["state"])
|
||||||
|
try:
|
||||||
|
# Leg 1: one intake fan. Intake 2 still turns on this machine; it
|
||||||
|
# is measured and published not fitted, and nothing judges it.
|
||||||
|
_set_gates(ctx, fc, {"cool_intake_fans": "1", "cool_fan_grace_s": INTAKE_GRACE_S})
|
||||||
|
c = _run_session(ctx, grbl, fc,
|
||||||
|
lambda c: c.get("verdict") == "OK" and _fan_gate(c, "intake_1").get("state") == "ok",
|
||||||
|
"one intake fan leg", wait=INTAKE_TRIP_WAIT_S + 10)
|
||||||
|
g1, g2 = _fan_gate(c, "intake_1"), _fan_gate(c, "intake_2")
|
||||||
|
ev["one_fan"] = {"cool": c, "intake_1": g1, "intake_2": g2}
|
||||||
|
ctx.check(g2.get("state") == "not fitted" and (g2.get("floor") or 0) == 0,
|
||||||
|
"intake_2 is not published not fitted with no floor: %s", g2)
|
||||||
|
ctx.check((g2.get("reading") or 0) > 0,
|
||||||
|
"intake_2 reads %s: the test needs a machine whose second intake turns", g2.get("reading"))
|
||||||
|
ctx.check(c.get("verdict") == "OK", "the one intake fan session is not OK: %s", c)
|
||||||
|
ctx.check(g1.get("state") == "ok" and (g1.get("floor") or 0) > 0,
|
||||||
|
"intake_1 is not judged ok at the intake floor: %s", g1)
|
||||||
|
|
||||||
|
# Leg 2: the intake floor at its legal maximum. Intake 1 is the
|
||||||
|
# one intake left in the gates; intake 2 stays out of them.
|
||||||
|
_set_gates(ctx, fc, {"cool_tach_intake_min_rpm": str(inl.get("hi"))})
|
||||||
|
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "AIRFLOW", "intake 1 trip leg",
|
||||||
|
wait=INTAKE_TRIP_WAIT_S)
|
||||||
|
g1, g2 = _fan_gate(c, "intake_1"), _fan_gate(c, "intake_2")
|
||||||
|
ev["intake_1_trip"] = {"cool": c, "intake_1": g1, "intake_2": g2}
|
||||||
|
ctx.check(c.get("verdict") == "AIRFLOW", "an intake floor of %s rpm did not trip: %s", inl.get("hi"), c)
|
||||||
|
ctx.check(c.get("fire_ok") is False and c.get("hold") is True,
|
||||||
|
"AIRFLOW without fire blocked and a hold: %s", c)
|
||||||
|
ctx.check(g1.get("state") == "TRIPPED", "intake_1 does not read TRIPPED: %s", g1)
|
||||||
|
ctx.check(g2.get("state") == "not fitted", "intake_2 was judged with one intake fan set: %s", g2)
|
||||||
|
ctx.check("intake_1" in (c.get("reason") or ""), "the reason does not name intake_1: %r", c.get("reason"))
|
||||||
|
c = _after_session(ctx, fc)
|
||||||
|
ev["intake_1_trip_after"] = c
|
||||||
|
ctx.check(c.get("verdict") == "OK" and c.get("hold") is False,
|
||||||
|
"the fan fault outlived its run session: %s", c)
|
||||||
|
|
||||||
|
# Restore, and prove it: intake 2 judged again, every fan at or
|
||||||
|
# above its floor. The restored grace is the shipped one.
|
||||||
|
_set_gates(ctx, fc, orig)
|
||||||
|
restored = True
|
||||||
|
c = _run_session(ctx, grbl, fc,
|
||||||
|
lambda c: c.get("verdict") == "OK" and not c.get("gates_off")
|
||||||
|
and all(g.get("state") == "ok" for g in (c.get("fan_gates") or {}).values()),
|
||||||
|
"restored", wait=INTAKE_TRIP_WAIT_S + 20)
|
||||||
|
ev["restored"] = c.get("fan_gates")
|
||||||
|
ctx.check(c.get("verdict") == "OK" and c.get("gates_off") == [],
|
||||||
|
"engine did not return to OK with every gate on after the restore: %s", c)
|
||||||
|
bad = {k: g for k, g in (c.get("fan_gates") or {}).items() if g.get("state") != "ok"}
|
||||||
|
ctx.check(not bad, "fans not at or above their floors after the restore: %s", bad)
|
||||||
|
finally:
|
||||||
|
if not restored:
|
||||||
|
st, body = fc.post("/settings", params=orig)
|
||||||
|
ctx.log("restore on failure: POST /settings %s -> %s", orig, st)
|
||||||
|
try:
|
||||||
|
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and not c.get("gates_off"),
|
||||||
|
"restore on failure")
|
||||||
|
ctx.log("restore on failure: engine %s gates_off %s", c.get("verdict"), c.get("gates_off"))
|
||||||
|
except Exception as e: # the original failure is the one to report
|
||||||
|
ctx.log("restore on failure: run session did not complete (%s)", e)
|
||||||
|
after = fc.settings()
|
||||||
|
ctx.check(all(after.get(k, "") == orig[k] for k in INTAKE_KEYS),
|
||||||
|
"settings not restored: %s", {k: after.get(k) for k in INTAKE_KEYS})
|
||||||
|
ctx.check(fc.wait_idle(60, abort=ctx.aborted), "machine did not return to idle")
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""The event stream's button and its telemetry, on the machine.
|
||||||
|
|
||||||
|
Its own module, so that no other test's fingerprint moves. The stream is
|
||||||
|
opened straight at forgectrl's read-only listener, as a client off the
|
||||||
|
machine would, from a loopback source address of its own so that it takes
|
||||||
|
a place of its own among the three.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .exthost import _stream
|
||||||
|
|
||||||
|
SOURCE = "127.0.0.9"
|
||||||
|
|
||||||
|
|
||||||
|
def _reader(sock, seen, stop):
|
||||||
|
"""Every event on the stream into seen, as (name, data), until stop."""
|
||||||
|
buf = b""
|
||||||
|
sock.settimeout(1.0)
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
k = sock.recv(4096)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if not k:
|
||||||
|
break
|
||||||
|
buf += k
|
||||||
|
while b"\n\n" in buf:
|
||||||
|
block, buf = buf.split(b"\n\n", 1)
|
||||||
|
name, data = None, None
|
||||||
|
for line in block.decode("utf-8", "replace").split("\n"):
|
||||||
|
if line.startswith("event: "):
|
||||||
|
name = line[7:]
|
||||||
|
elif line.startswith("data: "):
|
||||||
|
data = line[6:]
|
||||||
|
if name:
|
||||||
|
try:
|
||||||
|
seen.append((name, json.loads(data or "null"), time.time()))
|
||||||
|
except ValueError:
|
||||||
|
seen.append((name, data, time.time()))
|
||||||
|
|
||||||
|
|
||||||
|
@test("events.button-telemetry", title="The event stream tells a press of the button, and the telemetry",
|
||||||
|
subsystem="forgectrl", kind="operator", mode="grbl", est_min=2,
|
||||||
|
covers=[("forgectrl", "src/events.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*")],
|
||||||
|
actions=["button"],
|
||||||
|
steps=["The machine idle; you will be told to press the button once.",
|
||||||
|
"Nothing moves and nothing fires: nothing is waiting for the press."],
|
||||||
|
description="A stream opened at forgectrl's read-only listener gets a telemetry.tick within 12 s, "
|
||||||
|
"and it is what the daemon already holds: the cooling phase, the verdict, fire_ok, the "
|
||||||
|
"two coolant temperatures, the controller's state, and the lid, with no sensor read of "
|
||||||
|
"its own. The machine idle and nothing waiting for the button, one press (the bench "
|
||||||
|
"fixture's, or the operator's) arrives as two button events, pressed true and then false, "
|
||||||
|
"in that order: the button is looked at 25 times a second, so a press shorter than the "
|
||||||
|
"rest of the stream's 5 Hz look is not lost. That a press the machine is waiting for - a "
|
||||||
|
"job arming or armed, a wizard under the lease, the daemon's own wait - is no event is "
|
||||||
|
"forgectrl's events_test's, as are update.available and setup.flag, which a machine in "
|
||||||
|
"use cannot be made to show on demand.")
|
||||||
|
def button_telemetry(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
|
||||||
|
code, why, sock = _stream("127.0.0.1", keep=True, source=SOURCE)
|
||||||
|
ctx.check(code == 200 and sock, "the event stream: %s %s", code, why)
|
||||||
|
seen, stop = [], threading.Event()
|
||||||
|
th = threading.Thread(target=_reader, args=(sock, seen, stop), daemon=True)
|
||||||
|
th.start()
|
||||||
|
try:
|
||||||
|
ctx.wait_for(lambda: any(n == "telemetry.tick" for n, _d, _t in seen), 14)
|
||||||
|
tick = next((d for n, d, _t in seen if n == "telemetry.tick"), None)
|
||||||
|
ev["telemetry"] = tick
|
||||||
|
ctx.log("telemetry.tick: %s", tick)
|
||||||
|
ctx.check(isinstance(tick, dict) and set(tick) == {"phase", "verdict", "fire_ok", "down_c", "up_c", "state", "lid"},
|
||||||
|
"the telemetry's fields: %s", tick)
|
||||||
|
ctx.check(tick.get("lid") in ("closed", "open") and isinstance(tick.get("down_c"), (int, float)),
|
||||||
|
"its values: %s", tick)
|
||||||
|
mark = len(seen)
|
||||||
|
|
||||||
|
def both():
|
||||||
|
got = [d for n, d, _t in seen[mark:] if n == "button"]
|
||||||
|
return got if len(got) >= 2 else None
|
||||||
|
ctx.act("button", "press", until=both, text="Press the button once: nothing is waiting for it, and "
|
||||||
|
"the press is only reported.")
|
||||||
|
presses = [d for n, d, _t in seen[mark:] if n == "button"]
|
||||||
|
ev["button"] = presses
|
||||||
|
ctx.log("button events: %s", presses)
|
||||||
|
ctx.check(presses[:2] == [{"pressed": True}, {"pressed": False}], "a press is pressed, then released: %s",
|
||||||
|
presses)
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
th.join(3)
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""A package's page asking its own service, through the panel's relay.
|
||||||
|
|
||||||
|
Its own module rather than a test in exthost.py, for the reason extcore.py
|
||||||
|
gives: a test added there moves the fingerprint of every test in that
|
||||||
|
module. The helpers come from exthost, and importing them puts that
|
||||||
|
module's shared text in this test's fingerprint and not the other way
|
||||||
|
round. The package here is the reference package's id and key with a
|
||||||
|
service of its own, so exthost's put-back takes it away like the others.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until,
|
||||||
|
_write)
|
||||||
|
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||||
|
|
||||||
|
CALL_DIR = "/run/forgefirm/ext/call"
|
||||||
|
|
||||||
|
# The service: it says what it was handed, then answers its page's calls
|
||||||
|
# on the listening end the host gave it, one connection at a time.
|
||||||
|
CALL_SERVICE = r'''
|
||||||
|
import json, os, socket, stat, time
|
||||||
|
data = os.environ["FFX_DATA"]
|
||||||
|
|
||||||
|
|
||||||
|
def api(method, path):
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(5)
|
||||||
|
try:
|
||||||
|
s.connect(os.environ["FFX_API"])
|
||||||
|
s.sendall(("%s %s HTTP/1.1\r\nHost: forgeext\r\n\r\n" % (method, path)).encode())
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
c = s.recv(65536)
|
||||||
|
if not c:
|
||||||
|
break
|
||||||
|
buf += c
|
||||||
|
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def kind(n):
|
||||||
|
try:
|
||||||
|
return "socket" if stat.S_ISSOCK(os.fstat(n).st_mode) else "other"
|
||||||
|
except OSError:
|
||||||
|
return "closed"
|
||||||
|
|
||||||
|
|
||||||
|
fd = os.environ.get("FFX_CALL_FD")
|
||||||
|
listener = socket.socket(fileno=int(fd)) if fd else None
|
||||||
|
report = {"call_env": fd, "fds": {str(n): kind(n) for n in (3, 4, 5)},
|
||||||
|
"listening": listener.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN) if listener else None}
|
||||||
|
with open(os.path.join(data, "report.json.new"), "w") as f:
|
||||||
|
json.dump(report, f)
|
||||||
|
os.rename(os.path.join(data, "report.json.new"), os.path.join(data, "report.json"))
|
||||||
|
print("page-call service up", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def answer(conn, status, doc):
|
||||||
|
body = json.dumps(doc).encode()
|
||||||
|
conn.sendall(("HTTP/1.1 %d Answer\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n"
|
||||||
|
% (status, len(body))).encode() + body)
|
||||||
|
|
||||||
|
|
||||||
|
while listener:
|
||||||
|
conn, _ = listener.accept()
|
||||||
|
try:
|
||||||
|
conn.settimeout(5)
|
||||||
|
buf = b""
|
||||||
|
while b"\r\n\r\n" not in buf:
|
||||||
|
c = conn.recv(4096)
|
||||||
|
if not c:
|
||||||
|
break
|
||||||
|
buf += c
|
||||||
|
head, _, body = buf.partition(b"\r\n\r\n")
|
||||||
|
lines = head.decode("latin-1").split("\r\n")
|
||||||
|
method, path = lines[0].split()[:2]
|
||||||
|
want = 0
|
||||||
|
for line in lines[1:]:
|
||||||
|
k, _, v = line.partition(":")
|
||||||
|
if k.strip().lower() == "content-length":
|
||||||
|
want = int(v)
|
||||||
|
while len(body) < want:
|
||||||
|
c = conn.recv(4096)
|
||||||
|
if not c:
|
||||||
|
break
|
||||||
|
body += c
|
||||||
|
if path == "/echo":
|
||||||
|
answer(conn, 200, {"method": method, "path": path, "body": json.loads(body or b"{}")})
|
||||||
|
elif path == "/refuse":
|
||||||
|
answer(conn, 409, {"error": "not now"})
|
||||||
|
elif path == "/me":
|
||||||
|
answer(conn, 200, (api("GET", "/v0/self") or {}).get("id"))
|
||||||
|
else:
|
||||||
|
answer(conn, 404, {"error": "there is no " + path})
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
while True:
|
||||||
|
time.sleep(60)
|
||||||
|
'''
|
||||||
|
|
||||||
|
CALL_UI = "<!doctype html><title>forgetest page call</title>\n<p>the page that calls its own service</p>\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_caller(work):
|
||||||
|
"""The reference package's id and key, with a page and the service above: (archive, public key)."""
|
||||||
|
import io
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest page call", "version": "1.0.0",
|
||||||
|
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||||
|
"service": {"exec": "bin/caller.py"}, "capabilities": ["ui", "machine.read"]}
|
||||||
|
payload = os.path.join(work, "payload.tar.gz")
|
||||||
|
with tarfile.open(payload, "w:gz") as t:
|
||||||
|
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644),
|
||||||
|
("bin/caller.py", CALL_SERVICE, 0o755), ("ui/index.html", CALL_UI, 0o644)):
|
||||||
|
info = tarfile.TarInfo(name)
|
||||||
|
data = text.encode()
|
||||||
|
info.size, info.mode = len(data), mode
|
||||||
|
t.addfile(info, io.BytesIO(data))
|
||||||
|
conf = os.path.join(work, "fwup.conf")
|
||||||
|
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||||
|
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||||
|
% (REF_ID, payload))
|
||||||
|
key = os.path.join(work, REF_KEY)
|
||||||
|
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "caller.ffx")
|
||||||
|
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||||
|
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||||
|
return signed, key + ".pub"
|
||||||
|
|
||||||
|
|
||||||
|
@test("exthost.page-call", title="A package's page asks its own service, through the panel",
|
||||||
|
subsystem="exthost", kind="auto", hardware="api", est_min=4,
|
||||||
|
covers=[("forgeext", "src/call.*"), ("forgeext", "src/run.*"), ("forgeext", "src/sandbox.*"),
|
||||||
|
("forgeext", "src/main.c"), ("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c")],
|
||||||
|
requires=["exthost.service"],
|
||||||
|
description="A package with a page and a service may have the one ask the other. The host binds the "
|
||||||
|
"service's call socket in /run/forgefirm/ext/call, a directory that is root's alone "
|
||||||
|
"(0700), as root's 0600 socket, and hands the service only the listening end, at "
|
||||||
|
"descriptor 4 (FFX_CALL_FD=4), with nothing at 3 or 5. POST /ext/call relays a call "
|
||||||
|
"through the host's command line: a POST's JSON object reaches the service and its "
|
||||||
|
"answer comes back as its status and its JSON; a GET carries no body; the service's own "
|
||||||
|
"refusal is its status and its words, not the relay's; a service may ask the machine "
|
||||||
|
"while it answers. The form is held before anything runs: a method that is not GET or "
|
||||||
|
"POST, a GET with a body, and a body that is not a JSON object are 400, and a path with "
|
||||||
|
".. in it and a package that is not installed are the host's refusals, 409 in its words. "
|
||||||
|
"The command line answers alike. Disabled, the package's service stops, a call is refused "
|
||||||
|
"in words, and the socket's name is gone; enabled again, the service answers again. A "
|
||||||
|
"call to a frozen service and the relay's bridge in a browser are not this test's: "
|
||||||
|
"forgeext's sdk_test and the frame-isolation harness hold those. The package, the key, "
|
||||||
|
"the setting, and the setup record are put back as found.")
|
||||||
|
def page_call(ctx):
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||||
|
prior = fc.settings().get("ext_enabled") or ""
|
||||||
|
raw = read_file(record_path())
|
||||||
|
found_tree = _tree(EXT_ROOT)
|
||||||
|
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||||
|
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||||
|
etag = hdrs.get("etag")
|
||||||
|
work = tempfile.mkdtemp(prefix="forgetest-call.")
|
||||||
|
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||||
|
sock = os.path.join(CALL_DIR, REF_ID + ".sock")
|
||||||
|
report = os.path.join(EXT_ROOT, "data", REF_ID, "report.json")
|
||||||
|
|
||||||
|
def call(method, path, body=None, id_=REF_ID):
|
||||||
|
form = {"id": id_, "method": method, "path": path}
|
||||||
|
if body is not None:
|
||||||
|
form["body"] = body
|
||||||
|
return fc.post("/ext/call", data=form)
|
||||||
|
|
||||||
|
try:
|
||||||
|
archive, pub = _pack_caller(work)
|
||||||
|
shutil.copy(pub, owner_key)
|
||||||
|
os.chmod(owner_key, 0o644)
|
||||||
|
r = _forgeext("install", archive, "--consent-community")
|
||||||
|
ctx.check(r.get("ok") is True, "the install of a package with a page and a service -> %s", r.get("error"))
|
||||||
|
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||||
|
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||||
|
up = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and os.path.exists(report), 60)
|
||||||
|
ctx.check(up, "the service did not come up: %s", _svc(REF_ID))
|
||||||
|
|
||||||
|
# What the service was handed, from inside, and the socket from outside.
|
||||||
|
with open(report) as f:
|
||||||
|
inside = json.load(f)
|
||||||
|
ev["inside"] = inside
|
||||||
|
ctx.log("inside: %s", inside)
|
||||||
|
ctx.check(inside.get("call_env") == "4", "FFX_CALL_FD is %r, not 4", inside.get("call_env"))
|
||||||
|
ctx.check(inside.get("fds") == {"3": "closed", "4": "socket", "5": "closed"},
|
||||||
|
"its descriptors 3, 4, 5: %s", inside.get("fds"))
|
||||||
|
ctx.check(inside.get("listening") == 1, "descriptor 4 is not a listening socket: %s", inside.get("listening"))
|
||||||
|
d, s = os.lstat(CALL_DIR), os.lstat(sock)
|
||||||
|
ev["socket"] = {"dir": [oct(d.st_mode & 0o7777), d.st_uid], "sock": [oct(s.st_mode & 0o7777), s.st_uid]}
|
||||||
|
ctx.check(stat.S_ISDIR(d.st_mode) and d.st_uid == 0 and d.st_mode & 0o7777 == 0o700,
|
||||||
|
"%s is not root's 0700 directory: %s", CALL_DIR, ev["socket"]["dir"])
|
||||||
|
ctx.check(stat.S_ISSOCK(s.st_mode) and s.st_uid == 0 and s.st_mode & 0o777 == 0o600,
|
||||||
|
"%s is not root's 0600 socket: %s", sock, ev["socket"]["sock"])
|
||||||
|
|
||||||
|
# Calls through the relay, as the panel's bridge makes them.
|
||||||
|
st_, doc = call("POST", "/echo", '{"a": 1, "b": "two"}')
|
||||||
|
ev["post"] = [st_, doc]
|
||||||
|
ctx.log("POST /ext/call /echo -> %s %s", st_, doc)
|
||||||
|
ctx.check(st_ == 200 and (doc or {}).get("status") == 200
|
||||||
|
and doc.get("body") == {"method": "POST", "path": "/echo", "body": {"a": 1, "b": "two"}},
|
||||||
|
"a POST -> %s %s", st_, doc)
|
||||||
|
st_, doc = call("GET", "/echo")
|
||||||
|
ev["get"] = [st_, doc]
|
||||||
|
ctx.check(st_ == 200 and (doc or {}).get("body") == {"method": "GET", "path": "/echo", "body": {}},
|
||||||
|
"a GET -> %s %s", st_, doc)
|
||||||
|
st_, doc = call("GET", "/refuse")
|
||||||
|
ev["refuse"] = [st_, doc]
|
||||||
|
ctx.check(st_ == 200 and (doc or {}).get("status") == 409 and doc.get("body") == {"error": "not now"},
|
||||||
|
"the service's own refusal -> %s %s", st_, doc)
|
||||||
|
st_, doc = call("GET", "/me")
|
||||||
|
ev["me"] = [st_, doc]
|
||||||
|
ctx.check(st_ == 200 and (doc or {}).get("body") == REF_ID, "a service asking the machine while it answers "
|
||||||
|
"-> %s %s", st_, doc)
|
||||||
|
for args, want, words in ((("PUT", "/echo"), 400, "a call is GET or POST"),
|
||||||
|
(("GET", "/echo", "{}"), 400, "a GET call has no body"),
|
||||||
|
(("POST", "/echo", "[1]"), 400, "a call's body is a JSON object"),
|
||||||
|
(("GET", "/../etc"), 409, "a call's path starts with"),
|
||||||
|
(("GET", "/echo", None, "org.forgetest.nothere"), 409, "that package is not installed")):
|
||||||
|
st_, why = call(*args)
|
||||||
|
ev.setdefault("refusals", []).append([list(args), st_, why])
|
||||||
|
ctx.check(st_ == want and words in json.dumps(why), "%s -> %s %s, wanted %s %s", args, st_, why, want, words)
|
||||||
|
|
||||||
|
# The command line answers alike.
|
||||||
|
r = _forgeext("call", REF_ID, "POST", "/echo", '{"from": "the console"}')
|
||||||
|
ev["cli"] = r
|
||||||
|
ctx.check(r.get("ok") is True and r.get("status") == 200
|
||||||
|
and (r.get("body") or {}).get("body") == {"from": "the console"}, "forgeext call -> %s", r)
|
||||||
|
|
||||||
|
# Disabled: no service, no socket, a refusal in words; and back.
|
||||||
|
r = _forgeext("disable", REF_ID)
|
||||||
|
ctx.check(r.get("ok") is True, "disable -> %s", r.get("error"))
|
||||||
|
st_, why = call("GET", "/echo")
|
||||||
|
ev["disabled"] = [st_, why]
|
||||||
|
ctx.check(st_ == 409 and "disabled" in json.dumps(why), "a call to a disabled package -> %s %s", st_, why)
|
||||||
|
gone = _until(ctx, lambda: not os.path.exists(sock) and _svc(REF_ID).get("state") != "running", 30)
|
||||||
|
ctx.check(gone, "disabled, the socket's name is still there or the service still runs: %s", _svc(REF_ID))
|
||||||
|
r = _forgeext("enable", REF_ID)
|
||||||
|
ctx.check(r.get("ok") is True, "enable -> %s", r.get("error"))
|
||||||
|
back = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and os.path.exists(sock), 60)
|
||||||
|
ctx.check(back, "enabled again, the service did not come back: %s", _svc(REF_ID))
|
||||||
|
st_, doc = _until(ctx, lambda: (lambda a: a if a[0] == 200 else None)(call("GET", "/echo")), 20) or (0, None)
|
||||||
|
ctx.check(st_ == 200 and (doc or {}).get("status") == 200, "enabled again, it answers -> %s %s", st_, doc)
|
||||||
|
finally:
|
||||||
|
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||||
|
ctx.check(not os.path.exists(sock), "the call socket's name is left after the package went")
|
||||||
|
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""The catalog: the signed index, on the machine.
|
||||||
|
|
||||||
|
Its own module, for the reason extcore.py gives. The endorsement, the
|
||||||
|
judgment of each listed version, the index that never goes back, and a
|
||||||
|
withdrawn version are shown on a scratch root under /tmp with a throwaway
|
||||||
|
key standing in for the OpenGlow extension key, because only OpenGlow holds
|
||||||
|
the real one; the machine's own root is where every index that key did not
|
||||||
|
sign is refused.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .exthost import FWUP, _forgeext, _write
|
||||||
|
|
||||||
|
INDEX_URL = "https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/index-1.ffi"
|
||||||
|
STAGE_DIR = "/data/forgefirm/tmp"
|
||||||
|
PROBE_ID, OTHER_ID = "org.example.catprobe", "org.example.catother"
|
||||||
|
|
||||||
|
|
||||||
|
def _key(work, name):
|
||||||
|
subprocess.run([FWUP, "-g", "-o", os.path.join(work, name)], check=True, capture_output=True, timeout=60, cwd=work)
|
||||||
|
return os.path.join(work, name)
|
||||||
|
|
||||||
|
|
||||||
|
def _archive(work, name, product, members, version, key):
|
||||||
|
"""An fwup archive of the extension container's form: payload.tar.gz of members {name: text}, signed with key."""
|
||||||
|
payload = os.path.join(work, name + ".tar.gz")
|
||||||
|
with tarfile.open(payload, "w:gz") as t:
|
||||||
|
for member, text in members.items():
|
||||||
|
info = tarfile.TarInfo(member)
|
||||||
|
data = text.encode()
|
||||||
|
info.size, info.mode = len(data), 0o644
|
||||||
|
t.addfile(info, io.BytesIO(data))
|
||||||
|
conf = os.path.join(work, name + ".conf")
|
||||||
|
_write(conf, 'meta-product = "%s"\nmeta-description = "%s"\nmeta-version = "%s"\nmeta-platform = "forgefirm-ext"\n'
|
||||||
|
'file-resource payload.tar.gz {\n host-path = "%s"\n}\n' % (product, name, version, payload))
|
||||||
|
raw, signed = os.path.join(work, name + ".raw"), os.path.join(work, name + ".signed")
|
||||||
|
subprocess.run([FWUP, "-c", "-f", conf, "-o", raw], check=True, capture_output=True, timeout=60, cwd=work)
|
||||||
|
subprocess.run([FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed], check=True, capture_output=True,
|
||||||
|
timeout=60, cwd=work)
|
||||||
|
return signed
|
||||||
|
|
||||||
|
|
||||||
|
def _package(work, id_, key):
|
||||||
|
m = {"manifest": 1, "id": id_, "name": "catalog probe", "version": "1.0.0", "author": "forgetest", "license": "MIT",
|
||||||
|
"api": "0.1", "runtime": "data", "capabilities": []}
|
||||||
|
return _archive(work, id_, "ForgeFIRM extension", {"manifest.json": json.dumps(m)}, "1.0.0", key)
|
||||||
|
|
||||||
|
|
||||||
|
def _index(work, doc, key, version):
|
||||||
|
return _archive(work, "index-" + version, "ForgeFIRM extension index", {"index.json": json.dumps(doc)}, version, key)
|
||||||
|
|
||||||
|
|
||||||
|
def _staged():
|
||||||
|
try:
|
||||||
|
return sorted(n for n in os.listdir(STAGE_DIR) if n.startswith("ext-"))
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@test("exthost.catalog", title="The catalog: the signed index verified, kept, judged, and endorsing one key for one id",
|
||||||
|
subsystem="exthost", kind="auto", hardware="api", est_min=2,
|
||||||
|
covers=[("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c"), ("forgeext", "src/index.*"),
|
||||||
|
("forgeext", "src/pkg.*"), ("forgeext", "src/install.*"), ("forgeext", "src/main.c")],
|
||||||
|
description="GET /ext/catalog answers the index the host keeps (or null), judged against this firmware, and "
|
||||||
|
"the one address it is fetched from. On a scratch root, with a throwaway key standing in for the "
|
||||||
|
"OpenGlow extension key, the machine's own forgeext keeps an index signed with it, and the author "
|
||||||
|
"key it names for one id makes a package of that id signed with it read as community and "
|
||||||
|
"endorsed, where before it was unverified; the same key on another id counts for nothing. Read "
|
||||||
|
"back, each listed version is judged on the machine: one that asks for a capability this "
|
||||||
|
"firmware does not have is kept and not offered, and the offer is the newest it runs. An index "
|
||||||
|
"older than the one kept is refused however well signed, and one that withdraws the probe's "
|
||||||
|
"version makes the machine refuse to install it, in OpenGlow's words. On the machine's own root, "
|
||||||
|
"the stand-in's index (signed by a key that is not the OpenGlow extension key) is refused in "
|
||||||
|
"words, a package handed over as an index is refused by the product gate, and the index kept "
|
||||||
|
"is left as it was. POST /ext/catalog/get refuses an id with no such form (400), and one the "
|
||||||
|
"kept index does not list (404, or 409 with no index kept), before anything is fetched. Nothing "
|
||||||
|
"is left in the staging directory. POST /ext/catalog/refresh is not asked here: GitHub counts "
|
||||||
|
"every request of the index's address as a download, and that count is the operators'. The "
|
||||||
|
"refresh (curl, https alone and bounded, 502 in curl's words, 409 in the host's, the file "
|
||||||
|
"removed), the fetch of the offered version held to its size and SHA-256, and the tiers an "
|
||||||
|
"install takes from it, are forgectrl's extpkg_test and forgeext's install_test.")
|
||||||
|
def catalog(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
|
||||||
|
staged_before = _staged()
|
||||||
|
kept = _forgeext("index")
|
||||||
|
ctx.check(kept.get("ok") is True, "forgeext index -> %s", kept.get("error"))
|
||||||
|
ev["kept_before"] = (kept.get("index") or {}).get("version")
|
||||||
|
work = tempfile.mkdtemp(prefix="ft-catalog-", dir="/tmp")
|
||||||
|
try:
|
||||||
|
st, doc = fc.get("/ext/catalog")
|
||||||
|
ctx.check(st == 200 and isinstance(doc, dict) and doc.get("url") == INDEX_URL and "index" in doc
|
||||||
|
and doc.get("index") == kept.get("index") and "core_checked" in doc,
|
||||||
|
"GET /ext/catalog -> %s %s", st, doc)
|
||||||
|
|
||||||
|
# The endorsement, on a scratch root with a stand-in for the OpenGlow extension key.
|
||||||
|
root = os.path.join(work, "root")
|
||||||
|
os.makedirs(os.path.join(root, "keys"))
|
||||||
|
og, author = _key(work, "standin"), _key(work, "author")
|
||||||
|
probe, other = _package(work, PROBE_ID, author), _package(work, OTHER_ID, author)
|
||||||
|
with open(probe, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
with open(author + ".pub") as f:
|
||||||
|
author_pub = f.read().strip()
|
||||||
|
listed = {"version": "1.0.0", "url": "https://example.org/catprobe.ffx", "sha256": hashlib.sha256(data).hexdigest(),
|
||||||
|
"size": len(data), "capabilities": [], "api": "0.1"}
|
||||||
|
later = dict(listed, version="1.1.0", url="https://example.org/catprobe-1.1.0.ffx",
|
||||||
|
capabilities=["forgetest.not-a-capability"])
|
||||||
|
entry = {"id": PROBE_ID, "name": "catalog probe", "author": "forgetest", "key": author_pub,
|
||||||
|
"versions": [later, listed]}
|
||||||
|
idx = _index(work, {"index": 1, "packages": [entry]}, og, "2026.923.2")
|
||||||
|
|
||||||
|
def scratch(*args):
|
||||||
|
return _forgeext("--root", root, "--official-key", og + ".pub", "--no-reserve", *args)
|
||||||
|
r = scratch("inspect", probe)
|
||||||
|
ctx.check(r.get("ok") is True and r.get("tier") == "unverified", "before an index: %s %s", r.get("tier"), r.get("error"))
|
||||||
|
r = scratch("index-verify", idx)
|
||||||
|
ev["scratch_verify"] = r
|
||||||
|
ctx.check(r.get("ok") is True and r.get("packages") == 1 and r.get("version") == "2026.923.2",
|
||||||
|
"the stand-in's index is kept on the scratch root: %s", r)
|
||||||
|
read = scratch("index")
|
||||||
|
mine = ((read.get("index") or {}).get("packages") or [{}])[0]
|
||||||
|
vs = {v.get("version"): v for v in mine.get("versions", [])}
|
||||||
|
ev["judged"] = {"offer": mine.get("offer"), "1.1.0": vs.get("1.1.0", {}).get("why"),
|
||||||
|
"core_checked": read.get("core_checked")}
|
||||||
|
ctx.check(mine.get("id") == PROBE_ID and len(mine.get("key_id", "")) == 64, "and read back: %s", mine)
|
||||||
|
ctx.check(vs.get("1.1.0", {}).get("usable") is False and "not a capability" in vs.get("1.1.0", {}).get("why", "")
|
||||||
|
and vs.get("1.0.0", {}).get("usable") is True and mine.get("offer") == "1.0.0",
|
||||||
|
"judged on the machine: 1.1.0 kept and not offered, 1.0.0 offered: %s", ev["judged"])
|
||||||
|
r = scratch("inspect", probe)
|
||||||
|
ev["endorsed"] = {"tier": r.get("tier"), "endorsed": r.get("endorsed")}
|
||||||
|
ctx.check(r.get("tier") == "community" and r.get("endorsed") is True,
|
||||||
|
"signed with the key it endorses for its id: community, endorsed (%s %s)", r.get("tier"), r.get("endorsed"))
|
||||||
|
r = scratch("inspect", other)
|
||||||
|
ctx.check(r.get("tier") == "unverified" and r.get("endorsed") is False,
|
||||||
|
"the same key on another id: %s %s", r.get("tier"), r.get("endorsed"))
|
||||||
|
|
||||||
|
# Never back: an older index is refused, and the one kept stays.
|
||||||
|
r = scratch("index-verify", _index(work, {"index": 1, "packages": []}, og, "2026.923.1"))
|
||||||
|
ev["older"] = r.get("error")
|
||||||
|
ctx.check(r.get("ok") is False and "never goes back" in (r.get("error") or ""), "an older index -> %s", r.get("error"))
|
||||||
|
ctx.check((scratch("index").get("index") or {}).get("version") == "2026.923.2", "and the one kept stays")
|
||||||
|
|
||||||
|
# A withdrawn version does not install, from the catalog or from anywhere else.
|
||||||
|
gone = dict(entry, versions=[later], withdrawn=[{"version": "1.0.0", "reason": "forgetest withdrew it"}])
|
||||||
|
r = scratch("index-verify", _index(work, {"index": 1, "packages": [gone]}, og, "2026.923.3"))
|
||||||
|
ctx.check(r.get("ok") is True, "an index withdrawing the probe's version is kept: %s", r.get("error"))
|
||||||
|
r = scratch("inspect", probe)
|
||||||
|
ev["withdrawn"] = r.get("error")
|
||||||
|
ctx.check(r.get("ok") is False and "OpenGlow withdrew %s 1.0.0 from its catalog: forgetest withdrew it" % PROBE_ID
|
||||||
|
in (r.get("error") or ""), "the withdrawn version -> %s", r.get("error"))
|
||||||
|
|
||||||
|
# The machine's own root keeps no index but OpenGlow's.
|
||||||
|
refused = {}
|
||||||
|
for name, path, words in (("the stand-in's index", idx, "not signed with the OpenGlow extension key"),
|
||||||
|
("a package as an index", probe, "product")):
|
||||||
|
r = _forgeext("index-verify", path)
|
||||||
|
refused[name] = r.get("error")
|
||||||
|
ctx.check(r.get("ok") is False and words in (r.get("error") or ""), "%s -> %s", name, r.get("error"))
|
||||||
|
ev["refused"] = refused
|
||||||
|
ctx.check(_forgeext("index").get("index") == kept.get("index"), "a refused index changed the one kept")
|
||||||
|
|
||||||
|
# The relay's refusals, before anything is fetched.
|
||||||
|
st, why = fc.post("/ext/catalog/get", data={"id": "org.example;reboot"})
|
||||||
|
ctx.check(st == 400, "an id with no such form -> %s %s", st, why)
|
||||||
|
st, why = fc.post("/ext/catalog/get", data={"id": PROBE_ID})
|
||||||
|
want = (404, "not in the catalog") if kept.get("index") else (409, "fetch it first")
|
||||||
|
ev["get_unlisted"] = [st, why]
|
||||||
|
ctx.check(st == want[0] and want[1] in json.dumps(why), "an id the kept index does not list -> %s %s", st, why)
|
||||||
|
ctx.check(_forgeext("index").get("index") == kept.get("index"), "the refusals changed the index kept")
|
||||||
|
ctx.check(_staged() == staged_before, "the staging directory holds %s, and held %s before", _staged(), staged_before)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""What the extension host makes of the firmware's own version file.
|
||||||
|
|
||||||
|
A campaign only ever runs on a dev image - the release image does not
|
||||||
|
ship this suite - and a dev image stamps ${DATETIME} (dev), which is no
|
||||||
|
version at all. So the branch where a core range is actually judged
|
||||||
|
cannot be reached from here by any machine this suite runs on, and it is
|
||||||
|
not this module's to prove: that is forgeext's version_test, over the
|
||||||
|
file as each image build writes it. What is left here is the half a
|
||||||
|
machine can show, and it is the half that ties the daemon to the image:
|
||||||
|
that the host reads the image's own file, finds a build stamp, and
|
||||||
|
judges nothing rather than guessing.
|
||||||
|
|
||||||
|
Its own module rather than a test in exthost.py: a test's fingerprint
|
||||||
|
holds the shared text of the module it lives in, and the blank lines
|
||||||
|
between tests are shared text, so a test added to exthost.py moves the
|
||||||
|
fingerprint of every other test there and costs a campaign a re-run of
|
||||||
|
each - exthost.panel-install among them, which is an operator drill with
|
||||||
|
the button held. The helpers come from exthost, and importing them puts
|
||||||
|
that module's shared text in this test's fingerprint and not the other
|
||||||
|
way round.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .exthost import FWUP, _forgeext, _read, _write
|
||||||
|
|
||||||
|
|
||||||
|
@test("exthost.core-range", title="The host reads the image's own version file and judges by it",
|
||||||
|
subsystem="exthost", kind="auto", est_min=2,
|
||||||
|
covers=[("forgeext", "src/install.*"), ("forgeext", "src/manifest.*"), ("forgeext", "src/main.c")],
|
||||||
|
description="The firmware's version reaches the host from /etc/forgefirm-version, which the image "
|
||||||
|
"build writes. This test asks the host what it made of that file rather than reading the "
|
||||||
|
"file and believing it. On the dev image a campaign runs on, the first word is a build "
|
||||||
|
"stamp and no version: inspect answers core_checked false, and a package asking for a "
|
||||||
|
"minimum far above or a maximum far below is accepted all the same, because a range "
|
||||||
|
"cannot be judged against a stamp and the host does not guess one. A dev image that "
|
||||||
|
"began stamping something version-shaped would fail here, which is the regression this "
|
||||||
|
"test is for; the judging branch belongs to forgeext's version_test, since no image "
|
||||||
|
"carrying this suite can reach it. Nothing is installed, nothing runs, and nothing "
|
||||||
|
"moves: inspect changes nothing and the work directory goes at the end.")
|
||||||
|
def core_range(ctx):
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
ev = ctx.evidence
|
||||||
|
|
||||||
|
stamp = (_read("/etc/forgefirm-version", "").split() or [""])[0]
|
||||||
|
ctx.check(stamp, "/etc/forgefirm-version says nothing: the image did not stamp itself")
|
||||||
|
version = stamp[1:] if stamp[:1] == "v" and stamp[1:2].isdigit() else stamp
|
||||||
|
looks_like_a_version = bool(re.match(r"^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$", version))
|
||||||
|
ctx.log("/etc/forgefirm-version: %r -> %r", stamp, version)
|
||||||
|
ev["firmware_version"] = {"file": stamp, "version": version, "looks_like_a_version": looks_like_a_version}
|
||||||
|
|
||||||
|
# The suite ships on the dev image alone, so this is the only shape a
|
||||||
|
# campaign can meet. If it ever is not, the image changed and the
|
||||||
|
# judging path needs a proof that is not here.
|
||||||
|
ctx.check(not looks_like_a_version,
|
||||||
|
"this image stamped %r, which is a version: a campaign cannot run on a release image, so "
|
||||||
|
"either the dev image's stamp changed or this is not the image this suite belongs on", stamp)
|
||||||
|
|
||||||
|
work = tempfile.mkdtemp(prefix="forgetest-core-")
|
||||||
|
try:
|
||||||
|
def packed(core):
|
||||||
|
"""A data package that asks for nothing, with the core range given."""
|
||||||
|
m = {"manifest": 1, "id": "org.forgetest.corerange", "name": "forgetest core range",
|
||||||
|
"version": "1.0.0", "author": "forgetest", "license": "MIT", "api": "0.1",
|
||||||
|
"runtime": "data", "capabilities": []}
|
||||||
|
if core:
|
||||||
|
m["core"] = core
|
||||||
|
payload = os.path.join(work, "payload.tar.gz")
|
||||||
|
with tarfile.open(payload, "w:gz") as t:
|
||||||
|
data = json.dumps(m).encode()
|
||||||
|
info = tarfile.TarInfo("manifest.json")
|
||||||
|
info.size, info.mode = len(data), 0o644
|
||||||
|
t.addfile(info, io.BytesIO(data))
|
||||||
|
conf = os.path.join(work, "fwup.conf")
|
||||||
|
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "core range"\n'
|
||||||
|
'meta-version = "1.0.0"\nmeta-platform = "forgefirm-ext"\n'
|
||||||
|
'file-resource payload.tar.gz {\n host-path = "%s"\n}\n' % payload)
|
||||||
|
out = os.path.join(work, "corerange.ffx")
|
||||||
|
if os.path.exists(out):
|
||||||
|
os.remove(out)
|
||||||
|
subprocess.run([FWUP, "-c", "-f", conf, "-o", out], check=True, capture_output=True,
|
||||||
|
timeout=60, cwd=work)
|
||||||
|
return out
|
||||||
|
|
||||||
|
r = _forgeext("inspect", packed(None))
|
||||||
|
ctx.check(r.get("ok") is True, "a package with no core range was refused: %s", r.get("error"))
|
||||||
|
ctx.check(r.get("core_checked") is False,
|
||||||
|
"the host judged a range against the stamp %r: core_checked %s", stamp, r.get("core_checked"))
|
||||||
|
ev["no_range"] = r
|
||||||
|
|
||||||
|
# A stamp is no version, so neither end of a range is judged and
|
||||||
|
# neither refuses the package. The host says so rather than
|
||||||
|
# refusing on a comparison it cannot make.
|
||||||
|
ev["ranges"] = []
|
||||||
|
for core in ({"min": "99.0.0"}, {"max": "0.0.1"}, {"min": "0.0.1", "max": "0.0.2"}):
|
||||||
|
r = _forgeext("inspect", packed(core))
|
||||||
|
ctx.check(r.get("ok") is True and r.get("core_checked") is False,
|
||||||
|
"the host judged %s against the stamp anyway: %s", core, r)
|
||||||
|
ev["ranges"].append({"core": core, "inspect": r})
|
||||||
|
|
||||||
|
ctx.check("org.forgetest.corerange" not in
|
||||||
|
[x.get("id") for x in (_forgeext("list").get("packages") or [])],
|
||||||
|
"inspect installed the package: it must change nothing")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""The destinations the operator names for a package that asks for them.
|
||||||
|
|
||||||
|
Its own module rather than a test in exthost.py, for the reason extcore.py
|
||||||
|
gives. The package here is the reference package's id and key with a
|
||||||
|
service of its own, so exthost's put-back takes it away like the others.
|
||||||
|
|
||||||
|
What it dials is the machine's own DNS resolver on TCP port 53: an address
|
||||||
|
every machine on a network has, that is not the machine, and that answers.
|
||||||
|
Named by address, it gives the service no resolver of its own, so the
|
||||||
|
only way through to it is the one the operator named.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .exthost import (EXT_ROOT, FWUP, NFT, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _read, _svc, _tree,
|
||||||
|
_until, _write)
|
||||||
|
from .forgectrl import lan_ip
|
||||||
|
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||||
|
|
||||||
|
# The service: at every start, where it may connect and what one dial does.
|
||||||
|
DEST_SERVICE = r'''
|
||||||
|
import errno, json, os, socket, sys, time
|
||||||
|
data, target, port = os.environ["FFX_DATA"], sys.argv[1], int(sys.argv[2])
|
||||||
|
|
||||||
|
|
||||||
|
def api(method, path):
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(5)
|
||||||
|
try:
|
||||||
|
s.connect(os.environ["FFX_API"])
|
||||||
|
s.sendall(("%s %s HTTP/1.1\r\nHost: forgeext\r\n\r\n" % (method, path)).encode())
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
c = s.recv(65536)
|
||||||
|
if not c:
|
||||||
|
break
|
||||||
|
buf += c
|
||||||
|
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def dial(p):
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(4)
|
||||||
|
try:
|
||||||
|
s.connect((target, p))
|
||||||
|
return "connected"
|
||||||
|
except socket.timeout:
|
||||||
|
return "timeout"
|
||||||
|
except OSError as e:
|
||||||
|
return errno.errorcode.get(e.errno, str(e.errno))
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
report = {"pid": os.getpid(), "destinations": (api("GET", "/v0/self") or {}).get("destinations"),
|
||||||
|
"dial": dial(port), "dial_other_port": dial(port + 1)}
|
||||||
|
with open(os.path.join(data, "report.json.new"), "w") as f:
|
||||||
|
json.dump(report, f)
|
||||||
|
os.rename(os.path.join(data, "report.json.new"), os.path.join(data, "report.json"))
|
||||||
|
print("destinations service up: %s" % report, flush=True)
|
||||||
|
while True:
|
||||||
|
time.sleep(60)
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _resolver():
|
||||||
|
"""The first IPv4 nameserver of /etc/resolv.conf, or None."""
|
||||||
|
for line in _read("/etc/resolv.conf").splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2 and parts[0] == "nameserver" and parts[1].count(".") == 3:
|
||||||
|
return parts[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_dest(work, target, port):
|
||||||
|
"""The reference package's id and key, asking for the operator's destinations: (archive, public key)."""
|
||||||
|
import io
|
||||||
|
import tarfile
|
||||||
|
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest destinations", "version": "1.0.0",
|
||||||
|
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||||
|
"service": {"exec": "bin/dest.py", "args": [target, str(port)]},
|
||||||
|
"capabilities": ["net.outbound.operator"]}
|
||||||
|
payload = os.path.join(work, "payload.tar.gz")
|
||||||
|
with tarfile.open(payload, "w:gz") as t:
|
||||||
|
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/dest.py", DEST_SERVICE, 0o755)):
|
||||||
|
info = tarfile.TarInfo(name)
|
||||||
|
data = text.encode()
|
||||||
|
info.size, info.mode = len(data), mode
|
||||||
|
t.addfile(info, io.BytesIO(data))
|
||||||
|
conf = os.path.join(work, "fwup.conf")
|
||||||
|
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||||
|
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||||
|
% (REF_ID, payload))
|
||||||
|
key = os.path.join(work, REF_KEY)
|
||||||
|
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "dest.ffx")
|
||||||
|
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||||
|
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||||
|
return signed, key + ".pub"
|
||||||
|
|
||||||
|
|
||||||
|
@test("exthost.operator-destinations", title="The operator names where a package may connect, and takes it away",
|
||||||
|
subsystem="exthost", kind="auto", hardware="api", est_min=4,
|
||||||
|
covers=[("forgeext", "src/install.*"), ("forgeext", "src/state.*"), ("forgeext", "src/run.*"),
|
||||||
|
("forgeext", "src/super.*"), ("forgeext", "src/api.*"), ("forgeext", "src/caps.*"),
|
||||||
|
("forgeext", "src/manifest.*"), ("forgeext", "src/netrules.*"), ("forgeext", "src/main.c"),
|
||||||
|
("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c")],
|
||||||
|
requires=["exthost.service"],
|
||||||
|
description="A package that asks for net.outbound.operator may reach the places the operator names for it "
|
||||||
|
"and nothing else. Installed, it has none: its own GET /v0/self names no destination and its "
|
||||||
|
"dial to the machine's DNS resolver on TCP port 53 is refused inside the sandbox (EACCES). "
|
||||||
|
"The operator names that resolver through POST /ext/dest; the host starts the service again "
|
||||||
|
"(said in its log, and no crash), its answer names the destination, its dial connects, the "
|
||||||
|
"next port up is still refused, and the account's chain in the rule table carries the "
|
||||||
|
"address. The machine's own LAN address and loopback are refused in words, a destination "
|
||||||
|
"out of form is 400, and taking away one that was never named is refused. Taken away, the "
|
||||||
|
"service starts again with no destination, the dial is refused again, and the chain no longer "
|
||||||
|
"carries the address. The package, the key, the setting, and the setup record are put back as "
|
||||||
|
"found.")
|
||||||
|
def operator_destinations(ctx):
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
target = _resolver()
|
||||||
|
ctx.check(target, "/etc/resolv.conf names no IPv4 resolver: there is nothing to dial")
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(4)
|
||||||
|
try:
|
||||||
|
s.connect((target, 53))
|
||||||
|
reachable = True
|
||||||
|
except OSError as e:
|
||||||
|
reachable = str(e)
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
ev["resolver"] = {"address": target, "tcp_53_from_root": reachable}
|
||||||
|
ctx.check(reachable is True, "the resolver %s does not take a TCP connection on port 53 even from outside the "
|
||||||
|
"sandbox (%s): this network cannot show a way out opening", target, reachable)
|
||||||
|
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||||
|
prior = fc.settings().get("ext_enabled") or ""
|
||||||
|
raw = read_file(record_path())
|
||||||
|
found_tree = _tree(EXT_ROOT)
|
||||||
|
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||||
|
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||||
|
etag = hdrs.get("etag")
|
||||||
|
work = tempfile.mkdtemp(prefix="forgetest-dest.")
|
||||||
|
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||||
|
report = os.path.join(EXT_ROOT, "data", REF_ID, "report.json")
|
||||||
|
where = "%s:53" % target
|
||||||
|
|
||||||
|
def seen():
|
||||||
|
try:
|
||||||
|
with open(report) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fresh(than):
|
||||||
|
r = seen()
|
||||||
|
return r if r and r.get("pid") != than and _svc(REF_ID).get("state") == "running" else None
|
||||||
|
|
||||||
|
def chain_has(addr):
|
||||||
|
"""Does the package's own chain let it through to addr on TCP 53? No chain is no."""
|
||||||
|
acct = _svc(REF_ID).get("account") or ""
|
||||||
|
if not acct.startswith("ffx"):
|
||||||
|
return False
|
||||||
|
out = subprocess.run([NFT, "list", "chain", "inet", "ffx", "u%d" % (800 + int(acct[3:]))], capture_output=True,
|
||||||
|
text=True, timeout=30).stdout
|
||||||
|
return ("daddr %s tcp dport 53 accept" % addr) in out
|
||||||
|
|
||||||
|
try:
|
||||||
|
archive, pub = _pack_dest(work, target, 53)
|
||||||
|
shutil.copy(pub, owner_key)
|
||||||
|
os.chmod(owner_key, 0o644)
|
||||||
|
r = _forgeext("install", archive, "--consent-community")
|
||||||
|
ctx.check(r.get("ok") is True, "the install of a package asking for the operator's destinations -> %s",
|
||||||
|
r.get("error"))
|
||||||
|
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||||
|
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||||
|
first = _until(ctx, lambda: fresh(None), 60)
|
||||||
|
ctx.check(first, "the service did not come up: %s", _svc(REF_ID))
|
||||||
|
ev["none_named"] = first
|
||||||
|
ctx.log("none named: %s", first)
|
||||||
|
ctx.check(first.get("destinations") == [] and first.get("dial") == "EACCES",
|
||||||
|
"with nothing named it may reach nothing, and the sandbox refuses the dial: %s", first)
|
||||||
|
ctx.check(not chain_has(target), "the rule table carries %s before the operator named it", target)
|
||||||
|
|
||||||
|
# Refusals, in the host's words or the relay's.
|
||||||
|
refusals = []
|
||||||
|
for dest, want, words in (("%s:80" % lan_ip(), 409, "is this machine"), ("127.0.0.1:80", 409, "is this machine"),
|
||||||
|
("--root", 400, "host:port"), ("%s" % target, 409, "host:port")):
|
||||||
|
st_, why = fc.post("/ext/dest", data={"id": REF_ID, "action": "add", "dest": dest})
|
||||||
|
refusals.append([dest, st_, why])
|
||||||
|
ctx.check(st_ == want and words in json.dumps(why), "add %s -> %s %s, wanted %s %s", dest, st_, why, want, words)
|
||||||
|
st_, why = fc.post("/ext/dest", data={"id": REF_ID, "action": "remove", "dest": where})
|
||||||
|
refusals.append(["remove " + where, st_, why])
|
||||||
|
ctx.check(st_ == 409 and "is not one of" in json.dumps(why), "taking away one never named -> %s %s", st_, why)
|
||||||
|
ev["refusals"] = refusals
|
||||||
|
|
||||||
|
# Named: started again with its way out.
|
||||||
|
st_, doc = fc.post("/ext/dest", data={"id": REF_ID, "action": "add", "dest": where})
|
||||||
|
mine = [p for p in (doc or {}).get("packages", []) if p.get("id") == REF_ID] if isinstance(doc, dict) else []
|
||||||
|
ctx.check(st_ == 200 and mine and mine[0].get("destinations") == [where], "POST /ext/dest add %s -> %s %s",
|
||||||
|
where, st_, doc if st_ != 200 else mine)
|
||||||
|
named = _until(ctx, lambda: fresh(first.get("pid")), 60)
|
||||||
|
ev["named"] = named
|
||||||
|
ctx.log("named: %s", named)
|
||||||
|
ctx.check(named and named.get("destinations") == [where] and named.get("dial") == "connected"
|
||||||
|
and named.get("dial_other_port") == "EACCES",
|
||||||
|
"named, it is started again, names it, reaches it, and still not the next port: %s", named)
|
||||||
|
ctx.check(chain_has(target), "the rule table does not carry %s once named", target)
|
||||||
|
svc = _svc(REF_ID)
|
||||||
|
ctx.check(svc.get("state") == "running" and "quarantined" not in (svc.get("reason") or ""),
|
||||||
|
"the restart was no crash: %s", svc)
|
||||||
|
|
||||||
|
# Taken away: started again with none.
|
||||||
|
st_, doc = fc.post("/ext/dest", data={"id": REF_ID, "action": "remove", "dest": where})
|
||||||
|
ctx.check(st_ == 200, "POST /ext/dest remove %s -> %s %s", where, st_, doc)
|
||||||
|
gone = _until(ctx, lambda: fresh(named.get("pid") if named else None), 60)
|
||||||
|
ev["taken_away"] = gone
|
||||||
|
ctx.check(gone and gone.get("destinations") == [] and gone.get("dial") == "EACCES",
|
||||||
|
"taken away, it starts again and reaches nothing: %s", gone)
|
||||||
|
ctx.check(not chain_has(target), "the rule table still carries %s after it was taken away", target)
|
||||||
|
finally:
|
||||||
|
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||||
|
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""The host's own events: a service is told before it is stopped.
|
||||||
|
|
||||||
|
Its own module, for the reason extcore.py gives. The package here is the
|
||||||
|
reference package's id and key with a service of its own, so exthost's
|
||||||
|
put-back takes it away like the others.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until, _write)
|
||||||
|
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||||
|
|
||||||
|
# The service: it follows the host's event feed and keeps every host event it reads, with the
|
||||||
|
# monotonic time it read it, in its data directory.
|
||||||
|
LIFE_SERVICE = r'''
|
||||||
|
import json, os, socket, time
|
||||||
|
data = os.environ["FFX_DATA"]
|
||||||
|
|
||||||
|
|
||||||
|
def api(body):
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(40)
|
||||||
|
try:
|
||||||
|
s.connect(os.environ["FFX_API"])
|
||||||
|
payload = json.dumps(body).encode()
|
||||||
|
s.sendall(b"POST /v0/events HTTP/1.1\r\nHost: forgeext\r\nContent-Type: application/json\r\n"
|
||||||
|
b"Content-Length: %d\r\n\r\n" % len(payload) + payload)
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
c = s.recv(65536)
|
||||||
|
if not c:
|
||||||
|
break
|
||||||
|
buf += c
|
||||||
|
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def keep(name, doc):
|
||||||
|
with open(os.path.join(data, name + ".new"), "w") as f:
|
||||||
|
json.dump(doc, f)
|
||||||
|
os.rename(os.path.join(data, name + ".new"), os.path.join(data, name))
|
||||||
|
|
||||||
|
|
||||||
|
place = api({})["next"]
|
||||||
|
keep("ready.json", {"place": place, "t": time.monotonic()})
|
||||||
|
got = []
|
||||||
|
while True:
|
||||||
|
ans = api({"since": place, "wait": 20})
|
||||||
|
for e in ans.get("events") or []:
|
||||||
|
if e.get("event", "").startswith("ext."):
|
||||||
|
got.append({"event": e["event"], "data": e.get("data"), "t": time.monotonic()})
|
||||||
|
keep("host-events.json", got)
|
||||||
|
place = ans.get("next", place)
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_life(work):
|
||||||
|
import io
|
||||||
|
import tarfile
|
||||||
|
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest lifecycle", "version": "1.0.0",
|
||||||
|
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||||
|
"service": {"exec": "bin/life.py"}, "capabilities": ["events"]}
|
||||||
|
payload = os.path.join(work, "payload.tar.gz")
|
||||||
|
with tarfile.open(payload, "w:gz") as t:
|
||||||
|
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/life.py", LIFE_SERVICE, 0o755)):
|
||||||
|
info = tarfile.TarInfo(name)
|
||||||
|
data = text.encode()
|
||||||
|
info.size, info.mode = len(data), mode
|
||||||
|
t.addfile(info, io.BytesIO(data))
|
||||||
|
conf = os.path.join(work, "fwup.conf")
|
||||||
|
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||||
|
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||||
|
% (REF_ID, payload))
|
||||||
|
key = os.path.join(work, REF_KEY)
|
||||||
|
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "life.ffx")
|
||||||
|
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||||
|
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||||
|
return signed, key + ".pub"
|
||||||
|
|
||||||
|
|
||||||
|
@test("exthost.lifecycle", title="A package is told, in the feed it reads, before the host stops it",
|
||||||
|
subsystem="exthost", kind="auto", hardware="api", est_min=3,
|
||||||
|
covers=[("forgeext", "src/run.*"), ("forgeext", "src/machine.*"), ("forgeext", "src/evfeed.*"),
|
||||||
|
("forgeext", "src/api.*")],
|
||||||
|
requires=["exthost.service"],
|
||||||
|
description="A package that holds events reads the host's own events beside the machine's. With the "
|
||||||
|
"package running and following its feed, extensions are turned off: the host puts "
|
||||||
|
"ext.shutdown into the feed with the reason (\"extensions are off (ext_enabled)\"), and "
|
||||||
|
"stops the service a second later, so that the package has read it - the event is in its "
|
||||||
|
"data directory, read before its process ended. Holds do not wait for that second: they "
|
||||||
|
"follow extensions off at once, as exthost.hold-pause-tier holds. ext.will_freeze and "
|
||||||
|
"ext.thawed, which an armed window brings, are the automation package's host test's "
|
||||||
|
"(automation_test.py, where a service with job_time.run reads both). The package, the "
|
||||||
|
"key, the setting, and the setup record are put back as found.")
|
||||||
|
def lifecycle(ctx):
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||||
|
prior = fc.settings().get("ext_enabled") or ""
|
||||||
|
raw = read_file(record_path())
|
||||||
|
found_tree = _tree(EXT_ROOT)
|
||||||
|
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||||
|
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||||
|
etag = hdrs.get("etag")
|
||||||
|
work = tempfile.mkdtemp(prefix="forgetest-life.")
|
||||||
|
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||||
|
data = os.path.join(EXT_ROOT, "data", REF_ID)
|
||||||
|
|
||||||
|
def doc(name):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(data, name)) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
archive, pub = _pack_life(work)
|
||||||
|
shutil.copy(pub, owner_key)
|
||||||
|
os.chmod(owner_key, 0o644)
|
||||||
|
r = _forgeext("install", archive, "--consent-community")
|
||||||
|
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
|
||||||
|
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||||
|
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||||
|
ready = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and doc("ready.json"), 60)
|
||||||
|
ctx.check(ready, "the service did not come up and follow its feed: %s", _svc(REF_ID))
|
||||||
|
time.sleep(1.0)
|
||||||
|
pid = _svc(REF_ID).get("pid")
|
||||||
|
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
|
||||||
|
ctx.check(st == 200, "ext_enabled=0 -> %s %r", st, reply)
|
||||||
|
stopped = _until(ctx, lambda: _svc(REF_ID).get("state") != "running" and time.monotonic(), 20)
|
||||||
|
ctx.check(stopped, "the service was not stopped: %s", _svc(REF_ID))
|
||||||
|
ctx.check(not os.path.exists("/proc/%s" % pid), "its process %s outlived the stop", pid)
|
||||||
|
got = doc("host-events.json") or []
|
||||||
|
ev["host_events"] = got
|
||||||
|
ctx.log("what it read: %s", got)
|
||||||
|
ctx.check(len(got) >= 1 and got[0].get("event") == "ext.shutdown"
|
||||||
|
and (got[0].get("data") or {}).get("reason") == "extensions are off (ext_enabled)",
|
||||||
|
"it read ext.shutdown, with the reason, before it was stopped: %s", got)
|
||||||
|
finally:
|
||||||
|
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||||
|
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""An M-code a package answers, on the machine.
|
||||||
|
|
||||||
|
Its own module, for the reason extcore.py gives. The package is the
|
||||||
|
reference package's id and key with a service of its own that answers
|
||||||
|
M160 on its call socket, so exthost's put-back takes it away like the
|
||||||
|
others. The jobs are dark: they move and never command the laser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until, _write)
|
||||||
|
from .motion import _job, _job_post, _job_wait, _words, kernel_start, kernel_xy_mm, machine_idle
|
||||||
|
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||||
|
|
||||||
|
# The service: it answers M160 on the call socket the host hands it (fd 4), and keeps every call it was
|
||||||
|
# asked, with the monotonic time it was asked, in its data directory. P1 takes 2 s and is done; any
|
||||||
|
# other P is refused.
|
||||||
|
MC_SERVICE = r'''
|
||||||
|
import json, os, socket, time
|
||||||
|
data = os.environ["FFX_DATA"]
|
||||||
|
lst = socket.socket(fileno=int(os.environ["FFX_CALL_FD"]))
|
||||||
|
calls = []
|
||||||
|
while True:
|
||||||
|
c, _ = lst.accept()
|
||||||
|
try:
|
||||||
|
buf = b""
|
||||||
|
while b"\r\n\r\n" not in buf:
|
||||||
|
k = c.recv(4096)
|
||||||
|
if not k:
|
||||||
|
break
|
||||||
|
buf += k
|
||||||
|
head, _, body = buf.partition(b"\r\n\r\n")
|
||||||
|
n = 0
|
||||||
|
for line in head.split(b"\r\n")[1:]:
|
||||||
|
k, _, v = line.partition(b":")
|
||||||
|
if k.strip().lower() == b"content-length":
|
||||||
|
n = int(v)
|
||||||
|
while len(body) < n:
|
||||||
|
k = c.recv(4096)
|
||||||
|
if not k:
|
||||||
|
break
|
||||||
|
body += k
|
||||||
|
req = json.loads(body[:n] or b"{}")
|
||||||
|
path = head.split(b" ")[1].decode()
|
||||||
|
t0 = time.monotonic()
|
||||||
|
words = req.get("words", {})
|
||||||
|
if path == "/mcode" and words.get("P") == 1:
|
||||||
|
time.sleep(2.0)
|
||||||
|
status, ans = 200, {"message": "forgetest P1"}
|
||||||
|
elif path == "/mcode":
|
||||||
|
status, ans = 409, {"error": "the stand-in refused"}
|
||||||
|
else:
|
||||||
|
status, ans = 404, {"error": "no such call"}
|
||||||
|
calls.append({"path": path, "req": req, "t": t0, "status": status})
|
||||||
|
with open(os.path.join(data, "calls.json.new"), "w") as f:
|
||||||
|
json.dump(calls, f)
|
||||||
|
os.rename(os.path.join(data, "calls.json.new"), os.path.join(data, "calls.json"))
|
||||||
|
out = json.dumps(ans).encode()
|
||||||
|
c.sendall(b"HTTP/1.1 %d X\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"
|
||||||
|
% (status, len(out)) + out)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_mcode(work):
|
||||||
|
import io
|
||||||
|
import tarfile
|
||||||
|
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest M-code", "version": "1.0.0",
|
||||||
|
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||||
|
"service": {"exec": "bin/mcode.py"}, "capabilities": ["mcode:160", "job_time.run"]}
|
||||||
|
payload = os.path.join(work, "payload.tar.gz")
|
||||||
|
with tarfile.open(payload, "w:gz") as t:
|
||||||
|
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/mcode.py", MC_SERVICE, 0o755)):
|
||||||
|
info = tarfile.TarInfo(name)
|
||||||
|
data = text.encode()
|
||||||
|
info.size, info.mode = len(data), mode
|
||||||
|
t.addfile(info, io.BytesIO(data))
|
||||||
|
conf = os.path.join(work, "fwup.conf")
|
||||||
|
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||||
|
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||||
|
% (REF_ID, payload))
|
||||||
|
key = os.path.join(work, REF_KEY)
|
||||||
|
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "mcode.ffx")
|
||||||
|
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||||
|
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||||
|
return signed, key + ".pub"
|
||||||
|
|
||||||
|
|
||||||
|
def _port_state(fc):
|
||||||
|
st, body = fc.get("/motion/state")
|
||||||
|
return body if st == 200 and isinstance(body, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
@test("exthost.mcode", title="A job waits at an M-code a package answers, dark and still",
|
||||||
|
subsystem="exthost", kind="auto", mode="grbl", est_min=4,
|
||||||
|
covers=[("grblhal-glowforge", "src/glowforge_mcode.*"), ("grblhal-glowforge", "src/ctlport.*"),
|
||||||
|
("forgectrl", "src/mcode.*"), ("forgectrl", "src/extpkg.*"), ("forgectrl", "src/grblport.*"),
|
||||||
|
("forgeext", "src/main.c"), ("forgeext", "src/run.*"), ("forgeext", "src/caps.*"),
|
||||||
|
("forgeext", "src/manifest.*"), ("forgeext", "src/call.*")],
|
||||||
|
requires=["exthost.service", "motion.job"],
|
||||||
|
steps=["Bed clear; the head needs 10 mm of free travel toward +X. Nobody touches the gantry."],
|
||||||
|
description="A package that asks for mcode:160 and job_time.run, the one granted, is installed and "
|
||||||
|
"turned on; its service answers POST /mcode on its call socket. The host's status names it "
|
||||||
|
"as the one that answers M160. A dark job (out 5 mm, M160 P1, back) plays through POST "
|
||||||
|
"/job: at the M-code the controller's port state names M160 with P 1, the head stands "
|
||||||
|
"still across the wait (the kernel's counters and the port's position unchanged over "
|
||||||
|
"1 s), and the service answers after 2 s; the job then goes on and ends done, every line "
|
||||||
|
"acknowledged, the head back where it began, the service asked once with the code and "
|
||||||
|
"its words, no discharge. A job that names M161, which nothing answers, fails at that "
|
||||||
|
"line with nothing moved. A job at M160 P2, which the service refuses, is held: the "
|
||||||
|
"port's state goes to Hold with the head still, and the job is aborted from there. "
|
||||||
|
"Everything is put back as exthost.service puts it back; the refusals the controller "
|
||||||
|
"makes of a table or an answer out of form, the timeout, the reset, and the barrier's "
|
||||||
|
"darkness under an open armed window are the driver's mcode_test harness's.")
|
||||||
|
def mcode(ctx):
|
||||||
|
import tempfile
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
machine_idle(ctx)
|
||||||
|
prior = fc.settings().get("ext_enabled") or ""
|
||||||
|
raw = read_file(record_path())
|
||||||
|
found_tree = _tree(EXT_ROOT)
|
||||||
|
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||||
|
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||||
|
etag = hdrs.get("etag")
|
||||||
|
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
|
||||||
|
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||||
|
calls_file = os.path.join(EXT_ROOT, "data", REF_ID, "calls.json")
|
||||||
|
|
||||||
|
def calls():
|
||||||
|
try:
|
||||||
|
return json.loads(read_file(calls_file) or "[]")
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
archive, pub = _pack_mcode(work)
|
||||||
|
import shutil
|
||||||
|
shutil.copy(pub, owner_key)
|
||||||
|
os.chmod(owner_key, 0o644)
|
||||||
|
r = _forgeext("install", archive, "--consent-community", "--grant", "job_time.run")
|
||||||
|
ctx.check(r.get("ok") is True, "the install with job_time.run granted -> %s", r.get("error"))
|
||||||
|
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||||
|
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||||
|
x = _until(ctx, lambda: _svc(REF_ID) if _svc(REF_ID).get("state") == "running" else None, 90, poll=0.5)
|
||||||
|
ctx.check(x, "the service is not running: %s", _svc(REF_ID))
|
||||||
|
|
||||||
|
def listed():
|
||||||
|
try:
|
||||||
|
doc = json.loads(read_file("/run/forgefirm/ext/status.json") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return doc.get("mcodes") if doc.get("mcodes") == [{"code": 160, "id": REF_ID}] else None
|
||||||
|
ev["status_mcodes"] = _until(ctx, listed, 30, poll=0.5)
|
||||||
|
ctx.check(ev["status_mcodes"], "the host's status does not name the package as the one that answers M160")
|
||||||
|
ctx.sleep(3.0) # the relay tells the controller within its 2 s
|
||||||
|
|
||||||
|
# A dark job that waits at M160 P1 for 2 s.
|
||||||
|
x0, _y0 = kernel_start(ctx)
|
||||||
|
st, body = _job_post(fc, "(forgetest exthost.mcode)\nG21\nG91\nG1 X5 F1200\nM160 P1\nG1 X-5 F1200\nG90\n")
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "running",
|
||||||
|
"POST /job -> %s %s", st, _words(body)[:200])
|
||||||
|
waiting = _until(ctx, lambda: _port_state(fc) if isinstance(_port_state(fc).get("mcode"), dict) else None,
|
||||||
|
20, poll=0.1)
|
||||||
|
ctx.check(waiting and waiting["mcode"].get("code") == 160 and waiting["mcode"].get("words") == {"P": 1},
|
||||||
|
"the port's state does not name M160 P1 while the job waits: %s", waiting)
|
||||||
|
k1 = kernel_xy_mm(ctx)
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
k2 = kernel_xy_mm(ctx)
|
||||||
|
still = _port_state(fc)
|
||||||
|
ev["wait"] = {"port": waiting, "kernel": [k1, k2], "port_after_1s": still}
|
||||||
|
ctx.log("the job waits at M160: port %s, kernel %s -> %s", waiting, k1, k2)
|
||||||
|
ctx.check(abs(k2[0] - k1[0]) < 0.001 and abs(k2[1] - k1[1]) < 0.001,
|
||||||
|
"the head moved while the job waited: %s -> %s", k1, k2)
|
||||||
|
ctx.check(still.get("mpos") == waiting.get("mpos") and still.get("state") == "Idle",
|
||||||
|
"the port's position or state moved while the job waited: %s -> %s", waiting, still)
|
||||||
|
ctx.check(abs(k1[0] - x0 - 5.0) < 0.05, "the job did not wait at the M-code 5 mm out: %.3f", k1[0] - x0)
|
||||||
|
rec, far = _job_wait(ctx, fc, 40, x0)
|
||||||
|
x1 = kernel_xy_mm(ctx)[0]
|
||||||
|
got = calls()
|
||||||
|
ev["job"] = {"record": rec, "farthest_mm": round(far, 3), "end_mm": round(x1 - x0, 3), "calls": got}
|
||||||
|
ctx.log("the job at M160 P1: %s", ev["job"])
|
||||||
|
ctx.check(rec["state"] == "done" and rec["reason"] == "", "the job did not end well: %s", rec)
|
||||||
|
ctx.check(rec["sent"] == rec["acked"] == rec["lines"] + 1, "lines %s, sent %s, acked %s",
|
||||||
|
rec["lines"], rec["sent"], rec["acked"])
|
||||||
|
ctx.check(rec["lit"] is False and rec["emission"]["laser_on_samples"] == 0, "a dark job's witnesses: %s",
|
||||||
|
rec["emission"])
|
||||||
|
ctx.check(abs(x1 - x0) < 0.1, "the job did not end where it began: %.3f mm off", x1 - x0)
|
||||||
|
ctx.check(len(got) == 1 and got[0]["path"] == "/mcode" and got[0]["req"] == {"code": 160, "words": {"P": 1}}
|
||||||
|
and got[0]["status"] == 200, "the service was not asked M160 P1 once: %s", got)
|
||||||
|
|
||||||
|
# M161: nothing answers it, and the job stops where it is parsed.
|
||||||
|
x0 = kernel_xy_mm(ctx)[0]
|
||||||
|
st, body = _job_post(fc, "G21\nG91\nM161\nG1 X5 F1200\nG1 X-5 F1200\nG90\n")
|
||||||
|
rec, far = _job_wait(ctx, fc, 20, x0)
|
||||||
|
ev["unanswered"] = {"record": rec, "farthest_mm": round(far, 3)}
|
||||||
|
ctx.log("the job at M161: %s", ev["unanswered"])
|
||||||
|
ctx.check(rec["state"] == "failed" and "answered error:20" in (rec.get("reason") or ""),
|
||||||
|
"a job at M161 did not fail with error:20: %s", rec)
|
||||||
|
ctx.check(far < 0.01, "a job at an M-code nothing answers moved the head %.3f mm", far)
|
||||||
|
|
||||||
|
# M160 P2: the service refuses it, and the job is held there.
|
||||||
|
x0 = kernel_xy_mm(ctx)[0]
|
||||||
|
st, body = _job_post(fc, "G21\nG91\nM160 P2\nG1 X5 F1200\nG1 X-5 F1200\nG90\n")
|
||||||
|
held = _until(ctx, lambda: _port_state(fc) if _port_state(fc).get("state") == "Hold" else None, 30, poll=0.2)
|
||||||
|
ev["refused"] = {"port": held, "calls": calls()}
|
||||||
|
ctx.log("the job at M160 P2: %s", ev["refused"])
|
||||||
|
ctx.check(held and held.get("mcode") is None, "a job at a refused M160 is not held: %s", _port_state(fc))
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.01, "the held job moved the head")
|
||||||
|
st, body = fc.post("/job/abort")
|
||||||
|
ctx.check(st == 200, "POST /job/abort -> %s %s", st, _words(body)[:160])
|
||||||
|
rec, _far = _job_wait(ctx, fc, 20, x0)
|
||||||
|
ctx.check(rec["state"] == "failed", "the held job did not end aborted: %s", rec)
|
||||||
|
if _port_state(fc).get("state") == "Alarm":
|
||||||
|
st, body = _job_post(fc, "G21\n", unlock="1")
|
||||||
|
_job_wait(ctx, fc, 20, x0)
|
||||||
|
ctx.check(_port_state(fc).get("state") == "Idle", "the controller is not idle after the abort: %s",
|
||||||
|
_port_state(fc))
|
||||||
|
finally:
|
||||||
|
# A check that failed mid-job leaves the job running; it holds the settings until it ends.
|
||||||
|
if _job(fc).get("state") == "running":
|
||||||
|
fc.post("/job/abort")
|
||||||
|
_job_wait(ctx, fc, 20)
|
||||||
|
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||||
|
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""An extension package keeps the Grbl sender out, by the operator's grant.
|
||||||
|
|
||||||
|
Its own module rather than a test in exthost.py, for the reason extcore.py
|
||||||
|
gives: a test added there moves the fingerprint of every test in that
|
||||||
|
module. The package is the reference package's id and key with a service
|
||||||
|
that claims the sender out as it starts and says how its claim stands
|
||||||
|
every half second, so exthost's put-back takes it away like the others.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until,
|
||||||
|
_write)
|
||||||
|
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||||
|
|
||||||
|
CLAIM_DIR = "/run/forgefirm/sender-out"
|
||||||
|
GRBL_STATE = "/run/forgefirm/grbl.state"
|
||||||
|
KEEP_OUT_MSG = b"[MSG:The machine is in use: senders are kept out for now]"
|
||||||
|
|
||||||
|
# The service: it claims the sender out once, as it starts, writes the
|
||||||
|
# machine's answer, and then writes how its claim stands, twice a second.
|
||||||
|
KEEPER_SERVICE = r'''
|
||||||
|
import json, os, socket, time
|
||||||
|
data = os.environ["FFX_DATA"]
|
||||||
|
|
||||||
|
|
||||||
|
def api(method, path, body=None):
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(10)
|
||||||
|
try:
|
||||||
|
s.connect(os.environ["FFX_API"])
|
||||||
|
payload = b"" if body is None else json.dumps(body).encode()
|
||||||
|
head = "%s %s HTTP/1.1\r\nHost: forgeext\r\n" % (method, path)
|
||||||
|
if body is not None:
|
||||||
|
head += "Content-Type: application/json\r\nContent-Length: %d\r\n" % len(payload)
|
||||||
|
s.sendall(head.encode() + b"\r\n" + payload)
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
c = s.recv(65536)
|
||||||
|
if not c:
|
||||||
|
break
|
||||||
|
buf += c
|
||||||
|
top, _, content = buf.partition(b"\r\n\r\n")
|
||||||
|
return int(top.split()[1]), json.loads(content or b"null")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def write(name, doc):
|
||||||
|
p = os.path.join(data, name)
|
||||||
|
with open(p + ".new", "w") as f:
|
||||||
|
json.dump(doc, f)
|
||||||
|
os.rename(p + ".new", p)
|
||||||
|
|
||||||
|
|
||||||
|
status, doc = api("POST", "/v0/sender", {"out": True})
|
||||||
|
write("claim.json", {"status": status, "doc": doc})
|
||||||
|
print("keeper up: %s %s" % (status, doc), flush=True)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
write("state.json", dict(zip(("status", "doc"), api("GET", "/v0/sender"))))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_keeper(work):
|
||||||
|
"""The reference package's id and key, with the service above: (archive, public key)."""
|
||||||
|
import io
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest sender keeper", "version": "1.0.0",
|
||||||
|
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||||
|
"service": {"exec": "bin/keeper.py"}, "capabilities": ["sender.keep_out"]}
|
||||||
|
payload = os.path.join(work, "payload.tar.gz")
|
||||||
|
with tarfile.open(payload, "w:gz") as t:
|
||||||
|
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/keeper.py", KEEPER_SERVICE, 0o755)):
|
||||||
|
info = tarfile.TarInfo(name)
|
||||||
|
data = text.encode()
|
||||||
|
info.size, info.mode = len(data), mode
|
||||||
|
t.addfile(info, io.BytesIO(data))
|
||||||
|
conf = os.path.join(work, "fwup.conf")
|
||||||
|
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||||
|
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||||
|
% (REF_ID, payload))
|
||||||
|
key = os.path.join(work, REF_KEY)
|
||||||
|
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "keeper.ffx")
|
||||||
|
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||||
|
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||||
|
return signed, key + ".pub"
|
||||||
|
|
||||||
|
|
||||||
|
def _lan_address():
|
||||||
|
"""This machine's own address on the network: a sender that connects to it comes from the network
|
||||||
|
as the controller sees it, and not from the loopback."""
|
||||||
|
u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
u.connect(("192.0.2.1", 9))
|
||||||
|
return u.getsockname()[0]
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
finally:
|
||||||
|
u.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _sender(addr, secs=2.0):
|
||||||
|
"""A Grbl sender that connects and listens: (what it read, whether the controller closed it)."""
|
||||||
|
s = socket.create_connection((addr, 23), timeout=5)
|
||||||
|
s.settimeout(0.3)
|
||||||
|
got, closed, end = b"", False, time.time() + secs
|
||||||
|
try:
|
||||||
|
while time.time() < end:
|
||||||
|
try:
|
||||||
|
c = s.recv(4096)
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
if not c:
|
||||||
|
closed = True
|
||||||
|
break
|
||||||
|
got += c
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
return got, closed
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path):
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@test("exthost.sender-keep-out", title="An extension package keeps the Grbl sender out, by the operator's grant",
|
||||||
|
subsystem="exthost", kind="auto", hardware="api", est_min=4,
|
||||||
|
covers=[("forgectrl", "src/senderout.*"), ("forgectrl", "src/lease.*"), ("forgectrl", "src/grblport.*"),
|
||||||
|
("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"), ("forgeext", "src/api.*"),
|
||||||
|
("forgeext", "src/run.*"), ("forgeext", "src/machine.*"), ("forgeext", "src/holdkeep.*"),
|
||||||
|
("forgeext", "src/caps.*"), ("grblhal-glowforge", "src/ctlport.*"), ("grblhal-glowforge", "src/serial.*")],
|
||||||
|
requires=["exthost.service"],
|
||||||
|
description="A package the operator granted sender.keep_out keeps the Grbl sender out while it uses the "
|
||||||
|
"machine: the test installs a reference package whose service asks POST /v0/sender "
|
||||||
|
"{\"out\": true} as it starts, on an idle machine with no sender connected. The machine "
|
||||||
|
"grants it: /status names the package in sender_out and holds the machine lease as "
|
||||||
|
"ext:<id>, of the extension kind; the host keeps the claim fresh under "
|
||||||
|
"/run/forgefirm/sender-out; a Grbl sender that connects from the network (the machine's "
|
||||||
|
"own LAN address) reads the controller's message and is closed at once, and one from "
|
||||||
|
"this host still connects. The operator lets the sender back in from the panel "
|
||||||
|
"(POST /motion/sender out=0): the holder and the lease go, a sender from the network is "
|
||||||
|
"welcomed again, and the package's service reads released. Turned off and on, the "
|
||||||
|
"package's service claims again; turned off with its claim standing, forgectrl ends the "
|
||||||
|
"claim within seconds by itself, with the notice that names it, and lets senders in; the "
|
||||||
|
"notice is cleared from the panel. The package, the key, the setting, and the setup "
|
||||||
|
"record are put back as found.")
|
||||||
|
def sender_keep_out(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||||
|
st, m0 = fc.get("/mode")
|
||||||
|
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("mode") == "grbl" and m0.get("controller") == "running",
|
||||||
|
"start this test in grbl mode with the controller running (now %s)", m0)
|
||||||
|
grbl = _read_json(GRBL_STATE) or {}
|
||||||
|
ctx.check(not (grbl.get("sender") or {}).get("connected"),
|
||||||
|
"a Grbl sender is connected: close it first (this test would disconnect it)")
|
||||||
|
lan = _lan_address()
|
||||||
|
ctx.check(lan and not lan.startswith("127."), "the machine has no address on the network: %r", lan)
|
||||||
|
prior = fc.settings().get("ext_enabled") or ""
|
||||||
|
raw = read_file(record_path())
|
||||||
|
found_tree = _tree(EXT_ROOT)
|
||||||
|
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||||
|
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||||
|
etag = hdrs.get("etag")
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
work = tempfile.mkdtemp(prefix="forgetest-sender.")
|
||||||
|
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||||
|
data = os.path.join(EXT_ROOT, "data", REF_ID)
|
||||||
|
claim_file = os.path.join(CLAIM_DIR, REF_ID + ".json")
|
||||||
|
|
||||||
|
def sender_out():
|
||||||
|
return fc.status().get("sender_out") or {}
|
||||||
|
|
||||||
|
def holder():
|
||||||
|
return (sender_out().get("holder") or {}).get("id")
|
||||||
|
|
||||||
|
try:
|
||||||
|
got, closed = _sender(lan)
|
||||||
|
ev["before"] = [got[:80].decode(errors="replace"), closed]
|
||||||
|
ctx.check(b"Grbl" in got and not closed, "before: a sender from the network is not welcomed: %r", got[:80])
|
||||||
|
|
||||||
|
archive, pub = _pack_keeper(work)
|
||||||
|
shutil.copy(pub, owner_key)
|
||||||
|
os.chmod(owner_key, 0o644)
|
||||||
|
r = _forgeext("install", archive, "--consent-community")
|
||||||
|
ctx.check(r.get("ok") is False and "sender.keep_out" in (r.get("error") or ""),
|
||||||
|
"an install without the operator's grant is refused -> %s", r.get("error"))
|
||||||
|
r = _forgeext("install", archive, "--consent-community", "--grant", "sender.keep_out")
|
||||||
|
ctx.check(r.get("ok") is True, "the install with the grant -> %s", r.get("error"))
|
||||||
|
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||||
|
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||||
|
up = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and holder() == REF_ID, 60)
|
||||||
|
claim = _read_json(os.path.join(data, "claim.json"))
|
||||||
|
ev["claim"] = claim
|
||||||
|
ctx.log("the service's claim: %s; sender_out %s", claim, sender_out())
|
||||||
|
ctx.check(up and (claim or {}).get("status") == 200 and (claim.get("doc") or {}).get("out") is True,
|
||||||
|
"the service's claim was not granted: %s, %s", claim, sender_out())
|
||||||
|
|
||||||
|
doc = fc.status()
|
||||||
|
ev["kept"] = {"sender_out": doc.get("sender_out"), "lease": (doc.get("lease") or {}).get("holder")}
|
||||||
|
lease = (doc.get("lease") or {}).get("holder") or {}
|
||||||
|
ctx.check(lease.get("owner") == "ext:" + REF_ID and lease.get("kind") == "extension",
|
||||||
|
"the lease is not the package's: %s", lease)
|
||||||
|
def fresh():
|
||||||
|
k = _read_json(claim_file) or {}
|
||||||
|
return k if (k.get("id") == REF_ID and k.get("raised") is True and
|
||||||
|
time.clock_gettime(time.CLOCK_MONOTONIC) - float(k.get("ts_mono") or 0) < 2.0) else None
|
||||||
|
# The host writes the claim at its next turn; past forgectrl's 3 s grace the claim stands only
|
||||||
|
# because the host keeps it fresh.
|
||||||
|
kept = _until(ctx, fresh, 5)
|
||||||
|
ev["claim_file"] = kept
|
||||||
|
ctx.check(kept, "the host does not write the claim: %s", _read_json(claim_file))
|
||||||
|
time.sleep(5.0)
|
||||||
|
ctx.check(holder() == REF_ID and fresh(), "past the grace, the claim did not stand fresh: %s, %s",
|
||||||
|
sender_out(), _read_json(claim_file))
|
||||||
|
got, closed = _sender(lan)
|
||||||
|
ev["kept_out"] = [got[:120].decode(errors="replace"), closed]
|
||||||
|
ctx.check(closed and KEEP_OUT_MSG in got and b"Grbl" not in got,
|
||||||
|
"a sender from the network was not turned away: %r closed=%s", got[:120], closed)
|
||||||
|
got, closed = _sender("127.0.0.1", 1.0)
|
||||||
|
ev["loopback"] = [got[:80].decode(errors="replace"), closed]
|
||||||
|
ctx.check(b"Grbl" in got, "a sender from this host was not let in: %r", got[:80])
|
||||||
|
|
||||||
|
# The operator lets it back in, from the panel.
|
||||||
|
st, reply = fc.post("/motion/sender", data={"out": "0"})
|
||||||
|
ev["released"] = [st, reply]
|
||||||
|
ctx.check(st == 200 and holder() is None, "the operator's release -> %s %s", st, reply)
|
||||||
|
ctx.check(not ((fc.status().get("lease") or {}).get("holder")), "the lease stayed after the release")
|
||||||
|
got, closed = _sender(lan)
|
||||||
|
ctx.check(b"Grbl" in got and not closed, "released, a sender from the network is not welcomed: %r", got[:80])
|
||||||
|
saw = _until(ctx, lambda: ((_read_json(os.path.join(data, "state.json")) or {}).get("doc") or {}).get("released"), 10)
|
||||||
|
ctx.check(saw is True, "the package's service did not read released: %s",
|
||||||
|
_read_json(os.path.join(data, "state.json")))
|
||||||
|
|
||||||
|
# Off and on: the operator's release lasts until the package stops claiming, and the
|
||||||
|
# service claims again as it starts.
|
||||||
|
ctx.check(_forgeext("disable", REF_ID).get("ok") is True, "disable")
|
||||||
|
ctx.check(_until(ctx, lambda: not os.path.exists(claim_file), 15), "disabled, the host kept the claim file")
|
||||||
|
time.sleep(3.0) # past forgectrl's freshness window, so the release is forgotten
|
||||||
|
ctx.check(_forgeext("enable", REF_ID).get("ok") is True, "enable")
|
||||||
|
again = _until(ctx, lambda: holder() == REF_ID, 60)
|
||||||
|
ctx.check(again, "enabled again, the service's claim was not granted: %s", sender_out())
|
||||||
|
|
||||||
|
# Turned off with its claim standing: forgectrl ends it by itself.
|
||||||
|
t0 = time.time()
|
||||||
|
ctx.check(_forgeext("disable", REF_ID).get("ok") is True, "disable with the claim standing")
|
||||||
|
gone = _until(ctx, lambda: holder() is None, 15)
|
||||||
|
ev["stale_s"] = round(time.time() - t0, 1)
|
||||||
|
notice = sender_out().get("notice") or {}
|
||||||
|
ev["notice"] = notice
|
||||||
|
ctx.log("the claim ended %.1f s after the disable; notice %s", time.time() - t0, notice)
|
||||||
|
ctx.check(gone is True and notice.get("id") == REF_ID and notice.get("why") == "stopped",
|
||||||
|
"a claim its keeper stopped keeping did not end with the notice: %s", sender_out())
|
||||||
|
ctx.check(not os.path.exists(claim_file), "the claim file stayed after the service stopped")
|
||||||
|
got, closed = _sender(lan)
|
||||||
|
ctx.check(b"Grbl" in got and not closed, "after it ended, a sender from the network is not welcomed: %r", got[:80])
|
||||||
|
st, reply = fc.post("/motion/sender", data={"notice": "clear"})
|
||||||
|
ctx.check(st == 200 and not sender_out().get("notice"), "the notice was not cleared -> %s %s", st, reply)
|
||||||
|
finally:
|
||||||
|
if holder():
|
||||||
|
fc.post("/motion/sender", data={"out": "0"})
|
||||||
|
if sender_out().get("notice"):
|
||||||
|
fc.post("/motion/sender", data={"notice": "clear"})
|
||||||
|
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||||
|
ctx.check(holder() is None, "the sender is still kept out at the end")
|
||||||
|
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""A package's own check on the Setup page, on the machine.
|
||||||
|
|
||||||
|
Its own module, for the reason extcore.py gives. The package is the
|
||||||
|
reference package's id and key with a service of its own that runs the
|
||||||
|
check on its call socket, so exthost's put-back takes it away like the
|
||||||
|
others.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until, _write)
|
||||||
|
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||||
|
|
||||||
|
# The service: the check's steps on the call socket the host hands it (fd 4), its own record of whether the
|
||||||
|
# check passed, and every request it was asked, kept in its data directory.
|
||||||
|
WZ_SERVICE = r'''
|
||||||
|
import json, os, socket
|
||||||
|
data = os.environ["FFX_DATA"]
|
||||||
|
lst = socket.socket(fileno=int(os.environ["FFX_CALL_FD"]))
|
||||||
|
state = {"done": False, "calls": []}
|
||||||
|
while True:
|
||||||
|
c, _ = lst.accept()
|
||||||
|
try:
|
||||||
|
buf = b""
|
||||||
|
while b"\r\n\r\n" not in buf:
|
||||||
|
k = c.recv(4096)
|
||||||
|
if not k:
|
||||||
|
break
|
||||||
|
buf += k
|
||||||
|
head, _, body = buf.partition(b"\r\n\r\n")
|
||||||
|
n = 0
|
||||||
|
for line in head.split(b"\r\n")[1:]:
|
||||||
|
k, _, v = line.partition(b":")
|
||||||
|
if k.strip().lower() == b"content-length":
|
||||||
|
n = int(v)
|
||||||
|
while len(body) < n:
|
||||||
|
k = c.recv(4096)
|
||||||
|
if not k:
|
||||||
|
break
|
||||||
|
body += k
|
||||||
|
req = json.loads(body[:n] or b"{}")
|
||||||
|
method, path = head.split(b" ")[0].decode(), head.split(b" ")[1].decode()
|
||||||
|
if method == "GET" and path == "/wizard":
|
||||||
|
ans = {"title": "forgetest check", "done": state["done"]}
|
||||||
|
elif path == "/wizard/start":
|
||||||
|
ans = {"log": ["the check begins"], "phase": "asking", "progress": 50,
|
||||||
|
"prompt": {"kind": "confirm", "id": "ready", "text": "Is the machine ready?"}}
|
||||||
|
elif path == "/wizard/answer":
|
||||||
|
ok = req.get("id") == "ready" and req.get("answer") == "yes"
|
||||||
|
state["done"] = ok
|
||||||
|
ans = {"log": ["answered " + str(req.get("answer"))], "result": {"ok": ok, "summary": "ready" if ok else "not ready"}}
|
||||||
|
elif path == "/wizard/abort":
|
||||||
|
ans = {}
|
||||||
|
else:
|
||||||
|
ans = {"error": "no such call"}
|
||||||
|
state["calls"].append([method, path, req])
|
||||||
|
with open(os.path.join(data, "check.json.new"), "w") as f:
|
||||||
|
json.dump(state, f)
|
||||||
|
os.rename(os.path.join(data, "check.json.new"), os.path.join(data, "check.json"))
|
||||||
|
out = json.dumps(ans).encode()
|
||||||
|
c.sendall(b"HTTP/1.1 200 X\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"
|
||||||
|
% len(out) + out)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_check(work):
|
||||||
|
import io
|
||||||
|
import tarfile
|
||||||
|
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest check", "version": "1.0.0",
|
||||||
|
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||||
|
"service": {"exec": "bin/check.py"}, "capabilities": ["wizard"]}
|
||||||
|
payload = os.path.join(work, "payload.tar.gz")
|
||||||
|
with tarfile.open(payload, "w:gz") as t:
|
||||||
|
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/check.py", WZ_SERVICE, 0o755)):
|
||||||
|
info = tarfile.TarInfo(name)
|
||||||
|
data = text.encode()
|
||||||
|
info.size, info.mode = len(data), mode
|
||||||
|
t.addfile(info, io.BytesIO(data))
|
||||||
|
conf = os.path.join(work, "fwup.conf")
|
||||||
|
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||||
|
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||||
|
% (REF_ID, payload))
|
||||||
|
key = os.path.join(work, REF_KEY)
|
||||||
|
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "check.ffx")
|
||||||
|
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||||
|
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||||
|
return signed, key + ".pub"
|
||||||
|
|
||||||
|
|
||||||
|
@test("exthost.wizard", title="A package's own check runs on the Setup page, and the machine's setup is untouched",
|
||||||
|
subsystem="exthost", kind="auto", est_min=3,
|
||||||
|
covers=[("forgectrl", "src/wizpkg.*"), ("forgectrl", "src/wizdark.c"), ("forgectrl", "src/wiz.c"),
|
||||||
|
("forgectrl", "src/extpkg.*"), ("forgectrl", "src/ui/wizard.js"), ("forgeext", "src/main.c"),
|
||||||
|
("forgeext", "src/run.*"), ("forgeext", "src/caps.*"), ("forgeext", "src/manifest.*")],
|
||||||
|
requires=["exthost.service"],
|
||||||
|
description="A package that asks for wizard is installed and turned on; its service runs a check on its "
|
||||||
|
"call socket and keeps whether it passed. GET /wiz lists it among the extensions as "
|
||||||
|
"pkg:<id> with the title the package gives it, not done. POST /wiz/pkg:<id>/start runs it on "
|
||||||
|
"the wizard runner: GET /wiz/dark shows the package's log line, phase, progress, and its "
|
||||||
|
"confirm prompt, and the machine lease stays free while it waits (a package's check holds the "
|
||||||
|
"machine no more than the package does). The operator's Yes reaches the service as the answer "
|
||||||
|
"yes, the run ends complete with the package's summary, and GET /wiz then lists the check as "
|
||||||
|
"done, from the package's own answer. The machine's setup record is the same before and after: "
|
||||||
|
"no pkg: wizard in it and the gate as it was. A second run is aborted from its prompt and the "
|
||||||
|
"service is told. A check of a package that is not installed is 404. Everything is put back "
|
||||||
|
"as exthost.service puts it back.")
|
||||||
|
def wizard(ctx):
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
|
||||||
|
prior = fc.settings().get("ext_enabled") or ""
|
||||||
|
raw = read_file(record_path())
|
||||||
|
found_tree = _tree(EXT_ROOT)
|
||||||
|
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||||
|
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||||
|
etag = hdrs.get("etag")
|
||||||
|
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
|
||||||
|
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||||
|
wid = "pkg:" + REF_ID
|
||||||
|
check_file = os.path.join(EXT_ROOT, "data", REF_ID, "check.json")
|
||||||
|
|
||||||
|
def own():
|
||||||
|
try:
|
||||||
|
return json.loads(read_file(check_file) or "{}")
|
||||||
|
except ValueError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def listed():
|
||||||
|
st_, w = fc.get("/wiz")
|
||||||
|
return next((e for e in (w.get("extensions") or []) if e.get("id") == wid), None) if isinstance(w, dict) else None
|
||||||
|
|
||||||
|
def dark():
|
||||||
|
st_, d = fc.get("/wiz/dark")
|
||||||
|
return d if isinstance(d, dict) else {}
|
||||||
|
|
||||||
|
def record_view():
|
||||||
|
try:
|
||||||
|
rec = json.loads(read_file(record_path()) or "{}")
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return {"wizards": sorted((rec.get("wizards") or {}).keys()), "completed": rec.get("completed")}
|
||||||
|
|
||||||
|
before = record_view()
|
||||||
|
gate_before = fc.get("/wiz")[1].get("gate")
|
||||||
|
try:
|
||||||
|
archive, pub = _pack_check(work)
|
||||||
|
shutil.copy(pub, owner_key)
|
||||||
|
os.chmod(owner_key, 0o644)
|
||||||
|
r = _forgeext("install", archive, "--consent-community")
|
||||||
|
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
|
||||||
|
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||||
|
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||||
|
x = _until(ctx, lambda: _svc(REF_ID) if _svc(REF_ID).get("state") == "running" else None, 90, poll=0.5)
|
||||||
|
ctx.check(x, "the service is not running: %s", _svc(REF_ID))
|
||||||
|
|
||||||
|
e = _until(ctx, listed, 30, poll=1.0)
|
||||||
|
ev["listed"] = e
|
||||||
|
ctx.check(e and e.get("title") == "forgetest check" and e.get("done") is False,
|
||||||
|
"GET /wiz does not list the package's check, not done: %s", e)
|
||||||
|
|
||||||
|
st, body = fc.post("/wiz/%s/start" % wid)
|
||||||
|
ctx.check(st == 200, "POST /wiz/%s/start -> %s %s", wid, st, body)
|
||||||
|
d = _until(ctx, lambda: dark() if (dark().get("prompt") or {}).get("id") == "ready" else None, 20, poll=0.3)
|
||||||
|
ev["prompt"] = d
|
||||||
|
ctx.check(d and d.get("running") and d["prompt"].get("kind") == "confirm"
|
||||||
|
and any(l.endswith("the check begins") for l in (d.get("log") or [])) and d.get("progress") == 50,
|
||||||
|
"the check's prompt, log, and progress: %s", d)
|
||||||
|
lease = (fc.status().get("lease") or {}).get("holder")
|
||||||
|
ev["lease_while_waiting"] = lease
|
||||||
|
ctx.check(not lease, "a package's check holds the machine: %s", lease)
|
||||||
|
st, body = fc.post("/wiz/%s/answer" % wid, data={"seq": str(d["prompt"]["seq"]), "value": "Yes"})
|
||||||
|
ctx.check(st == 200, "the answer -> %s %s", st, body)
|
||||||
|
end = _until(ctx, lambda: dark() if not dark().get("running") else None, 20, poll=0.3)
|
||||||
|
ev["end"] = end
|
||||||
|
ctx.log("the check ended: %s", end)
|
||||||
|
ctx.check(end and not end.get("error") and (end.get("result") or {}).get("summary") == "ready",
|
||||||
|
"the check did not end complete with the package's summary: %s", end)
|
||||||
|
mine = own()
|
||||||
|
ctx.check(mine.get("done") is True and ["POST", "/wizard/answer", {"id": "ready", "answer": "yes"}] in
|
||||||
|
(mine.get("calls") or []), "the service was not answered yes: %s", mine)
|
||||||
|
ctx.sleep(11.0) # the Setup page's list is asked again at most every 10 s
|
||||||
|
e = listed()
|
||||||
|
ev["listed_after"] = e
|
||||||
|
ctx.check(e and e.get("done") is True, "GET /wiz does not list the check as done: %s", e)
|
||||||
|
|
||||||
|
after = record_view()
|
||||||
|
ev["record"] = {"before": before, "after": after}
|
||||||
|
ctx.check(after == before and not any(w.startswith("pkg:") for w in (after or {}).get("wizards", [])),
|
||||||
|
"the machine's setup record changed: %s -> %s", before, after)
|
||||||
|
ctx.check(fc.get("/wiz")[1].get("gate") == gate_before, "the setup gate moved")
|
||||||
|
|
||||||
|
# A second run, aborted from its prompt.
|
||||||
|
st, body = fc.post("/wiz/%s/start" % wid)
|
||||||
|
d = _until(ctx, lambda: dark() if (dark().get("prompt") or {}).get("id") == "ready" else None, 20, poll=0.3)
|
||||||
|
ctx.check(st == 200 and d, "the second run did not reach its prompt: %s %s", st, body)
|
||||||
|
fc.post("/wiz/%s/abort" % wid)
|
||||||
|
end = _until(ctx, lambda: dark() if not dark().get("running") else None, 20, poll=0.3)
|
||||||
|
ctx.check(end and end.get("error") == "aborted", "the abort: %s", end)
|
||||||
|
ctx.check(["POST", "/wizard/abort", {}] in (own().get("calls") or []), "the service was not told of the abort")
|
||||||
|
|
||||||
|
st, body = fc.post("/wiz/pkg:org.forgetest.nothere/start")
|
||||||
|
ctx.check(st == 404, "a check of a package that is not installed -> %s %s", st, body)
|
||||||
|
finally:
|
||||||
|
# A check that failed mid-run leaves the package's check waiting at its prompt.
|
||||||
|
if dark().get("running") and dark().get("id") == wid:
|
||||||
|
fc.post("/wiz/%s/abort" % wid)
|
||||||
|
_until(ctx, lambda: not dark().get("running"), 20, poll=0.3)
|
||||||
|
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||||
|
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
|
# 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
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
from .. import hw
|
from .. import hw
|
||||||
@@ -35,8 +41,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 +62,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 +104,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)
|
||||||
@@ -124,14 +143,39 @@ def auth(ctx):
|
|||||||
"GET /fuse-identity with the token but no button -> %s %r (expected the two-factor refusal)",
|
"GET /fuse-identity with the token but no button -> %s %r (expected the two-factor refusal)",
|
||||||
st, body)
|
st, body)
|
||||||
|
|
||||||
# the cooling report channel: the loopback peer is accepted. An idle
|
# the cooling report channel is the running controller's alone. A
|
||||||
# report is what the controller sends every period; the engine is idle
|
# loopback peer is not enough: anything on this host is one. The
|
||||||
# here, so it changes nothing. A dual-stack listener reports this peer
|
# supervisor hands the controller a secret at its spawn, and the route
|
||||||
# as ::ffff:127.0.0.1, which the check must recognize in full.
|
# asks for it. An idle report is what the controller sends every
|
||||||
st, body = fc.post("/cool/state", params={"mode": "idle", "armed": "0"})
|
# period; the engine is idle here, so the accepted one changes nothing.
|
||||||
|
# A dual-stack listener reports this peer as ::ffff:127.0.0.1, which
|
||||||
|
# the loopback check must recognize in full.
|
||||||
|
report = {"mode": "idle", "armed": "0"}
|
||||||
|
st, body = fc.post("/cool/state", params=report)
|
||||||
|
ev["cool_state_from_loopback_no_secret"] = st
|
||||||
|
ctx.log("POST /cool/state from loopback, no secret -> %s %s", st, body if isinstance(body, str) else "")
|
||||||
|
ctx.check(st == 403 and "controller's alone" in str(body),
|
||||||
|
"/cool/state from loopback without the secret -> %s %r", st, body)
|
||||||
|
st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": "0" * 32})
|
||||||
|
ctx.check(st == 403, "/cool/state with a secret that is not the controller's -> %s %r", st, body)
|
||||||
|
# The secret itself, as only root on this host can read it: out of
|
||||||
|
# the running controller's environment. It is never logged.
|
||||||
|
secret = None
|
||||||
|
pid = (fc.get("/mode")[1] or {}).get("pid")
|
||||||
|
if pid:
|
||||||
|
try:
|
||||||
|
with open("/proc/%d/environ" % int(pid), "rb") as f:
|
||||||
|
for item in f.read().split(b"\0"):
|
||||||
|
if item.startswith(b"GF_REPORT_SECRET="):
|
||||||
|
secret = item.split(b"=", 1)[1].decode("ascii", "replace")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
ev["controller_has_a_secret"] = bool(secret)
|
||||||
|
ctx.check(secret and len(secret) == 32, "the running controller (pid %s) was handed no report secret", pid)
|
||||||
|
st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": secret})
|
||||||
ev["cool_state_from_loopback"] = st
|
ev["cool_state_from_loopback"] = st
|
||||||
ctx.log("POST /cool/state from loopback -> %s %s", st, body if isinstance(body, dict) else "")
|
ctx.log("POST /cool/state from loopback with the controller's secret -> %s", st)
|
||||||
ctx.check(st == 200, "/cool/state refused the loopback peer (%s %r): the controller's "
|
ctx.check(st == 200, "/cool/state refused the controller's own secret (%s %r): the controller's "
|
||||||
"reports never reach the engine", st, body)
|
"reports never reach the engine", st, body)
|
||||||
|
|
||||||
# ...and a non-loopback peer is refused, even with a token. Over HTTP
|
# ...and a non-loopback peer is refused, even with a token. Over HTTP
|
||||||
@@ -139,12 +183,11 @@ 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")
|
||||||
token = {"X-ForgeFIRM-Token": fc.token}
|
token = {"X-ForgeFIRM-Token": fc.token, "X-ForgeFIRM-Report": secret}
|
||||||
report = {"mode": "idle", "armed": "0"}
|
|
||||||
st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token)
|
st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token)
|
||||||
loc = hdrs.get("location", "")
|
loc = hdrs.get("location", "")
|
||||||
ev["cool_state_from_lan_http"] = {"status": st, "location": loc}
|
ev["cool_state_from_lan_http"] = {"status": st, "location": loc}
|
||||||
@@ -279,7 +322,12 @@ def settings_bounds(ctx):
|
|||||||
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
|
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
|
||||||
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
|
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
|
||||||
("forgectrl", "src/curverec.*")],
|
("forgectrl", "src/curverec.*")],
|
||||||
description="The panel page is served, /status carries the machine telemetry the panel and "
|
description="The panel page is served, with a policy that lets no frame navigate anywhere "
|
||||||
|
"(frame-src 'none': a package's page renders in a frame of it, and a frame that "
|
||||||
|
"could go to another address could carry what it was shown in the URL; what the "
|
||||||
|
"header does in each browser is the frame-isolation harness's to prove, and this "
|
||||||
|
"holds the machine's own page to carrying it), /status carries the machine "
|
||||||
|
"telemetry the panel and "
|
||||||
"the acceptance tool read (including the sys block: CPU busy percent over the "
|
"the acceptance tool read (including the sys block: CPU busy percent over the "
|
||||||
"interval since the previous read, memory used percent; and homed_axes, "
|
"interval since the previous read, memory used percent; and homed_axes, "
|
||||||
"the axes that carry a reference, Z alone once the lens has taken its "
|
"the axes that carry a reference, Z alone once the lens has taken its "
|
||||||
@@ -307,6 +355,12 @@ def panel_serves(ctx):
|
|||||||
# the head script sets and the one save bar every settings tab shares.
|
# the head script sets and the one save bar every settings tab shares.
|
||||||
ctx.check("data-bs-theme" in text, "the panel lacks the theme attribute (inflate failed?)")
|
ctx.check("data-bs-theme" in text, "the panel lacks the theme attribute (inflate failed?)")
|
||||||
ctx.check('id="savebar"' in text, "the panel lacks the save bar")
|
ctx.check('id="savebar"' in text, "the panel lacks the save bar")
|
||||||
|
from .setup import request
|
||||||
|
st_h, _page, hdrs = request(fc.base, "GET", "/", headers={"Host": fc.host_header()})
|
||||||
|
ev["page_policy"] = hdrs.get("content-security-policy")
|
||||||
|
ctx.log("GET / Content-Security-Policy: %s", ev["page_policy"])
|
||||||
|
ctx.check(st_h == 200 and "frame-src 'none'" in (ev["page_policy"] or ""),
|
||||||
|
"the panel page lets a frame navigate: %s %r", st_h, ev["page_policy"])
|
||||||
|
|
||||||
s = fc.status()
|
s = fc.status()
|
||||||
for key in ("state", "switches", "coolant", "fans"):
|
for key in ("state", "switches", "coolant", "fans"):
|
||||||
@@ -399,3 +453,455 @@ def panel_serves(ctx):
|
|||||||
st, text = fc.get("/curve/ladder.gcode", raw=True)
|
st, text = fc.get("/curve/ladder.gcode", raw=True)
|
||||||
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
|
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
|
||||||
"GET /curve/ladder.gcode -> %s without the ladder", st)
|
"GET /curve/ladder.gcode -> %s without the ladder", st)
|
||||||
|
|
||||||
|
|
||||||
|
class _EventStream:
|
||||||
|
"""GET /events over a raw socket bound to a chosen loopback source
|
||||||
|
address. Every address in 127.0.0.0/8 is this host, so the test can be
|
||||||
|
several peers at once, and the daemon counts streams per peer address."""
|
||||||
|
|
||||||
|
def __init__(self, base, source, timeout=5.0):
|
||||||
|
u = urllib.parse.urlsplit(base)
|
||||||
|
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
self.sock.bind((source, 0))
|
||||||
|
self.sock.settimeout(timeout)
|
||||||
|
self.sock.connect((u.hostname or "127.0.0.1", u.port or 80))
|
||||||
|
self.sock.sendall(b"GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n"
|
||||||
|
b"Connection: close\r\n\r\n" % (u.netloc or "127.0.0.1").encode())
|
||||||
|
self.buf = b""
|
||||||
|
self.eof = False # the response is over: the last chunk, or the socket closed
|
||||||
|
head = self._until(b"\r\n\r\n")
|
||||||
|
self.status = int(head.split(b" ", 2)[1]) if head.startswith(b"HTTP/") else 0
|
||||||
|
self.head = head.decode("latin-1", "replace")
|
||||||
|
|
||||||
|
def _fill(self):
|
||||||
|
try:
|
||||||
|
chunk = self.sock.recv(4096)
|
||||||
|
except socket.timeout:
|
||||||
|
return False
|
||||||
|
if not chunk:
|
||||||
|
self.eof = True
|
||||||
|
return False
|
||||||
|
self.buf += chunk
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _until(self, mark):
|
||||||
|
while mark not in self.buf:
|
||||||
|
if not self._fill():
|
||||||
|
break
|
||||||
|
i = self.buf.find(mark)
|
||||||
|
if i < 0:
|
||||||
|
out, self.buf = self.buf, b""
|
||||||
|
return out
|
||||||
|
out, self.buf = self.buf[:i], self.buf[i + len(mark):]
|
||||||
|
return out
|
||||||
|
|
||||||
|
def text(self, seconds):
|
||||||
|
"""Everything the stream says in the next `seconds` (chunk framing
|
||||||
|
and all: the checks look for event names inside it)."""
|
||||||
|
end = time.time() + seconds
|
||||||
|
self.sock.settimeout(0.2) # the window is the caller's, not one long read
|
||||||
|
while time.time() < end and not self.eof:
|
||||||
|
self._fill()
|
||||||
|
if self.buf.endswith(b"\r\n0\r\n\r\n"):
|
||||||
|
self.eof = True
|
||||||
|
out, self.buf = self.buf, b""
|
||||||
|
return out.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@test("events.stream", title="The event stream: edges arrive, and the cap holds",
|
||||||
|
subsystem="forgectrl", kind="auto", mode="grbl", est_min=2,
|
||||||
|
covers=[("forgectrl", "src/events.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"),
|
||||||
|
("forgectrl", "src/grblport.*")],
|
||||||
|
description="GET /events from three loopback source addresses: each gets 200, "
|
||||||
|
"text/event-stream, and the hello event. A fourth address gets 503 with the "
|
||||||
|
"reason, and GET /settings still answers while the three are held. A second "
|
||||||
|
"stream from the first address is served, and the older one gets bye and the "
|
||||||
|
"end of its response. An edge arrives on an open stream: POST /motion/release "
|
||||||
|
"and /motion/energize show up as motors.released and motors.energized, with "
|
||||||
|
"ids that count up (X and Y lose their reference, as after any release). With "
|
||||||
|
"every stream closed, the places come back within two keep-alive intervals.")
|
||||||
|
def events_stream(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
fc = hw.Forgectrl()
|
||||||
|
streams = []
|
||||||
|
try:
|
||||||
|
# A stream an earlier test closed keeps its place until the daemon's
|
||||||
|
# next write to it (its keep-alive), so the three places may not be
|
||||||
|
# free yet: that is the daemon as documented, and the last part of
|
||||||
|
# this test measures it. The three are opened once they can be.
|
||||||
|
deadline = time.time() + 25
|
||||||
|
while True:
|
||||||
|
for src in ("127.0.0.2", "127.0.0.3", "127.0.0.4"):
|
||||||
|
streams.append(_EventStream(fc.base, src))
|
||||||
|
if all(s.status == 200 for s in streams) or time.time() > deadline:
|
||||||
|
break
|
||||||
|
ctx.log("an earlier stream still holds a place (%s): waiting for it to be given back",
|
||||||
|
[s.status for s in streams])
|
||||||
|
for s in streams:
|
||||||
|
s.close()
|
||||||
|
streams = []
|
||||||
|
ctx.sleep(3)
|
||||||
|
for s, src in zip(streams, ("127.0.0.2", "127.0.0.3", "127.0.0.4")):
|
||||||
|
hello = s.text(1.0)
|
||||||
|
ctx.check(s.status == 200 and "text/event-stream" in s.head and "event: hello" in hello,
|
||||||
|
"GET /events from %s -> %s, %r", src, s.status, hello[:120])
|
||||||
|
fourth = _EventStream(fc.base, "127.0.0.5")
|
||||||
|
body = fourth.text(1.0)
|
||||||
|
fourth.close()
|
||||||
|
ev["fourth"] = [fourth.status, body.strip()[-120:]]
|
||||||
|
ctx.log("the fourth stream -> %s %s", fourth.status, ev["fourth"][1])
|
||||||
|
ctx.check(fourth.status == 503 and "every event stream is taken" in body,
|
||||||
|
"the fourth stream -> %s %r, expected 503 and the reason", fourth.status, body[:160])
|
||||||
|
st, _body = fc.get("/settings")
|
||||||
|
ctx.check(st == 200, "GET /settings -> %s with three event streams held", st)
|
||||||
|
|
||||||
|
# One per address, by replacement.
|
||||||
|
newer = _EventStream(fc.base, "127.0.0.2")
|
||||||
|
hello = newer.text(1.0)
|
||||||
|
ctx.check(newer.status == 200 and "event: hello" in hello,
|
||||||
|
"a second stream from one address -> %s %r", newer.status, hello[:120])
|
||||||
|
bye = streams[0].text(2.0)
|
||||||
|
ev["replaced"] = {"bye": "event: bye" in bye, "ended": streams[0].eof}
|
||||||
|
ctx.check("event: bye" in bye and "replaced" in bye, "the older stream was not told: %r", bye[-160:])
|
||||||
|
ctx.check(streams[0].eof, "the older stream was not ended")
|
||||||
|
streams[0].close()
|
||||||
|
streams[0] = newer
|
||||||
|
|
||||||
|
# An edge, on a stream that has been open all along.
|
||||||
|
st, body = fc.post("/motion/release")
|
||||||
|
ctx.check(st == 200, "POST /motion/release -> %s %s", st, body)
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
st, body = fc.post("/motion/energize")
|
||||||
|
ctx.check(st == 200, "POST /motion/energize -> %s %s", st, body)
|
||||||
|
text = streams[1].text(2.0)
|
||||||
|
ev["edges"] = [l for l in text.splitlines() if l.startswith(("id:", "event:"))]
|
||||||
|
ctx.log("the stream carried: %s", ev["edges"])
|
||||||
|
i_rel, i_en = text.find("event: motors.released"), text.find("event: motors.energized")
|
||||||
|
ctx.check(0 <= i_rel < i_en, "the release and the energize did not arrive in order: %r", text[-300:])
|
||||||
|
ids = [int(l.split(":")[1]) for l in text.splitlines() if l.startswith("id:")]
|
||||||
|
ctx.check(ids and ids == sorted(ids) and len(set(ids)) == len(ids), "the ids do not count up: %s", ids)
|
||||||
|
finally:
|
||||||
|
for s in streams:
|
||||||
|
s.close()
|
||||||
|
if fc.status().get("motors_released"):
|
||||||
|
fc.post("/motion/energize")
|
||||||
|
|
||||||
|
# A closed client is noticed at the daemon's next write to it.
|
||||||
|
def reopened():
|
||||||
|
s = _EventStream(fc.base, "127.0.0.5")
|
||||||
|
try:
|
||||||
|
return s.status == 200
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
took = ctx.wait_for(reopened, 25, poll=1.0)
|
||||||
|
ev["place_back_s"] = took
|
||||||
|
ctx.check(took is not None, "no stream could be opened 25 s after every stream was closed")
|
||||||
|
ctx.log("PASS: three streams, the fourth refused in words with the settings route answering, a "
|
||||||
|
"replacement told and ended, two edges in order, and a place back after %.0f s", took)
|
||||||
|
|
||||||
|
|
||||||
|
def _lease(fc):
|
||||||
|
return (fc.status().get("lease") or {})
|
||||||
|
|
||||||
|
|
||||||
|
def _text(body):
|
||||||
|
return body if isinstance(body, str) else json.dumps(body)
|
||||||
|
|
||||||
|
|
||||||
|
@test("forgectrl.lease", title="The machine lease: a holder refuses everything else, by name",
|
||||||
|
subsystem="forgectrl", kind="auto", est_min=2,
|
||||||
|
covers=[("forgectrl", "src/lease.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"),
|
||||||
|
("forgectrl", "src/wizdark.*"), ("forgectrl", "src/diag.*"), ("forgectrl", "src/curverec.*"),
|
||||||
|
("forgectrl", "src/jobrun.*"), ("forgectrl", "src/update.*"), ("forgectrl", "src/logs.*"), ("forgectrl", "src/super.*"),
|
||||||
|
("forgectrl", "src/events.*")],
|
||||||
|
description="The switches check is started and left waiting at its first prompt: it moves "
|
||||||
|
"nothing, and it holds the machine lease for as long as it waits. /status must "
|
||||||
|
"name it as the holder (wizard:switches, kind hardware), the event stream must "
|
||||||
|
"report lease.changed, and everything that asks the lease must be refused with "
|
||||||
|
"409 and the holder's name: a diagnostic, the dose-curve recorder, a log export, a "
|
||||||
|
"mode switch to the mode already in force, POST /controller/start, and POST "
|
||||||
|
"/settings (with 'settings are locked'). Only requests that would do no harm if "
|
||||||
|
"the lease failed are made: no reboot, no boot-slot change, no update job. The "
|
||||||
|
"check is then aborted, and the lease must read free, lease.changed must say so, "
|
||||||
|
"and POST /settings must be accepted again. Whatever a failed refusal started is "
|
||||||
|
"stopped on the way out.")
|
||||||
|
def lease(ctx):
|
||||||
|
from .setup_dark import dark
|
||||||
|
|
||||||
|
ev = ctx.evidence
|
||||||
|
fc = hw.Forgectrl()
|
||||||
|
wid, owner = "switches", "wizard:switches"
|
||||||
|
rest = _lease(fc)
|
||||||
|
ctx.check("holder" in rest and rest["holder"] is None, "the machine is not free at the start: %s", rest)
|
||||||
|
ctx.check(set(rest.get("observed") or {}) == {"sender", "motors_released"},
|
||||||
|
"what is observed outside the lease is not reported: %s", rest)
|
||||||
|
mode = (fc.get("/mode")[1] or {}).get("mode")
|
||||||
|
units = fc.settings().get("ui_units") or "metric"
|
||||||
|
stream = _EventStream(fc.base, "127.0.0.6")
|
||||||
|
ctx.check(stream.status == 200, "GET /events -> %s", stream.status)
|
||||||
|
stream.text(0.5)
|
||||||
|
started = False
|
||||||
|
try:
|
||||||
|
st, body = fc.post("/wiz/%s/start" % wid)
|
||||||
|
ctx.check(st == 200, "the %s check did not start (%s %s)", wid, st, _text(body))
|
||||||
|
started = True
|
||||||
|
ok = ctx.wait_for(lambda: (_lease(fc).get("holder") or {}).get("owner") == owner, 10, poll=0.2)
|
||||||
|
held = _lease(fc).get("holder") or {}
|
||||||
|
ev["holder"] = held
|
||||||
|
ctx.log("the lease while the check waits: %s", held)
|
||||||
|
ctx.check(ok is not None and held.get("kind") == "hardware" and "switches" in (held.get("words") or ""),
|
||||||
|
"the lease does not name the running check: %s", held)
|
||||||
|
|
||||||
|
refused = {}
|
||||||
|
for name, path, kw in (
|
||||||
|
("a diagnostic", "/diag/flow-verify", {}),
|
||||||
|
("the recorder", "/curve/record", {}),
|
||||||
|
("a log export", "/logs/export", {}),
|
||||||
|
("a mode switch", "/mode", {"params": {"controller": mode}}),
|
||||||
|
("the controller's start", "/controller/start", {}),
|
||||||
|
("a settings write", "/settings", {"params": {"ui_units": units}})):
|
||||||
|
st, body = fc.post(path, **kw)
|
||||||
|
refused[name] = [st, _text(body)[:160]]
|
||||||
|
ctx.check(st == 409 and "holds the machine" in _text(body) and "switches" in _text(body),
|
||||||
|
"%s under the wizard's hold -> %s %s", name, st, _text(body)[:160])
|
||||||
|
ctx.check("settings are locked" in refused["a settings write"][1],
|
||||||
|
"the settings refusal does not say they are locked: %s", refused["a settings write"])
|
||||||
|
ev["refused"] = refused
|
||||||
|
ctx.check((_lease(fc).get("holder") or {}).get("owner") == owner,
|
||||||
|
"a refused request took the lease away: %s", _lease(fc))
|
||||||
|
finally:
|
||||||
|
if started:
|
||||||
|
fc.post("/wiz/%s/abort" % wid)
|
||||||
|
ctx.wait_for(lambda: not dark(fc).get("running"), 30)
|
||||||
|
# Nothing a failed refusal may have started is left running.
|
||||||
|
fc.post("/diag/abort")
|
||||||
|
fc.post("/curve/stop")
|
||||||
|
|
||||||
|
ok = ctx.wait_for(lambda: _lease(fc).get("holder") is None, 30, poll=0.5)
|
||||||
|
ctx.check(ok is not None, "the lease was not given back after the abort: %s", _lease(fc))
|
||||||
|
st, body = fc.post("/settings", params={"ui_units": units})
|
||||||
|
ctx.check(st == 200, "POST /settings with the machine free again -> %s %s", st, _text(body)[:120])
|
||||||
|
text = stream.text(2.0)
|
||||||
|
stream.close()
|
||||||
|
changes = [l for l in text.splitlines() if l.startswith("data:") and "owner" in l]
|
||||||
|
ev["lease_changed"] = changes
|
||||||
|
ctx.log("the event stream: %s", changes)
|
||||||
|
i_take, i_free = text.find('"owner":"%s"' % owner), text.find('"owner":null')
|
||||||
|
ctx.check("event: lease.changed" in text and 0 <= i_take < i_free,
|
||||||
|
"lease.changed did not report the hold and then its end: %r", text[-300:])
|
||||||
|
ctx.log("PASS: %s held the machine, six requests were refused by name, the abort freed it, and the "
|
||||||
|
"event stream said both", owner)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- scoped tokens
|
||||||
|
|
||||||
|
def _words_of(body):
|
||||||
|
b = body if isinstance(body, (dict, str)) else ""
|
||||||
|
return b.get("error", "") if isinstance(b, dict) else b
|
||||||
|
|
||||||
|
|
||||||
|
@test("forgectrl.tokens", title="Scoped API tokens: a token reaches what it was granted, and nothing else",
|
||||||
|
subsystem="forgectrl", kind="auto", mode="grbl", est_min=2,
|
||||||
|
covers=_COVERS_AUTH + [("forgectrl", "src/tokens.*"), ("forgectrl", "src/grblport.*"),
|
||||||
|
("forgectrl", "src/settings.*")],
|
||||||
|
steps=["Bed clear; the head needs 2 mm of free travel toward +X."],
|
||||||
|
description="Three tokens are created through the panel's route: one holding machine.read "
|
||||||
|
"and camera.lid, one holding motion.jog, one holding camera.lid alone. Each is then "
|
||||||
|
"used the way a client on the network uses it, over HTTPS to the machine's own LAN "
|
||||||
|
"address, so the daemon sees a peer that is not this host. The first reads /status "
|
||||||
|
"as a bearer token, /mode in the panel token's header, and /cam/status as a bearer "
|
||||||
|
"token; the camera-only token reads /cam/status as ?key=. Refused, each with 403 "
|
||||||
|
"and the reason in words: the first token in a URL (it holds more than a camera), "
|
||||||
|
"the camera-only token on the head camera, and for the first token a jog, a "
|
||||||
|
"settings write, a mode switch, and the token routes. The second jogs the head 1 mm with no "
|
||||||
|
"session and no panel token (the kernel's counters are the witness) and is refused "
|
||||||
|
"/status. A token presented over plain HTTP from the LAN is refused, and the "
|
||||||
|
"daemon's log names it. A token that is not this machine's is refused. With "
|
||||||
|
"panel_open_reads=0 the LAN reads /status with machine.read and with nothing else. "
|
||||||
|
"The list names both tokens with a last-used time and carries neither a token nor "
|
||||||
|
"a hash, and the store is mode 0600 and holds neither token. A revoked token is "
|
||||||
|
"refused at once, with reads open again. Both tokens are revoked, the setting "
|
||||||
|
"restored, and the head returned on the way out. No token is written to the log "
|
||||||
|
"or the evidence.")
|
||||||
|
def scoped_tokens(ctx):
|
||||||
|
from .setup import request, decode
|
||||||
|
from .motion import kernel_xy_mm, machine_idle
|
||||||
|
|
||||||
|
ev = ctx.evidence
|
||||||
|
fc = hw.Forgectrl()
|
||||||
|
ip = lan_ip()
|
||||||
|
ctx.check(ip, "cannot determine the board's LAN address")
|
||||||
|
tls, plain = "https://%s" % ip, "http://%s" % ip
|
||||||
|
machine_idle(ctx)
|
||||||
|
x0 = kernel_xy_mm(ctx)[0]
|
||||||
|
reads_were = str(fc.settings().get("panel_open_reads", "1"))
|
||||||
|
mode = (fc.get("/mode")[1] or {}).get("mode")
|
||||||
|
units = fc.settings().get("ui_units") or "metric"
|
||||||
|
made = {}
|
||||||
|
|
||||||
|
def create(name, caps):
|
||||||
|
st, body = fc.post("/tokens", data={"name": name, "caps": caps})
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict) and str(body.get("token", "")).startswith("fft_")
|
||||||
|
and len(body["token"]) == 36, "POST /tokens %s -> %s", name, st)
|
||||||
|
made[name] = body
|
||||||
|
return body["token"]
|
||||||
|
|
||||||
|
def lan(token, method, path, how="bearer", base=None, data=None):
|
||||||
|
headers = {}
|
||||||
|
if token and how == "bearer":
|
||||||
|
headers["Authorization"] = "Bearer " + token
|
||||||
|
elif token and how == "header":
|
||||||
|
headers["X-ForgeFIRM-Token"] = token
|
||||||
|
elif token and how == "key":
|
||||||
|
path += ("&" if "?" in path else "?") + "key=" + token
|
||||||
|
st, content, _h = request(base or tls, method, path, data=data, headers=headers)
|
||||||
|
return st, decode(content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Whatever an earlier, interrupted run left behind.
|
||||||
|
st, listed = fc.get("/tokens")
|
||||||
|
ctx.check(st == 200 and isinstance(listed, dict), "GET /tokens -> %s", st)
|
||||||
|
for t in listed.get("tokens", []):
|
||||||
|
if t.get("name", "").startswith("forgetest "):
|
||||||
|
fc.post("/tokens/revoke", data={"id": t["id"]})
|
||||||
|
st, body = fc.post("/tokens", data={"name": "forgetest bad", "caps": "settings.write"})
|
||||||
|
ev["outside_the_list"] = [st, _words_of(body)[:120]]
|
||||||
|
ctx.check(st == 400 and "not a capability" in _words_of(body),
|
||||||
|
"a capability outside the list -> %s %s", st, _words_of(body)[:120])
|
||||||
|
hub = create("forgetest hub", "machine.read,camera.lid")
|
||||||
|
pend = create("forgetest jogger", "motion.jog")
|
||||||
|
view = create("forgetest viewer", "camera.lid")
|
||||||
|
|
||||||
|
passed = {}
|
||||||
|
for name, call in (("bearer GET /status", lambda: lan(hub, "GET", "/status")),
|
||||||
|
("header GET /mode", lambda: lan(hub, "GET", "/mode", how="header")),
|
||||||
|
("bearer GET /cam/status", lambda: lan(hub, "GET", "/cam/status")),
|
||||||
|
("camera-only ?key= GET /cam/status",
|
||||||
|
lambda: lan(view, "GET", "/cam/status", how="key"))):
|
||||||
|
st, body = call()
|
||||||
|
passed[name] = st
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict), "the hub token, %s -> %s %s", name, st,
|
||||||
|
_words_of(body)[:120])
|
||||||
|
ev["hub_passed"] = passed
|
||||||
|
|
||||||
|
refused = {}
|
||||||
|
for name, want, call in (
|
||||||
|
("the head camera", "does not hold camera.head",
|
||||||
|
lambda: lan(view, "GET", "/cam/snapshot?cam=head", how="key")),
|
||||||
|
("a token with more than a camera, in a URL", "in a URL may hold camera capabilities only",
|
||||||
|
lambda: lan(hub, "GET", "/cam/status", how="key")),
|
||||||
|
("a jog", "does not hold motion.jog", lambda: lan(hub, "POST", "/motion/jog", data={"x": "1"})),
|
||||||
|
("a settings write", "no scoped token reaches", lambda: lan(hub, "POST", "/settings",
|
||||||
|
data={"ui_units": units})),
|
||||||
|
("a mode switch", "no scoped token reaches", lambda: lan(hub, "POST", "/mode",
|
||||||
|
data={"controller": mode})),
|
||||||
|
("the token list", "no scoped token reaches", lambda: lan(hub, "GET", "/tokens")),
|
||||||
|
("a token of its own", "no scoped token reaches",
|
||||||
|
lambda: lan(hub, "POST", "/tokens", data={"name": "forgetest child", "caps": "machine.read"})),
|
||||||
|
("the panel's ?token= form", "authentication required",
|
||||||
|
lambda: lan(None, "POST", "/settings?token=" + hub, data={"ui_units": units})),
|
||||||
|
("a stranger's token", "authentication required",
|
||||||
|
lambda: lan("fft_" + "0" * 32, "GET", "/status")),
|
||||||
|
("the jog token on /status", "does not hold machine.read", lambda: lan(pend, "GET", "/status"))):
|
||||||
|
st, body = call()
|
||||||
|
refused[name] = [st, _words_of(body)[:120]]
|
||||||
|
ctx.check(st == 403 and want in _words_of(body), "%s -> %s %s (wanted 403 %r)", name, st,
|
||||||
|
_words_of(body)[:120], want)
|
||||||
|
ev["refused"] = refused
|
||||||
|
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.01, "a refused jog moved the head")
|
||||||
|
|
||||||
|
# A write with a scoped token and nothing else: no session, no panel token.
|
||||||
|
st, body = lan(pend, "POST", "/motion/jog", data={"x": "1", "feed": "1200"})
|
||||||
|
ctx.check(st == 200, "the jog token's jog -> %s %s", st, _words_of(body)[:120])
|
||||||
|
ctx.wait_for(lambda: abs(kernel_xy_mm(ctx)[0] - x0 - 1.0) < 0.02, 10, poll=0.1)
|
||||||
|
machine_idle(ctx)
|
||||||
|
moved = kernel_xy_mm(ctx)[0] - x0
|
||||||
|
ev["jog_mm"] = round(moved, 3)
|
||||||
|
ctx.log("the jog token jogged the head %.3f mm from the LAN with no session", moved)
|
||||||
|
ctx.check(abs(moved - 1.0) < 0.02, "the jog moved %.3f mm, not 1", moved)
|
||||||
|
|
||||||
|
# Plain HTTP from the LAN: refused, and the log says which token crossed in the clear.
|
||||||
|
log_path = "/data/log/forgefirm/forgectrl/forgectrl.log"
|
||||||
|
try:
|
||||||
|
log_from = os.path.getsize(log_path)
|
||||||
|
except OSError:
|
||||||
|
log_from = 0
|
||||||
|
st, body = lan(hub, "GET", "/status", base=plain)
|
||||||
|
ev["plain_http"] = [st, _words_of(body)[:120]]
|
||||||
|
ctx.check(st == 403 and "HTTPS only" in _words_of(body), "a token over plain HTTP -> %s %s", st,
|
||||||
|
_words_of(body)[:120])
|
||||||
|
|
||||||
|
def logged():
|
||||||
|
try:
|
||||||
|
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
f.seek(log_from)
|
||||||
|
return "scoped token %s was presented over plain HTTP" % made["forgetest hub"]["id"] in f.read()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
ctx.check(ctx.wait_for(logged, 10, poll=0.5) is not None, "the log does not name the token sent in the clear")
|
||||||
|
|
||||||
|
# Reads closed to the LAN: machine.read opens them, nothing else does.
|
||||||
|
st, body = fc.post("/settings", params={"panel_open_reads": "0"})
|
||||||
|
ctx.check(st == 200, "closing the reads -> %s", st)
|
||||||
|
closed = {"nobody": lan(None, "GET", "/status")[0], "machine.read": lan(hub, "GET", "/status")[0],
|
||||||
|
"motion.jog": lan(pend, "GET", "/status")[0]}
|
||||||
|
ev["reads_closed"] = closed
|
||||||
|
ctx.check(closed == {"nobody": 403, "machine.read": 200, "motion.jog": 403},
|
||||||
|
"with the reads closed: %s", closed)
|
||||||
|
st, body = fc.post("/settings", params={"panel_open_reads": reads_were})
|
||||||
|
ctx.check(st == 200, "restoring panel_open_reads=%s -> %s", reads_were, st)
|
||||||
|
|
||||||
|
# The list and the store: names and times, never a token or a hash.
|
||||||
|
st, listed = fc.get("/tokens")
|
||||||
|
mine = {t["name"]: t for t in listed.get("tokens", []) if t.get("name", "").startswith("forgetest ")}
|
||||||
|
ev["listed"] = {n: {k: t[k] for k in ("id", "caps", "last_used")} for n, t in mine.items()}
|
||||||
|
ctx.check(set(mine) == {"forgetest hub", "forgetest jogger", "forgetest viewer"},
|
||||||
|
"the list: %s", sorted(mine))
|
||||||
|
ctx.check(mine["forgetest hub"]["caps"] == ["machine.read", "camera.lid"] and
|
||||||
|
mine["forgetest jogger"]["caps"] == ["motion.jog"], "the capabilities listed: %s", ev["listed"])
|
||||||
|
ctx.check(all(t["last_used"] > 0 for t in mine.values()), "a used token reads as never used: %s", ev["listed"])
|
||||||
|
text = json.dumps(listed)
|
||||||
|
ctx.check(hub[4:] not in text and pend[4:] not in text and view[4:] not in text and "sha" not in text and
|
||||||
|
all(set(t) == {"id", "name", "caps", "created", "last_used"} for t in mine.values()),
|
||||||
|
"the list carries more than names, capabilities, and times")
|
||||||
|
store = "/data/forgefirm/tokens"
|
||||||
|
mode_bits = os.stat(store).st_mode & 0o777
|
||||||
|
with open(store, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
kept = f.read()
|
||||||
|
ev["store"] = {"mode": oct(mode_bits),
|
||||||
|
"holds_a_token": hub[4:] in kept or pend[4:] in kept or view[4:] in kept}
|
||||||
|
ctx.check(mode_bits == 0o600 and not ev["store"]["holds_a_token"], "the store: %s", ev["store"])
|
||||||
|
|
||||||
|
# A revoke ends it at once, and the open reads are no way around it.
|
||||||
|
st, body = fc.post("/tokens/revoke", data={"id": made["forgetest hub"]["id"]})
|
||||||
|
ctx.check(st == 200, "the revoke -> %s %s", st, _words_of(body)[:120])
|
||||||
|
st, body = lan(hub, "GET", "/status")
|
||||||
|
ev["after_revoke"] = [st, _words_of(body)[:120]]
|
||||||
|
ctx.check(st == 403 and "authentication required" in _words_of(body),
|
||||||
|
"a revoked token -> %s %s", st, _words_of(body)[:120])
|
||||||
|
ctx.check(lan(pend, "POST", "/motion/cancel")[0] == 200, "the revoke took the other token too")
|
||||||
|
finally:
|
||||||
|
fc.post("/settings", params={"panel_open_reads": reads_were})
|
||||||
|
for t in made.values():
|
||||||
|
fc.post("/tokens/revoke", data={"id": t.get("id", "")})
|
||||||
|
back = x0 - kernel_xy_mm(ctx)[0]
|
||||||
|
if abs(back) > 0.02:
|
||||||
|
machine_idle(ctx)
|
||||||
|
fc.post("/motion/jog", params={"x": "%.3f" % back, "feed": "1200"})
|
||||||
|
ctx.wait_for(lambda: abs(kernel_xy_mm(ctx)[0] - x0) < 0.02, 10, poll=0.1)
|
||||||
|
machine_idle(ctx)
|
||||||
|
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.05, "the head was not returned: %.3f mm off",
|
||||||
|
kernel_xy_mm(ctx)[0] - x0)
|
||||||
|
st, listed = fc.get("/tokens")
|
||||||
|
ctx.check(not [t for t in listed.get("tokens", []) if t.get("name", "").startswith("forgetest ")],
|
||||||
|
"a test token was left on the machine")
|
||||||
|
ctx.log("PASS: each token reached what it was granted from the LAN with no session, was refused "
|
||||||
|
"everything else in words, was refused over plain HTTP and once revoked, and the machine "
|
||||||
|
"keeps no token")
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""A camera home's offset pair, on the machine.
|
||||||
|
|
||||||
|
Its own module, so that no other test's fingerprint moves. The homing is
|
||||||
|
the web-service session cloud.mode-switch runs. The helpers for a test
|
||||||
|
that lets the service move the head live here too: the motions ran whole,
|
||||||
|
and the head goes back to where the test found it, by the travel the
|
||||||
|
service's motions logged. No counter reading can say where that was: every
|
||||||
|
service motion zeroes the step counters at its start, and a home and every
|
||||||
|
controller start zero them again.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from ..baseline import XY_STEPS_PER_MM, counter_frame, counter_steps_per_mm, read_position
|
||||||
|
from ..catalog import test
|
||||||
|
from ..runner import Failed
|
||||||
|
from . import cloud as logs # the log paths, read at each call
|
||||||
|
from .cloud import _HOMING_PATH, gfhome_homing, log_lines_since, log_size
|
||||||
|
from .motion import _drop_reference, clean_slate, machine_idle, wait_idle, wait_state
|
||||||
|
|
||||||
|
HOME_X, HOME_Y = 4.5, -3.25
|
||||||
|
RETURN_MAX_MM = 600.0 # no hand-back jogs farther on an axis: the bed is smaller
|
||||||
|
# The client's own record of a motion's end: the kernel counters, which
|
||||||
|
# the motion zeroed at its start (the "(actual/expected)" line of the same
|
||||||
|
# motion is not this one). A print's park keeps the counters and drives
|
||||||
|
# them back, so the line after it is the print's net travel.
|
||||||
|
MOTION_END = re.compile(r"end positions \((-?\d+), (-?\d+), (-?\d+)\)")
|
||||||
|
|
||||||
|
|
||||||
|
def session_mark(path=None):
|
||||||
|
"""Where a client's log ends now (gfhome's by default): the start of
|
||||||
|
the next session's record."""
|
||||||
|
return log_size(path or logs.GFHOME_LOG)
|
||||||
|
|
||||||
|
|
||||||
|
def session_travel(offset, path=None):
|
||||||
|
"""The X/Y travel of the service motions logged in a client's log
|
||||||
|
(gfhome's by default) since offset, in steps, or None when it cannot be
|
||||||
|
known. Every service motion zeroes the kernel counters at its start and
|
||||||
|
logs where they ended, so the travel is the sum of those ends. A service
|
||||||
|
motion runs at x8 or not at all (the client refuses any other mode), so
|
||||||
|
the steps are x8 steps. A motion refused before it moved logs no end
|
||||||
|
and moved nothing; a motion with no "end motion" (one that raised after
|
||||||
|
it moved, or a client killed inside it) leaves the travel unknown, and
|
||||||
|
so does a log rotated under the run."""
|
||||||
|
path = path or logs.GFHOME_LOG
|
||||||
|
if log_size(path) < offset:
|
||||||
|
return None
|
||||||
|
x = y = 0
|
||||||
|
inside, end = False, None
|
||||||
|
for ln in log_lines_since(path, offset):
|
||||||
|
ln = ln.rstrip()
|
||||||
|
m = MOTION_END.search(ln)
|
||||||
|
if ln.endswith(" start motion"):
|
||||||
|
if inside:
|
||||||
|
return None
|
||||||
|
inside, end = True, None
|
||||||
|
elif m and inside:
|
||||||
|
end = (int(m.group(1)), int(m.group(2)))
|
||||||
|
elif ln.endswith(" end motion") and inside:
|
||||||
|
if end:
|
||||||
|
x, y = x + end[0], y + end[1]
|
||||||
|
inside = False
|
||||||
|
return None if inside else (x, y)
|
||||||
|
|
||||||
|
|
||||||
|
def judge_whole_motions(ctx, ev, offset):
|
||||||
|
"""Every service motion of the homing session since offset ran whole.
|
||||||
|
One that stopped short (the cooling engine's stop, a fault) left the
|
||||||
|
head short of the camera home, and the position declared there is
|
||||||
|
false."""
|
||||||
|
short = [ln.strip()[:160] for ln in log_lines_since(logs.GFHOME_LOG, offset)
|
||||||
|
if "run stopped short" in ln
|
||||||
|
or ("_finish_action motion [" in ln and 'event ":completed"' not in ln)]
|
||||||
|
ev["short_motions"] = short
|
||||||
|
ctx.check(not short, "a service motion of the homing did not run whole, so the head stopped short of "
|
||||||
|
"the camera home and the declared position is false: %s", short)
|
||||||
|
|
||||||
|
|
||||||
|
def camera_home_return(ctx, ev, offset):
|
||||||
|
"""Hand the head back to where the test found it after gfhome_homing
|
||||||
|
ran from gfhome's log offset, whatever the session decided. The head is
|
||||||
|
the session's logged travel from there, plus, once homed, whatever the
|
||||||
|
counters read since: the home zeroed them where the session left the
|
||||||
|
head and wrote the anchor, and a controller start since would have
|
||||||
|
removed it. Unhomed, the counters still hold the last motion's end,
|
||||||
|
which the travel already counts. The camera home is dropped first,
|
||||||
|
since its envelope need not hold the spot the head came from."""
|
||||||
|
def back():
|
||||||
|
now = [0, 0, 0]
|
||||||
|
if ev.get("homed"):
|
||||||
|
now = read_position() if counter_frame() is not None else None
|
||||||
|
_drop_reference(ctx, ctx.forgectrl)
|
||||||
|
# Read after the stop: a runner the stop ended has written its last line.
|
||||||
|
travel = session_travel(offset)
|
||||||
|
rec = ev["return"] = {"session_travel_steps": travel, "counters_since_home": now}
|
||||||
|
ctx.check(travel is not None and now is not None,
|
||||||
|
"the homing session's travel cannot be known (gfhome log %s, counters since the home %s): "
|
||||||
|
"the head is left where it stands and the hand-back moves nothing", travel, now)
|
||||||
|
_jog_back(ctx, rec, travel, now)
|
||||||
|
_hand_back(ctx, ev, "return", back)
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_mode_return(ctx, ev, offset):
|
||||||
|
"""Hand the head back to where the test found it after a stay in cloud
|
||||||
|
mode that began at the cloud client's log offset, on the GRBL
|
||||||
|
controller the switch back started: that start zeroed the counters
|
||||||
|
where the service left the head, and they hold whatever moved since.
|
||||||
|
The client's record of its motions is the rest (a hunt's lens travel is
|
||||||
|
Z, which a hand-back never touches)."""
|
||||||
|
def back():
|
||||||
|
travel = session_travel(offset, logs.GFCLOUD_LOG)
|
||||||
|
now = read_position()
|
||||||
|
rec = ev["cloud_return"] = {"session_travel_steps": travel, "counters_since_start": now}
|
||||||
|
ctx.check(travel is not None and now is not None,
|
||||||
|
"the travel in cloud mode cannot be known (gfcloud log %s, counters %s): the head is left "
|
||||||
|
"where it stands and the hand-back moves nothing", travel, now)
|
||||||
|
_jog_back(ctx, rec, travel, now)
|
||||||
|
_hand_back(ctx, ev, "cloud_return", back)
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_back(ctx, ev, key, back):
|
||||||
|
"""Run a hand-back from a test's finally. The first failure wins: when
|
||||||
|
the test has failed already (the exception in flight), a hand-back that
|
||||||
|
fails as well is logged and recorded, and the test's own failure is the
|
||||||
|
one reported."""
|
||||||
|
first = sys.exc_info()[1]
|
||||||
|
try:
|
||||||
|
back()
|
||||||
|
except Failed as e:
|
||||||
|
if first is None:
|
||||||
|
raise
|
||||||
|
ev.setdefault(key, {})["failed"] = str(e)
|
||||||
|
ctx.log("the head is not back, and the run has failed already: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _jog_back(ctx, rec, travel, since):
|
||||||
|
"""Jog the head back by the service's travel (x8 steps) and what the
|
||||||
|
counters read since (the controller's own steps), on a controller
|
||||||
|
without a home. One more start then zeroes the counters where the head
|
||||||
|
began, which the rest of the catalog counts on."""
|
||||||
|
spm = counter_steps_per_mm()
|
||||||
|
dx = travel[0] / XY_STEPS_PER_MM + since[0] / spm
|
||||||
|
dy = travel[1] / XY_STEPS_PER_MM + since[1] / spm
|
||||||
|
rec["mm"] = [round(dx, 3), round(dy, 3)]
|
||||||
|
ctx.log("the head is %.3f/%.3f mm from where the test found it", dx, dy)
|
||||||
|
ctx.check(abs(dx) <= RETURN_MAX_MM and abs(dy) <= RETURN_MAX_MM,
|
||||||
|
"a travel of %.1f/%.1f mm exceeds %.0f mm - not jogging back", dx, dy, RETURN_MAX_MM)
|
||||||
|
if abs(dx) >= 0.05 or abs(dy) >= 0.05:
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
clean_slate(ctx, g)
|
||||||
|
start = read_position()
|
||||||
|
r = g.command("$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy))
|
||||||
|
ctx.check(not any(x.startswith("error") for x in r), "the return jog was refused: %s", r)
|
||||||
|
states = wait_idle(ctx, g, timeout=60)[1]
|
||||||
|
ctx.check(states[-1:] != ["TIMEOUT"], "the return jog did not end: %s", states)
|
||||||
|
machine_idle(ctx)
|
||||||
|
end = read_position()
|
||||||
|
rec["counters_across_jog"] = [start, end]
|
||||||
|
ctx.check(start is not None and end is not None and abs((end[0] - start[0]) / spm + dx) < 0.1
|
||||||
|
and abs((end[1] - start[1]) / spm + dy) < 0.1,
|
||||||
|
"the return jog did not travel %.3f/%.3f mm: counters %s -> %s", -dx, -dy, start, end)
|
||||||
|
_drop_reference(ctx, ctx.forgectrl)
|
||||||
|
ctx.counters_rezeroed()
|
||||||
|
ctx.log("head returned to where the test found it (%.3f/%.3f mm)", -dx, -dy)
|
||||||
|
|
||||||
|
|
||||||
|
@test("homing.cloud-offsets", title="A camera home declares its offset pair, and the envelope reaches back to a "
|
||||||
|
"negative one",
|
||||||
|
subsystem="homing", kind="auto", mode="grbl", est_min=7,
|
||||||
|
covers=_HOMING_PATH + [("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||||
|
("grblhal-glowforge", "src/glowforge_cooling.*")],
|
||||||
|
requires=["forgectrl.auth", "motion.pacing"],
|
||||||
|
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. The head "
|
||||||
|
"travels to the camera home and back to where it was."],
|
||||||
|
description="With cloud mode on, homing_mode = gfcloud and the camera-home offsets set to (4.5, -3.25), "
|
||||||
|
"$H runs the web-service homing session. Every service motion of the session runs whole: "
|
||||||
|
"the runner reports to the cooling engine in the controller's place, and one stopped short "
|
||||||
|
"(the engine's stop, a fault) leaves the head short of the home and the position declared "
|
||||||
|
"there false. When the session ends, the controller declares the position (4.5, -3.25): a "
|
||||||
|
"camera-home offset may be negative, and the declared position is snapped to the step "
|
||||||
|
"grid. The work envelope reaches back to the home on the negative axis, since the head "
|
||||||
|
"stands there: a jog 2 mm out in +Y and 2 mm back ends at the home, accepted, and the same "
|
||||||
|
"in X. The homing mode and the offsets are put back as found, the camera home is dropped, "
|
||||||
|
"and the head is jogged back to where the test found it by the travel the session's "
|
||||||
|
"motions logged; a travel that cannot be known fails the test and moves nothing. That an "
|
||||||
|
"offset past the axis travel is refused, and the envelope's edge on the grid, are the "
|
||||||
|
"driver's unit and harness cases.")
|
||||||
|
def cloud_offsets(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
s0 = fc.settings() or {}
|
||||||
|
ctx.check(s0.get("cloud_enabled") == "1", "cloud mode is off: a camera home needs it on")
|
||||||
|
found = {k: s0.get(k) or "" for k in ("homing_mode", "gfcloud_home_x", "gfcloud_home_y")}
|
||||||
|
ev["found"] = found
|
||||||
|
session_at = None
|
||||||
|
try:
|
||||||
|
for k, v in (("homing_mode", "gfcloud"), ("gfcloud_home_x", str(HOME_X)), ("gfcloud_home_y", str(HOME_Y))):
|
||||||
|
st, body = fc.post("/settings", data={k: v})
|
||||||
|
ctx.check(st == 200, "%s=%s -> %s %s", k, v, st, body)
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
session_at = session_mark()
|
||||||
|
gfhome_homing(ctx, ev, g)
|
||||||
|
judge_whole_motions(ctx, ev, session_at)
|
||||||
|
mpos = g.status_report().get("MPos") or (None, None, None)
|
||||||
|
ev["declared"] = mpos
|
||||||
|
ctx.log("the camera home declared %s", mpos)
|
||||||
|
ctx.check(mpos[0] is not None and abs(mpos[0] - HOME_X) < 0.02 and abs(mpos[1] - HOME_Y) < 0.02,
|
||||||
|
"the camera home did not declare (%.2f, %.2f): %s", HOME_X, HOME_Y, mpos)
|
||||||
|
jogs = {}
|
||||||
|
for axis, home in (("Y", HOME_Y), ("X", HOME_X)):
|
||||||
|
out = g.command("$J=G91 G21 %s2 F1000" % axis, timeout=10)
|
||||||
|
wait_state(ctx, g, "Idle", 20)
|
||||||
|
back = g.command("$J=G91 G21 %s-2 F1000" % axis, timeout=10)
|
||||||
|
wait_state(ctx, g, "Idle", 20)
|
||||||
|
at = g.status_report().get("MPos")
|
||||||
|
jogs[axis] = {"out": out[-1:], "back": back[-1:], "at": at}
|
||||||
|
ctx.check(out[-1:] == ["ok"] and back[-1:] == ["ok"],
|
||||||
|
"a jog out and back to the home in %s was refused: %s", axis, jogs[axis])
|
||||||
|
i = "XY".index(axis)
|
||||||
|
ctx.check(at and abs(at[i] - home) < 0.02, "the jog back in %s did not end at the home: %s", axis, at)
|
||||||
|
ev["jogs"] = jogs
|
||||||
|
ctx.log("out and back to the home: %s", jogs)
|
||||||
|
finally:
|
||||||
|
# The settings are refused for a moment after the suite's Grbl client closes; each is put back when taken.
|
||||||
|
for k, v in found.items():
|
||||||
|
def put():
|
||||||
|
st_, body_ = (fc.post("/settings", params={k: ""}) if v == "" else fc.post("/settings", data={k: v}))
|
||||||
|
if st_ != 200:
|
||||||
|
ctx.log("restore %s=%r -> %s %s", k, v, st_, body_)
|
||||||
|
return st_ == 200 or None
|
||||||
|
took = ctx.wait_for(put, 20, poll=0.5)
|
||||||
|
ctx.log("restore %s=%r -> %s", k, v, "taken" if took is not None else "not taken")
|
||||||
|
if session_at is not None:
|
||||||
|
camera_home_return(ctx, ev, session_at)
|
||||||
@@ -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:
|
||||||
@@ -99,25 +111,57 @@ def fds_of(pid):
|
|||||||
|
|
||||||
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
|
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
|
||||||
always=True, est_min=1,
|
always=True, est_min=1,
|
||||||
covers=[("forgectrl", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
|
covers=[("forgectrl", "init/**"), ("forgeext", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
|
||||||
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
|
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
|
||||||
("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 extension host as one process that starts after forgectrl and "
|
||||||
|
"stops before it on a machine with no package installed and nothing running under a "
|
||||||
|
"pool account, the data directory open for search on a machine with an account, the file modes the release depends on, and the mounts: the "
|
||||||
"rootfs read-only, /data writable, the account files and the banner rendered "
|
"rootfs read-only, /data writable, the account files and the banner rendered "
|
||||||
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only.")
|
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only.")
|
||||||
def image_health(ctx):
|
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()
|
||||||
@@ -225,6 +269,29 @@ def image_health(ctx):
|
|||||||
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links")
|
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links")
|
||||||
ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[0])
|
ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[0])
|
||||||
|
|
||||||
|
# 5b. the extension host: one process, up after forgectrl and down
|
||||||
|
# before it, and a campaign's machine is extension-free
|
||||||
|
host = []
|
||||||
|
for pid in hw.pidof("forgeext"):
|
||||||
|
argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
|
||||||
|
if argv[:2] == [hw.FORGEEXT, "run"]:
|
||||||
|
host.append(pid)
|
||||||
|
ev["extension_host_pids"] = host
|
||||||
|
ctx.check(len(host) == 1, "the extension host is not one running process: %s", host)
|
||||||
|
s5 = sorted(os.path.basename(x) for x in glob.glob("/etc/rc5.d/S*"))
|
||||||
|
sx = [x for x in s5 if x.endswith("forgeext")]
|
||||||
|
sf = [x for x in s5 if x.endswith("forgectrl")]
|
||||||
|
kx = [x for x in k if x.endswith("forgeext")]
|
||||||
|
ev["forgeext_links"] = {"start": sx, "kill": kx}
|
||||||
|
ctx.check(sx and sf and sf[0] < sx[0], "the extension host must start after forgectrl: %s, %s", sf, sx)
|
||||||
|
ctx.check(kx and kx[0] < kf[0], "the extension host must stop before forgectrl: %s, %s", kx, kf)
|
||||||
|
ev["extension_packages"] = hw.ext_packages()
|
||||||
|
ev["pool_processes"] = len(hw.pool_pids())
|
||||||
|
ctx.log("extension host pid %s, packages %s, pool processes %d", host, ev["extension_packages"], ev["pool_processes"])
|
||||||
|
ctx.check(not ev["extension_packages"], "a campaign wants an extension-free machine: installed %s",
|
||||||
|
ev["extension_packages"])
|
||||||
|
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"])
|
||||||
|
|
||||||
# 6. logging lever present, no userspace watchdog daemon
|
# 6. logging lever present, no userspace watchdog daemon
|
||||||
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
|
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
|
||||||
ev["logging_init"] = logging
|
ev["logging_init"] = logging
|
||||||
@@ -242,6 +309,14 @@ def image_health(ctx):
|
|||||||
ev[path] = "%o" % m
|
ev[path] = "%o" % m
|
||||||
ctx.log("%s mode %o", path, m)
|
ctx.log("%s mode %o", path, m)
|
||||||
ctx.check(m == 0o600, "%s mode %o, expected 600", path, m)
|
ctx.check(m == 0o600, "%s mode %o, expected 600", path, m)
|
||||||
|
# an operator account walks through the data directory to its home, and a
|
||||||
|
# package's account to its files: forgefirm-users opens it for search at
|
||||||
|
# every render, whoever made it and under whatever umask
|
||||||
|
if os.path.exists("/data/forgefirm/users"):
|
||||||
|
m = stat.S_IMODE(os.stat("/data/forgefirm").st_mode)
|
||||||
|
ev["/data/forgefirm"] = "%o" % m
|
||||||
|
ctx.log("/data/forgefirm mode %o", m)
|
||||||
|
ctx.check(m & 0o011 == 0o011, "/data/forgefirm mode %o: no account can walk through it to its home", m)
|
||||||
if os.path.isdir("/data"):
|
if os.path.isdir("/data"):
|
||||||
s = os.statvfs("/data")
|
s = os.statvfs("/data")
|
||||||
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
|
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
|
||||||
@@ -251,7 +326,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 +338,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 +369,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 +434,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
|
||||||
@@ -25,7 +30,8 @@ from ..catalog import test
|
|||||||
from .. import hw
|
from .. import hw
|
||||||
from ..runner import Failed
|
from ..runner import Failed
|
||||||
from .motion import (kernel_xy_mm, kernel_start, check_kernel_returned, wait_state,
|
from .motion import (kernel_xy_mm, kernel_start, check_kernel_returned, wait_state,
|
||||||
wait_state_text, wait_left_state, wait_idle, drain_text, Watch)
|
wait_state_text, wait_left_state, wait_idle, drain_text, Watch, machine_idle,
|
||||||
|
_job, _job_post, _job_wait, _lease_holder, _port_state, _words)
|
||||||
|
|
||||||
_LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
|
_LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
|
||||||
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"),
|
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"),
|
||||||
@@ -252,6 +258,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 +592,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,12 +666,16 @@ 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)...")
|
||||||
w = Watch(g)
|
w = Watch(g)
|
||||||
ctx.act("button", "press", text="The button is lit white: the press arms the job and the "
|
# The press waits for the button to light: pressed before the job
|
||||||
"move starts.", until=w.in_state("Run"), timeout=180, fail=False)
|
# reaches its arm wait, it is lost, and the move never starts.
|
||||||
|
ctx.arm_press()
|
||||||
|
ctx.wait_for(w.in_state("Run"), 180)
|
||||||
|
ctx.clear_notice()
|
||||||
st = w.last["state"] if w.last else None
|
st = w.last["state"] if w.last else None
|
||||||
ctx.check(st and st.startswith("Run"), "motion never started (state=%s) - arm refused or no press", st)
|
ctx.check(st and st.startswith("Run"), "motion never started (state=%s) - arm refused or no press", st)
|
||||||
ctx.log("moving under laser: %s; feed-hold in 2 s", st)
|
ctx.log("moving under laser: %s; feed-hold in 2 s", st)
|
||||||
@@ -674,6 +712,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 +744,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 +800,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 +830,264 @@ 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.0 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: the gated LASER_ON output (cnc/laser_on), read about 3300 times a "
|
||||||
|
"second, reads the laser off in every read that falls inside the hold from 0.3 s "
|
||||||
|
"after the first Hold:0 (the pause tier's lit deceleration still playing out of "
|
||||||
|
"the kernel's 200 ms queue) until the cut resumes, at least 500 reads, and the "
|
||||||
|
"same reads saw the cut lit in the second before the pause. 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
|
||||||
|
# The dark judge's witness is the gated LASER_ON output itself
|
||||||
|
# (cnc/laser_on, 1 = on), read fast. The kernel's sampled count latches
|
||||||
|
# once a second over the second before, so it reads 0 only once a whole
|
||||||
|
# window has closed inside the hold, up to 2 s in, and the hold can be
|
||||||
|
# shorter than that. About 3300 reads a second on the bench reference;
|
||||||
|
# each read yields the CPU to anything runnable (the controller).
|
||||||
|
FAST_S = 0.1
|
||||||
|
# The pause tier's first deceleration is lit on purpose (a pause leaves
|
||||||
|
# no gap in the cut), and the controller reports Hold:0 once it has
|
||||||
|
# produced the deceleration, not once the kernel has played it: the
|
||||||
|
# driver's queue depth (200 ms, GFSINK_DEPTH_MS's default) and its
|
||||||
|
# producer lead (10 ms) are still to play. The dark span starts that
|
||||||
|
# long after the first Hold:0, with margin.
|
||||||
|
DRAIN_S = 0.3
|
||||||
|
MIN_DARK_READS = 500
|
||||||
|
MIN_LIT_READS = 10
|
||||||
|
|
||||||
|
def fast_reads(seconds, t_ref):
|
||||||
|
"""Reads of cnc/laser_on for `seconds`: [start, end] in seconds
|
||||||
|
from t_ref, then the reads taken, the reads that saw the laser on,
|
||||||
|
and the reads that failed."""
|
||||||
|
start = time.time()
|
||||||
|
n = on = bad = 0
|
||||||
|
while time.time() - start < seconds:
|
||||||
|
v = hw.sysfs_int("cnc/laser_on")
|
||||||
|
n += 1
|
||||||
|
if v is None:
|
||||||
|
bad += 1
|
||||||
|
elif v:
|
||||||
|
on += 1
|
||||||
|
_os.sched_yield()
|
||||||
|
return [round(start - t_ref, 3), round(time.time() - t_ref, 3), n, on, bad]
|
||||||
|
|
||||||
|
def dark_span(rows, drain_s):
|
||||||
|
"""The fast reads that fell wholly inside the hold, drain_s or more
|
||||||
|
after the first Hold:0 row: a burst counts when the rows either side
|
||||||
|
of it both read Hold, so the hold held through it. [reads, on,
|
||||||
|
failed, bursts, from_s, to_s], or None without a Hold:0 row. Pure:
|
||||||
|
tests/test_laser_verdict.py runs it over synthetic trails."""
|
||||||
|
hold0 = next((r["t"] for r in rows if r["gstate"].startswith("Hold:0")), None)
|
||||||
|
if hold0 is None:
|
||||||
|
return None
|
||||||
|
reads = on = bad = bursts = 0
|
||||||
|
first = last = None
|
||||||
|
for a, b in zip(rows, rows[1:]):
|
||||||
|
f = a.get("fast")
|
||||||
|
if not f or not a["gstate"].startswith("Hold") or not b["gstate"].startswith("Hold"):
|
||||||
|
continue
|
||||||
|
if f[0] < hold0 + drain_s:
|
||||||
|
continue
|
||||||
|
reads, on, bad, bursts = reads + f[2], on + f[3], bad + f[4], bursts + 1
|
||||||
|
first = f[0] if first is None else first
|
||||||
|
last = f[1]
|
||||||
|
return [reads, on, bad, bursts, first, last]
|
||||||
|
|
||||||
|
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"])
|
||||||
|
# The witness's own proof: over the lit cut the same reads see it lit.
|
||||||
|
lit_ctl = fast_reads(1.0, time.time())
|
||||||
|
ctx.log("fast reads of cnc/laser_on over the lit cut: %d of %d on, %d failed", lit_ctl[3],
|
||||||
|
lit_ctl[2], lit_ctl[4])
|
||||||
|
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()})
|
||||||
|
if st.startswith("Hold"):
|
||||||
|
trail[-1]["fast"] = fast_reads(FAST_S, t0)
|
||||||
|
else:
|
||||||
|
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
|
||||||
|
if resumed_at is None and st.startswith("Hold"):
|
||||||
|
row["fast"] = fast_reads(FAST_S, t0)
|
||||||
|
else:
|
||||||
|
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 through the hold: the gate masked the stream. First the witness:
|
||||||
|
# the reads that judge the dark saw the cut lit before the pause.
|
||||||
|
ev["fast_lit_control"] = {"reads": lit_ctl[2], "on": lit_ctl[3], "failed": lit_ctl[4]}
|
||||||
|
ctx.check(lit_ctl[4] == 0 and lit_ctl[3] >= MIN_LIT_READS,
|
||||||
|
"the fast reads of cnc/laser_on did not see the lit cut before the pause (%d of %d on, "
|
||||||
|
"%d failed): they cannot witness the dark", lit_ctl[3], lit_ctl[2], lit_ctl[4])
|
||||||
|
dark = dark_span(trail, DRAIN_S)
|
||||||
|
ev["fast_dark"] = (dict(zip(("reads", "on", "failed", "bursts", "from_s", "to_s"), dark))
|
||||||
|
if dark else None)
|
||||||
|
ctx.check(dark is not None, "no Hold:0 was seen, so the hold cannot be judged dark")
|
||||||
|
ctx.check(dark[2] == 0, "cnc/laser_on could not be read %d times inside the hold", dark[2])
|
||||||
|
ctx.check(dark[1] == 0, "the laser was on in %d of %d reads of cnc/laser_on inside the hold "
|
||||||
|
"(%s s to %s s, %.1f s or more after the first Hold:0)", dark[1], dark[0], dark[4], dark[5],
|
||||||
|
DRAIN_S)
|
||||||
|
ctx.check(dark[0] >= MIN_DARK_READS, "only %d reads of cnc/laser_on fell inside the hold after the "
|
||||||
|
"drain: too few to judge it dark (at least %d)", dark[0], MIN_DARK_READS)
|
||||||
|
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, the laser off in all %d reads inside the "
|
||||||
|
"hold (%s s to %s s), resumed lit at +%s s with no press, disarmed %.1f s after Idle",
|
||||||
|
ev["held_at_s"], dark[0], dark[4], dark[5], 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",
|
||||||
@@ -1010,3 +1325,214 @@ def pause_resume_lid_cancel(ctx):
|
|||||||
ctx.log("PASS: button paused the burn (emission 0, armed kept, latch unlocked) and resumed it; "
|
ctx.log("PASS: button paused the burn (emission 0, armed kept, latch unlocked) and resumed it; "
|
||||||
"the lid then canceled it - emission 0 at +%s s, reset without alarm, returned (drift "
|
"the lid then canceled it - emission 0 at +%s s, reset without alarm, returned (drift "
|
||||||
"%.3f mm), button latch SET", zero_at, drift)
|
"%.3f mm), button latch SET", zero_at, drift)
|
||||||
|
|
||||||
|
|
||||||
|
def _port_jog_sampled(ctx, fc, params, samples, timeout=30.0):
|
||||||
|
"""One jog through the controller port, sampled at 8 Hz until the port
|
||||||
|
says it is over. Returns the HTTP status and body of the jog request."""
|
||||||
|
st, body = fc.post("/motion/jog", params=params)
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < timeout:
|
||||||
|
ctx.checkpoint()
|
||||||
|
smp = sample(ctx)
|
||||||
|
if smp:
|
||||||
|
samples.append(smp)
|
||||||
|
try:
|
||||||
|
ps = fc.get("/motion/state")[1]
|
||||||
|
except hw.HwError:
|
||||||
|
ps = None
|
||||||
|
if st != 200 or (isinstance(ps, dict) and ps.get("state") == "Idle" and not ps.get("port_jog")):
|
||||||
|
break
|
||||||
|
time.sleep(0.125)
|
||||||
|
return st, body
|
||||||
|
|
||||||
|
|
||||||
|
@test("laser.port-dark", title="A panel jog under an open armed window ships dark",
|
||||||
|
subsystem="laser", kind="live", mode="grbl", est_min=3,
|
||||||
|
covers=_LASER_COVERS + [("forgectrl", "src/grblport.*")],
|
||||||
|
requires=["laser.emission-witness", "motion.port-jog"], actions=["button"],
|
||||||
|
steps=["Scrap under the head with 20 mm of free +X travel; lid closed; exhaust on.",
|
||||||
|
"Press the physical button when it lights white (the arm)."],
|
||||||
|
description="A 20 mm line at M3 S400/F600 with no M5 and no program end after it, so the "
|
||||||
|
"armed window stays open with M3 modal and S above zero: the state in which an "
|
||||||
|
"injected G1 would fire. The line itself must be witnessed lit, or the case "
|
||||||
|
"proves nothing. Then POST /motion/jog takes the head back over the line and out "
|
||||||
|
"again, through the controller port. The window must read armed before and "
|
||||||
|
"after the jogs, and through both the kernel's LASER_ON sample count stays 0, "
|
||||||
|
"the HV current stays at its idle reading, and the head's beam detector does "
|
||||||
|
"not rise over its level before the jogs. The client then ends the job (M5, "
|
||||||
|
"M2) and the window closes.")
|
||||||
|
def port_dark(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||||
|
prepare(ctx, g)
|
||||||
|
base = sample(ctx)
|
||||||
|
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||||
|
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||||
|
ctx.ready(ARM_CUE % "20 mm +X")
|
||||||
|
ctx.arm_press()
|
||||||
|
jog_samples = []
|
||||||
|
try:
|
||||||
|
# No M5 and no M2: the window stays open, M3 S400 stays modal.
|
||||||
|
lit = run_and_sample(ctx, g, ["G91", "G21", "M3", "S400", "G1 X20 F600"])
|
||||||
|
peak = max((s["emission"] or 0 for s in lit), default=0)
|
||||||
|
ctx.check(peak > 0, "no emission witnessed on the G1: the dark jogs would prove nothing")
|
||||||
|
|
||||||
|
# The cut is over and dark again; the window is still open.
|
||||||
|
ok = ctx.wait_for(lambda: (sample(ctx) or {}).get("emission") == 0, 10, poll=0.125)
|
||||||
|
ctx.check(ok is not None, "emission_samples did not return to 0 after the G1")
|
||||||
|
before = sample(ctx)
|
||||||
|
ctx.check(before and before["armed"], "the armed window closed before the jogs: %s",
|
||||||
|
before and before["phase"])
|
||||||
|
ctx.sleep(0.6) # past the port's sender-quiet interval
|
||||||
|
|
||||||
|
for params in ({"x": "-20", "feed": "600"}, {"x": "20", "feed": "2400"},
|
||||||
|
{"x": "-20", "feed": "2400"}):
|
||||||
|
st, body = _port_jog_sampled(ctx, fc, params, jog_samples)
|
||||||
|
ctx.check(st == 200, "POST /motion/jog %s under the open window -> %s %s", params, st, body)
|
||||||
|
ctx.sleep(0.6)
|
||||||
|
after = sample(ctx)
|
||||||
|
ctx.check(after and after["armed"],
|
||||||
|
"the armed window closed during the jogs: the dark samples were not under it")
|
||||||
|
finally:
|
||||||
|
ctx.clear_notice()
|
||||||
|
r = g.command("M5")
|
||||||
|
r += g.command("M2")
|
||||||
|
ctx.check(not any(x.startswith("error") for x in r), "the client could not end the job: %s", r)
|
||||||
|
ev["disarm_s"] = wait_disarm(ctx, 30)
|
||||||
|
ctx.check(ev["disarm_s"] is not None, "the armed window did not close after the program end")
|
||||||
|
|
||||||
|
lit_in_jog = [s for s in jog_samples if s["emission"]]
|
||||||
|
hv_max = max((s["hv"] for s in jog_samples if s["hv"] is not None), default=0)
|
||||||
|
beams = [s["beam"] for s in jog_samples if s.get("beam") is not None]
|
||||||
|
beam_rise = (max(beams) - before["beam"]) if beams and before.get("beam") is not None else None
|
||||||
|
ev.update({"emission_peak_g1": peak, "jog_samples": len(jog_samples),
|
||||||
|
"jog_emission_samples": len(lit_in_jog), "jog_hv_max": hv_max,
|
||||||
|
"jog_beam_rise": beam_rise, "trail": trail(jog_samples)})
|
||||||
|
ctx.log("G1 emission peak %s; %d samples across three port jogs: %d with emission, HV max %s, "
|
||||||
|
"beam detector rise %s", peak, len(jog_samples), len(lit_in_jog), hv_max, beam_rise)
|
||||||
|
ctx.check(len(jog_samples) >= 16, "only %d samples across the jogs", len(jog_samples))
|
||||||
|
ctx.check(not lit_in_jog, "a port jog fired under the open window: %d samples with emission, "
|
||||||
|
"first emission_samples %s", len(lit_in_jog), lit_in_jog[0]["emission"] if lit_in_jog else None)
|
||||||
|
ctx.check(hv_max <= HV_DARK_MAX, "HV current %s during a port jog (idle reads ~0)", hv_max)
|
||||||
|
ctx.check(beam_rise is not None and beam_rise < BEAM_DELTA_MIN,
|
||||||
|
"the head's beam detector rose %s during a port jog", beam_rise)
|
||||||
|
ctx.log("PASS: lit on the G1 (peak %s), and three port jogs under the same open window with M3 S400 "
|
||||||
|
"modal shipped dark by all three witnesses", peak)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the recorder, as a job of the runner's
|
||||||
|
|
||||||
|
def _laser_keys(fc):
|
||||||
|
s = fc.settings()
|
||||||
|
return {k: s.get(k) for k in ("laser_floor_density", "laser_dose_curve")}
|
||||||
|
|
||||||
|
|
||||||
|
def _arming(fc):
|
||||||
|
rep = ((fc.status().get("grbl") or {}).get("report") or {})
|
||||||
|
return bool((rep.get("laser") or {}).get("arming"))
|
||||||
|
|
||||||
|
|
||||||
|
@test("laser.recorder-dark", title="The dose-curve recorder takes the machine as a job, arms, and is stopped dark",
|
||||||
|
subsystem="laser", kind="auto", mode="grbl", est_min=2,
|
||||||
|
covers=_LASER_COVERS + [("forgectrl", "src/curverec.*"), ("forgectrl", "src/jobrun.*"),
|
||||||
|
("forgectrl", "src/jobstream.*"), ("forgectrl", "src/lease.*"),
|
||||||
|
("forgectrl", "src/settings.*")],
|
||||||
|
requires=["motion.job"],
|
||||||
|
steps=["Lid closed, bed clear. Nobody presses the button: the recorder is stopped before its press.",
|
||||||
|
"The ladder begins at the controller's X0 Y0: the head goes there, and is brought back."],
|
||||||
|
description="POST /curve/record starts the dose ladder as a job of the job runner's. While it "
|
||||||
|
"waits for a press that never comes: /status names the recorder as the lease "
|
||||||
|
"holder of kind sender, GET /job reports the run (owner recorder, not a posted "
|
||||||
|
"program), the floor and the curve are overridden (0 and off), the controller "
|
||||||
|
"reaches its arm wait, POST /job/abort refuses to stop a run it did not start, a "
|
||||||
|
"posted job is refused in the recorder's name, and the kernel's LASER_ON sample "
|
||||||
|
"count and the HV current stay dark. POST /curve/stop then ends it: the recorder "
|
||||||
|
"says it was stopped before the ladder fired, the lease is free, and both laser "
|
||||||
|
"keys read as they did before. The head is returned by a posted job.")
|
||||||
|
def recorder_dark(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
fc = hw.Forgectrl()
|
||||||
|
machine_idle(ctx)
|
||||||
|
x0, y0 = kernel_start(ctx)
|
||||||
|
keys0 = _laser_keys(fc)
|
||||||
|
base = sample(ctx)
|
||||||
|
ctx.check(base and not base["emission"], "emission_samples nonzero before the recording (%s)", base)
|
||||||
|
ctx.check(not _lease_holder(fc), "the machine is not free at the start: %s", _lease_holder(fc))
|
||||||
|
started = False
|
||||||
|
samples = []
|
||||||
|
try:
|
||||||
|
st, body = fc.post("/curve/record")
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "waiting",
|
||||||
|
"POST /curve/record -> %s %s", st, _words(body)[:200])
|
||||||
|
started = True
|
||||||
|
ok = ctx.wait_for(lambda: _lease_holder(fc).get("owner") == "recorder", 5, poll=0.1)
|
||||||
|
held = _lease_holder(fc)
|
||||||
|
ev["holder"] = held
|
||||||
|
ctx.check(ok is not None and held.get("kind") == "sender", "the lease does not name the recorder: %s", held)
|
||||||
|
rec = _job(fc)
|
||||||
|
ev["job_record"] = rec
|
||||||
|
ctx.check(rec["state"] == "running" and rec["owner"] == "recorder" and rec["program"] is False,
|
||||||
|
"GET /job while the recorder runs: %s", rec)
|
||||||
|
over = _laser_keys(fc)
|
||||||
|
ev["override"] = over
|
||||||
|
ctx.check(str(over["laser_dose_curve"]) == "off" and float(over["laser_floor_density"] or 0) == 0.0,
|
||||||
|
"the floor and the curve are not overridden for the ladder: %s", over)
|
||||||
|
|
||||||
|
ok = ctx.wait_for(lambda: _arming(fc), 40, poll=0.25)
|
||||||
|
ctx.check(ok is not None, "the controller never reached its arm wait: %s",
|
||||||
|
(fc.status().get("grbl") or {}).get("report"))
|
||||||
|
ctx.log("the recorder holds the machine as %s, and the controller waits for the press", held)
|
||||||
|
st, body = fc.post("/job/abort")
|
||||||
|
ev["abort_route"] = [st, _words(body)[:160]]
|
||||||
|
ctx.check(st == 409 and "stop it where it was started" in _words(body),
|
||||||
|
"POST /job/abort on the recorder's run -> %s %s", st, _words(body)[:160])
|
||||||
|
st, body = _job_post(fc, "G21\n")
|
||||||
|
ev["job_beside"] = [st, _words(body)[:160]]
|
||||||
|
ctx.check(st == 409 and "the dose-curve recorder holds the machine" in _words(body),
|
||||||
|
"a posted job beside the recorder -> %s %s", st, _words(body)[:160])
|
||||||
|
for _ in range(16): # two seconds in the arm wait, by the witnesses
|
||||||
|
samples.append(sample(ctx))
|
||||||
|
ctx.sleep(0.125)
|
||||||
|
ctx.check(_lease_holder(fc).get("owner") == "recorder", "a refused request took the lease away")
|
||||||
|
finally:
|
||||||
|
if started:
|
||||||
|
st, body = fc.post("/curve/stop")
|
||||||
|
ev["stopped"] = body if isinstance(body, dict) else _words(body)[:160]
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "failed"
|
||||||
|
and "before the ladder fired" in body.get("reason", ""), "POST /curve/stop -> %s %s",
|
||||||
|
st, _words(body)[:200])
|
||||||
|
ok = ctx.wait_for(lambda: not _lease_holder(fc), 10, poll=0.2)
|
||||||
|
ctx.check(ok is not None, "the lease was not given back: %s", _lease_holder(fc))
|
||||||
|
keys1 = _laser_keys(fc)
|
||||||
|
ev["keys"] = {"before": keys0, "after": keys1}
|
||||||
|
ctx.check(keys1 == keys0, "the laser keys were not restored: %s, were %s", keys1, keys0)
|
||||||
|
|
||||||
|
lit = [s for s in samples if s and s["emission"]]
|
||||||
|
hv_max = max((s["hv"] for s in samples if s and s["hv"] is not None), default=0)
|
||||||
|
ev["witnesses"] = {"samples": len(samples), "with_emission": len(lit), "hv_max": hv_max,
|
||||||
|
"job": _job(fc)["emission"]}
|
||||||
|
ctx.check(not lit and hv_max <= HV_DARK_MAX and _job(fc)["emission"]["laser_on_samples"] == 0,
|
||||||
|
"the recorder's arm wait was not dark: %s", ev["witnesses"])
|
||||||
|
|
||||||
|
# The ladder's first move took the head to the controller's X0 Y0.
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
dx, dy = (a - b for a, b in zip(kernel_xy_mm(ctx), (x0, y0)))
|
||||||
|
ev["moved_mm"] = [round(dx, 3), round(dy, 3)]
|
||||||
|
ev["controller_after_stop"] = _port_state(fc).get("state")
|
||||||
|
ctx.log("after the stop the controller is in %s; the head is %.3f, %.3f mm from where it was found",
|
||||||
|
ev["controller_after_stop"], dx, dy)
|
||||||
|
if abs(dx) > 0.05 or abs(dy) > 0.05:
|
||||||
|
st, body = _job_post(fc, "G21\nG91\nG1 X%.3f Y%.3f F2400\nG90\n" % (-dx, -dy), unlock="1")
|
||||||
|
ctx.check(st == 200, "the returning job -> %s %s", st, _words(body)[:200])
|
||||||
|
rec, _far = _job_wait(ctx, fc, 60)
|
||||||
|
ctx.check(rec["state"] == "done", "the returning job: %s", rec)
|
||||||
|
elif str(ev["controller_after_stop"]).startswith("Alarm"):
|
||||||
|
st, body = _job_post(fc, "G21\n", unlock="1")
|
||||||
|
rec, _far = _job_wait(ctx, fc, 20)
|
||||||
|
ctx.check(st == 200 and rec["state"] == "done", "the unlocking job: %s %s", st, rec)
|
||||||
|
check_kernel_returned(ctx, ev, (x0, y0), tag="recorder")
|
||||||
|
machine_idle(ctx)
|
||||||
|
ctx.log("PASS: the recorder ran as a job of the runner's, held the machine as a sender through its arm "
|
||||||
|
"wait with every witness dark, and its stop freed the machine and restored the laser keys")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -20,7 +25,14 @@ 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/extpkg.*"), ("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",
|
||||||
@@ -28,8 +40,36 @@ _LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forg
|
|||||||
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. system/extensions.json is in it as well: what is "
|
||||||
|
"installed that is not the firmware, with each package's tier, whether it is "
|
||||||
|
"enabled, and what the operator granted it, which is what a report about a "
|
||||||
|
"machine running extensions has to be read against.")
|
||||||
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 +122,40 @@ 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")
|
||||||
|
|
||||||
|
# What is installed that is not the firmware. A report that starts "it
|
||||||
|
# stopped working" reads differently with packages on the machine, and
|
||||||
|
# the bundle is where that is read.
|
||||||
|
import json
|
||||||
|
|
||||||
|
ext = [name for name in by_name if name.endswith("system/extensions.json")]
|
||||||
|
ctx.check(ext, "the bundle carries no system/extensions.json")
|
||||||
|
if ext:
|
||||||
|
doc = json.loads(by_name[ext[0]].decode("utf-8", "replace"))
|
||||||
|
ev["extensions"] = {"enabled": doc.get("enabled"), "safe_mode": doc.get("safe_mode"),
|
||||||
|
"packages": [{k: p.get(k) for k in ("id", "version", "tier", "enabled", "grants")}
|
||||||
|
for p in doc.get("packages", [])]}
|
||||||
|
ctx.log("the bundle's extension listing: %s", ev["extensions"])
|
||||||
|
ctx.check(set(doc) >= {"enabled", "safe_mode", "host", "packages"},
|
||||||
|
"the listing is not the status document: %s", sorted(doc))
|
||||||
|
for p in doc.get("packages", []):
|
||||||
|
ctx.check(set(p) >= {"id", "version", "tier", "enabled", "grants"},
|
||||||
|
"a listed package does not say what it is: %s", sorted(p))
|
||||||
|
|
||||||
|
|
||||||
# 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 +169,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", "forgeext", "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}
|
||||||
@@ -211,7 +284,7 @@ def routing(ctx):
|
|||||||
# a probe under another name must NOT leak into these files
|
# a probe under another name must NOT leak into these files
|
||||||
rc, out = hw.run(["logger", "-t", "forgetest-stray", "-p", "daemon.err", "%s stray" % nonce])
|
rc, out = hw.run(["logger", "-t", "forgetest-stray", "-p", "daemon.err", "%s stray" % nonce])
|
||||||
time.sleep(1.5)
|
time.sleep(1.5)
|
||||||
for name in ("grblhal", "gfhome", "forgectrl"):
|
for name in ("grblhal", "gfhome", "forgectrl", "forgeext"):
|
||||||
path = os.path.join(LOGS_ROOT, name, name + ".log")
|
path = os.path.join(LOGS_ROOT, name, name + ".log")
|
||||||
ctx.check(not any((nonce + " stray") in l for l in _tail_lines(path, 100)),
|
ctx.check(not any((nonce + " stray") in l for l in _tail_lines(path, 100)),
|
||||||
"a stray program's line landed in %s", path)
|
"a stray program's line landed in %s", path)
|
||||||
|
|||||||
+1106
-24
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
@@ -425,8 +427,12 @@ def gate_blocks_controllers(ctx):
|
|||||||
ev["override_mode"] = m
|
ev["override_mode"] = m
|
||||||
ctx.check(took is not None and isinstance(m, dict) and m.get("controller") == "running",
|
ctx.check(took is not None and isinstance(m, dict) and m.get("controller") == "running",
|
||||||
"the controller did not come back under the override: %s", m)
|
"the controller did not come back under the override: %s", m)
|
||||||
ctx.check(hw.grbl_port_open(timeout=5), "the Grbl port is closed with the controller running")
|
# /mode reads running from the fork; the controller binds its port
|
||||||
ctx.log("override: the controller is back (%s)", m)
|
# a few tens of milliseconds later
|
||||||
|
opened = ctx.wait_for(lambda: hw.grbl_port_open(timeout=1), 10, poll=0.05)
|
||||||
|
ev["port_open_after_s"] = opened
|
||||||
|
ctx.check(opened is not None, "the Grbl port is closed with the controller running")
|
||||||
|
ctx.log("override: the controller is back (%s), the Grbl port accepts after %.3f s", m, opened)
|
||||||
|
|
||||||
# the real record is back under a restart; the override reads as found
|
# the real record is back under a restart; the override reads as found
|
||||||
after = wiz(fc)
|
after = wiz(fc)
|
||||||
@@ -442,11 +448,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,11 +495,13 @@ 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"),
|
||||||
|
("forgectrl", "docs/advisories/safety-and-risk.md"), ("forgectrl", "docs/advisories/licenses.md"),
|
||||||
|
("forgectrl", "docs/advisories/privacy.md"), ("forgectrl", "docs/advisories/cloud-service.md")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
description="GET /advisories/safety-and-risk serves the document as markdown with an ETag "
|
description="GET /advisories/safety-and-risk serves the document as markdown with an ETag "
|
||||||
"equal to the SHA-256 of the body and to the hash GET /wiz lists; an unknown "
|
"equal to the SHA-256 of the body and to the hash GET /wiz lists; an unknown "
|
||||||
@@ -507,7 +515,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 +590,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"),
|
||||||
@@ -591,7 +599,11 @@ def advisories_rehash(ctx):
|
|||||||
description="The test makes its own account: the account record is moved aside under a "
|
description="The test makes its own account: the account record is moved aside under a "
|
||||||
"forgectrl restart, the account route creates a temporary one with a password "
|
"forgectrl restart, the account route creates a temporary one with a password "
|
||||||
"only the test knows, and the real record comes back under another restart at "
|
"only the test knows, and the real record comes back under another restart at "
|
||||||
"the end, the system accounts replayed and the temporary home removed. Over "
|
"the end, the system accounts replayed and the temporary home removed. The data "
|
||||||
|
"directory is set to 0700 first, as a strict umask leaves it: the render that makes "
|
||||||
|
"the account opens it for search and nothing else (0711), and tried as the account, "
|
||||||
|
"its home can be entered and written while the data directory cannot be listed and "
|
||||||
|
"the account record cannot be read. Over "
|
||||||
"HTTPS (self-signed, so unverified): GET /wiz reports no session and the "
|
"HTTPS (self-signed, so unverified): GET /wiz reports no session and the "
|
||||||
"certificate fingerprint; POST /login with a wrong password is refused (401); "
|
"certificate fingerprint; POST /login with a wrong password is refused (401); "
|
||||||
"five failures lock the address (429, wait at most 30 s) and the right password "
|
"five failures lock the address (429, wait at most 30 s) and the right password "
|
||||||
@@ -606,15 +618,33 @@ def account_login(ctx):
|
|||||||
before = wiz(fc)
|
before = wiz(fc)
|
||||||
ev["account_before"] = (before.get("users") or {}).get("name")
|
ev["account_before"] = (before.get("users") or {}).get("name")
|
||||||
homes = "/data/forgefirm/home"
|
homes = "/data/forgefirm/home"
|
||||||
|
dir_mode = os.stat(data_dir()).st_mode & 0o7777
|
||||||
|
ev["data_dir_mode_found"] = "%04o" % dir_mode
|
||||||
|
try:
|
||||||
with installed(ctx, {users_path(): None}):
|
with installed(ctx, {users_path(): None}):
|
||||||
w = wiz(fc)
|
w = wiz(fc)
|
||||||
ctx.check(not (w.get("users") or {}).get("exists"), "an account still exists: %s", w.get("users"))
|
ctx.check(not (w.get("users") or {}).get("exists"), "an account still exists: %s", w.get("users"))
|
||||||
|
# the data directory as a strict umask leaves it: the account's render has to open it
|
||||||
|
os.chmod(data_dir(), 0o700)
|
||||||
st, body = fc.post("/wiz/account", data={"name": name, "password": pw})
|
st, body = fc.post("/wiz/account", data={"name": name, "password": pw})
|
||||||
ctx.log("POST /wiz/account (temporary %r) -> %s %s", name, st, body if isinstance(body, dict) else "")
|
ctx.log("POST /wiz/account (temporary %r) -> %s %s", name, st, body if isinstance(body, dict) else "")
|
||||||
ctx.check(st == 200, "the temporary account was not created: %s %s", st, body)
|
ctx.check(st == 200, "the temporary account was not created: %s %s", st, body)
|
||||||
w = wiz(fc)
|
w = wiz(fc)
|
||||||
ctx.check((w.get("users") or {}).get("name") == name, "the account reads %s", w.get("users"))
|
ctx.check((w.get("users") or {}).get("name") == name, "the account reads %s", w.get("users"))
|
||||||
|
reach = home_reach(name)
|
||||||
|
ev["home_reach"] = reach
|
||||||
|
ev["data_dir_mode_after_render"] = "%04o" % (os.stat(data_dir()).st_mode & 0o7777)
|
||||||
|
ctx.log("as %s, from a data directory found 0700 (now %s): %s", name, ev["data_dir_mode_after_render"], reach)
|
||||||
|
ctx.check(reach.get("cd") == "ok" and reach.get("write") == "ok",
|
||||||
|
"the account cannot use its own home %s: %s", reach.get("home"), reach)
|
||||||
|
ctx.check(ev["data_dir_mode_after_render"] == "0711", "the render left the data directory at %s, expected "
|
||||||
|
"0711 (the search bit and nothing else)", ev["data_dir_mode_after_render"])
|
||||||
|
ctx.check(reach.get("list_data_dir") == "EACCES" and reach.get("read_record") == "EACCES",
|
||||||
|
"the account reads what is not its own: %s", reach)
|
||||||
login_checks(ctx, name, pw)
|
login_checks(ctx, name, pw)
|
||||||
|
finally:
|
||||||
|
# as found; the replay below is the product's own say on the mode
|
||||||
|
os.chmod(data_dir(), dir_mode)
|
||||||
rc, out = hw.initd("forgefirm-users", "reload")
|
rc, out = hw.initd("forgefirm-users", "reload")
|
||||||
ev["users_reload_rc"] = rc
|
ev["users_reload_rc"] = rc
|
||||||
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
|
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
|
||||||
@@ -627,6 +657,49 @@ def account_login(ctx):
|
|||||||
ev["account_before"])
|
ev["account_before"])
|
||||||
|
|
||||||
|
|
||||||
|
# Run as root: becomes the account and tries its own home, and what is
|
||||||
|
# not its own. One JSON line.
|
||||||
|
HOME_REACH = r'''
|
||||||
|
import errno, json, os, pwd, sys
|
||||||
|
p = pwd.getpwnam(sys.argv[1])
|
||||||
|
os.setgroups([])
|
||||||
|
os.setgid(p.pw_gid)
|
||||||
|
os.setuid(p.pw_uid)
|
||||||
|
|
||||||
|
|
||||||
|
def tried(fn):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
return "ok"
|
||||||
|
except OSError as e:
|
||||||
|
return errno.errorcode.get(e.errno, str(e.errno))
|
||||||
|
|
||||||
|
|
||||||
|
def write():
|
||||||
|
path = os.path.join(p.pw_dir, ".forgetest-reach")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write("x")
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
|
print(json.dumps({"uid": os.getuid(), "home": p.pw_dir, "cd": tried(lambda: os.chdir(p.pw_dir)), "write": tried(write),
|
||||||
|
"list_data_dir": tried(lambda: os.listdir(sys.argv[2])),
|
||||||
|
"read_record": tried(lambda: open(os.path.join(sys.argv[2], "users")).read())}))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def home_reach(name):
|
||||||
|
"""What the account can do with its own home and with the data
|
||||||
|
directory around it, tried as the account."""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
p = subprocess.run([sys.executable, "-c", HOME_REACH, name, data_dir()], capture_output=True, text=True, timeout=30)
|
||||||
|
try:
|
||||||
|
return json.loads(p.stdout)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": (p.stderr or p.stdout).strip()[-200:]}
|
||||||
|
|
||||||
|
|
||||||
def login_checks(ctx, name, pw):
|
def login_checks(ctx, name, pw):
|
||||||
"""The login rules against an account whose password the test knows."""
|
"""The login rules against an account whose password the test knows."""
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
@@ -705,7 +778,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 +850,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 +862,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 +878,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 +887,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 +923,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,15 +964,20 @@ 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"), ("forgectrl", "src/builtin.*")],
|
||||||
requires=["forgectrl.settings-bounds"],
|
requires=["forgectrl.settings-bounds"],
|
||||||
description="The test turns cloud mode off itself with one write and puts every setting "
|
description="The test turns cloud mode off itself with one write and puts every setting "
|
||||||
"back as found. cloud_enabled=0 takes the gfcloud homing and the cloud boot mode "
|
"back as found. GET /extensions lists cloud mode as a built-in with its two "
|
||||||
"down with it, as the cloud step does. With it at 0, POST /settings "
|
"roles (homing gfcloud, controller cloud), enabled as cloud_enabled says and each "
|
||||||
"controller_mode=cloud and homing_mode=gfcloud are refused (409) and leave the "
|
"role active exactly when its setting selects it. cloud_enabled=0 takes the "
|
||||||
|
"gfcloud homing and the cloud boot mode "
|
||||||
|
"down with it, as the cloud step does, and the list then reads off with no role "
|
||||||
|
"active. With it at 0, POST /settings "
|
||||||
|
"controller_mode=cloud and homing_mode=gfcloud are refused (409, in the words the "
|
||||||
|
"table gives each role) and leave the "
|
||||||
"settings unchanged, cloud_enabled=1 without the typed phrase is refused (400) "
|
"settings unchanged, cloud_enabled=1 without the typed phrase is refused (400) "
|
||||||
"and leaves it at 0, and POST /mode controller=cloud is refused (409) with a "
|
"and leaves it at 0, and POST /mode controller=cloud is refused (409) with a "
|
||||||
"message that names cloud mode, the machine staying in GRBL mode.")
|
"message that names cloud mode, the machine staying in GRBL mode.")
|
||||||
@@ -901,6 +988,29 @@ def cloud_disabled_surface(ctx):
|
|||||||
found = fc.settings()
|
found = fc.settings()
|
||||||
prior = found.get("cloud_enabled") or ""
|
prior = found.get("cloud_enabled") or ""
|
||||||
ev["found"] = {k: found.get(k) for k in ("cloud_enabled", "homing_mode", "controller_mode")}
|
ev["found"] = {k: found.get(k) for k in ("cloud_enabled", "homing_mode", "controller_mode")}
|
||||||
|
|
||||||
|
def cloud_entry():
|
||||||
|
st, doc = fc.get("/extensions")
|
||||||
|
ctx.check(st == 200 and isinstance(doc, dict), "GET /extensions -> %s", st)
|
||||||
|
ext = [e for e in doc.get("extensions", []) if e.get("id") == "cloud"]
|
||||||
|
ctx.check(len(ext) == 1, "GET /extensions lists cloud mode %d times", len(ext))
|
||||||
|
return ext[0]
|
||||||
|
|
||||||
|
# The list agrees with the settings as found.
|
||||||
|
e = cloud_entry()
|
||||||
|
ev["extension_found"] = {"enabled": e.get("enabled"), "roles": e.get("roles")}
|
||||||
|
ctx.check(e.get("builtin") is True and e.get("enable_key") == "cloud_enabled" and e.get("tab") == "gfcloud"
|
||||||
|
and e.get("setup_step") == "cloud", "the cloud entry: %s", e)
|
||||||
|
ctx.check(e.get("enabled") is (prior == "1"), "the list says enabled=%s with cloud_enabled=%r",
|
||||||
|
e.get("enabled"), prior)
|
||||||
|
roles = {r["role"]: r for r in e.get("roles", [])}
|
||||||
|
ctx.check(set(roles) == {"homing", "controller"} and roles["homing"]["provider"] == "gfcloud"
|
||||||
|
and roles["homing"]["fallback"] == "none" and roles["controller"]["provider"] == "cloud"
|
||||||
|
and roles["controller"]["fallback"] == "grbl", "the cloud entry's roles: %s", e.get("roles"))
|
||||||
|
for r in roles.values():
|
||||||
|
want = prior == "1" and found.get(r["select_key"]) == r["provider"]
|
||||||
|
ctx.check(r["active"] is want, "role %s reads active=%s with %s=%r", r["role"], r["active"],
|
||||||
|
r["select_key"], found.get(r["select_key"]))
|
||||||
try:
|
try:
|
||||||
if prior != "0":
|
if prior != "0":
|
||||||
# the one write sweeps what pointed at the cloud
|
# the one write sweeps what pointed at the cloud
|
||||||
@@ -916,12 +1026,18 @@ def cloud_disabled_surface(ctx):
|
|||||||
"cloud_enabled=0 left controller_mode at %r, expected grbl",
|
"cloud_enabled=0 left controller_mode at %r, expected grbl",
|
||||||
swept.get("controller_mode"))
|
swept.get("controller_mode"))
|
||||||
ctx.check(fc.settings().get("cloud_enabled") == "0", "cloud_enabled does not read 0")
|
ctx.check(fc.settings().get("cloud_enabled") == "0", "cloud_enabled does not read 0")
|
||||||
|
off = cloud_entry()
|
||||||
|
ev["extension_off"] = {"enabled": off.get("enabled"), "active": [r["active"] for r in off["roles"]]}
|
||||||
|
ctx.check(off.get("enabled") is False and not any(r["active"] for r in off["roles"]),
|
||||||
|
"the list with cloud mode off: %s", ev["extension_off"])
|
||||||
before = fc.settings()
|
before = fc.settings()
|
||||||
for key, val in (("controller_mode", "cloud"), ("homing_mode", "gfcloud")):
|
for key, val, words in (("controller_mode", "cloud", "cloud mode is not enabled on this machine"),
|
||||||
|
("homing_mode", "gfcloud", "cloud homing needs cloud mode enabled")):
|
||||||
st, body = fc.post("/settings", data={key: val})
|
st, body = fc.post("/settings", data={key: val})
|
||||||
ev["settings %s=%s" % (key, val)] = st
|
ev["settings %s=%s" % (key, val)] = st
|
||||||
ctx.log("POST /settings %s=%s -> %s %s", key, val, st, body if isinstance(body, str) else "")
|
ctx.log("POST /settings %s=%s -> %s %s", key, val, st, body if isinstance(body, str) else "")
|
||||||
ctx.check(st == 409, "%s=%s with cloud off -> %s, expected 409", key, val, st)
|
ctx.check(st == 409, "%s=%s with cloud off -> %s, expected 409", key, val, st)
|
||||||
|
ctx.check(isinstance(body, str) and words in body, "%s=%s was refused in other words: %r", key, val, body)
|
||||||
# the pair in one request is refused too: the request's own switch counts
|
# the pair in one request is refused too: the request's own switch counts
|
||||||
st, body = fc.post("/settings", data={"cloud_enabled": "0", "controller_mode": "cloud"})
|
st, body = fc.post("/settings", data={"cloud_enabled": "0", "controller_mode": "cloud"})
|
||||||
ctx.check(st == 409, "cloud_enabled=0 with controller_mode=cloud -> %s, expected 409", st)
|
ctx.check(st == 409, "cloud_enabled=0 with controller_mode=cloud -> %s, expected 409", st)
|
||||||
@@ -961,12 +1077,155 @@ def cloud_disabled_surface(ctx):
|
|||||||
for key in ("cloud_enabled", "homing_mode", "controller_mode"):
|
for key in ("cloud_enabled", "homing_mode", "controller_mode"):
|
||||||
ctx.check((after.get(key) or "") == (found.get(key) or ""), "%s not restored: %r, was %r",
|
ctx.check((after.get(key) or "") == (found.get(key) or ""), "%s not restored: %r, was %r",
|
||||||
key, after.get(key), found.get(key))
|
key, after.get(key), found.get(key))
|
||||||
|
back = cloud_entry()
|
||||||
|
ctx.check(back.get("enabled") == e.get("enabled") and back.get("roles") == e.get("roles"),
|
||||||
|
"the list did not come back as found: %s, was %s", back, e)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- extensions
|
||||||
|
|
||||||
|
@test("setup.extensions-consent", title="Extensions are turned on over their own advisory",
|
||||||
|
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||||
|
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"),
|
||||||
|
("forgectrl", "src/wiz.c"), ("forgectrl", "src/settings.*"),
|
||||||
|
("forgectrl", "docs/advisories/extensions.md"), ("forgectrl", "src/ui/embed_docs.cmake")],
|
||||||
|
requires=["forgectrl.settings-bounds", "setup.advisories-rehash"],
|
||||||
|
description="The Extensions advisory is on demand: GET /wiz does not list it among the "
|
||||||
|
"first-run documents, GET /advisories/extensions serves it as markdown with an "
|
||||||
|
"ETag equal to the SHA-256 of the body, and the first-run accept refuses it (400) "
|
||||||
|
"and leaves the press. The test turns ext_enabled off itself and puts it back as "
|
||||||
|
"found. From off, POST /settings ext_enabled=1 is refused without the advisory's "
|
||||||
|
"hash and with a stale one (409), and without the typed phrase and with the phrase "
|
||||||
|
"in another case (400); each refusal leaves the setting at 0 and the record on "
|
||||||
|
"disk untouched. A request that carries the hash and the phrase beside a write the "
|
||||||
|
"daemon refuses (controller_mode=cloud with cloud_enabled=0, 409) is refused whole "
|
||||||
|
"and records nothing. With the "
|
||||||
|
"hash and the phrase it is accepted (200): the setting reads 1, the record on disk "
|
||||||
|
"holds on_demand.extensions with that hash and the typed method, the first-run "
|
||||||
|
"documents and their press are as they were, and the data directory has gained "
|
||||||
|
"the search bit for group and others and nothing else (a package's account walks "
|
||||||
|
"through it to its own files). Re-sending 1 while it stands and "
|
||||||
|
"sending 0 ask for nothing. The previous record is put back under a forgectrl "
|
||||||
|
"restart.")
|
||||||
|
def extensions_consent(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||||
|
raw = read_file(record_path())
|
||||||
|
ctx.check(raw, "no setup record at %s", record_path())
|
||||||
|
before = wiz(fc)
|
||||||
|
ev["before"] = wiz_summary(before)
|
||||||
|
prior = fc.settings().get("ext_enabled") or ""
|
||||||
|
dir_mode = os.stat(data_dir()).st_mode & 0o7777
|
||||||
|
ev["found"] = {"ext_enabled": prior, "data_dir_mode": "%04o" % dir_mode}
|
||||||
|
|
||||||
|
def on_disk():
|
||||||
|
try:
|
||||||
|
return json.loads((read_file(record_path()) or b"{}").decode("utf-8"))
|
||||||
|
except ValueError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
ctx.check("extensions" not in [d.get("id") for d in before.get("documents") or []],
|
||||||
|
"GET /wiz lists the Extensions advisory among the first-run documents")
|
||||||
|
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||||
|
etag = hdrs.get("etag")
|
||||||
|
ev["etag"] = etag
|
||||||
|
ctx.log("GET /advisories/extensions -> %s, %d bytes, ETag %s", st, len(body), etag)
|
||||||
|
ctx.check(st == 200 and body, "GET /advisories/extensions -> %s", st)
|
||||||
|
ctx.check((hdrs.get("content-type") or "").startswith("text/markdown"),
|
||||||
|
"the document is not served as markdown: %r", hdrs.get("content-type"))
|
||||||
|
ctx.check(etag == hashlib.sha256(body).hexdigest(), "the ETag is not the SHA-256 of the body")
|
||||||
|
|
||||||
|
try:
|
||||||
|
st, reply = fc.post("/wiz/advisories/accept",
|
||||||
|
data={"doc": "extensions", "hash": etag, "phrase": SAFETY_PHRASE})
|
||||||
|
ev["first_run_accept"] = st
|
||||||
|
ctx.log("the first-run accept of the on-demand document -> %s %s", st, reply)
|
||||||
|
ctx.check(st == 400, "the first-run accept of the on-demand document -> %s, expected 400", st)
|
||||||
|
ctx.check(wiz(fc).get("acceptance_done") == before.get("acceptance_done"),
|
||||||
|
"the refused accept changed the press record")
|
||||||
|
|
||||||
|
if prior == "1":
|
||||||
|
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
|
||||||
|
ctx.check(st == 200, "ext_enabled=0 -> %s %s", st, reply)
|
||||||
|
ctx.log("extensions turned off for the test (found %r)", prior)
|
||||||
|
ctx.check((fc.settings().get("ext_enabled") or "0") == "0", "ext_enabled does not read off")
|
||||||
|
start = on_disk()
|
||||||
|
|
||||||
|
for name, form, want, words in (
|
||||||
|
("no hash", {"ext_enabled": "1"}, 409, "read the Extensions advisory first"),
|
||||||
|
("stale hash", {"ext_enabled": "1", "advisory": "0" * 64, "phrase": SAFETY_PHRASE}, 409,
|
||||||
|
"read the Extensions advisory first"),
|
||||||
|
("no phrase", {"ext_enabled": "1", "advisory": etag}, 400, "type I UNDERSTAND"),
|
||||||
|
("phrase in another case", {"ext_enabled": "1", "advisory": etag,
|
||||||
|
"phrase": SAFETY_PHRASE.lower()}, 400, "type I UNDERSTAND"),
|
||||||
|
("good consent beside a refused write", {"ext_enabled": "1", "advisory": etag,
|
||||||
|
"phrase": SAFETY_PHRASE, "cloud_enabled": "0",
|
||||||
|
"controller_mode": "cloud"},
|
||||||
|
409, "cloud mode is not enabled on this machine")):
|
||||||
|
st, reply = fc.post("/settings", data=form)
|
||||||
|
ev[name] = st
|
||||||
|
ctx.log("POST /settings ext_enabled=1, %s -> %s %s", name, st, reply if isinstance(reply, str) else "")
|
||||||
|
ctx.check(st == want, "%s -> %s, expected %s", name, st, want)
|
||||||
|
ctx.check(isinstance(reply, str) and words in reply, "%s was refused in other words: %r", name, reply)
|
||||||
|
ctx.check((fc.settings().get("ext_enabled") or "0") == "0", "%s: a refused write turned extensions on", name)
|
||||||
|
ctx.check(on_disk() == start, "%s: a refused write changed the record on disk", name)
|
||||||
|
|
||||||
|
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||||
|
ev["accept"] = st
|
||||||
|
ctx.log("POST /settings ext_enabled=1 with the hash and the phrase -> %s", st)
|
||||||
|
ctx.check(st == 200, "the consent -> %s %r", st, reply)
|
||||||
|
ctx.check(fc.settings().get("ext_enabled") == "1", "ext_enabled does not read 1")
|
||||||
|
rec = on_disk()
|
||||||
|
entry = (rec.get("on_demand") or {}).get("extensions") or {}
|
||||||
|
ev["recorded"] = entry
|
||||||
|
ctx.check(entry.get("hash") == etag and entry.get("method") == "typed" and entry.get("accepted"),
|
||||||
|
"the record on disk does not carry the acceptance: %s", entry)
|
||||||
|
ctx.check("extensions" not in (rec.get("advisories") or {}),
|
||||||
|
"the acceptance landed among the first-run documents")
|
||||||
|
# a package's account walks through the data directory to its own files: the
|
||||||
|
# consent adds the search bit, and nothing else
|
||||||
|
now_mode = os.stat(data_dir()).st_mode & 0o7777
|
||||||
|
ev["data_dir_mode_after"] = "%04o" % now_mode
|
||||||
|
ctx.check(now_mode == dir_mode | 0o011, "the data directory went from %04o to %04o, expected %04o",
|
||||||
|
dir_mode, now_mode, dir_mode | 0o011)
|
||||||
|
ctx.check(rec.get("advisories") == start.get("advisories") and rec.get("acceptance") == start.get("acceptance"),
|
||||||
|
"the first-run documents or their press moved")
|
||||||
|
after = wiz(fc)
|
||||||
|
ctx.check(after.get("acceptance_done") == before.get("acceptance_done") and after.get("gate") == before.get("gate"),
|
||||||
|
"the machine's gate moved with the consent: %s", wiz_summary(after))
|
||||||
|
for form in ({"ext_enabled": "1"}, {"ext_enabled": "0"}):
|
||||||
|
st, reply = fc.post("/settings", data=form)
|
||||||
|
ctx.check(st == 200, "%s while on -> %s, expected 200", form, st)
|
||||||
|
ctx.check(fc.settings().get("ext_enabled") == "0", "ext_enabled=0 did not turn extensions off")
|
||||||
|
finally:
|
||||||
|
if prior == "":
|
||||||
|
st, reply = fc.post("/settings", params={"ext_enabled": ""})
|
||||||
|
elif prior == "1":
|
||||||
|
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||||
|
else:
|
||||||
|
st, reply = fc.post("/settings", data={"ext_enabled": prior})
|
||||||
|
ctx.log("restore ext_enabled=%r -> %s", prior, st)
|
||||||
|
if prior != "1":
|
||||||
|
os.chmod(data_dir(), dir_mode)
|
||||||
|
with ctx.takeover():
|
||||||
|
write_file(record_path(), raw)
|
||||||
|
ctx.log("the previous record is back under a restart")
|
||||||
|
|
||||||
|
ctx.check((fc.settings().get("ext_enabled") or "") == prior, "ext_enabled not restored: %r, was %r",
|
||||||
|
fc.settings().get("ext_enabled"), prior)
|
||||||
|
ctx.check(read_file(record_path()) == raw, "the record on disk is not the one found")
|
||||||
|
ctx.check(prior == "1" or os.stat(data_dir()).st_mode & 0o7777 == dir_mode, "the data directory's mode is not the one found")
|
||||||
|
final = wiz(fc)
|
||||||
|
ev["after_restore"] = wiz_summary(final)
|
||||||
|
ctx.check(final.get("acceptance_done") == before.get("acceptance_done") and final.get("gate") == before.get("gate"),
|
||||||
|
"the machine does not read as before: %s", wiz_summary(final))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------- 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 +1261,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
|
|
||||||
end = off
|
|
||||||
hops = 0
|
|
||||||
while True:
|
|
||||||
if off >= len(pkt):
|
|
||||||
raise ValueError("truncated name")
|
|
||||||
n = pkt[off]
|
|
||||||
if n == 0:
|
|
||||||
off += 1
|
|
||||||
break
|
|
||||||
if n & 0xC0 == 0xC0:
|
|
||||||
if off + 1 >= len(pkt):
|
|
||||||
raise ValueError("truncated pointer")
|
|
||||||
ptr = ((n & 0x3F) << 8) | pkt[off + 1]
|
|
||||||
if not jumped:
|
|
||||||
end = off + 2
|
|
||||||
jumped = True
|
|
||||||
off = ptr
|
|
||||||
hops += 1
|
|
||||||
if hops > 32:
|
|
||||||
raise ValueError("pointer loop")
|
|
||||||
continue
|
|
||||||
off += 1
|
|
||||||
parts.append(pkt[off:off + n].decode("ascii", "replace"))
|
|
||||||
off += n
|
|
||||||
if not jumped:
|
|
||||||
end = off
|
|
||||||
return ".".join(parts), end
|
|
||||||
|
|
||||||
|
|
||||||
def mdns_answers(pkt, qid=None):
|
|
||||||
"""The A records of a DNS response: [(name, address)]. A qid, when
|
|
||||||
given, must match the packet's id; the answer flag (QR) must be set."""
|
|
||||||
if len(pkt) < 12:
|
|
||||||
return []
|
|
||||||
pid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", pkt[:12])
|
|
||||||
if qid is not None and pid != qid:
|
|
||||||
return []
|
|
||||||
if not flags & 0x8000:
|
|
||||||
return []
|
|
||||||
off = 12
|
|
||||||
try:
|
|
||||||
for _ in range(qd):
|
|
||||||
_name, off = _dns_name(pkt, off)
|
|
||||||
off += 4
|
|
||||||
out = []
|
out = []
|
||||||
for _ in range(an + ns + ar):
|
for name in os.listdir("/proc"):
|
||||||
name, off = _dns_name(pkt, off)
|
if not name.isdigit():
|
||||||
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
|
continue
|
||||||
# a multicast answer carries id 0; the direct one echoes the query's
|
raw = read_file("/proc/%s/cmdline" % name)
|
||||||
for n, addr in mdns_answers(pkt, qid if s is tx else None):
|
if raw:
|
||||||
if n == name.lower():
|
out.append(raw.decode("utf-8", "replace").replace("\0", " ").strip())
|
||||||
found.add(addr)
|
return out
|
||||||
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",
|
@test("setup.machine-name", title="The machine names itself from its MAC address",
|
||||||
kind="auto", est_min=1,
|
subsystem="setup", 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
|
||||||
@@ -1240,6 +1423,7 @@ def first_run(ctx):
|
|||||||
except OSError:
|
except OSError:
|
||||||
homes_before = set()
|
homes_before = set()
|
||||||
state = {"temp_account": None}
|
state = {"temp_account": None}
|
||||||
|
try:
|
||||||
with Restore(ctx, FIRST_RUN_SETTINGS):
|
with Restore(ctx, FIRST_RUN_SETTINGS):
|
||||||
with installed(ctx, {record_path(): seed, users_path(): None, override_path(): b""}):
|
with installed(ctx, {record_path(): seed, users_path(): None, override_path(): b""}):
|
||||||
w = wiz(fc)
|
w = wiz(fc)
|
||||||
@@ -1248,9 +1432,13 @@ def first_run(ctx):
|
|||||||
and w.get("advisories_complete") is False and w.get("acceptance_done") is False,
|
and w.get("advisories_complete") is False and w.get("acceptance_done") is False,
|
||||||
"the machine does not read as a first run: %s", wiz_summary(w))
|
"the machine does not read as a first run: %s", wiz_summary(w))
|
||||||
m = mode(fc)
|
m = mode(fc)
|
||||||
ctx.check(m.get("controller") == "gated", "the controller is %s on a first run", m.get("controller"))
|
ctx.check(m.get("controller") == "gated", "the controller is %s on a first run",
|
||||||
|
m.get("controller"))
|
||||||
yield state
|
yield state
|
||||||
# the real records are back; the system accounts follow the record
|
finally:
|
||||||
|
# the real records are back, whether or not the block passed; the
|
||||||
|
# system accounts follow the record, and a temporary account the
|
||||||
|
# block created goes with its home
|
||||||
rc, out = hw.initd("forgefirm-users", "reload")
|
rc, out = hw.initd("forgefirm-users", "reload")
|
||||||
ev["users_reload_rc"] = rc
|
ev["users_reload_rc"] = rc
|
||||||
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
|
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
|
||||||
@@ -1283,12 +1471,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 +1567,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 +1586,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 +1622,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 +1646,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 +1681,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 +1751,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 +1762,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 +1802,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 +1823,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 +1845,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, "
|
||||||
+95
-31
@@ -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,10 +118,10 @@ 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", "interlock"],
|
||||||
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 "
|
||||||
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
|
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
|
||||||
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
|
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
|
||||||
@@ -136,47 +153,66 @@ 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
|
||||||
|
before = fc.settings() or {}
|
||||||
|
ev["settings_before"] = dict(before)
|
||||||
|
|
||||||
|
def on_prompt(p):
|
||||||
|
ctx.fail("the sensors check asked a question: %s", p)
|
||||||
|
last = run_check(ctx, "sensors", on_prompt, 120)
|
||||||
r = last.get("result") or {}
|
r = last.get("result") or {}
|
||||||
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
||||||
"exhaust_rpm_idle", "intake_rpm_idle"):
|
"exhaust_rpm_idle", "intake_rpm_idle"):
|
||||||
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
||||||
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
|
ctx.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"),
|
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"))
|
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"],
|
||||||
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
|
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
|
||||||
"for 35 s, the purge is measured on and off, and five settings are written: the "
|
"for 35 s, the purge is measured on and off, and six 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, the grace from the slowest spin-up, and the "
|
||||||
"fan must read at least 1000 rpm. The five settings are put back as found at the "
|
"intake fans fitted (2 when both intakes turn; 1 when intake 2 never reads a pulse "
|
||||||
"end and the controller must be running again.")
|
"and intake 1 passes, and then intake 2 is reported not fitted and the intake floor "
|
||||||
|
"comes from intake 1). Each fan judged must read at least 1000 rpm. The six "
|
||||||
|
"settings are put back as found at the end, the purge fan is on again and drawing "
|
||||||
|
"over the floor the check just wrote, and the controller must be running again.")
|
||||||
def check_airflow(ctx):
|
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"]
|
||||||
with Restore(ctx, keys):
|
with Restore(ctx, keys + ["cool_intake_fans"]):
|
||||||
last = run_check(ctx, "airflow", lambda p: None, 240)
|
last = run_check(ctx, "airflow", lambda p: None, 240)
|
||||||
r = last.get("result") or {}
|
r = last.get("result") or {}
|
||||||
fans = r.get("fans") or {}
|
fans = r.get("fans") or {}
|
||||||
|
intakes = r.get("intake_fans")
|
||||||
|
ctx.check(intakes in (1, 2), "the result carries no intake fan count: %r", intakes)
|
||||||
|
ctx.log("intake fans fitted: %s", intakes)
|
||||||
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
||||||
f = fans.get(name) or {}
|
f = fans.get(name) or {}
|
||||||
|
if name == "intake 2" and intakes == 1:
|
||||||
|
ctx.check(f == {"fitted": False}, "intake 2 with one intake fan: %s", f)
|
||||||
|
continue
|
||||||
ctx.check(f.get("ok") is True, "%s: %s", name, f)
|
ctx.check(f.get("ok") is True, "%s: %s", name, f)
|
||||||
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
|
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
|
||||||
floors = r.get("floors") or {}
|
floors = r.get("floors") or {}
|
||||||
@@ -185,13 +221,41 @@ def check_airflow(ctx):
|
|||||||
s = ctx.forgectrl.settings() or {}
|
s = ctx.forgectrl.settings() or {}
|
||||||
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.check(s.get("cool_intake_fans") == str(intakes),
|
||||||
|
"cool_intake_fans reads %r, the check found %r", s.get("cool_intake_fans"), intakes)
|
||||||
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 +289,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 +330,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 +348,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"),
|
||||||
+117
-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,14 @@ 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/jobrun.*"), ("forgectrl", "src/lease.*"),
|
||||||
("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 +42,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 +77,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 +111,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 +206,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 +228,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 +275,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")
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""The cipher forgectrl's HTTPS listener chooses, and where its records
|
||||||
|
are sealed.
|
||||||
|
|
||||||
|
The listener puts ChaCha20-Poly1305 first, whatever the client prefers,
|
||||||
|
and the image seals the records in the kernel: GnuTLS hands each
|
||||||
|
connection's keys to the socket after the handshake. ChaCha20-Poly1305
|
||||||
|
runs in the kernel's NEON code; AES-GCM, for a client without ChaCha20,
|
||||||
|
runs its AES on the CAAM crypto engine and its GHASH in NEON. This test
|
||||||
|
reads all of it from the outside: the cipher the listener picks for a
|
||||||
|
client that prefers AES-GCM, the kernel's TLS counters, the crypto
|
||||||
|
engine's interrupt, and the drivers the kernel resolved, with each
|
||||||
|
connection carrying the same page, which must arrive identical to the
|
||||||
|
plain-HTTP copy.
|
||||||
|
|
||||||
|
Its own module: a test's fingerprint holds the shared text of the module
|
||||||
|
it lives in, so a test added to forgectrl.py would move the fingerprint
|
||||||
|
of every test there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
|
||||||
|
OPENSSL = "/usr/bin/openssl"
|
||||||
|
ENGINE_IRQ = "2101000.jr" # the CAAM's first job ring
|
||||||
|
RECORD = 16384 # the largest TLS record's plaintext
|
||||||
|
PAGE = "/" # the panel page: large, and the same bytes every time
|
||||||
|
CHACHA_DRIVER = "rfc7539(chacha20-neon,poly1305-neon)"
|
||||||
|
# A desktop browser's offer: AES-GCM first, because its CPU has AES instructions.
|
||||||
|
DESKTOP_13 = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256"
|
||||||
|
DESKTOP_12 = "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305"
|
||||||
|
|
||||||
|
|
||||||
|
def _tls_stat():
|
||||||
|
"""The kernel's TLS counters, by name."""
|
||||||
|
out = {}
|
||||||
|
with open("/proc/net/tls_stat") as f:
|
||||||
|
for line in f:
|
||||||
|
p = line.split()
|
||||||
|
if len(p) == 2 and p[1].isdigit():
|
||||||
|
out[p[0]] = int(p[1])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _passive_opens():
|
||||||
|
"""The TCP connections the machine has accepted, on every port and both
|
||||||
|
families (the TCP counters are shared)."""
|
||||||
|
with open("/proc/net/snmp") as f:
|
||||||
|
rows = [line.split() for line in f if line.startswith("Tcp:")]
|
||||||
|
return int(rows[1][rows[0].index("PassiveOpens")])
|
||||||
|
|
||||||
|
|
||||||
|
def _engine_irqs():
|
||||||
|
with open("/proc/interrupts") as f:
|
||||||
|
for line in f:
|
||||||
|
p = line.split()
|
||||||
|
if p and p[-1] == ENGINE_IRQ and len(p) > 1 and p[1].isdigit():
|
||||||
|
return int(p[1])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _driver(name):
|
||||||
|
"""The driver answering an algorithm name: the entry of that name with
|
||||||
|
the highest priority in /proc/crypto."""
|
||||||
|
best, drv, entry = -1, None, {}
|
||||||
|
with open("/proc/crypto") as f:
|
||||||
|
lines = f.read().splitlines() + [""]
|
||||||
|
for line in lines:
|
||||||
|
if not line.strip():
|
||||||
|
if entry.get("name") == name and int(entry.get("priority", -1)) > best:
|
||||||
|
best, drv = int(entry["priority"]), entry.get("driver")
|
||||||
|
entry = {}
|
||||||
|
continue
|
||||||
|
k, _, v = line.partition(":")
|
||||||
|
entry[k.strip()] = v.strip()
|
||||||
|
return drv
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch(version, offer, work):
|
||||||
|
"""GET the page over HTTPS on loopback with the given offer. Returns
|
||||||
|
the suite, the status line, the body, the kernel counters' change,
|
||||||
|
the crypto engine's interrupts during the connection, and how many
|
||||||
|
other connections the machine accepted meanwhile."""
|
||||||
|
req = os.path.join(work, "req")
|
||||||
|
with open(req, "wb") as f:
|
||||||
|
f.write(b"GET " + PAGE.encode() + b" HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
|
||||||
|
opt = ["-tls1_3", "-ciphersuites", offer] if version == "1.3" else ["-tls1_2", "-cipher", offer]
|
||||||
|
s0, i0, p0 = _tls_stat(), _engine_irqs(), _passive_opens()
|
||||||
|
with open(req, "rb") as stdin:
|
||||||
|
r = subprocess.run([OPENSSL, "s_client", "-connect", "127.0.0.1:443", "-brief", "-ign_eof"] + opt,
|
||||||
|
stdin=stdin, capture_output=True, timeout=60)
|
||||||
|
s1, i1, p1 = _tls_stat(), _engine_irqs(), _passive_opens()
|
||||||
|
info = {}
|
||||||
|
for line in r.stderr.decode("utf-8", "replace").splitlines():
|
||||||
|
k, _, v = line.partition(":")
|
||||||
|
info[k.strip()] = v.strip()
|
||||||
|
head, _, body = r.stdout.partition(b"\r\n\r\n")
|
||||||
|
delta = {k: s1.get(k, 0) - s0.get(k, 0) for k in ("TlsTxSw", "TlsRxSw", "TlsDecryptError")}
|
||||||
|
return (info.get("Ciphersuite"), head.split(b"\r\n")[0].decode("latin-1"), body, delta,
|
||||||
|
(i1 or 0) - (i0 or 0), p1 - p0 - 1)
|
||||||
|
|
||||||
|
|
||||||
|
@test("forgectrl.tls-records", title="HTTPS: ChaCha20 chosen, records sealed in the kernel",
|
||||||
|
subsystem="forgectrl", kind="auto", est_min=1,
|
||||||
|
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c")],
|
||||||
|
description="forgectrl's HTTPS listener chooses the cipher, ChaCha20-Poly1305 first, and the "
|
||||||
|
"image seals the records in the kernel: GnuTLS hands each connection's keys to the "
|
||||||
|
"socket after the handshake (/etc/gnutls/config turns it on). A client that lists "
|
||||||
|
"AES-GCM first, as a desktop browser does, gets ChaCha20-Poly1305 over TLS 1.3 and "
|
||||||
|
"TLS 1.2, and the kernel takes both directions' keys; the kernel's rfc7539 is the "
|
||||||
|
"NEON ChaCha20 and Poly1305. A client offering AES-GCM alone gets it, the kernel "
|
||||||
|
"takes its keys, and the crypto engine's interrupt counts the records: the AES runs "
|
||||||
|
"on the CAAM (gcm(aes) is its ctr(aes) with the NEON GHASH), which writes each "
|
||||||
|
"record by DMA while the CPU fills the next, so those connections run three times "
|
||||||
|
"over each protocol and every copy must be exact. A TLS 1.2 client "
|
||||||
|
"offering a CBC suite alone still connects and stays in GnuTLS, which is the control "
|
||||||
|
"for the kernel's counters; the ChaCha20 runs are the control for the engine's "
|
||||||
|
"interrupt. Every copy of the panel page must be the plain-HTTP page byte for byte, "
|
||||||
|
"and the kernel must count no decrypt error. The counters and the interrupt are the "
|
||||||
|
"machine's, so a connection during which another client connected (a panel "
|
||||||
|
"reconnecting after a restart) is measured again. Nothing on the machine changes.")
|
||||||
|
def tls_records(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
|
||||||
|
conf = ""
|
||||||
|
if os.path.exists("/etc/gnutls/config"):
|
||||||
|
with open("/etc/gnutls/config") as f:
|
||||||
|
conf = f.read()
|
||||||
|
ctx.check(any(line.split() == ["ktls", "=", "true"] for line in conf.splitlines()),
|
||||||
|
"/etc/gnutls/config does not turn kernel TLS on: %r", conf)
|
||||||
|
with open("/proc/sys/net/ipv4/tcp_available_ulp") as f:
|
||||||
|
ulp = f.read().split()
|
||||||
|
ev["tcp_available_ulp"] = ulp
|
||||||
|
ctx.check("tls" in ulp, "the kernel has no TLS layer (tcp_available_ulp %s)", ulp)
|
||||||
|
ctx.check(_engine_irqs() is not None, "/proc/interrupts has no %s", ENGINE_IRQ)
|
||||||
|
|
||||||
|
with urllib.request.urlopen("http://127.0.0.1" + PAGE, timeout=30) as r:
|
||||||
|
plain = r.read()
|
||||||
|
ev["page_bytes"] = len(plain)
|
||||||
|
ctx.check(len(plain) > 8 * RECORD, "the page is %d bytes, too small to span records", len(plain))
|
||||||
|
records = len(plain) // RECORD
|
||||||
|
|
||||||
|
work = tempfile.mkdtemp(prefix="forgetest-tls-")
|
||||||
|
try:
|
||||||
|
runs = {}
|
||||||
|
# The AES-GCM connections run three times each: their records are written by the crypto
|
||||||
|
# engine's DMA while the CPU fills the next one, which is where a record's last bytes were
|
||||||
|
# once lost.
|
||||||
|
cases = [("desktop-1.3", "1.3", DESKTOP_13, "TLS_CHACHA20_POLY1305_SHA256", True),
|
||||||
|
("desktop-1.2", "1.2", DESKTOP_12, "ECDHE-ECDSA-CHACHA20-POLY1305", True)]
|
||||||
|
for n in (1, 2, 3):
|
||||||
|
cases += [("aes-1.3#%d" % n, "1.3", "TLS_AES_128_GCM_SHA256", "TLS_AES_128_GCM_SHA256", True),
|
||||||
|
("aes-1.2#%d" % n, "1.2", "ECDHE-ECDSA-AES128-GCM-SHA256", "ECDHE-ECDSA-AES128-GCM-SHA256",
|
||||||
|
True)]
|
||||||
|
cases.append(("cbc-1.2", "1.2", "ECDHE-ECDSA-AES128-SHA", "ECDHE-ECDSA-AES128-SHA", False))
|
||||||
|
for label, version, offer, want, kernel in cases:
|
||||||
|
# The kernel's counters and the engine's interrupt are the machine's, not the
|
||||||
|
# connection's: a window in which another client connected (a panel reconnecting
|
||||||
|
# after a restart, a page polling this suite once a second) says nothing about this
|
||||||
|
# one, and is measured again, after a pause that no steady poller stays in step with.
|
||||||
|
for attempt in range(1, 31):
|
||||||
|
suite, status, body, delta, irqs, others = _fetch(version, offer, work)
|
||||||
|
if others <= 0:
|
||||||
|
break
|
||||||
|
ctx.log("%s: %d other connection(s) during the window; measured again", label, others)
|
||||||
|
ctx.sleep(random.uniform(0.1, 0.9))
|
||||||
|
ctx.check(others <= 0, "%s: another client connected during each of %d windows", label, attempt)
|
||||||
|
run = {"offer": offer, "suite": suite, "status": status, "bytes": len(body),
|
||||||
|
"same_as_http": body == plain, "tls_stat": delta, "engine_irqs": irqs, "attempts": attempt}
|
||||||
|
runs[label] = run
|
||||||
|
ctx.log("%s: offer %s -> %s, %s, %d bytes, same %s, tls_stat %s, %s +%d",
|
||||||
|
label, offer, suite, status, len(body), body == plain, delta, ENGINE_IRQ, irqs)
|
||||||
|
ctx.check(suite == want, "%s: offered %s, the listener chose %s, not %s", label, offer, suite, want)
|
||||||
|
ctx.check(status.endswith("200 OK") and body == plain,
|
||||||
|
"%s: the page arrived %s, %d bytes, not the plain-HTTP %d", label, status, len(body),
|
||||||
|
len(plain))
|
||||||
|
ctx.check(delta["TlsDecryptError"] == 0, "%s: the kernel counted %d decrypt errors", label,
|
||||||
|
delta["TlsDecryptError"])
|
||||||
|
if kernel:
|
||||||
|
ctx.check(delta["TlsTxSw"] == 1 and delta["TlsRxSw"] == 1,
|
||||||
|
"%s: the kernel did not take both directions' keys (tls_stat %s)", label, delta)
|
||||||
|
else:
|
||||||
|
ctx.check(delta["TlsTxSw"] == 0 and delta["TlsRxSw"] == 0,
|
||||||
|
"%s: the kernel took keys for a cipher it does not seal (tls_stat %s)", label, delta)
|
||||||
|
ev["runs"] = runs
|
||||||
|
|
||||||
|
# The AES of AES-GCM runs on the crypto engine: its interrupt counts
|
||||||
|
# the records. ChaCha20 never touches the engine, so those runs are
|
||||||
|
# the control.
|
||||||
|
for label in ("aes-1.3#1", "aes-1.2#1"):
|
||||||
|
ctx.check(runs[label]["engine_irqs"] >= records // 4,
|
||||||
|
"%s: %d records crossed and the crypto engine interrupted %d times", label, records,
|
||||||
|
runs[label]["engine_irqs"])
|
||||||
|
for label in ("desktop-1.3", "desktop-1.2"):
|
||||||
|
ctx.check(runs[label]["engine_irqs"] < max(2, records // 8),
|
||||||
|
"%s: the crypto engine interrupted %d times for ChaCha20 records", label,
|
||||||
|
runs[label]["engine_irqs"])
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
|
||||||
|
# The drivers the kernel resolved for the two record ciphers (both
|
||||||
|
# exist now: the connections above instantiated them).
|
||||||
|
chacha, gcm = _driver("rfc7539(chacha20,poly1305)"), _driver("gcm(aes)")
|
||||||
|
ev["drivers"] = {"rfc7539(chacha20,poly1305)": chacha, "gcm(aes)": gcm}
|
||||||
|
ctx.log("rfc7539(chacha20,poly1305) is %s; gcm(aes) is %s", chacha, gcm)
|
||||||
|
ctx.check(chacha == CHACHA_DRIVER, "the kernel's ChaCha20-Poly1305 is %s, not %s", chacha, CHACHA_DRIVER)
|
||||||
|
ctx.check(gcm and "ctr-aes-caam" in gcm and "ghash-ce" in gcm,
|
||||||
|
"the kernel's gcm(aes) is %s: not the crypto engine's AES with the NEON GHASH", gcm)
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""The crumb tray's mode, on the machine.
|
||||||
|
|
||||||
|
Its own module, so that no other test's fingerprint moves. With the tray
|
||||||
|
out, the GRBL controller's Z is the focal height above the floor of the cut
|
||||||
|
area: its Z position and its Z range move up by tray_offset_mm on the lens
|
||||||
|
step grid, and nothing moves. The mode persists as the controller's marker
|
||||||
|
in the data directory. That it survives a controller restart is the
|
||||||
|
driver's harness case (tray_test.py): a restart here would drop the X and
|
||||||
|
Y reference, and the machine would not be handed back as it was found.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .. import hw
|
||||||
|
from .motion import (_kernel_position, _port_state, _words, clean_slate, drain_text, machine_idle)
|
||||||
|
|
||||||
|
TRAY_MARKER = "/data/forgefirm/tray.out"
|
||||||
|
LENS_SPM = 2.922 # $102: the lens screw's half-steps per mm
|
||||||
|
SETUP_CARD = "sheet.place" # the first setup card: it asks before it moves anything
|
||||||
|
|
||||||
|
|
||||||
|
def _grid_mm(offset_mm):
|
||||||
|
"""The offset on the lens step grid, the controller's way (lroundf)."""
|
||||||
|
return math.floor(offset_mm * LENS_SPM + 0.5) / LENS_SPM
|
||||||
|
|
||||||
|
|
||||||
|
def _lens(fc):
|
||||||
|
return fc.status().get("lens") or {}
|
||||||
|
|
||||||
|
|
||||||
|
def _judge(ctx, fc, ev, key, mode, found, shift):
|
||||||
|
"""The machine reads the tray `mode`, with Z and the reach moved by
|
||||||
|
`shift` mm from what the test found."""
|
||||||
|
s = fc.status()
|
||||||
|
lens, pos = s.get("lens") or {}, s.get("pos") or {}
|
||||||
|
port = _port_state(fc)
|
||||||
|
rec = ev[key] = {"lens": lens, "pos_z": pos.get("z"), "port_tray": port.get("tray"),
|
||||||
|
"port_z": port.get("mpos", [None] * 3)[2], "marker": os.path.exists(TRAY_MARKER),
|
||||||
|
"home": [s.get("homed_axes"), s.get("home_source")]}
|
||||||
|
ctx.log("%s: %s", key, rec)
|
||||||
|
ctx.check(lens.get("tray") == mode and port.get("tray") == mode,
|
||||||
|
"%s: the status says tray %s and the port %s, not %s", key, lens.get("tray"), port.get("tray"), mode)
|
||||||
|
ctx.check(rec["marker"] == (mode == "out"), "%s: the marker %s is %s with the tray %s", key, TRAY_MARKER,
|
||||||
|
"present" if rec["marker"] else "absent", mode)
|
||||||
|
for k in ("reach_min", "reach_max"):
|
||||||
|
ctx.check(lens.get(k) is not None and abs(lens[k] - (found["lens"][k] + shift)) < 0.011,
|
||||||
|
"%s: %s reads %s, not %.2f", key, k, lens.get(k), found["lens"][k] + shift)
|
||||||
|
ctx.check(pos.get("z") is not None and abs(pos["z"] - (found["pos_z"] + shift)) < 0.006,
|
||||||
|
"%s: the status Z reads %s, not %.2f", key, pos.get("z"), found["pos_z"] + shift)
|
||||||
|
ctx.check(rec["port_z"] is not None and abs(rec["port_z"] - (found["port_z"] + shift)) < 0.002,
|
||||||
|
"%s: the controller's Z reads %s, not %.3f", key, rec["port_z"], found["port_z"] + shift)
|
||||||
|
ctx.check(rec["home"] == found["home"], "%s: the reference changed across the switch: %s, not %s",
|
||||||
|
key, rec["home"], found["home"])
|
||||||
|
|
||||||
|
|
||||||
|
def _tray_route(fc, mode):
|
||||||
|
st, body = fc.post("/motion/tray", params={"tray": mode})
|
||||||
|
return st, _words(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _card_refused(ctx, fc, ev):
|
||||||
|
"""A setup card is refused while the tray is out. The machine is idle a
|
||||||
|
moment after the suite's Grbl client closes; until then the refusal is
|
||||||
|
the idle rule's. A card that started anyway is ended at once: the first
|
||||||
|
one asks before it moves anything."""
|
||||||
|
path = "/wiz/%s/start" % SETUP_CARD
|
||||||
|
deadline = time.time() + 20
|
||||||
|
while True:
|
||||||
|
st, body = fc.post(path)
|
||||||
|
words = _words(body)
|
||||||
|
if st == 200:
|
||||||
|
fc.post("/wiz/%s/abort" % SETUP_CARD)
|
||||||
|
ctx.wait_for(lambda: not (fc.get("/wiz/dark")[1] or {}).get("running"), 20, poll=0.5)
|
||||||
|
if st != 409 or "not idle" not in words or time.time() > deadline:
|
||||||
|
break
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
ev["setup_card"] = [st, words]
|
||||||
|
ctx.log("a setup card started with the tray out -> %s %s", st, words)
|
||||||
|
ctx.check(st == 409 and "crumb tray" in words, "a setup card started with the tray out -> %s %s", st, words)
|
||||||
|
|
||||||
|
|
||||||
|
@test("motion.tray", title="The crumb tray: tray out moves Z and its range up by the offset, and nothing moves",
|
||||||
|
subsystem="motion", kind="auto", mode="grbl", est_min=2,
|
||||||
|
covers=[("grblhal-glowforge", "src/glowforge_tray.*"), ("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||||
|
("grblhal-glowforge", "src/ctlport.*"), ("grblhal-glowforge", "src/glowforge_io.*"),
|
||||||
|
("forgectrl", "src/tray.*"), ("forgectrl", "src/grblport.*"), ("forgectrl", "src/status.*"),
|
||||||
|
("forgectrl", "src/main.c"), ("forgectrl", "src/wizdark.*"), ("forgectrl", "src/ui/panel.js"),
|
||||||
|
("forgectrl", "src/ui/index.html")],
|
||||||
|
requires=["motion.port-jog"],
|
||||||
|
steps=["Nothing moves; the crumb tray can stay where it is."],
|
||||||
|
description="The tray mode is switched to the other one and back, through the panel's route and "
|
||||||
|
"through M103 from a Grbl client, both ways. Each switch changes the numbers and "
|
||||||
|
"moves nothing: /status says the tray, its Z and the lens reach move by the offset on "
|
||||||
|
"the lens step grid (1.35 in is 100 half-steps, 34.22 mm), the reference and what set it "
|
||||||
|
"stand, the controller port's state "
|
||||||
|
"agrees, the marker in the data directory follows, the client is told [MSG:Tray in] or "
|
||||||
|
"[MSG:Tray out] and its next line draws ok, and the kernel's position and byte counters "
|
||||||
|
"do not move. In the other mode, a jog to the Z the lens read before the switch is "
|
||||||
|
"refused (error:15): the two Z ranges never overlap; back in the mode found, the same "
|
||||||
|
"jog is taken. A setup card started with the tray out is refused in the operator's "
|
||||||
|
"words, with nothing sent. The page "
|
||||||
|
"the panel serves carries the switch. The tray mode is put back as found in a finally. "
|
||||||
|
"The controller is not restarted: that would drop the X and Y reference. That the mode "
|
||||||
|
"survives a restart, the errors of M103, and its wait for the move before it are the "
|
||||||
|
"driver's harness cases.")
|
||||||
|
def tray(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
s0 = fc.status()
|
||||||
|
found_mode = (s0.get("lens") or {}).get("tray")
|
||||||
|
ctx.check(found_mode in ("in", "out"), "the status carries no tray mode: %s", s0.get("lens"))
|
||||||
|
other = "out" if found_mode == "in" else "in"
|
||||||
|
p_found = "P1" if found_mode == "out" else "P0"
|
||||||
|
p_other = "P1" if other == "out" else "P0"
|
||||||
|
shift = _grid_mm((s0.get("lens") or {}).get("tray_offset_mm") or 34.29) * (1 if other == "out" else -1)
|
||||||
|
machine_idle(ctx)
|
||||||
|
found = {"lens": s0.get("lens"), "pos_z": (s0.get("pos") or {}).get("z"),
|
||||||
|
"port_z": _port_state(fc)["mpos"][2], "kernel": _kernel_position(),
|
||||||
|
"home": [s0.get("homed_axes"), s0.get("home_source")]}
|
||||||
|
ev["found"] = {"tray": found_mode, "shift_mm": round(shift, 4), **found}
|
||||||
|
ctx.log("found the tray %s; switching to %s moves Z %+.3f mm", found_mode, other, shift)
|
||||||
|
ctx.check(found["pos_z"] is not None, "the status carries no Z")
|
||||||
|
|
||||||
|
st, page = fc.get("/", raw=True)
|
||||||
|
text = page.decode("utf-8", "replace") if isinstance(page, bytes) else str(page)
|
||||||
|
for want in ('id="traybtn"', "function trayToggle", "/motion/tray?tray="):
|
||||||
|
ctx.check(st == 200 and want in text, "the panel page carries no %s", want)
|
||||||
|
|
||||||
|
switched = False
|
||||||
|
try:
|
||||||
|
if found_mode == "out":
|
||||||
|
_card_refused(ctx, fc, ev)
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
clean_slate(ctx, g)
|
||||||
|
ctx.sleep(0.6) # past the port's sender-quiet interval
|
||||||
|
|
||||||
|
# Through the panel's route.
|
||||||
|
st, words = _tray_route(fc, other)
|
||||||
|
ctx.check(st == 200, "POST /motion/tray tray=%s -> %s %s", other, st, words)
|
||||||
|
switched = True
|
||||||
|
said = drain_text(g, 0.5)
|
||||||
|
ctx.check("[MSG:Tray %s]" % other in said, "the client was not told the tray is %s: %r", other, said)
|
||||||
|
_judge(ctx, fc, ev, "route_" + other, other, found, shift)
|
||||||
|
r = g.command("G4 P0")
|
||||||
|
ctx.check(r[-1:] == ["ok"], "the client's line after the switch drew %s", r)
|
||||||
|
|
||||||
|
# The Z the lens read before the switch is outside this mode's range.
|
||||||
|
jog = "$J=G90 G21 Z%.3f F200" % found["port_z"]
|
||||||
|
r = g.command(jog)
|
||||||
|
ev["jog_" + other] = [jog, r]
|
||||||
|
ctx.check(r[-1:] == ["error:15"], "with the tray %s, %s drew %s, not error:15", other, jog, r)
|
||||||
|
g.command("") # the empty line that acknowledges the error
|
||||||
|
|
||||||
|
# Back through M103, from the client.
|
||||||
|
r = g.command("M103 " + p_found)
|
||||||
|
ev["m103_" + found_mode] = r
|
||||||
|
ctx.check(r[-1:] == ["ok"] and "[MSG:Tray %s]" % found_mode in r,
|
||||||
|
"M103 %s drew %s", p_found, r)
|
||||||
|
switched = False
|
||||||
|
_judge(ctx, fc, ev, "m103_" + found_mode, found_mode, found, 0.0)
|
||||||
|
r = g.command(jog)
|
||||||
|
ev["jog_" + found_mode] = [jog, r]
|
||||||
|
ctx.check(r[-1:] == ["ok"], "with the tray %s again, %s drew %s", found_mode, jog, r)
|
||||||
|
machine_idle(ctx)
|
||||||
|
|
||||||
|
# And the other way: M103 out, the route back.
|
||||||
|
r = g.command("M103 " + p_other)
|
||||||
|
ev["m103_" + other] = r
|
||||||
|
ctx.check(r[-1:] == ["ok"] and "[MSG:Tray %s]" % other in r, "M103 %s drew %s", p_other, r)
|
||||||
|
switched = True
|
||||||
|
_judge(ctx, fc, ev, "m103_" + other, other, found, shift)
|
||||||
|
|
||||||
|
if other == "out":
|
||||||
|
_card_refused(ctx, fc, ev)
|
||||||
|
|
||||||
|
st, words = _tray_route(fc, found_mode)
|
||||||
|
ctx.check(st == 200, "POST /motion/tray tray=%s -> %s %s", found_mode, st, words)
|
||||||
|
switched = False
|
||||||
|
_judge(ctx, fc, ev, "route_" + found_mode, found_mode, found, 0.0)
|
||||||
|
finally:
|
||||||
|
if switched:
|
||||||
|
# The route is refused for a moment after the suite's Grbl client closes.
|
||||||
|
first = sys.exc_info()[1]
|
||||||
|
took = ctx.wait_for(lambda: _tray_route(fc, found_mode)[0] == 200 or None, 20, poll=0.5)
|
||||||
|
ctx.log("put the tray back %s -> %s", found_mode, "taken" if took is not None else "not taken")
|
||||||
|
ev["put_back"] = took is not None
|
||||||
|
if first is None:
|
||||||
|
ctx.check(took is not None, "the tray mode is left %s, not put back %s", other, found_mode)
|
||||||
|
machine_idle(ctx)
|
||||||
|
k1 = _kernel_position()
|
||||||
|
ev["kernel"] = [found["kernel"], k1]
|
||||||
|
ctx.check(k1 == found["kernel"], "the kernel moved across the switches: %s -> %s", found["kernel"], k1)
|
||||||
|
ctx.log("PASS: the tray mode switched both ways through the route and through M103, Z and the reach "
|
||||||
|
"moved by %.3f mm and back, nothing moved, and it is %s as found", abs(shift), found_mode)
|
||||||
@@ -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,158 @@ def slots_and_signature(ctx):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
shutil.rmtree(work, ignore_errors=True)
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@test("update.product-gate", title="The firmware upload takes firmware only: an extension package is refused by name",
|
||||||
|
subsystem="update", kind="auto", est_min=1,
|
||||||
|
covers=_UPDATE_COVERS + [("forgectrl", "src/fwproduct.c"), ("forgectrl", "src/fwproduct.h")],
|
||||||
|
requires=["forgectrl.auth"],
|
||||||
|
description="Firmware and extension packages are the same container, and a signature says who "
|
||||||
|
"made an archive, never what it is. Archives made on the spot with the machine's own "
|
||||||
|
"fwup go to POST /update/upload: an extension package unsigned, one signed with a "
|
||||||
|
"throwaway key, and one that carries a task are each refused with 400 in words that "
|
||||||
|
"say what they are, and nothing is staged; an archive of some other product is "
|
||||||
|
"refused as not firmware; and one whose product is ForgeFIRM firmware is taken as an "
|
||||||
|
"unsigned upload, which is what it is. Nothing is applied: the apply calls the same "
|
||||||
|
"gate before fwup -a, and its proof is the daemon's host test, because a catalog test "
|
||||||
|
"that could fail there would write a slot.")
|
||||||
|
def product_gate(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
staged = "/data/forgefirm/upload.fw"
|
||||||
|
ctx.check(not os.path.exists(staged), "an upload is already staged: %s", staged)
|
||||||
|
work = tempfile.mkdtemp(prefix="forgetest-gate-")
|
||||||
|
|
||||||
|
def archive(name, product, task=False, sign=False):
|
||||||
|
conf = os.path.join(work, name + ".conf")
|
||||||
|
with open(conf, "w") as f:
|
||||||
|
f.write('meta-product = "%s"\nmeta-version = "0.0.0-test"\n'
|
||||||
|
'file-resource payload.tar.gz { host-path = "payload.tar.gz" }\n' % product)
|
||||||
|
if task:
|
||||||
|
f.write('task upgrade.a { on-resource payload.tar.gz { raw_write(0) } }\n')
|
||||||
|
out = name + ".fw"
|
||||||
|
cmd = "cd %s && fwup -c -f %s.conf -o %s" % (work, name, out)
|
||||||
|
if sign:
|
||||||
|
cmd += " && fwup -S -s fwup-key.priv -i %s -o %s.signed && mv %s.signed %s" % (out, out, out, out)
|
||||||
|
rc, text = hw.run(["sh", "-c", cmd], timeout=60)
|
||||||
|
ctx.check(rc == 0, "could not make %s (rc %s): %s", name, rc, text.strip()[:200])
|
||||||
|
return os.path.join(work, out)
|
||||||
|
|
||||||
|
def upload(path):
|
||||||
|
mark = "forgetestGateBoundary9c2"
|
||||||
|
body = ('--%s\r\nContent-Disposition: form-data; name="file"; filename="%s"\r\n'
|
||||||
|
'Content-Type: application/octet-stream\r\n\r\n' % (mark, os.path.basename(path))).encode()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
body += f.read() + ("\r\n--%s--\r\n" % mark).encode()
|
||||||
|
return fc.post("/update/upload", data=body,
|
||||||
|
headers={"Content-Type": "multipart/form-data; boundary=%s" % mark})
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(os.path.join(work, "payload.tar.gz"), "wb") as f:
|
||||||
|
f.write(b"forgetest product-gate drill\n" * 8)
|
||||||
|
rc, text = hw.run(["sh", "-c", "cd %s && fwup -g" % work], timeout=60)
|
||||||
|
ctx.check(rc == 0, "fwup -g did not make a key pair (rc %s): %s", rc, text.strip()[:200])
|
||||||
|
seen = {}
|
||||||
|
for name, product, task, sign in (("extension-unsigned", "ForgeFIRM extension", False, False),
|
||||||
|
("extension-signed", "ForgeFIRM extension", False, True),
|
||||||
|
("extension-with-task", "ForgeFIRM extension", True, False)):
|
||||||
|
st, body = upload(archive(name, product, task, sign))
|
||||||
|
words = body.get("error", "") if isinstance(body, dict) else str(body)
|
||||||
|
seen[name] = [st, words]
|
||||||
|
ctx.log("upload %s -> %s %s", name, st, words)
|
||||||
|
ctx.check(st == 400 and "an extension package, not firmware" in words,
|
||||||
|
"%s at the firmware upload -> %s %s", name, st, words)
|
||||||
|
ctx.check(not os.path.exists(staged), "%s was refused and is staged all the same", name)
|
||||||
|
st, body = upload(archive("another-product", "forgetest"))
|
||||||
|
words = body.get("error", "") if isinstance(body, dict) else str(body)
|
||||||
|
seen["another-product"] = [st, words]
|
||||||
|
ctx.log("upload another-product -> %s %s", st, words)
|
||||||
|
ctx.check(st == 400 and "not firmware" in words and not os.path.exists(staged),
|
||||||
|
"an archive of another product -> %s %s", st, words)
|
||||||
|
st, body = upload(archive("firmware-unsigned", "ForgeFIRM firmware"))
|
||||||
|
seen["firmware-unsigned"] = [st, body]
|
||||||
|
ctx.log("upload firmware-unsigned -> %s %s", st, body)
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict) and body.get("signature") == "unsigned"
|
||||||
|
and os.path.exists(staged), "an unsigned archive that is firmware -> %s %s", st, body)
|
||||||
|
ev["uploads"] = seen
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(staged)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
ctx.log("PASS: three extension packages and one foreign product refused at the firmware upload with "
|
||||||
|
"nothing staged; firmware taken as the unsigned upload it is")
|
||||||
|
|
||||||
|
|
||||||
|
_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)
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""An update job holds the machine, on the machine.
|
||||||
|
|
||||||
|
Its own module, so that no other test's fingerprint moves. The job is a
|
||||||
|
probe download of the published release: the download job itself, run on
|
||||||
|
the release's acceptance record instead of its firmware file, because
|
||||||
|
GitHub counts every request of the firmware file as an install. The
|
||||||
|
verification refuses the record and the probe keeps nothing; a download
|
||||||
|
the machine had staged is left as it was.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .motion import _job_post, _words
|
||||||
|
|
||||||
|
DL_FW = "/data/forgefirm/download.fw"
|
||||||
|
DL_PROBE = "/data/forgefirm/download-probe"
|
||||||
|
HELD = "an update job (download) holds the machine"
|
||||||
|
|
||||||
|
|
||||||
|
def _as_found(path):
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
|
return [st.st_size, st.st_mtime_ns]
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@test("update.job-locks", title="An update job holds the machine: the settings are locked while it runs",
|
||||||
|
subsystem="update", kind="auto", est_min=2,
|
||||||
|
covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/relcheck.*"), ("forgectrl", "src/lease.*")],
|
||||||
|
requires=["update.release-check"],
|
||||||
|
description="With a release published on the releases API, POST /update/download?probe=1 starts the "
|
||||||
|
"download job (202) on the release's acceptance record in place of its firmware file, so "
|
||||||
|
"the test adds nothing to the firmware file's download count. The machine lease is taken "
|
||||||
|
"before the 202: a settings write and a posted job right after it are each refused in the "
|
||||||
|
"job's name (409, an update job (download) holds the machine, the settings locked), and "
|
||||||
|
"/status names the job (update:download, of kind system) as the holder while it runs. The "
|
||||||
|
"job fetches the record through the download's own path and ends refused by the signature "
|
||||||
|
"check with the record discarded. When it ends, the lease is free and the same settings "
|
||||||
|
"write is taken. A download the machine had staged is untouched and the probe leaves no "
|
||||||
|
"file. That a log export does not lock the settings while every other holder does is "
|
||||||
|
"lease_test's; that a probe never names the firmware file is relcheck_test's.")
|
||||||
|
def job_locks(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
|
||||||
|
st, rel = fc.get("/update/release")
|
||||||
|
if st == 200 and isinstance(rel, dict) and not rel.get("checked"):
|
||||||
|
# the kept answer does not outlive a restart of the daemon: ask now
|
||||||
|
st, rel = fc.post("/update/check")
|
||||||
|
ctx.log("the release was not checked since the daemon started: POST /update/check -> %s", st)
|
||||||
|
ev["release"] = {k: (rel or {}).get(k) for k in ("available", "version")} if isinstance(rel, dict) else rel
|
||||||
|
ctx.check(st == 200 and isinstance(rel, dict) and rel.get("available"), "no release is published to probe: %s", rel)
|
||||||
|
staged = _as_found(DL_FW)
|
||||||
|
ev["staged_download"] = staged
|
||||||
|
ctx.check(_as_found(DL_PROBE) is None, "a probe's file is left over from before: %s", DL_PROBE)
|
||||||
|
units = fc.settings().get("ui_units") or "metric"
|
||||||
|
|
||||||
|
def holder():
|
||||||
|
return ((fc.status().get("lease") or {}).get("holder") or {})
|
||||||
|
|
||||||
|
def job():
|
||||||
|
st_, j = fc.get("/update/status")
|
||||||
|
return j if isinstance(j, dict) else {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# The record is small, so the job can end within a second. The
|
||||||
|
# lease is taken before the 202, so the settings write goes first
|
||||||
|
# and at once: it is the same value, so a probe that ended before
|
||||||
|
# it only costs another probe. The posted job goes only while the
|
||||||
|
# settings are refused in the job's name.
|
||||||
|
refused = None
|
||||||
|
for attempt in (1, 2, 3):
|
||||||
|
st, body = fc.post("/update/download", params={"probe": "1"})
|
||||||
|
ctx.check(st == 202, "POST /update/download probe=1 -> %s %s", st, _words(body)[:200])
|
||||||
|
st, body = fc.post("/settings", params={"ui_units": units})
|
||||||
|
if st == 200:
|
||||||
|
ctx.wait_for(lambda: job().get("running") is False or None, 120, poll=0.5)
|
||||||
|
result = job().get("result") or {}
|
||||||
|
ctx.log("probe %d ended before the settings write was read (%s); again", attempt, result)
|
||||||
|
ctx.check("signature verification failed" in str(result.get("error")),
|
||||||
|
"the probe ended, and not refused by the signature check: %s", result)
|
||||||
|
continue
|
||||||
|
st2, body2 = _job_post(fc, "G21\n", name="beside-an-update")
|
||||||
|
held = holder()
|
||||||
|
refused = {"a settings write": [st, _words(body)[:200]], "a posted job": [st2, _words(body2)[:200]]}
|
||||||
|
break
|
||||||
|
ctx.check(refused is not None, "three probes each ended before a settings write could be refused")
|
||||||
|
ev["refused"] = refused
|
||||||
|
ev["holder"] = held
|
||||||
|
ctx.log("beside the probe: %s; the lease: %s", refused, held)
|
||||||
|
ctx.check(st == 409 and HELD in _words(body) and "settings are locked" in _words(body),
|
||||||
|
"a settings write beside the probe -> %s %s", st, _words(body)[:200])
|
||||||
|
ctx.check(st2 == 409 and HELD in _words(body2), "a job beside the probe -> %s %s", st2, _words(body2)[:200])
|
||||||
|
if held:
|
||||||
|
ctx.check(held.get("owner") == "update:download" and held.get("kind") == "system",
|
||||||
|
"the probe does not hold the machine as the download job: %s", held)
|
||||||
|
else:
|
||||||
|
ctx.log("the probe had ended before /status was read; its refusals name it")
|
||||||
|
ctx.wait_for(lambda: job().get("running") is False or None, 600, poll=1.0)
|
||||||
|
done = job()
|
||||||
|
ev["job"] = done
|
||||||
|
ctx.log("the probe ended: %s", done)
|
||||||
|
result = done.get("result") or {}
|
||||||
|
ctx.check(result.get("ok") is False and "signature verification failed" in str(result.get("error")),
|
||||||
|
"the probe did not end refused by the signature check (the record fetched and discarded): %s",
|
||||||
|
result)
|
||||||
|
ctx.check(not holder(), "the lease was not given back: %s", holder())
|
||||||
|
st, body = fc.post("/settings", params={"ui_units": units})
|
||||||
|
ctx.check(st == 200, "the same settings write after the job -> %s %s", st, _words(body)[:200])
|
||||||
|
finally:
|
||||||
|
# The job runs on after a check that failed: its end is waited for.
|
||||||
|
ctx.wait_for(lambda: job().get("running") is False or None, 600, poll=2.0)
|
||||||
|
ctx.check(_as_found(DL_FW) == staged, "the staged download moved: %s, and %s before", _as_found(DL_FW), staged)
|
||||||
|
ctx.check(_as_found(DL_PROBE) is None, "the probe left its file: %s", DL_PROBE)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user