mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
201
Commits
v0.0.3
...
c40dc6c24c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c40dc6c24c | ||
|
|
08c7df9cbb | ||
|
|
61652e237d | ||
|
|
c177aabefa | ||
|
|
01f59914e8 | ||
|
|
87155be433 | ||
|
|
893602e44c | ||
|
|
205f9f22f2 | ||
|
|
7f2da68e34 | ||
|
|
0d63f8740e | ||
|
|
558889659c | ||
|
|
4c6ca8d701 | ||
|
|
f9c298a347 | ||
|
|
754c239e08 | ||
|
|
b3a40b4aea | ||
|
|
107676b77d | ||
|
|
6594f57362 | ||
|
|
40c478d56c | ||
|
|
70e13bdd88 | ||
|
|
b90299acdc | ||
|
|
30c0a05b86 | ||
|
|
cfa6557847 | ||
|
|
b5f355cb61 | ||
|
|
b447ae77f1 | ||
|
|
21046cd6ff | ||
|
|
098cfdf193 | ||
|
|
1e28eb92cd | ||
|
|
33e8c6e43f | ||
|
|
71556b2480 | ||
|
|
7172bba92b | ||
|
|
8e1fe5090d | ||
|
|
520d62ac05 | ||
|
|
8536ae7076 | ||
|
|
09cf9242a9 | ||
|
|
280ceee849 | ||
|
|
8149a44f93 | ||
|
|
c91f6daa9e | ||
|
|
f8dc21b309 | ||
|
|
a0a4edb322 | ||
|
|
67ac8f4064 | ||
|
|
0350b943d7 | ||
|
|
35add7cad0 | ||
|
|
d7379d9fc0 | ||
|
|
21f53e4e83 | ||
|
|
ef15c55577 | ||
|
|
571f702113 | ||
|
|
6a434bbf77 | ||
|
|
bef1bf9902 | ||
|
|
7246707df5 | ||
|
|
ed1322518b | ||
|
|
8003d6f18c | ||
|
|
a9c794be34 | ||
|
|
1191389a2b | ||
|
|
4c5dde2549 | ||
|
|
005ff8008b | ||
|
|
748d28686d | ||
|
|
ecbfe64cef | ||
|
|
a729197a8f | ||
|
|
ed52e752ee | ||
|
|
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 |
@@ -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'
|
||||||
|
|||||||
@@ -11,15 +11,24 @@
|
|||||||
# release with source missing
|
# release with source missing
|
||||||
# - shared UI files: theme.css and the vendored Bootstrap the page
|
# - shared UI files: theme.css and the vendored Bootstrap the page
|
||||||
# carries are byte-identical to forgectrl's at its pinned revision
|
# carries are byte-identical to forgectrl's at its pinned revision
|
||||||
|
# - the extension key: the public key the image installs for the official
|
||||||
|
# tier is byte-identical to the one the extension catalog publishes
|
||||||
|
# (keys/forgefirm-ext.pub on its main branch)
|
||||||
|
# - the catalog's address: EXTPKG_INDEX_URL in the pinned forgectrl's
|
||||||
|
# src/extpkg.h is the catalog's latest-release download of the file its
|
||||||
|
# tools/publish.py passes to gh release create; read, never requested
|
||||||
|
# (every request of it is counted as a download)
|
||||||
# - coverage lint: every source path of every component pinned by the
|
# - coverage lint: every source path of every component pinned by the
|
||||||
# recipes must be selected by some catalog test's coverage globs (the
|
# recipes must be selected by some catalog test's coverage globs or by a
|
||||||
# tree manifest is generated from the pins with git - no Yocto build);
|
# host-tested row naming the host test that proves it (the tree manifest
|
||||||
# enforced: an uncovered path fails the job (https://docs.forgefirm.org/developers/acceptance/).
|
# is generated from the pins with git - no Yocto build); enforced: an
|
||||||
|
# uncovered path fails the job (https://docs.forgefirm.org/developers/testing/).
|
||||||
|
|
||||||
name: forgetest-ci
|
name: forgetest-ci
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [master]
|
||||||
paths:
|
paths:
|
||||||
- 'forgetest/**'
|
- 'forgetest/**'
|
||||||
- 'scripts/acceptance-gate.py'
|
- 'scripts/acceptance-gate.py'
|
||||||
@@ -48,6 +57,13 @@ jobs:
|
|||||||
ref: scarthgap
|
ref: scarthgap
|
||||||
path: meta-openglow
|
path: meta-openglow
|
||||||
|
|
||||||
|
- name: Checkout forgefirm-extensions-catalog (sibling)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: openglow-org/forgefirm-extensions-catalog
|
||||||
|
ref: main
|
||||||
|
path: forgefirm-extensions-catalog
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -57,6 +73,68 @@ jobs:
|
|||||||
working-directory: forgefirm
|
working-directory: forgefirm
|
||||||
run: python scripts/manifest-from-tree.py --out tree-manifest.json
|
run: python scripts/manifest-from-tree.py --out tree-manifest.json
|
||||||
|
|
||||||
|
- name: The image's extension key is the catalog's published key
|
||||||
|
run: |
|
||||||
|
image=forgefirm/meta-forgefirm/recipes-forgefirm/forgefirm-keys/files/ext/forgefirm-ext.pub
|
||||||
|
published=forgefirm-extensions-catalog/keys/forgefirm-ext.pub
|
||||||
|
for f in "$image" "$published"; do
|
||||||
|
test -f "$f" || { echo "missing: $f"; exit 1; }
|
||||||
|
done
|
||||||
|
cmp -s "$image" "$published" || { echo "$image is not $published"; exit 1; }
|
||||||
|
|
||||||
|
- name: The catalog's address in the pinned forgectrl is the file the catalog publishes (read, never requested)
|
||||||
|
run: |
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import ast, glob, os, re, subprocess, sys
|
||||||
|
|
||||||
|
PREFIX = "https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/"
|
||||||
|
|
||||||
|
def fail(why):
|
||||||
|
print(why)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# forgectrl's pin, and its src/extpkg.h from the tree manifest's cache
|
||||||
|
pin = open("forgefirm/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc").read()
|
||||||
|
m = re.search(r'^SRCREV = "([0-9a-f]{40})"$', pin, re.M)
|
||||||
|
if not m:
|
||||||
|
fail("forgectrl-pin.inc names no SRCREV")
|
||||||
|
rev = m.group(1)
|
||||||
|
header = None
|
||||||
|
for repo in sorted(glob.glob("forgefirm/.manifest-cache/*")):
|
||||||
|
p = subprocess.run(["git", "-C", repo, "show", rev + ":src/extpkg.h"], capture_output=True, text=True)
|
||||||
|
if p.returncode == 0:
|
||||||
|
header = p.stdout
|
||||||
|
break
|
||||||
|
if header is None:
|
||||||
|
fail("forgectrl %s is not in forgefirm/.manifest-cache" % rev)
|
||||||
|
m = re.search(r'#define\s+EXTPKG_INDEX_URL\s*(?:\\\n\s*)?"([^"]*)"', header)
|
||||||
|
if not m:
|
||||||
|
fail("forgectrl %s's src/extpkg.h defines no EXTPKG_INDEX_URL" % rev[:7])
|
||||||
|
url = m.group(1)
|
||||||
|
|
||||||
|
# the file the catalog's publish tool passes to gh release create
|
||||||
|
tree = ast.parse(open("forgefirm-extensions-catalog/tools/publish.py").read())
|
||||||
|
assigned = {n.targets[0].id: n.value for n in ast.walk(tree)
|
||||||
|
if isinstance(n, ast.Assign) and len(n.targets) == 1 and isinstance(n.targets[0], ast.Name)}
|
||||||
|
|
||||||
|
def file_name(v):
|
||||||
|
if isinstance(v, ast.Name):
|
||||||
|
v = assigned.get(v.id)
|
||||||
|
if isinstance(v, ast.Call) and v.args:
|
||||||
|
v = v.args[-1]
|
||||||
|
return os.path.basename(v.value) if isinstance(v, ast.Constant) and isinstance(v.value, str) else None
|
||||||
|
|
||||||
|
names = [file_name(n.args[3]) if len(n.args) > 3 else None for n in ast.walk(tree)
|
||||||
|
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "gh"
|
||||||
|
and [a.value for a in n.args[:2] if isinstance(a, ast.Constant)] == ["release", "create"]]
|
||||||
|
if len(names) != 1 or not names[0]:
|
||||||
|
fail("tools/publish.py passes no one file to gh release create: %s" % names)
|
||||||
|
|
||||||
|
print("forgectrl %s: %s; the catalog publishes %s" % (rev[:7], url, names[0]))
|
||||||
|
if url != PREFIX + names[0]:
|
||||||
|
fail("the address is not %s" % (PREFIX + names[0]))
|
||||||
|
EOF
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
working-directory: forgefirm/forgetest
|
working-directory: forgefirm/forgetest
|
||||||
# -B: no bytecode caches under the package directory (the recipe's fetch
|
# -B: no bytecode caches under the package directory (the recipe's fetch
|
||||||
|
|||||||
@@ -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,6 @@ 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 controller gate until the next reboot (/run is tmpfs), so a
|
|
||||||
# campaign runs on a machine whose required wizards are not complete.
|
|
||||||
# Nothing lifts the agreements or the account: the bench accepts them
|
|
||||||
# once, and that stays in /data.
|
|
||||||
mkdir -p /run/forgefirm
|
|
||||||
: > /run/forgefirm/commissioning-override
|
|
||||||
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
|
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)
|
||||||
|
|
||||||
|
|||||||
+192
-27
@@ -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
|
||||||
@@ -14,7 +19,9 @@ restores it after (on every exit path), so a test cannot hand the next one
|
|||||||
restored where the interface allows.
|
restored where the interface allows.
|
||||||
|
|
||||||
The lid lamp is fixed too, at forgectrl's `lid_lamp_idle` setting (unset =
|
The lid lamp is fixed too, at forgectrl's `lid_lamp_idle` setting (unset =
|
||||||
236): the daemon asserts it at start and at every controller spawn.
|
236): the daemon asserts it at start and at every controller spawn. The
|
||||||
|
lamp fades toward a level written to it, so a level written a moment
|
||||||
|
before the check is read again until it holds before it is judged.
|
||||||
|
|
||||||
The controller mode in force decides what the baseline owns. In GRBL mode
|
The controller mode in force decides what the baseline owns. In GRBL mode
|
||||||
everything above applies. In cloud mode the cloud client owns what it
|
everything above applies. In cloud mode the cloud client owns what it
|
||||||
@@ -36,6 +43,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 +56,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 = [
|
||||||
@@ -111,6 +119,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 = [
|
||||||
@@ -128,11 +142,21 @@ PRESERVED_SYSFS = [] # sysfs attrs captured before, restored afte
|
|||||||
|
|
||||||
LID_LAMP_ATTR = "pic/lid_led"
|
LID_LAMP_ATTR = "pic/lid_led"
|
||||||
LID_LAMP_DEFAULT = "236" # forgectrl's lid_lamp_idle default
|
LID_LAMP_DEFAULT = "236" # forgectrl's lid_lamp_idle default
|
||||||
|
# The lid LED runs on the kernel's "smooth" trigger: a level written to it
|
||||||
|
# is reached by a fade over a fraction of a second, so a read right after
|
||||||
|
# a write (the supervisor re-applies the idle level at every controller
|
||||||
|
# spawn) finds the lamp part-way there: on the bench reference, 232 read
|
||||||
|
# 0.05 s after the supervisor re-applies 236. The lamp is read again every
|
||||||
|
# LID_LAMP_POLL_S until it holds the level, for at most LID_LAMP_SETTLE_S,
|
||||||
|
# before it is called a leftover.
|
||||||
|
LID_LAMP_SETTLE_S = 3.0
|
||||||
|
LID_LAMP_POLL_S = 0.1
|
||||||
|
|
||||||
BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon after boot
|
BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon after boot
|
||||||
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
|
||||||
|
|
||||||
@@ -147,11 +171,12 @@ RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most th
|
|||||||
POSITION_DEADBAND_MM = 0.1
|
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
|
||||||
@@ -175,7 +200,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)}
|
||||||
@@ -207,6 +232,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:
|
||||||
@@ -217,17 +259,25 @@ def read_position():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def position_quantized(was, now):
|
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
|
"""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
|
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
|
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
|
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
|
of a millimeter, which is a step or four, and whether that rounds to
|
||||||
the same integer is chance."""
|
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]:
|
if was is None or now is None or now[2] != was[2]:
|
||||||
return False
|
return False
|
||||||
return (abs(now[0] - was[0]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM and
|
return (abs(now[0] - was[0]) / spm <= POSITION_DEADBAND_MM and
|
||||||
abs(now[1] - was[1]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM)
|
abs(now[1] - was[1]) / spm <= POSITION_DEADBAND_MM)
|
||||||
|
|
||||||
|
|
||||||
def read_ring_residue():
|
def read_ring_residue():
|
||||||
@@ -257,6 +307,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
|
||||||
@@ -321,7 +376,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)."""
|
||||||
@@ -430,7 +485,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")
|
||||||
@@ -450,9 +506,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)))
|
||||||
@@ -596,13 +656,37 @@ 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"))
|
||||||
idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731
|
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 {})
|
||||||
|
# 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
|
# Armed, or holding, is the run's doing. A phase alone is
|
||||||
# not: the engine clears smoke at run duty after an armed
|
# not: the engine clears smoke at run duty after an armed
|
||||||
# session and cools down after a hot one, both timed and
|
# session and cools down after a hot one, both timed and
|
||||||
@@ -614,7 +698,7 @@ class Baseline:
|
|||||||
if w is not None and not dirt:
|
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 "
|
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
|
||||||
"work, not a leftover" % (found, w))
|
"work, not a leftover" % (found, w))
|
||||||
else:
|
return
|
||||||
# The engine holds for a job. A run that ended without
|
# The engine holds for a job. A run that ended without
|
||||||
# ending its job leaves one alive, and no amount of
|
# ending its job leaves one alive, and no amount of
|
||||||
# waiting ends it: stand the machine down and let the
|
# waiting ends it: stand the machine down and let the
|
||||||
@@ -627,6 +711,37 @@ class Baseline:
|
|||||||
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
||||||
act 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).
|
||||||
In cloud mode the cloud client owns the lamp (its lid-image level)."""
|
In cloud mode the cloud client owns the lamp (its lid-image level)."""
|
||||||
@@ -639,13 +754,34 @@ class Baseline:
|
|||||||
got = hw.sysfs_read(LID_LAMP_ATTR)
|
got = hw.sysfs_read(LID_LAMP_ATTR)
|
||||||
if got is None or got == want:
|
if got is None or got == want:
|
||||||
return
|
return
|
||||||
|
held, now = self._lamp_holds(want)
|
||||||
|
if held:
|
||||||
|
self.log("lid lamp: read %s, then held %s; the fade toward a level just written, "
|
||||||
|
"not a leftover" % (got, want))
|
||||||
|
return
|
||||||
|
found = now if now is not None else got
|
||||||
try:
|
try:
|
||||||
hw.sysfs_write(LID_LAMP_ATTR, want)
|
hw.sysfs_write(LID_LAMP_ATTR, want)
|
||||||
back = hw.sysfs_read(LID_LAMP_ATTR)
|
held, back = self._lamp_holds(want)
|
||||||
act = "restored" if back == want else "failed: reads %s" % back
|
act = "restored" if held else "failed: reads %s" % back
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
act = "failed: %s" % e
|
act = "failed: %s" % e
|
||||||
left.append(Leftover(LID_LAMP_ATTR, got, want, act))
|
left.append(Leftover(LID_LAMP_ATTR, found, want, act))
|
||||||
|
|
||||||
|
def _lamp_holds(self, want):
|
||||||
|
"""Read the lid lamp every LID_LAMP_POLL_S, for at most
|
||||||
|
LID_LAMP_SETTLE_S, until it holds `want`: two reads in a row at
|
||||||
|
it, so a fade that only passes through the level does not count.
|
||||||
|
Returns (held, the last read)."""
|
||||||
|
last = prev = None
|
||||||
|
for _ in range(max(2, int(round(LID_LAMP_SETTLE_S / LID_LAMP_POLL_S)))):
|
||||||
|
if self.abort():
|
||||||
|
break
|
||||||
|
time.sleep(LID_LAMP_POLL_S)
|
||||||
|
prev, last = last, hw.sysfs_read(LID_LAMP_ATTR)
|
||||||
|
if last == want and prev == want:
|
||||||
|
return True, last
|
||||||
|
return False, last
|
||||||
|
|
||||||
def _kernel_side(self, left):
|
def _kernel_side(self, left):
|
||||||
if hw.sysfs_read("cnc/state") is None:
|
if hw.sysfs_read("cnc/state") is None:
|
||||||
@@ -693,13 +829,14 @@ class Baseline:
|
|||||||
self.log("leds/%s: target 0, brightness %s; the fade from a level the machine "
|
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")))
|
"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:
|
||||||
@@ -786,13 +923,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
|
||||||
act = self._return_head(was, now)
|
# wherever the head then stood. Unless the test said where that was
|
||||||
if position_quantized(was, now):
|
# (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, "
|
self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, "
|
||||||
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
|
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
|
||||||
else:
|
else:
|
||||||
left.append(Leftover("position", now, was, act))
|
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.
|
||||||
|
|
||||||
@@ -252,6 +257,64 @@ TOOLS = [
|
|||||||
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
|
"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 "
|
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
|
||||||
"bench-page tool."},
|
"bench-page tool."},
|
||||||
|
{"id": "ctlport-test", "title": "Controller port and line multiplexer harness", "script": "ctlport_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "A scripted sender that counts every response, beside the controller port's one client, on the "
|
||||||
|
"null-sink controller: a port jog's status goes to the port and never to the sender, a port "
|
||||||
|
"error does not reach the sender's parser, a sender line cancels a port jog and gets its own ok "
|
||||||
|
"(a line queued right behind the port's included), the refusals, the single client, a CR LF "
|
||||||
|
"sender, a soft reset, and the client as the dead-man. A CI harness (the grblHAL repo): needs "
|
||||||
|
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||||
|
{"id": "cool-report-test", "title": "Cooling report secret harness", "script": "cool_report_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "The controller's cooling reports on the null-sink controller, read by a stand-in for "
|
||||||
|
"forgectrl's listener: every report carries the secret the supervisor hands the controller at "
|
||||||
|
"its spawn (GF_REPORT_SECRET), none does when there is none, a value that is not 32 hex digits "
|
||||||
|
"(a CR LF with a header behind it included) never reaches the wire, and the homing runner the "
|
||||||
|
"controller starts is handed it (it reports in the controller's place during a gfcloud $H), "
|
||||||
|
"and none when the controller has none. A CI harness (the grblHAL repo): needs the host-built "
|
||||||
|
"null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||||
|
{"id": "mcode-test", "title": "Package M-code barrier harness", "script": "mcode_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "The M-codes extension packages answer (M160 to M179), on the null-sink controller with a "
|
||||||
|
"scripted sender and a stand-in for the machine daemon on the port: the table and its refusals, "
|
||||||
|
"a number nothing answers is error:20 where it is parsed, an answered one waits with the head "
|
||||||
|
"still and a port jog refused while the port's state names it, the answer's words, an answer "
|
||||||
|
"that the work was not done and no answer in time each hold the job, a soft reset ends the "
|
||||||
|
"wait, and a wait under an open armed window with M3 S500 ships no FIRE tick. Imports its "
|
||||||
|
"sender and port client from ctlport_test.py. A CI harness (the grblHAL repo): needs the "
|
||||||
|
"host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||||
|
{"id": "envelope-test", "title": "Measured envelope harness", "script": "envelope_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "The measured work envelope and the bed check's port op, on the null-sink controller with a "
|
||||||
|
"scripted sender and a port client: unset keys are the axis travel, envelope_x_mm and _y are the "
|
||||||
|
"far edges at a home and are held to 50 mm up to the travel plus 30 mm, the port's envelope open "
|
||||||
|
"is refused before a home and under an open armed window and lets the port's jogs reach the "
|
||||||
|
"travel plus 30 mm, envelope apply puts the keys' edges back, and the sender's first line (never "
|
||||||
|
"a status poll or an empty line), a soft reset, and the port client going away each close an "
|
||||||
|
"open envelope, the sender's line only after it closed. Imports its sender and port client from "
|
||||||
|
"ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink controller, "
|
||||||
|
"not the machine, so it is not a bench-page tool."},
|
||||||
|
{"id": "manual-home-test", "title": "Manual home and motor release harness", "script": "manual_home_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "The manual homing provider and the motor release on the null-sink controller: $H under manual "
|
||||||
|
"ships nothing and declares X0 Y0 with the soft limits on and Z kept; while the motors are "
|
||||||
|
"released every motion source is refused and ships nothing, $X and a soft reset do not unlock "
|
||||||
|
"it, and only $ME and a manual $H write the energize, once each; the port's panel operations "
|
||||||
|
"do the same with the sender's count exact. Imports its sender and port client from "
|
||||||
|
"ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink controller, "
|
||||||
|
"not the machine, so it is not a bench-page tool."},
|
||||||
|
{"id": "tray-test", "title": "Crumb tray mode harness", "script": "tray_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "The crumb tray's mode on the null-sink controller: M103 P1 and P0 move Z and its envelope by "
|
||||||
|
"tray_offset_mm on the lens step grid, tell the sender, write and remove the marker beside the "
|
||||||
|
"shared config, and ship no step; a missing P, a P other than 0 or 1 and an axis word are "
|
||||||
|
"errors; the switch waits for the move before it; the port's tray op, and its refusals during "
|
||||||
|
"a program and at a package's M-code; the mode survives a soft reset and a restart, and a start "
|
||||||
|
"over the marker references Z in the tray-out frame; the offset's range; an unreferenced lens; "
|
||||||
|
"and a camera home in both modes hands the runner the same park. Imports its sender and port "
|
||||||
|
"client from ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink "
|
||||||
|
"controller, not the machine, so it is not a bench-page tool."},
|
||||||
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
|
{"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,22 +1,36 @@
|
|||||||
|
# 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 or by a host-tested row (manifest.HOST_TESTED),
|
||||||
and every coverage glob must select at least one path of its component.
|
except the allowlisted non-behavioral paths; every coverage glob and every
|
||||||
|
host-tested glob must select at least one path of its component; and every
|
||||||
|
host-tested row must name a host test that exists and a CI workflow that
|
||||||
|
runs it.
|
||||||
|
|
||||||
Why a hard rule: under the domain model an uncovered file is worse than an
|
Why a hard rule: under the domain model an uncovered file is worse than an
|
||||||
untested one - a change there leaves every inherited PASS valid when it
|
untested one - a change there leaves every inherited PASS valid when it
|
||||||
should have invalidated them. The lint is the floor (the file is
|
should have invalidated them. The lint is the floor (the file is
|
||||||
fingerprinted by at least one test); whether that test exercises the
|
fingerprinted by at least one test, or a named host test that CI runs
|
||||||
change stays with the change author. An entry that selects nothing is
|
answers for it); whether that test exercises the change stays with the
|
||||||
the same defect from the other side: the test's fingerprint ignores the
|
change author. An entry that selects nothing is the same defect from the
|
||||||
file it named.
|
other side: the test's fingerprint ignores the file it named.
|
||||||
|
|
||||||
python3 -m forgetest.coverage [--manifest PATH] [--enforce] [--json]
|
Whether a workflow runs its host test is read from the workflow's text at
|
||||||
|
the pinned revision, in the sources scripts/manifest-from-tree.py fetched
|
||||||
|
(--sources, default the checkout's .manifest-cache). Without them (on an
|
||||||
|
image) that part is not checked, and the report says so.
|
||||||
|
|
||||||
|
python3 -m forgetest.coverage [--manifest PATH] [--sources DIR] [--enforce] [--json]
|
||||||
|
|
||||||
Exit 0 when nothing is uncovered (or when only reporting), 1 under
|
Exit 0 when nothing is uncovered (or when only reporting), 1 under
|
||||||
--enforce with uncovered paths, 2 on a usage or load error.
|
--enforce with uncovered paths, 2 on a usage or load error.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from . import catalog as _catalog
|
from . import catalog as _catalog
|
||||||
@@ -28,16 +42,22 @@ ALLOW = _manifest.NON_BEHAVIORAL
|
|||||||
|
|
||||||
|
|
||||||
def run(manifest, tests, allow=ALLOW):
|
def run(manifest, tests, allow=ALLOW):
|
||||||
return _manifest.coverage_report(manifest, tests, allow)
|
return _manifest.coverage_report(manifest, tests, allow, host=_manifest.HOST_TESTED)
|
||||||
|
|
||||||
|
|
||||||
def empty(manifest, tests):
|
def empty(manifest, tests):
|
||||||
return _manifest.empty_covers(manifest, tests)
|
return _manifest.empty_covers(manifest, tests)
|
||||||
|
|
||||||
|
|
||||||
|
def host_problems(manifest, sources=_manifest.SOURCES):
|
||||||
|
return _manifest.host_row_problems(manifest, host=_manifest.HOST_TESTED, sources=sources)
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
ap = argparse.ArgumentParser(description="forgetest coverage lint")
|
ap = argparse.ArgumentParser(description="forgetest coverage lint")
|
||||||
ap.add_argument("--manifest", default=None, help="manifest JSON (default: the running image's)")
|
ap.add_argument("--manifest", default=None, help="manifest JSON (default: the running image's)")
|
||||||
|
ap.add_argument("--sources", default=_manifest.SOURCES,
|
||||||
|
help="the pinned sources manifest-from-tree.py fetched (default: the checkout's .manifest-cache)")
|
||||||
ap.add_argument("--enforce", action="store_true", help="exit 1 when any path is uncovered")
|
ap.add_argument("--enforce", action="store_true", help="exit 1 when any path is uncovered")
|
||||||
ap.add_argument("--json", action="store_true", help="machine-readable report")
|
ap.add_argument("--json", action="store_true", help="machine-readable report")
|
||||||
args = ap.parse_args(argv)
|
args = ap.parse_args(argv)
|
||||||
@@ -51,19 +71,30 @@ def main(argv=None):
|
|||||||
report = run(manifest, tests)
|
report = run(manifest, tests)
|
||||||
total = sum(len(v) for v in report.values())
|
total = sum(len(v) for v in report.values())
|
||||||
hollow = empty(manifest, tests)
|
hollow = empty(manifest, tests)
|
||||||
|
sources = args.sources if args.sources and os.path.isdir(args.sources) else None
|
||||||
|
rows = host_problems(manifest, sources)
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps({"uncovered": report, "total": total, "tests": len(tests),
|
print(json.dumps({"uncovered": report, "total": total, "tests": len(tests),
|
||||||
"empty": [list(e) for e in hollow]}, indent=1, sort_keys=True))
|
"empty": [list(e) for e in hollow],
|
||||||
|
"host_tested": [list(r) for r in rows],
|
||||||
|
"workflows_read": sources is not None and not any("cannot be read" in r[3] for r in rows)},
|
||||||
|
indent=1, sort_keys=True))
|
||||||
else:
|
else:
|
||||||
|
if _manifest.HOST_TESTED and sources is None:
|
||||||
|
print("host-tested: no sources, so whether a workflow runs each host test is not checked")
|
||||||
for comp in sorted(report):
|
for comp in sorted(report):
|
||||||
print("%s: %d uncovered path(s)" % (comp, len(report[comp])))
|
print("%s: %d uncovered path(s)" % (comp, len(report[comp])))
|
||||||
for p in report[comp]:
|
for p in report[comp]:
|
||||||
print(" %s" % p)
|
print(" %s" % p)
|
||||||
for tid, comp, pat in hollow:
|
for tid, comp, pat in hollow:
|
||||||
print("%s: covers (%s, %s) selects nothing" % (tid, comp, pat))
|
print("%s: covers (%s, %s) selects nothing" % (tid, comp, pat))
|
||||||
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, %d tests"
|
for comp, pat, ref, why in rows:
|
||||||
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(tests)))
|
print("host-tested (%s, %s) by %s: %s" % (comp, pat, ref, why))
|
||||||
if args.enforce and (total or hollow):
|
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, "
|
||||||
|
"%d host-tested row problem(s), %d tests"
|
||||||
|
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(rows),
|
||||||
|
len(tests)))
|
||||||
|
if args.enforce and (total or hollow or rows):
|
||||||
return 1
|
return 1
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -9,9 +14,12 @@ exercised against a mock on a host:
|
|||||||
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
|
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
|
||||||
FORGETEST_INITD default /etc/init.d
|
FORGETEST_INITD default /etc/init.d
|
||||||
"""
|
"""
|
||||||
|
import glob
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import select
|
||||||
import socket
|
import socket
|
||||||
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -305,6 +313,89 @@ def button_lit():
|
|||||||
return any(v > 0 for v in vals)
|
return any(v > 0 for v in vals)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- the button
|
||||||
|
|
||||||
|
EV_SW = 0x05
|
||||||
|
SW_BUTTON = 2 # the button's EV_SW code (bit 2 of the switch word)
|
||||||
|
|
||||||
|
|
||||||
|
def switches_device():
|
||||||
|
"""The machine's switches as forgectrl and the controller find them:
|
||||||
|
GF_SWITCH_DEV, else the input device the device tree names
|
||||||
|
"switches". None when there is none."""
|
||||||
|
dev = os.environ.get("GF_SWITCH_DEV")
|
||||||
|
if dev:
|
||||||
|
return dev
|
||||||
|
for d in sorted(glob.glob("/sys/class/input/event*")):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(d, "device", "name")) as f:
|
||||||
|
if f.read().strip() == "switches":
|
||||||
|
return "/dev/input/" + os.path.basename(d)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ButtonEdges:
|
||||||
|
"""The button as edges, from the switches' input events. A press is an
|
||||||
|
event however short it is, so nothing has to be held long enough for a
|
||||||
|
poll to land on it: an operator's press is never longer than a quarter
|
||||||
|
second. Open it before the press is asked for. It reads without a
|
||||||
|
grab, so forgectrl and the controller see the switches as always."""
|
||||||
|
|
||||||
|
EVENT = struct.Struct("llHHi") # struct input_event: time, type, code, value
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.fd = None
|
||||||
|
self.pending = b"" # events read and not yet looked at
|
||||||
|
dev = switches_device()
|
||||||
|
if dev:
|
||||||
|
try:
|
||||||
|
self.fd = os.open(dev, os.O_RDONLY | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0))
|
||||||
|
except OSError:
|
||||||
|
self.fd = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self):
|
||||||
|
return self.fd is not None
|
||||||
|
|
||||||
|
def wait(self, value, timeout, checkpoint=None):
|
||||||
|
"""The seconds until the button's next edge to `value` (1 pressed,
|
||||||
|
0 released), or None at the timeout. checkpoint() is called about
|
||||||
|
every 0.1 s (an abort raises out of it)."""
|
||||||
|
size = self.EVENT.size
|
||||||
|
t0 = time.time()
|
||||||
|
while True:
|
||||||
|
# The events already read come first: a short press arrives
|
||||||
|
# with its release in one read, and the release is the next
|
||||||
|
# wait's.
|
||||||
|
while len(self.pending) >= size:
|
||||||
|
_sec, _usec, typ, code, val = self.EVENT.unpack_from(self.pending, 0)
|
||||||
|
self.pending = self.pending[size:]
|
||||||
|
if typ == EV_SW and code == SW_BUTTON and val == value:
|
||||||
|
return time.time() - t0
|
||||||
|
if time.time() - t0 >= timeout:
|
||||||
|
return None
|
||||||
|
if checkpoint:
|
||||||
|
checkpoint()
|
||||||
|
ready, _, _ = select.select([self.fd], [], [], 0.1)
|
||||||
|
if not ready:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = os.read(self.fd, size * 64)
|
||||||
|
except BlockingIOError:
|
||||||
|
continue
|
||||||
|
if not data:
|
||||||
|
time.sleep(0.02) # a FIFO with no writer (the host tests) reads empty
|
||||||
|
continue
|
||||||
|
self.pending += data
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.fd is not None:
|
||||||
|
os.close(self.fd)
|
||||||
|
self.fd = None
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- init.d
|
# --------------------------------------------------------------- init.d
|
||||||
|
|
||||||
def initd(service, action, timeout=60):
|
def initd(service, action, timeout=60):
|
||||||
@@ -319,6 +410,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 +481,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)
|
||||||
@@ -14,6 +19,7 @@ import functools
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import posixpath
|
||||||
import re
|
import re
|
||||||
|
|
||||||
DEFAULT_PATH = "/etc/forgefirm-manifest.json"
|
DEFAULT_PATH = "/etc/forgefirm-manifest.json"
|
||||||
@@ -88,10 +94,136 @@ 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/**"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Paths a host test in CI proves, which no catalog test covers. The catalog
|
||||||
|
# is for what needs the machine or an operator; software whose inputs can be
|
||||||
|
# emulated is proven on a host. A row is (component, glob, host test, CI
|
||||||
|
# workflow), the last two named as "<repository>:<path>": a component of
|
||||||
|
# the manifest (its tests/ and .github/ files are listed there), or
|
||||||
|
# "forgefirm" for this repository (the null-sink harnesses under
|
||||||
|
# scripts/bench/, which grblhal-glowforge's workflow runs). The workflow
|
||||||
|
# must run the host test: name it, or discover it (a glob, unittest
|
||||||
|
# discover, pytest on its directory). A host-tested path counts as covered
|
||||||
|
# in the coverage lint, and like every path no test covers it is in no
|
||||||
|
# fingerprint: a change there makes no acceptance run necessary. A path a
|
||||||
|
# catalog test exercises on the hardware belongs in that test's covers, not
|
||||||
|
# here.
|
||||||
|
HOST_TESTED = [
|
||||||
|
# An extension package's holds as the engine reads them (forgeext's
|
||||||
|
# holdkeep_test and run_test prove the writer's side).
|
||||||
|
("forgectrl", "src/holds.*", "forgectrl:tests/holds_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The M-code relay to the package that answers (the controller's side is
|
||||||
|
# the null-sink mcode_test harness, under grblhal-glowforge's src/**).
|
||||||
|
("forgectrl", "src/mcode.*", "forgectrl:tests/mcode_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The digest primitives (SHA-256, HMAC, the constant-time compare) that
|
||||||
|
# the advisories' hashes, the tokens and the camera key are made with.
|
||||||
|
("forgectrl", "src/sha256.*", "forgectrl:tests/sha256_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The built-in extensions' table: the cloud entry, its refusals while it
|
||||||
|
# is off, the sweep when it goes off, the list.
|
||||||
|
("forgectrl", "src/builtin.*", "forgectrl:tests/builtin_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The setup record as a printable page: no script, the sheet id, a
|
||||||
|
# heading for each completed step, everything escaped.
|
||||||
|
("forgectrl", "src/recordhtml.*", "forgectrl:tests/recordhtml_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# An extension package's claim on the Grbl sender as the machine keeps
|
||||||
|
# it: the claim and its refusals, its end by the package, the operator
|
||||||
|
# or a stale keeper (forgeext's run_test proves the writer's side).
|
||||||
|
("forgectrl", "src/senderout.*", "forgectrl:tests/senderout_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# A package's check on the Setup page: each step held to its form, the
|
||||||
|
# check run through the host, the result the package's own (the routes
|
||||||
|
# against a live host's stand-in are tests/host_ext.sh).
|
||||||
|
("forgectrl", "src/wizpkg.*", "forgectrl:tests/wizpkg_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The camera key: made, kept, checked and rotated (the reads it opens
|
||||||
|
# from the LAN with the reads closed are tests/host_lan.sh).
|
||||||
|
("forgectrl", "src/camkey.*", "forgectrl:tests/camkey_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The log export's sanitizer: every identifying class taken out, the
|
||||||
|
# ordinary log text left as it was (the export's routes and the
|
||||||
|
# installer's directory on a host are tests/host_guard.sh).
|
||||||
|
("forgectrl", "src/sanitize.*", "forgectrl:tests/sanitize_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The package routes' relay to the extension host: the argument vector,
|
||||||
|
# the closed list of actions, the host's words, the catalog's fetch (the
|
||||||
|
# routes against a live host's stand-in are tests/host_ext.sh).
|
||||||
|
("forgectrl", "src/extpkg.*", "forgectrl:tests/extpkg_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The event stream: a button edge, a lease changing hands, telemetry
|
||||||
|
# (the live order, the take before the free, is tests/host_lease.sh).
|
||||||
|
("forgectrl", "src/events.*", "forgectrl:tests/events_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The crumb tray's mode as the daemon keeps it: its offset and what it
|
||||||
|
# refuses (the route and the panel's switch are tests/host_routes.sh).
|
||||||
|
("forgectrl", "src/tray.*", "forgectrl:tests/tray_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The product gate on the firmware paths: firmware only, an extension
|
||||||
|
# package refused in words that say what it is (the upload route over
|
||||||
|
# a stand-in fwup is tests/host_update.sh).
|
||||||
|
("forgectrl", "src/fwproduct.*", "forgectrl:tests/fwproduct_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The controller port's client and its two operation sets (the motion
|
||||||
|
# routes and a package's jog through the host's credential are
|
||||||
|
# tests/host_routes.sh).
|
||||||
|
("forgectrl", "src/grblport.*", "forgectrl:tests/grblport_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The panel page as the daemon serves it: bundled, the theme and the
|
||||||
|
# save bar in it, and its policy frame-src 'none'.
|
||||||
|
("forgectrl", "src/ui.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The data and run directories from the environment: the SSH flag in the
|
||||||
|
# run directory, written and removed by POST /system/ssh.
|
||||||
|
("forgectrl", "src/paths.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The first run in the page's order: the four documents at their hashes,
|
||||||
|
# the press step breathing teal, the press taken as the acceptance, the
|
||||||
|
# form steps through main.c's hooks (extensions.md stays with
|
||||||
|
# exthost.service, which covers it).
|
||||||
|
("forgectrl", "src/advisories.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
("forgectrl", "docs/advisories/safety-and-risk.md", "forgectrl:tests/host_setup.sh",
|
||||||
|
"forgectrl:.github/workflows/build.yml"),
|
||||||
|
("forgectrl", "docs/advisories/licenses.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
("forgectrl", "docs/advisories/privacy.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
("forgectrl", "docs/advisories/cloud-service.md", "forgectrl:tests/host_setup.sh",
|
||||||
|
"forgectrl:.github/workflows/build.yml"),
|
||||||
|
("forgectrl", "src/button.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
("forgectrl", "src/led.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
("forgectrl", "src/hooks.h", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The setup sheet id from the fuse serial, against golden ids.
|
||||||
|
("forgectrl", "src/sheetid.*", "forgectrl:tests/sheetid_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The logging path: the image's rsyslog.conf and the rendered rules, each
|
||||||
|
# logger in its own file at its level, a stray tag in system/, the kern
|
||||||
|
# facility in kernel/.
|
||||||
|
("forgectrl", "src/fflog.*", "forgectrl:tests/fflog_e2e.sh", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# POST /job's multipart reader: the form's parts reach the runner's
|
||||||
|
# start; a missing name, a $ line and a malformed boundary are refused
|
||||||
|
# with nothing started.
|
||||||
|
("forgectrl", "src/jobpost.*", "forgectrl:tests/jobpost_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The scoped tokens: the closed capability list, the judgment, the
|
||||||
|
# store that holds only hashes, and the extension host's credential
|
||||||
|
# (mode 0600, exactly the host's capabilities).
|
||||||
|
("forgectrl", "src/tokens.*", "forgectrl:tests/tokens_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# This repository's root, when the package runs from a checkout of it; a
|
||||||
|
# "forgefirm:" file is looked for there. Installed on an image, the package
|
||||||
|
# is not in a checkout, and such a reference is not checked.
|
||||||
|
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
REPO_ROOT = _ROOT if os.path.isdir(os.path.join(_ROOT, "meta-forgefirm")) else None
|
||||||
|
# The pinned sources scripts/manifest-from-tree.py fetches (one bare repo
|
||||||
|
# per source URL); a component's workflow is read from there. Without them
|
||||||
|
# (on an image) whether a workflow runs a host test is not checked.
|
||||||
|
SOURCES = os.path.join(REPO_ROOT, ".manifest-cache") if REPO_ROOT else None
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -194,17 +326,332 @@ def empty_covers(manifest, tests):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
|
def _ref_problem(manifest, ref, what, root):
|
||||||
"""Which manifest paths no test covers.
|
"""Why a "<repository>:<path>" reference names no file, or None. A
|
||||||
|
component's file must be in the manifest; a "forgefirm:" one must exist
|
||||||
|
under `root`, and is not checked without one."""
|
||||||
|
repo, sep, path = ref.partition(":")
|
||||||
|
if not sep or not path:
|
||||||
|
return "names no repository:path for its %s" % what
|
||||||
|
if repo == "forgefirm":
|
||||||
|
if root is not None and not os.path.isfile(os.path.join(root, path)):
|
||||||
|
return "no such %s" % what
|
||||||
|
return None
|
||||||
|
if repo in DEV_ONLY_COMPONENTS or manifest.files(repo) is None:
|
||||||
|
return "no repository %r for its %s" % (repo, what)
|
||||||
|
if path not in {p for p, _b in manifest.files(repo)}:
|
||||||
|
return "no such %s" % what
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_source(manifest, ref, root=REPO_ROOT, sources=SOURCES):
|
||||||
|
"""(text, why) for a "<repository>:<path>" file: a "forgefirm:" file
|
||||||
|
from `root`, a component's from its pinned blob in the bare repo
|
||||||
|
`sources` holds for its source URL. (None, None) when it cannot be read
|
||||||
|
here (no root, no sources); (None, why) when it should have been and
|
||||||
|
was not, which is a defect, never a pass."""
|
||||||
|
import subprocess
|
||||||
|
repo, _sep, path = ref.partition(":")
|
||||||
|
if repo == "forgefirm":
|
||||||
|
if root is None:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
with open(os.path.join(root, path), encoding="utf-8") as f:
|
||||||
|
return f.read(), None
|
||||||
|
except (OSError, UnicodeDecodeError) as e:
|
||||||
|
return None, "cannot be read: %s" % e
|
||||||
|
if not sources:
|
||||||
|
return None, None
|
||||||
|
comp = manifest.components.get(repo) or {}
|
||||||
|
blob = dict(manifest.files(repo) or []).get(path)
|
||||||
|
url = comp.get("source")
|
||||||
|
if not (blob and url):
|
||||||
|
return None, "cannot be read: no blob or source URL in the manifest"
|
||||||
|
cache = os.path.join(sources, hashlib.sha1(url.encode()).hexdigest()[:16])
|
||||||
|
try:
|
||||||
|
return subprocess.run(["git", "cat-file", "-p", blob], cwd=cache, capture_output=True,
|
||||||
|
check=True).stdout.decode("utf-8", "replace"), None
|
||||||
|
except (OSError, subprocess.CalledProcessError) as e:
|
||||||
|
return None, "cannot be read from %s: %s" % (cache, e)
|
||||||
|
|
||||||
|
|
||||||
|
def repo_name(manifest, repo):
|
||||||
|
"""A repository's GitHub name: its source URL's last part, or this
|
||||||
|
repository's."""
|
||||||
|
if repo == "forgefirm":
|
||||||
|
return "forgefirm"
|
||||||
|
url = ((manifest.components.get(repo) or {}).get("source") or "").rstrip("/")
|
||||||
|
name = url.rsplit("/", 1)[-1]
|
||||||
|
return name[:-4] if name.endswith(".git") else name
|
||||||
|
|
||||||
|
|
||||||
|
_STEP_KEYS = ("name", "uses", "run", "id", "if", "with", "env", "working-directory", "shell",
|
||||||
|
"continue-on-error", "timeout-minutes")
|
||||||
|
_STEP_RX = re.compile(r"^(\s*)-\s+(%s)\s*:" % "|".join(re.escape(k) for k in _STEP_KEYS))
|
||||||
|
_KEY_RX = re.compile(r"^(\s*)([\w-]+)\s*:\s*(.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _indent(line):
|
||||||
|
return len(line) - len(line.lstrip(" "))
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_steps(text):
|
||||||
|
"""A workflow's steps, each a dict of its keys: "uses", "run" (the
|
||||||
|
command text, a block's lines joined), "working-directory", and "with"
|
||||||
|
(a dict). YAML read by its indentation, as far as these workflows use
|
||||||
|
it; comments, whole lines and from ' #' to the end of a line, are not
|
||||||
|
read. Anything outside a step (a trigger's paths, a job's name) is not
|
||||||
|
a step."""
|
||||||
|
lines = []
|
||||||
|
for ln in text.splitlines():
|
||||||
|
if ln.lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
|
lines.append(re.sub(r"\s+#.*$", "", ln).rstrip())
|
||||||
|
steps, i = [], 0
|
||||||
|
while i < len(lines):
|
||||||
|
m = _STEP_RX.match(lines[i])
|
||||||
|
if not m:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
dash = len(m.group(1))
|
||||||
|
body = [" " * (dash + 2) + lines[i][dash + 1:].lstrip()]
|
||||||
|
i += 1
|
||||||
|
while i < len(lines) and (not lines[i].strip() or _indent(lines[i]) > dash):
|
||||||
|
body.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
step, k = {}, 0
|
||||||
|
key_col = _indent(body[0])
|
||||||
|
while k < len(body):
|
||||||
|
km = _KEY_RX.match(body[k])
|
||||||
|
if not km or _indent(body[k]) != key_col:
|
||||||
|
k += 1
|
||||||
|
continue
|
||||||
|
key, val = km.group(2), km.group(3).strip()
|
||||||
|
if len(val) > 1 and val[0] == val[-1] and val[0] in "'\"":
|
||||||
|
val = val[1:-1] # a quoted scalar, not a quote inside a command
|
||||||
|
k += 1
|
||||||
|
child = []
|
||||||
|
while k < len(body) and (not body[k].strip() or _indent(body[k]) > key_col):
|
||||||
|
child.append(body[k])
|
||||||
|
k += 1
|
||||||
|
if key == "with":
|
||||||
|
step["with"] = {c.group(2): c.group(3).strip().strip("'\"")
|
||||||
|
for c in (_KEY_RX.match(x) for x in child) if c}
|
||||||
|
elif key == "run":
|
||||||
|
step["run"] = "\n".join(x.strip() for x in child) if val[:1] in ("|", ">") else val
|
||||||
|
else:
|
||||||
|
step[key] = val
|
||||||
|
steps.append(step)
|
||||||
|
return steps
|
||||||
|
|
||||||
|
|
||||||
|
def checkout_path(steps, name, own):
|
||||||
|
"""Where a workflow checks the repository `name` out (its path, "" for
|
||||||
|
the workspace), or None when it does not: its own repository is the
|
||||||
|
checkout that names no repository, another one the checkout whose
|
||||||
|
repository is <owner>/<name>."""
|
||||||
|
for s in steps:
|
||||||
|
if not s.get("uses", "").startswith("actions/checkout"):
|
||||||
|
continue
|
||||||
|
w = s.get("with", {})
|
||||||
|
repo = w.get("repository")
|
||||||
|
if (repo is None and own) or (repo is not None and repo.rsplit("/", 1)[-1].lower() == name.lower()):
|
||||||
|
return posixpath.normpath(w.get("path") or ".")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_SPLIT_RX = re.compile(r"&&|\|\||[;|\n]|(?:^|(?<=\s))(?:then|do|else)(?=\s|$)")
|
||||||
|
_PREFIXES = ("sudo", "env", "time", "exec", "command", "nice")
|
||||||
|
_INTERPRETER_RX = re.compile(r"^(?:python[0-9.]*|bash|sh)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _commands(run):
|
||||||
|
"""The simple commands of a step's run text, each as its words, with
|
||||||
|
what only starts the real command taken off: sudo and its flags, env,
|
||||||
|
time, exec, and variable assignments."""
|
||||||
|
import shlex
|
||||||
|
out = []
|
||||||
|
for part in _SPLIT_RX.split(run.replace("\\\n", " ")):
|
||||||
|
try:
|
||||||
|
words = shlex.split(part)
|
||||||
|
except ValueError:
|
||||||
|
words = part.split()
|
||||||
|
while words:
|
||||||
|
if words[0] in _PREFIXES:
|
||||||
|
words = words[1:]
|
||||||
|
while words and words[0].startswith("-"):
|
||||||
|
words = words[1:]
|
||||||
|
elif re.match(r"^[A-Za-z_]\w*=", words[0]):
|
||||||
|
words = words[1:]
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if words:
|
||||||
|
out.append(words)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _segments_match(path, pattern):
|
||||||
|
"""A path against a path or a glob, one segment against one segment
|
||||||
|
('*' never crosses a '/')."""
|
||||||
|
import fnmatch
|
||||||
|
a, b = path.split("/"), pattern.split("/")
|
||||||
|
return len(a) == len(b) and all(fnmatch.fnmatchcase(x, y) for x, y in zip(a, b))
|
||||||
|
|
||||||
|
|
||||||
|
def _discovers(args, full, at):
|
||||||
|
"""Whether `python -m unittest discover ARGS` finds the test: its
|
||||||
|
directory is the start directory (-s, default the working directory)
|
||||||
|
and its file name matches the pattern (-p, default test*.py)."""
|
||||||
|
import fnmatch
|
||||||
|
start, pattern = ".", "test*.py"
|
||||||
|
for i, a in enumerate(args):
|
||||||
|
if a in ("-s", "--start-directory") and i + 1 < len(args):
|
||||||
|
start = args[i + 1]
|
||||||
|
elif a in ("-p", "--pattern") and i + 1 < len(args):
|
||||||
|
pattern = args[i + 1]
|
||||||
|
return posixpath.dirname(full) == at(start) and fnmatch.fnmatchcase(posixpath.basename(full), pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def _unittest_names(args, full, at):
|
||||||
|
"""Whether `python -m unittest ARGS` (not discover) runs the test: an
|
||||||
|
argument is its file (tests/test_x.py), or a dotted name whose module
|
||||||
|
is its file (tests.test_x, tests.test_x.Case.test_one)."""
|
||||||
|
skip = False
|
||||||
|
for a in args:
|
||||||
|
if skip:
|
||||||
|
skip = False
|
||||||
|
continue
|
||||||
|
if a in ("-k",):
|
||||||
|
skip = True
|
||||||
|
continue
|
||||||
|
if a.startswith("-") or "$" in a:
|
||||||
|
continue
|
||||||
|
if a.endswith(".py"):
|
||||||
|
if at(a) == full:
|
||||||
|
return True
|
||||||
|
continue
|
||||||
|
parts = a.split(".")
|
||||||
|
if any(at("/".join(parts[:k]) + ".py") == full for k in range(len(parts), 0, -1)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _pytest_runs(args, full, at):
|
||||||
|
"""Whether pytest ARGS collects the test: named, or its directory given
|
||||||
|
and its file name one pytest collects (test_*.py, *_test.py)."""
|
||||||
|
import fnmatch
|
||||||
|
name = posixpath.basename(full)
|
||||||
|
collected = fnmatch.fnmatchcase(name, "test_*.py") or fnmatch.fnmatchcase(name, "*_test.py")
|
||||||
|
for a in args:
|
||||||
|
if a.startswith("-") or "$" in a:
|
||||||
|
continue
|
||||||
|
if at(a) == full or (collected and at(a) == posixpath.dirname(full)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_runs(steps, test_path):
|
||||||
|
"""Whether a step of the workflow runs the test at test_path (its path
|
||||||
|
in the workspace: the checkout's path joined to the test's path in its
|
||||||
|
repository). Only what a step's run commands execute counts, each path
|
||||||
|
resolved against the step's working-directory: the command itself
|
||||||
|
(a script run directly, or a C test's built binary, <dir>/<stem>);
|
||||||
|
the script an interpreter runs (python, bash, sh), a glob matching the
|
||||||
|
whole path; a for loop's list over the test; python -m unittest given
|
||||||
|
the test's file or a dotted name whose module is that file
|
||||||
|
(tests.test_x, tests.test_x.Case.test_one); unittest discover from the
|
||||||
|
test's own directory with a pattern its name matches; pytest on the
|
||||||
|
test or its own directory. An argument to anything else (echo, a build,
|
||||||
|
an install) does not run the test."""
|
||||||
|
full = posixpath.normpath(test_path)
|
||||||
|
stem, ext = posixpath.splitext(posixpath.basename(full))
|
||||||
|
compiled = ext in (".c", ".cc", ".cpp")
|
||||||
|
for s in steps:
|
||||||
|
run = s.get("run")
|
||||||
|
if not run:
|
||||||
|
continue
|
||||||
|
wd = s.get("working-directory") or "."
|
||||||
|
|
||||||
|
def at(p):
|
||||||
|
return posixpath.normpath(posixpath.join(wd, p))
|
||||||
|
|
||||||
|
def names(p):
|
||||||
|
return "$" not in p and _segments_match(full, at(p))
|
||||||
|
for words in _commands(run):
|
||||||
|
cmd, args = words[0], words[1:]
|
||||||
|
if cmd == "for" and "in" in args:
|
||||||
|
if any(names(p) for p in args[args.index("in") + 1:]):
|
||||||
|
return True
|
||||||
|
elif _INTERPRETER_RX.match(posixpath.basename(cmd)):
|
||||||
|
if "-m" in args:
|
||||||
|
i = args.index("-m")
|
||||||
|
mod, rest = (args[i + 1], args[i + 2:]) if i + 1 < len(args) else ("", [])
|
||||||
|
if mod == "unittest" and rest[:1] == ["discover"]:
|
||||||
|
if _discovers(rest[1:], full, at):
|
||||||
|
return True
|
||||||
|
elif (mod == "unittest" and _unittest_names(rest, full, at)) or \
|
||||||
|
(mod == "pytest" and _pytest_runs(rest, full, at)):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
script = next((a for a in args if not a.startswith("-")), None)
|
||||||
|
if script and names(script):
|
||||||
|
return True
|
||||||
|
elif posixpath.basename(cmd) == "pytest":
|
||||||
|
if _pytest_runs(args, full, at):
|
||||||
|
return True
|
||||||
|
elif names(cmd) or (compiled and "/" in cmd and posixpath.basename(cmd) == stem):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def host_row_problems(manifest, host=HOST_TESTED, root=REPO_ROOT, sources=SOURCES):
|
||||||
|
"""HOST_TESTED rows that prove nothing: a glob that selects no
|
||||||
|
behavioral file of its component (the same defect as an empty covers
|
||||||
|
entry), a host test or a workflow that does not exist, a workflow that
|
||||||
|
cannot be read where it should be, one that does not check out the
|
||||||
|
test's repository, or one with no step that runs the test. Whether a
|
||||||
|
workflow runs its test is not checked where it cannot be read (see
|
||||||
|
read_source). Returns [(component, glob, host test, why)]."""
|
||||||
|
out = []
|
||||||
|
for comp, pat, ref, ci in host:
|
||||||
|
files = manifest.files(comp)
|
||||||
|
if files is None or not [p for p, _b in match_files(files, pat) if not non_behavioral(comp, p)]:
|
||||||
|
out.append((comp, pat, ref, "selects nothing"))
|
||||||
|
why = _ref_problem(manifest, ref, "host test", root) or _ref_problem(manifest, ci, "workflow", root)
|
||||||
|
if why:
|
||||||
|
out.append((comp, pat, ref, why))
|
||||||
|
continue
|
||||||
|
text, why = read_source(manifest, ci, root, sources)
|
||||||
|
if why:
|
||||||
|
out.append((comp, pat, ref, "%s %s" % (ci, why)))
|
||||||
|
continue
|
||||||
|
if text is None:
|
||||||
|
continue
|
||||||
|
steps = workflow_steps(text)
|
||||||
|
test_repo, _sep, test_path = ref.partition(":")
|
||||||
|
prefix = checkout_path(steps, repo_name(manifest, test_repo), test_repo == ci.partition(":")[0])
|
||||||
|
if prefix is None:
|
||||||
|
out.append((comp, pat, ref, "%s does not check out %s" % (ci, test_repo)))
|
||||||
|
elif not workflow_runs(steps, posixpath.join(prefix, test_path)):
|
||||||
|
out.append((comp, pat, ref, "%s does not run it" % ci))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def coverage_report(manifest, tests, allow=NON_BEHAVIORAL, host=HOST_TESTED):
|
||||||
|
"""Which manifest paths neither a test nor a host 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. host:
|
||||||
|
the HOST_TESTED rows, whose paths count as covered. Returns
|
||||||
{component: [uncovered paths]} for the non-dev-only components.
|
{component: [uncovered paths]} for the non-dev-only components.
|
||||||
"""
|
"""
|
||||||
covered = {}
|
covered = {}
|
||||||
for t in tests:
|
for t in tests:
|
||||||
for comp, pat in t.covers:
|
for comp, pat in t.covers:
|
||||||
covered.setdefault(comp, []).append(glob_to_regex(pat))
|
covered.setdefault(comp, []).append(glob_to_regex(pat))
|
||||||
|
for comp, pat, _ref, _ci in host:
|
||||||
|
covered.setdefault(comp, []).append(glob_to_regex(pat))
|
||||||
allowed = {}
|
allowed = {}
|
||||||
for comp, pat in allow:
|
for comp, pat in allow:
|
||||||
allowed.setdefault(comp, []).append(glob_to_regex(pat))
|
allowed.setdefault(comp, []).append(glob_to_regex(pat))
|
||||||
@@ -212,11 +659,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
|
||||||
@@ -120,6 +125,8 @@ ACT_TIMEOUT_S = 180
|
|||||||
# machine by pressing its button. Generous: they may be setting up the
|
# machine by pressing its button. Generous: they may be setting up the
|
||||||
# scrap, and nothing fires until it lands.
|
# scrap, and nothing fires until it lands.
|
||||||
PRESENCE_TIMEOUT_S = 600
|
PRESENCE_TIMEOUT_S = 600
|
||||||
|
ARM_LIT_WAIT_S = 1800 # an arm can wait minutes (a coolant settle) before it lights
|
||||||
|
ARM_PRESS_WAIT_S = 3600 # the controller's own button timeout, at most
|
||||||
|
|
||||||
|
|
||||||
class Run:
|
class Run:
|
||||||
@@ -311,17 +318,31 @@ class Context:
|
|||||||
|
|
||||||
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
|
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
|
||||||
if fixture is not None and fixture.covers("button"):
|
if fixture is not None and fixture.covers("button"):
|
||||||
|
# The press is read as an edge from the switches' events, opened
|
||||||
|
# before the notice goes up: a press of any length counts, and
|
||||||
|
# nobody holds the button for a poll to land on it.
|
||||||
|
edges = hw.ButtonEdges()
|
||||||
self.notice(text + " Then press the button on the machine to start.")
|
self.notice(text + " Then press the button on the machine to start.")
|
||||||
self.log("READY: waiting for the operator's press on the machine "
|
self.log("READY: waiting for the operator's press on the machine "
|
||||||
"(the bench then does every press in this test)")
|
"(the bench then does every press in this test)")
|
||||||
pressed = self.wait_for(lambda: self.switch("button") is True, PRESENCE_TIMEOUT_S)
|
try:
|
||||||
|
if edges.available:
|
||||||
|
pressed = edges.wait(1, PRESENCE_TIMEOUT_S, self.checkpoint)
|
||||||
|
# The release, so this press is never read as the arm
|
||||||
|
# press or a pause toggle; a short press has queued it.
|
||||||
|
if pressed is not None:
|
||||||
|
edges.wait(0, 10, self.checkpoint)
|
||||||
|
else:
|
||||||
|
pressed = self.wait_for(lambda: self.switch("button") is True, PRESENCE_TIMEOUT_S,
|
||||||
|
poll=0.05)
|
||||||
|
if pressed is not None:
|
||||||
|
self.wait_for(lambda: self.switch("button") is False, 10, poll=0.05)
|
||||||
|
finally:
|
||||||
|
edges.close()
|
||||||
self.clear_notice()
|
self.clear_notice()
|
||||||
if pressed is None:
|
if pressed is None:
|
||||||
raise Failed("no press on the machine within %d s: %s"
|
raise Failed("no press on the machine within %d s: %s"
|
||||||
% (PRESENCE_TIMEOUT_S, text))
|
% (PRESENCE_TIMEOUT_S, text))
|
||||||
# Let the button come back up, so this press is never read as
|
|
||||||
# the arm press or a pause toggle.
|
|
||||||
self.wait_for(lambda: self.switch("button") is False, 10)
|
|
||||||
self.run.fixture_takeover = True
|
self.run.fixture_takeover = True
|
||||||
self.evidence.setdefault("actions", []).append(
|
self.evidence.setdefault("actions", []).append(
|
||||||
{"channel": "button", "state": "presence", "by": "operator",
|
{"channel": "button", "state": "presence", "by": "operator",
|
||||||
@@ -361,7 +382,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
|
||||||
@@ -400,7 +421,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:
|
||||||
@@ -456,6 +477,7 @@ class Context:
|
|||||||
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
|
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
|
||||||
"gone - asking the operator for the arm press")
|
"gone - asking the operator for the arm press")
|
||||||
self.notice(text)
|
self.notice(text)
|
||||||
|
self._clear_when_taken()
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
fixture.act("button", "press")
|
fixture.act("button", "press")
|
||||||
@@ -468,6 +490,36 @@ class Context:
|
|||||||
self.log("ARM press by the fixture, at the machine's own press prompt")
|
self.log("ARM press by the fixture, at the machine's own press prompt")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _clear_when_taken(self, lit_timeout=ARM_LIT_WAIT_S):
|
||||||
|
"""Take the arm notice down the moment the press is taken: the
|
||||||
|
controller lights the button white for its arm wait and puts it out
|
||||||
|
at the press. Without this the notice stood until the test's next
|
||||||
|
step, and an operator who still saw it pressed again, which pauses
|
||||||
|
the job. A notice replaced meanwhile is left alone."""
|
||||||
|
with self.run._lock:
|
||||||
|
nid = (self.run.notice or {}).get("id")
|
||||||
|
if nid is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def watch():
|
||||||
|
t0 = time.time()
|
||||||
|
while hw.button_lit() is not True:
|
||||||
|
if time.time() - t0 > lit_timeout:
|
||||||
|
return
|
||||||
|
time.sleep(0.05)
|
||||||
|
while hw.button_lit() is not False:
|
||||||
|
if time.time() - t0 > lit_timeout + ARM_PRESS_WAIT_S:
|
||||||
|
return
|
||||||
|
time.sleep(0.05)
|
||||||
|
with self.run._lock:
|
||||||
|
same = (self.run.notice or {}).get("id") == nid
|
||||||
|
if same:
|
||||||
|
self.run.notice = None
|
||||||
|
if same:
|
||||||
|
self.log("ARM: the button went out at the press; the notice is down")
|
||||||
|
|
||||||
|
threading.Thread(target=watch, daemon=True, name="forgetest-arm-notice").start()
|
||||||
|
|
||||||
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
|
||||||
@@ -494,6 +546,7 @@ class Context:
|
|||||||
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
|
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
|
||||||
"gone - asking the operator for the arm press")
|
"gone - asking the operator for the arm press")
|
||||||
self.notice(text)
|
self.notice(text)
|
||||||
|
self._clear_when_taken()
|
||||||
return False
|
return False
|
||||||
self.log("ARM: the fixture presses when the button lights (the bench's arm_press opt-in)")
|
self.log("ARM: the fixture presses when the button lights (the bench's arm_press opt-in)")
|
||||||
|
|
||||||
@@ -534,7 +587,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
|
||||||
@@ -544,6 +597,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")
|
||||||
|
|
||||||
@@ -573,11 +630,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()
|
||||||
@@ -604,6 +666,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")
|
||||||
@@ -626,10 +700,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:
|
||||||
@@ -638,7 +848,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,24 @@
|
|||||||
|
# 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_burns # 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 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 homeoff # noqa: F401,E402
|
||||||
|
from . import tlsrec # noqa: F401,E402
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""camera.* - the lid camera pipeline through forgectrl."""
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
import json
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""camera.* - the lid camera pipeline through forgectrl."""
|
||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
from .. import hw
|
from .. import hw
|
||||||
from ..baseline import LID_LAMP_ATTR
|
from ..baseline import LID_LAMP_ATTR
|
||||||
@@ -52,13 +55,16 @@ def _jpeg_size(data):
|
|||||||
|
|
||||||
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
|
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
|
||||||
kind="auto", est_min=2,
|
kind="auto", est_min=2,
|
||||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
covers=_CAM_COVERS,
|
||||||
steps=["Setup: lid closed."],
|
steps=["Setup: lid closed."],
|
||||||
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
|
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
|
||||||
"far smaller), a second snapshot with the lid lamp switched off differs from it "
|
"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 +117,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
|
||||||
@@ -121,7 +169,7 @@ def snapshot(ctx):
|
|||||||
|
|
||||||
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera",
|
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera",
|
||||||
kind="auto", est_min=1,
|
kind="auto", est_min=1,
|
||||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
covers=_CAM_COVERS,
|
||||||
description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an "
|
description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an "
|
||||||
"8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it "
|
"8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it "
|
||||||
"actually returns are that size. A machine that reports 'unknown', or whose frames "
|
"actually returns are that size. A machine that reports 'unknown', or whose frames "
|
||||||
@@ -164,7 +212,7 @@ def sensor_profile(ctx):
|
|||||||
|
|
||||||
@test("camera.h264-stream", title="H.264 live stream", subsystem="camera",
|
@test("camera.h264-stream", title="H.264 live stream", subsystem="camera",
|
||||||
kind="auto", est_min=2,
|
kind="auto", est_min=2,
|
||||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
covers=_CAM_COVERS,
|
||||||
steps=["Setup: lid closed."],
|
steps=["Setup: lid closed."],
|
||||||
description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an "
|
description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an "
|
||||||
"init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few "
|
"init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few "
|
||||||
@@ -226,13 +274,15 @@ def h264_stream(ctx):
|
|||||||
|
|
||||||
@test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera",
|
@test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera",
|
||||||
kind="auto", est_min=1,
|
kind="auto", est_min=1,
|
||||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
covers=_CAM_COVERS,
|
||||||
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 +319,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.*")],
|
||||||
|
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 +456,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:
|
||||||
@@ -382,66 +494,3 @@ def lid_privacy(ctx):
|
|||||||
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
|
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
|
||||||
"snapshot after closing the lid -> %s", st)
|
"snapshot after closing the lid -> %s", st)
|
||||||
ctx.log("lid closed again: snapshot %d bytes", len(data))
|
ctx.log("lid closed again: snapshot %d bytes", len(data))
|
||||||
|
|
||||||
|
|
||||||
@test("camera.key-read", title="The camera key reads without a login", subsystem="camera",
|
|
||||||
kind="auto", est_min=1,
|
|
||||||
covers=[("forgectrl", "src/camkey.*"), ("forgectrl", "src/auth.c"),
|
|
||||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/panel.js"),
|
|
||||||
("forgectrl", "src/ui/index.html"), ("forgectrl", "src/ui/help.js")],
|
|
||||||
requires=["forgectrl.auth"],
|
|
||||||
description="With the read routes closed to the network (panel_open_reads=0), a camera "
|
|
||||||
"status read from the board's LAN address is refused without the key and "
|
|
||||||
"served with it, on HTTP and on HTTPS; a wrong key is refused; the key never "
|
|
||||||
"authorizes a write; a rotation from the panel route stops the old key and "
|
|
||||||
"the new one reads. The setting is put back after.")
|
|
||||||
def key_read(ctx):
|
|
||||||
from .commission import request
|
|
||||||
from .forgectrl import lan_ip
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
ip = lan_ip()
|
|
||||||
ctx.check(ip, "cannot determine the board's LAN address")
|
|
||||||
http_base = "http://%s" % ip
|
|
||||||
tls_base = "https://%s" % ip
|
|
||||||
token = {"X-ForgeFIRM-Token": fc.token}
|
|
||||||
|
|
||||||
st, body, _ = request(fc.base, "GET", "/system/camera-key", headers=token)
|
|
||||||
ctx.check(st == 200, "GET /system/camera-key from loopback -> %s", st)
|
|
||||||
key = json.loads(body)["key"]
|
|
||||||
ctx.check(len(key) == 32, "the key is not 32 characters: %r", key)
|
|
||||||
ev["key_len"] = len(key)
|
|
||||||
|
|
||||||
before = fc.settings().get("panel_open_reads", "")
|
|
||||||
st, _ = fc.post("/settings", params={"panel_open_reads": "0"})
|
|
||||||
ctx.check(st == 200, "closing the reads -> %s", st)
|
|
||||||
try:
|
|
||||||
for base in (http_base, tls_base):
|
|
||||||
st, _, _ = request(base, "GET", "/cam/status")
|
|
||||||
ctx.log("GET %s/cam/status (no key) -> %s", base, st)
|
|
||||||
ctx.check(st == 403, "a closed read without the key -> %s, expected 403", st)
|
|
||||||
st, _, _ = request(base, "GET", "/cam/status?key=" + key)
|
|
||||||
ctx.log("GET %s/cam/status?key=... -> %s", base, st)
|
|
||||||
ctx.check(st == 200, "a read with the key -> %s, expected 200", st)
|
|
||||||
st, _, _ = request(base, "GET", "/cam/status", headers={"X-ForgeFIRM-Camera-Key": key})
|
|
||||||
ctx.check(st == 200, "a read with the key header -> %s, expected 200", st)
|
|
||||||
wrong = ("0" if key[0] != "0" else "1") + key[1:]
|
|
||||||
st, _, _ = request(base, "GET", "/cam/status?key=" + wrong)
|
|
||||||
ctx.check(st == 403, "a wrong key -> %s, expected 403", st)
|
|
||||||
# the key is a read credential only
|
|
||||||
st, _, _ = request(tls_base, "POST", "/settings", data={"panel_open_reads": "0"},
|
|
||||||
headers={"X-ForgeFIRM-Camera-Key": key})
|
|
||||||
ctx.log("POST /settings with the camera key -> %s", st)
|
|
||||||
ctx.check(st == 403, "the camera key authorized a write (%s)", st)
|
|
||||||
# rotation
|
|
||||||
st, body, _ = request(fc.base, "POST", "/system/camera-key?rotate=1", headers=token)
|
|
||||||
ctx.check(st == 200, "rotate -> %s", st)
|
|
||||||
new = json.loads(body)["key"]
|
|
||||||
ctx.check(new != key, "rotation kept the same key")
|
|
||||||
st, _, _ = request(http_base, "GET", "/cam/status?key=" + key)
|
|
||||||
ctx.check(st == 403, "the old key still reads after rotation (%s)", st)
|
|
||||||
st, _, _ = request(http_base, "GET", "/cam/status?key=" + new)
|
|
||||||
ctx.check(st == 200, "the new key does not read (%s)", st)
|
|
||||||
ev["rotated"] = True
|
|
||||||
finally:
|
|
||||||
fc.post("/settings", params={"panel_open_reads": before})
|
|
||||||
|
|||||||
+128
-285
@@ -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,9 +372,11 @@ 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":
|
||||||
# Back to exactly what the machine had: unset is the empty
|
# Back to exactly what the machine had: unset is the empty
|
||||||
@@ -380,6 +387,7 @@ def mode_switch(ctx):
|
|||||||
if not ev["homing_mode"]
|
if not ev["homing_mode"]
|
||||||
else fc.post("/settings", data={"homing_mode": 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"])
|
||||||
|
|
||||||
@@ -494,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
|
||||||
|
|
||||||
@@ -553,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)."""
|
||||||
@@ -685,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
|
||||||
@@ -1010,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)
|
||||||
@@ -1211,173 +1262,30 @@ def lid_interlock_abort_body(ctx, ev, off, job, offset):
|
|||||||
ev.get("edge_to_stop_ms"))
|
ev.get("edge_to_stop_ms"))
|
||||||
|
|
||||||
|
|
||||||
@test("cloud.lid-during-button-wait", title="Lid open at the cloud button prompt cancels the print",
|
# ---------------------------------------------------------------- the dark print (exthost.armed-freeze)
|
||||||
subsystem="cloud", kind="operator", est_min=8,
|
|
||||||
covers=_MACHINE_RUN, requires=[], actions=["lid"],
|
|
||||||
steps=[OFFLINE_STEP, LID_STEP,
|
|
||||||
"The job is longer than the ring holds, so the ring is filled before the button "
|
|
||||||
"lights; that takes a minute. When the button lights white, do NOT press it - open "
|
|
||||||
"the lid, and close it when told. Nothing moves and nothing fires."],
|
|
||||||
description="A cloud print waiting for the button is canceled by the lid: the wait ends "
|
|
||||||
"with the lid named as the reason, the laser latch relocks, the armed window "
|
|
||||||
"closes, no run starts, and the job ends ':cancelled'. The job is longer than "
|
|
||||||
"the ring, so its feeder is alive through the wait with the rest of the print "
|
|
||||||
"in hand: the cancel has to stop that feeder before the park clears the ring, "
|
|
||||||
"and the ring has to stay empty afterward. A feeder left alive would refill "
|
|
||||||
"it and the park, which ignores the lid, would play the print's path.")
|
|
||||||
def lid_during_button_wait(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
offset = enter_offline(ctx)
|
|
||||||
job = offline_job(ctx, "wait.puls", seconds=3500)
|
|
||||||
off = Offline().__enter__()
|
|
||||||
try:
|
|
||||||
lid_during_button_wait_body(ctx, ev, off, job, offset)
|
|
||||||
finally:
|
|
||||||
off.__exit__(None, None, None)
|
|
||||||
offline_cleanup(ctx)
|
|
||||||
|
|
||||||
|
def dark_print_body(ctx, ev, off, job, offset, fc):
|
||||||
def lid_during_button_wait_body(ctx, ev, off, job, offset):
|
|
||||||
off.print_ready(9003, job)
|
|
||||||
got = wait_log(ctx, offset, ["job is longer than the ring", "waiting for button"], 180)
|
|
||||||
ctx.check(got["job is longer than the ring"],
|
|
||||||
"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)
|
|
||||||
relock = "button wait lid opened - relocking the laser"
|
|
||||||
got = wait_log(ctx, offset, [relock], 60)
|
|
||||||
fin = wait_action_finished(ctx, offset, "print", 60)
|
|
||||||
ev["log"] = {relock: bool(got[relock]), "print finished": message(fin)}
|
|
||||||
ctx.check(got[relock], "the lid did not end the button wait")
|
|
||||||
ctx.check(fin and CANCELLED in fin, "the print did not end ':cancelled': %s",
|
|
||||||
message(fin) or "no finish line")
|
|
||||||
# No run may start between the button wait and the cancel: the print
|
|
||||||
# itself, or a park (the head never moved, there is nothing to park).
|
|
||||||
# The service's moves BEFORE the print are legitimate runs and are
|
|
||||||
# outside this window.
|
|
||||||
lines = log_lines_since(GFCLOUD_LOG, offset)
|
|
||||||
wait_i = next((i for i, ln in enumerate(lines) if "waiting for button" in ln), None)
|
|
||||||
end_i = action_finish_index(lines, "print")
|
|
||||||
if end_i is None or wait_i is None or end_i < wait_i:
|
|
||||||
end_i = len(lines)
|
|
||||||
ran = ([ln for ln in lines[wait_i:end_i] if "starting run" in ln]
|
|
||||||
if wait_i is not None else [])
|
|
||||||
ev["runs_started_after_wait"] = len(ran)
|
|
||||||
ctx.check(not ran, "a run started after the lid-open cancel (%d)", len(ran))
|
|
||||||
st, cs = ctx.forgectrl.get("/cool/status")
|
|
||||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
|
||||||
ev["latch_locked"] = latch_locked()
|
|
||||||
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
|
|
||||||
ctx.check(ev["latch_locked"], "kernel latch not locked after the cancel")
|
|
||||||
ev["button_dark"] = hw.button_lit()
|
|
||||||
ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
|
|
||||||
# The park cleared the ring and nothing refilled it: the feeder is gone,
|
|
||||||
# the device is out of live-feed mode, and the program total stands at
|
|
||||||
# zero and stays there. A feeder left alive shows here as a total that
|
|
||||||
# comes back within its retry period.
|
|
||||||
total = read_program_total()
|
|
||||||
ctx.sleep(2)
|
|
||||||
later = read_program_total()
|
|
||||||
streaming = hw.sysfs_int("cnc/streaming", 0)
|
|
||||||
ev["program_total_after"] = {"first": total, "later": later}
|
|
||||||
ev["streaming_after"] = streaming
|
|
||||||
ctx.check(total == 0 and later == 0,
|
|
||||||
"the ring is not empty after the cancel (program total %s, then %s): the feeder "
|
|
||||||
"outlived the job", total, later)
|
|
||||||
ctx.check(streaming == 0, "cnc/streaming is %s after the cancel", streaming)
|
|
||||||
ctx.act("lid", "close")
|
|
||||||
settle_cloud(ctx, offset)
|
|
||||||
ev["events"] = offline_events(off)
|
|
||||||
ctx.log("PASS: lid open at the button prompt canceled the print; latch locked, armed=false, "
|
|
||||||
"button dark, ring empty with the feed stopped")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- the cooling verdict
|
|
||||||
|
|
||||||
HOLD_KEYS = ("cool_temp_min", "cool_temp_start")
|
|
||||||
WARMUP_ABOVE_C = 1.0 # the start gate this far above the loop: a few heater minutes
|
|
||||||
WARMUP_RELEASE_S = 720 # the flow heater warms the bulk 0.4 to 0.8 C a minute
|
|
||||||
WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP"
|
|
||||||
WARMUP_RELEASE_LINE = "cooling verdict clean after WARMUP; starting the run"
|
|
||||||
|
|
||||||
|
|
||||||
@test("cloud.verdict-hold", title="A cloud print armed under the warm-up gate waits for the engine",
|
|
||||||
subsystem="cloud", kind="operator", est_min=14,
|
|
||||||
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
|
||||||
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
|
|
||||||
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
|
|
||||||
steps=[OFFLINE_STEP,
|
|
||||||
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate just above "
|
|
||||||
"the coolant and restores it. Press the button when it lights: the print then waits "
|
|
||||||
"for the warm-up (a few minutes with the loop heater on), runs dark, and finishes."],
|
|
||||||
description="The cooling-engine contract in cloud mode, on the engine itself: the start gate "
|
|
||||||
"set just above the coolant makes the armed session open under the warm-up "
|
|
||||||
"(WARMUP: fire blocked, hold), and the client waits it out after the button "
|
|
||||||
"instead of canceling - nothing runs, the latch stays unlocked for the armed "
|
|
||||||
"window - until the engine releases into run, and the release starts the print, "
|
|
||||||
"which completes. The hold mid-run (the same laser-off pause as the button's, "
|
|
||||||
"resumed on the engine's resume_ok) and the bound on a hold are host-tested: the "
|
|
||||||
"gates apply at session open, so no setting can produce a hold mid-run here.")
|
|
||||||
def verdict_hold(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
before = fc.settings()
|
|
||||||
orig = {k: before.get(k, "") for k in HOLD_KEYS}
|
|
||||||
ev["orig"] = orig
|
|
||||||
up = (fc.status().get("coolant") or {}).get("up_c")
|
|
||||||
ctx.check(up is not None and 10.0 <= up <= 30.0, "coolant outside the test's range (up_c %s)", up)
|
|
||||||
offset = enter_offline(ctx)
|
|
||||||
job = offline_job(ctx, "hold.puls", seconds=30)
|
|
||||||
off = Offline().__enter__()
|
|
||||||
try:
|
|
||||||
verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig)
|
|
||||||
finally:
|
|
||||||
st, body = fc.post("/settings", params=orig)
|
|
||||||
ctx.log("restore: POST /settings %s -> %s", orig, st)
|
|
||||||
off.__exit__(None, None, None)
|
|
||||||
offline_cleanup(ctx)
|
|
||||||
|
|
||||||
|
|
||||||
def verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig):
|
|
||||||
gate = round(up + WARMUP_ABOVE_C, 1)
|
|
||||||
st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
|
|
||||||
"cool_temp_min": orig["cool_temp_min"]})
|
|
||||||
ctx.check(st == 200, "POST /settings cool_temp_start=%s -> %s %s", gate, st, body)
|
|
||||||
ev["gate"] = gate
|
|
||||||
off.print_ready(9005, job)
|
off.print_ready(9005, 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")
|
||||||
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
|
ctx.act("button", "press", text="The button is lit: press it. The print runs dark and finishes.",
|
||||||
got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30)
|
until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=PRESS_TIMEOUT_S)
|
||||||
ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up")
|
run = wait_print_running(ctx, offset, 60)
|
||||||
st, c = fc.get("/cool/status")
|
ctx.check(run is not None, "the print did not start after the press")
|
||||||
ev["warmup"] = c
|
ctx.sleep(1.0)
|
||||||
ctx.check(isinstance(c, dict) and c.get("verdict") == "WARMUP" and c.get("hold") is True
|
ev["latch_locked_in_run"] = latch_locked()
|
||||||
and c.get("fire_ok") is False,
|
ctx.check(ev["latch_locked_in_run"] is False, "the latch is locked during the run")
|
||||||
"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()
|
|
||||||
run = wait_print_running(ctx, offset, WARMUP_RELEASE_S)
|
|
||||||
finally:
|
|
||||||
ctx.clear_notice()
|
|
||||||
ev["release_s"] = round(time.time() - t0)
|
|
||||||
got = wait_log(ctx, offset, [WARMUP_RELEASE_LINE], 5)
|
|
||||||
ev["log"] = {WARMUP_WAIT_LINE: True, WARMUP_RELEASE_LINE: bool(got[WARMUP_RELEASE_LINE])}
|
|
||||||
ctx.check(got[WARMUP_RELEASE_LINE], "the warm-up did not release into the run within %d s",
|
|
||||||
WARMUP_RELEASE_S)
|
|
||||||
ctx.check(run is not None, "the print did not start after the warm-up")
|
|
||||||
fin = wait_action_finished(ctx, offset, "print", 180)
|
fin = wait_action_finished(ctx, offset, "print", 180)
|
||||||
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 COMPLETED in fin, "the print did not complete: %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 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)
|
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: locked at the button, unlocked for the run, locked after it; the print completed")
|
||||||
|
|
||||||
|
|
||||||
@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)",
|
||||||
@@ -1613,68 +1521,3 @@ def oversize_stream_body(ctx, ev, off, job, offset, before):
|
|||||||
"stopped it, parked, relocked and reported ':cancelled'")
|
"stopped it, parked, relocked and reported ':cancelled'")
|
||||||
|
|
||||||
|
|
||||||
@test("cloud.paused-lid-cancel", title="A paused cloud print is canceled by the lid",
|
|
||||||
subsystem="cloud", kind="live", est_min=6,
|
|
||||||
covers=_MACHINE_RUN, requires=["cloud.pause-resume", "cloud.lid-interlock-abort"],
|
|
||||||
actions=["button", "lid"],
|
|
||||||
steps=[OFFLINE_STEP, ARM_STEP, LID_STEP,
|
|
||||||
"The head needs 40 mm of free +X and +Y travel.",
|
|
||||||
"Press the button to start; when asked, press it again (pause), then open the lid and "
|
|
||||||
"leave it open until the head is back; close it when told."],
|
|
||||||
description="A job paused on the button is canceled by the lid, from the state the factory "
|
|
||||||
"ends it in - there is no resume past a lid open. The same tail as every abort: "
|
|
||||||
"the motion stops, the head parks back at the job start, the latch relocks and "
|
|
||||||
"the armed window closes, the button goes dark, and the print ends ':cancelled'. "
|
|
||||||
"(The app's own cancel of a running print is exercised where a print has to be "
|
|
||||||
"ended that way: cloud.oversize-stream.)")
|
|
||||||
def paused_lid_cancel(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
offset = enter_offline(ctx)
|
|
||||||
job = offline_job(ctx, "paused.puls", seconds=60)
|
|
||||||
off = Offline().__enter__()
|
|
||||||
try:
|
|
||||||
paused_lid_cancel_body(ctx, ev, off, job, offset)
|
|
||||||
finally:
|
|
||||||
off.__exit__(None, None, None)
|
|
||||||
offline_cleanup(ctx)
|
|
||||||
|
|
||||||
|
|
||||||
def paused_lid_cancel_body(ctx, ev, off, job, offset):
|
|
||||||
# -- paused on the button, then the lid ---------------------------------
|
|
||||||
offline_start_print(ctx, off, 9005, job, offset)
|
|
||||||
ctx.act("button", "press", text="The head is moving: the press pauses the print.",
|
|
||||||
until=lambda: log_has(offset, PAUSE_LINES[0]), timeout=PRESS_TIMEOUT_S, fail=False)
|
|
||||||
got = wait_log(ctx, offset, list(PAUSE_LINES), 10)
|
|
||||||
ev["paused"] = {k: bool(v) for k, v in got.items()}
|
|
||||||
ctx.check(got["button pressed mid-run; pausing"], "the press did not pause the print")
|
|
||||||
ctx.check(got["paused at"], "the pause did not settle (no 'paused at')")
|
|
||||||
ctx.act("lid", "open", text="The print is paused: leave the lid open until the head has returned "
|
|
||||||
"to the corner.", timeout=60)
|
|
||||||
lid_stop = "lid opened mid-run; stopping motion"
|
|
||||||
got = wait_log(ctx, offset, [lid_stop, "start return home", "return home complete"], 120)
|
|
||||||
fin1 = wait_action_finished(ctx, offset, "print", 60)
|
|
||||||
ev["lid_from_pause"] = {k: message(v) for k, v in got.items()}
|
|
||||||
ev["lid_from_pause"]["print finished"] = message(fin1)
|
|
||||||
for k, v in ev["lid_from_pause"].items():
|
|
||||||
ctx.log(" [print] %s: %s", k, "seen" if v else "MISSING")
|
|
||||||
ctx.check(got[lid_stop], "the lid did not end the paused print")
|
|
||||||
ctx.check(got["return home complete"], "the paused print did not park to completion")
|
|
||||||
ctx.check(fin1 and CANCELLED in fin1, "the print did not end ':cancelled': %s",
|
|
||||||
message(fin1) or "no finish line")
|
|
||||||
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the park")
|
|
||||||
kpos = read_position()
|
|
||||||
ev["counters_after"] = kpos
|
|
||||||
ctx.check(kpos is not None and abs(kpos[0]) <= 3 and abs(kpos[1]) <= 3,
|
|
||||||
"the head did not come back to the job start (kernel counters %s)", kpos)
|
|
||||||
st, cs = ctx.forgectrl.get("/cool/status")
|
|
||||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
|
||||||
ev["latch_locked_after"] = latch_locked()
|
|
||||||
ctx.check(not ev["armed_after"], "armed window still open after the paused print was canceled")
|
|
||||||
ctx.check(ev["latch_locked_after"],
|
|
||||||
"kernel latch not locked after the paused print was canceled")
|
|
||||||
ev["button_dark"] = hw.button_lit()
|
|
||||||
ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
|
|
||||||
ctx.act("lid", "close")
|
|
||||||
settle_cloud(ctx, offset)
|
|
||||||
ev["events"] = offline_events(off)
|
|
||||||
ctx.log("PASS: a paused print canceled by the lid stopped, parked, relocked and reported ':cancelled'")
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,277 +0,0 @@
|
|||||||
"""commission.sheet - the setup's sheet (the live wizards) in one run,
|
|
||||||
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
|
|
||||||
polled, the prompts answered from here, each burn armed by a press, the
|
|
||||||
result judged, and every setting a wizard wrote put back as found. One
|
|
||||||
piece of wood at least 200 x 150 mm (8 x 6 in) carries the placement,
|
|
||||||
the frame, and the five cards. Each wizard re-runs on the real record,
|
|
||||||
so a completed one completes again at the same version.
|
|
||||||
|
|
||||||
The operator's part is one press on the machine at the start (the
|
|
||||||
presence check of the ready gate; with the bench actuator up it then
|
|
||||||
makes every arm press) and nothing else. The judgments a person makes
|
|
||||||
by eye on the sheet (the thinnest line, the faintest rung, the best
|
|
||||||
corner, the frame's fit) are answered here with a middle option or the
|
|
||||||
witnesses: the test proves each wizard's mechanics and the emission
|
|
||||||
witnesses, not the operator's eye, and the settings it writes are
|
|
||||||
restored. The page is where the real numbers go in.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .commission_dark import run_check, Restore
|
|
||||||
|
|
||||||
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
|
||||||
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
|
|
||||||
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
|
||||||
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
|
||||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
|
||||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
|
||||||
("forgectrl", "src/super.c"),
|
|
||||||
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
|
||||||
("forgectrl", "src/accel.*"), ("forgectrl", "src/wizcalc.*"),
|
|
||||||
("grblhal-glowforge", "src/glowforge_laser.c"),
|
|
||||||
("grblhal-glowforge", "src/glowforge_status.c"),
|
|
||||||
("grblhal-glowforge", "src/glowforge_homing.*"),
|
|
||||||
("grblhal-glowforge", "src/glowforge_io.*")]
|
|
||||||
|
|
||||||
# The live wizards in the order the sheet burns them: the wizard id, the
|
|
||||||
# answers to its prompts (a middle pick where a person would look at the
|
|
||||||
# sheet), and the keys its result must carry. The arm press is not timed
|
|
||||||
# from here: each card opens a press prompt of its own when the button is
|
|
||||||
# lit and the tube still dark, and run_check presses on that.
|
|
||||||
CARDS = [
|
|
||||||
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"]),
|
|
||||||
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"},
|
|
||||||
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
|
|
||||||
"focus_range_mm", "stops"]),
|
|
||||||
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"]),
|
|
||||||
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"]),
|
|
||||||
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"]),
|
|
||||||
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"]),
|
|
||||||
]
|
|
||||||
# Every setting a card writes, restored as found when the run ends.
|
|
||||||
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
|
|
||||||
"laser_floor_density", "laser_dose_curve", "laser_corner_gamma",
|
|
||||||
"cool_laser_heat_density", "cool_laser_heat_cw"]
|
|
||||||
# The evidence run_check and the witnesses write at the top level, moved
|
|
||||||
# under the card's own key once it is done.
|
|
||||||
CARD_EVIDENCE = ("started", "elapsed_s", "phases", "log", "error", "result",
|
|
||||||
"preview_bytes", "program_lines", "emission")
|
|
||||||
|
|
||||||
|
|
||||||
def preview_ok(ctx, wid):
|
|
||||||
"""The page's preview and the program for the wizard are served."""
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
|
|
||||||
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
|
|
||||||
ctx.evidence["preview_bytes"] = len(body)
|
|
||||||
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
|
|
||||||
# The text and the frame are M4 at the mark dose; the patterns bring
|
|
||||||
# their own M3 or M4 lines. A program is one with a laser-on command.
|
|
||||||
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
|
||||||
"no program for %s (%s)", wid, st)
|
|
||||||
ctx.evidence["program_lines"] = body.count(b"\n")
|
|
||||||
# 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):
|
|
||||||
"""The three witnesses of the burn, from the wizard's result."""
|
|
||||||
e = (last.get("result") or {}).get("emission") or {}
|
|
||||||
ctx.evidence["emission"] = e
|
|
||||||
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
|
|
||||||
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
|
|
||||||
ctx.check(e.get("thermopile_delta", 0) >= 100, "the thermopile did not see the beam (%s)",
|
|
||||||
e.get("thermopile_delta"))
|
|
||||||
|
|
||||||
|
|
||||||
def file_card(ctx, wid):
|
|
||||||
"""Move the card's evidence under its own key."""
|
|
||||||
ev = ctx.evidence
|
|
||||||
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
|
||||||
|
|
||||||
|
|
||||||
# The lens settings: the head's numbers, which a fresh machine does not
|
|
||||||
# have. Cleared before the cards so the run is a fresh machine's.
|
|
||||||
LENS_SETTINGS = CARD_SETTINGS[:3]
|
|
||||||
FALLBACK_WINDOW = (10, 12)
|
|
||||||
|
|
||||||
|
|
||||||
def fresh_lens(ctx):
|
|
||||||
"""The lens as a fresh machine has it: the head's numbers unknown, so
|
|
||||||
the frame runs in the fallback window and the focus card has to find
|
|
||||||
the stops and write them before its ladder burns. The machine's own
|
|
||||||
numbers come back with the restore at the end of the run."""
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
for k in LENS_SETTINGS:
|
|
||||||
st, _ = fc.post("/settings", params={k: ""})
|
|
||||||
ctx.check(st == 200, "clearing %s -> %s", k, st)
|
|
||||||
lens = (fc.status() or {}).get("lens") or {}
|
|
||||||
ctx.evidence["lens_fresh"] = lens
|
|
||||||
ctx.check(lens.get("stops_found") is False and (lens.get("below"), lens.get("above")) == FALLBACK_WINDOW,
|
|
||||||
"the lens window is not the fallback after the clear: %s", lens)
|
|
||||||
|
|
||||||
|
|
||||||
def focus_window_ok(ctx):
|
|
||||||
"""The window the focus card burned its ladder in is the one it wrote
|
|
||||||
before its controller started, and the one the machine holds now: the
|
|
||||||
result's window is the stops it found (or the fallback), the settings
|
|
||||||
hold that window, every ladder height lies in its reach, and the
|
|
||||||
program served now agrees with /status."""
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
r = ((ctx.evidence.get("cards") or {}).get("laser.focus") or {}).get("result") or {}
|
|
||||||
w, stops = r.get("window") or {}, r.get("stops") or {}
|
|
||||||
s = fc.settings() or {}
|
|
||||||
held = (str(s.get("lens_stop_below_steps")), str(s.get("lens_stop_above_steps")))
|
|
||||||
ctx.check(held == (str(w.get("below")), str(w.get("above"))),
|
|
||||||
"the settings hold the window %s, the ladder ran in %s/%s", held, w.get("below"), w.get("above"))
|
|
||||||
if stops.get("found"):
|
|
||||||
ctx.check((w.get("below"), w.get("above")) == (stops.get("below"), stops.get("above")),
|
|
||||||
"the ladder's window %s is not the stops found %s", w, stops)
|
|
||||||
else:
|
|
||||||
ctx.check((w.get("below"), w.get("above")) == FALLBACK_WINDOW,
|
|
||||||
"stops not found (%s), but the ladder's window is %s", stops.get("why"), w)
|
|
||||||
zs = r.get("ladder_z") or []
|
|
||||||
lo, hi = w.get("reach_min"), w.get("reach_max")
|
|
||||||
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
|
|
||||||
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
|
|
||||||
ev = ctx.evidence
|
|
||||||
before = dict(ev.get("program_z") or {}).get("laser.focus")
|
|
||||||
preview_ok(ctx, "laser.focus")
|
|
||||||
ev["focus_window"] = {"window": w, "stops": stops, "ladder_z": zs,
|
|
||||||
"settings": {k: s.get(k) for k in LENS_SETTINGS},
|
|
||||||
"program_z_before": before,
|
|
||||||
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
|
|
||||||
"preview_bytes": ev.pop("preview_bytes", None),
|
|
||||||
"program_lines": ev.pop("program_lines", None)}
|
|
||||||
|
|
||||||
|
|
||||||
def thickness_answer(ctx):
|
|
||||||
"""The thickness the placement is answered with, in the machine's
|
|
||||||
units (the number prompts read ui_units), and the millimeters the
|
|
||||||
record must carry for it."""
|
|
||||||
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
|
|
||||||
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
|
|
||||||
|
|
||||||
|
|
||||||
def place(ctx):
|
|
||||||
"""The placement: the lens reference, two jogs, the origin at the
|
|
||||||
head's position, the full sheet, a thickness. Nothing fires."""
|
|
||||||
jogs = ["X+10", "X-10"]
|
|
||||||
answer, want_mm = thickness_answer(ctx)
|
|
||||||
ctx.evidence["thickness_answer"] = answer
|
|
||||||
|
|
||||||
def on_prompt(p):
|
|
||||||
# Each jog answer closes the prompt and the wizard asks again with
|
|
||||||
# the next sequence number, so the pad is answered one move at a
|
|
||||||
# time until the origin is set.
|
|
||||||
if p.get("id") == "place":
|
|
||||||
return jogs.pop(0) if jogs else "Set origin"
|
|
||||||
if p.get("id") == "sheet-kind":
|
|
||||||
return "Full sheet"
|
|
||||||
if p.get("id") == "thickness":
|
|
||||||
return answer
|
|
||||||
return None
|
|
||||||
|
|
||||||
last = run_check(ctx, "sheet.place", on_prompt, 300)
|
|
||||||
r = last.get("result") or {}
|
|
||||||
ctx.check("origin_x" in r and "origin_y" in r, "no origin in the result: %s", r)
|
|
||||||
ctx.check((r.get("steps_per_mm") or {}).get("x", 0) > 0, "no steps per mm: %s", r)
|
|
||||||
ctx.check(r.get("z_referenced") is True, "the lens was not referenced")
|
|
||||||
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
|
|
||||||
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
|
|
||||||
ctx.check(r.get("alone") is False, "alone %s", r.get("alone"))
|
|
||||||
ctx.log("placed: datum at %s, %s; steps per mm %s", r.get("origin_x"), r.get("origin_y"),
|
|
||||||
r.get("steps_per_mm"))
|
|
||||||
file_card(ctx, "sheet.place")
|
|
||||||
|
|
||||||
|
|
||||||
def burn(ctx, wid, answers, want):
|
|
||||||
"""One live card: the preview, the burn after the press with the
|
|
||||||
prompts answered from `answers`, the witnesses, the keys of the
|
|
||||||
result in `want`. run_check makes the arm press when the card asks
|
|
||||||
for it."""
|
|
||||||
preview_ok(ctx, wid)
|
|
||||||
|
|
||||||
def on_prompt(p):
|
|
||||||
return answers.get(p.get("id"))
|
|
||||||
|
|
||||||
try:
|
|
||||||
last = run_check(ctx, wid, on_prompt, 900)
|
|
||||||
finally:
|
|
||||||
ctx.clear_notice()
|
|
||||||
emission_ok(ctx, last)
|
|
||||||
r = last.get("result") or {}
|
|
||||||
missing = [k for k in want if k not in r]
|
|
||||||
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
|
|
||||||
ctx.log("%s: %s, emission %s", wid, {k: r.get(k) for k in want}, ctx.evidence.get("emission"))
|
|
||||||
file_card(ctx, wid)
|
|
||||||
|
|
||||||
|
|
||||||
@test("commission.sheet", title="The commissioning sheet: the placement, the frame, and the five cards",
|
|
||||||
subsystem="commission", kind="live", hardware="takeover", mode="grbl", est_min=35,
|
|
||||||
covers=SHEET_COVERS,
|
|
||||||
requires=["forgectrl.auth", "commission.check-motion", "laser.emission-witness", "cooling.flow-verify"],
|
|
||||||
actions=["button"], hands=["scrap"],
|
|
||||||
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
|
|
||||||
"it goes with its top edge at the top of the cut area (the head's home corner); "
|
|
||||||
"lid closed. Eye protection on, exhaust on, extinguisher in reach.",
|
|
||||||
"Start the test and press the machine's button once. That press says you are at the "
|
|
||||||
"machine, and the bench actuator makes every arm press after it, each one when the "
|
|
||||||
"card itself asks for it. With no actuator the start is the Ready answer instead, "
|
|
||||||
"and you press the button each time the page asks: six times, the frame and the "
|
|
||||||
"five cards.",
|
|
||||||
"Nothing else: the test answers every prompt itself and puts back every setting the "
|
|
||||||
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
|
|
||||||
"tails."],
|
|
||||||
description="One run over the sheet's seven wizards on one piece of wood, as a fresh "
|
|
||||||
"machine runs them: the three lens settings are cleared first, so the frame "
|
|
||||||
"runs in the fallback window and the focus card has to find the stops and write "
|
|
||||||
"them before its ladder burns. "
|
|
||||||
"POST /wiz/sheet.place/start references the lens, takes the controller in "
|
|
||||||
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
|
|
||||||
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
|
|
||||||
"0.125 in). Then, each after the press and each with its preview "
|
|
||||||
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the "
|
|
||||||
"program commands inside the reach /status reports: the frame "
|
|
||||||
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
|
|
||||||
"lens on its hall edge, the stops found by the head accelerometer and written "
|
|
||||||
"as the window before the controller starts, twelve lines from the top of the "
|
|
||||||
"window to the bottom; the pick is line 11, the thickness kept; after it the "
|
|
||||||
"settings, the result's window, and the served program must hold the window "
|
|
||||||
"the ladder ran in); the floor card (twelve "
|
|
||||||
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
|
|
||||||
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
|
|
||||||
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
|
|
||||||
"through M102 inside one armed job; the pick is 1.50); and the flow-load card "
|
|
||||||
"(the coolant loop settled, the box and the patch at 60 percent, the "
|
|
||||||
"downstream peak over 100 s of dark as the coefficient). Every burn must be "
|
|
||||||
"seen by the three witnesses (the tube current, the kernel's LASER_ON samples, "
|
|
||||||
"the thermopile rise); every result must carry its keys; the record must "
|
|
||||||
"carry each wizard at version 1; the seven settings the cards write are "
|
|
||||||
"restored at the end.")
|
|
||||||
def sheet(ctx):
|
|
||||||
ctx.ready("The sheet: the placement, then the frame and the five cards burn after a press "
|
|
||||||
"each. The bench presses when the button lights.")
|
|
||||||
place(ctx)
|
|
||||||
with Restore(ctx, CARD_SETTINGS):
|
|
||||||
fresh_lens(ctx)
|
|
||||||
for wid, answers, want in CARDS:
|
|
||||||
burn(ctx, wid, answers, want)
|
|
||||||
if wid == "laser.focus":
|
|
||||||
focus_window_ok(ctx)
|
|
||||||
ctx.log("PASS: the sheet's seven wizards ran on one piece as a fresh machine's; settings restored")
|
|
||||||
+130
-1059
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,11 +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 socket
|
import socket
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
from .. import hw
|
|
||||||
|
|
||||||
# The write guard reads the account and the session store (a machine with
|
# The write guard reads the account and the session store (a machine with
|
||||||
# an account needs a session from the LAN; this host and the dev image
|
# an account needs a session from the LAN; this host and the dev image
|
||||||
@@ -56,6 +59,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),
|
||||||
@@ -136,14 +140,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
|
||||||
@@ -151,12 +180,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}
|
||||||
@@ -283,131 +311,3 @@ def settings_bounds(ctx):
|
|||||||
ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied)
|
ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied)
|
||||||
ctx.check(back == want, "lamp did not return to %s after the restore (reads %s)", want, back)
|
ctx.check(back == want, "lamp did not return to %s after the restore (reads %s)", want, back)
|
||||||
ctx.log("lid lamp follows the setting live and returns to %s", want)
|
ctx.log("lid lamp follows the setting live and returns to %s", want)
|
||||||
|
|
||||||
|
|
||||||
@test("forgectrl.panel-serves", title="Control panel and status endpoints", subsystem="forgectrl",
|
|
||||||
kind="auto", est_min=1,
|
|
||||||
covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"),
|
|
||||||
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
|
|
||||||
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
|
|
||||||
("forgectrl", "src/curverec.*")],
|
|
||||||
description="The panel page is served, /status carries the machine telemetry the panel and "
|
|
||||||
"the acceptance tool read (including the sys block: CPU busy percent over the "
|
|
||||||
"interval since the previous read, memory used percent; and homed_axes, "
|
|
||||||
"the axes that carry a reference, Z alone once the lens has taken its "
|
|
||||||
"own at the controller's start), and /cam/status "
|
|
||||||
"answers. In GRBL mode with a live controller, /status also echoes the "
|
|
||||||
"controller's published state file as the grbl block (fresh age, machine "
|
|
||||||
"state, sender session, laser window and dose model, modal report), "
|
|
||||||
"GET /grbl/settings serves the published $$ view, and the controller's "
|
|
||||||
"settings store is /data/forgefirm/EEPROM-glowforge.DAT with nothing of "
|
|
||||||
"it at the top of /data.")
|
|
||||||
def panel_serves(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
st, body = fc.get("/", raw=True)
|
|
||||||
ev["panel_status"] = st
|
|
||||||
ctx.log("GET / -> %s (%d bytes)", st, len(body) if body else 0)
|
|
||||||
ctx.check(st == 200, "GET / -> %s", st)
|
|
||||||
text = body.decode("utf-8", "replace")
|
|
||||||
ctx.check("<html" in text.lower() and "ForgeFIRM" in text, "the panel does not look like the panel")
|
|
||||||
ctx.check(fc.token and fc.token in text, "the panel does not embed the bearer token")
|
|
||||||
ctx.check("<link " not in text and "<script src=" not in text,
|
|
||||||
"the panel references an external asset (the build did not bundle src/ui/)")
|
|
||||||
# The daemon stores the page gzipped and inflates it once at first
|
|
||||||
# request; what it serves is the plain page with the theme attribute
|
|
||||||
# the head script sets and the one save bar every settings tab shares.
|
|
||||||
ctx.check("data-bs-theme" in text, "the panel lacks the theme attribute (inflate failed?)")
|
|
||||||
ctx.check('id="savebar"' in text, "the panel lacks the save bar")
|
|
||||||
|
|
||||||
s = fc.status()
|
|
||||||
for key in ("state", "switches", "coolant", "fans"):
|
|
||||||
ctx.check(key in s, "/status lacks %r", key)
|
|
||||||
ev["state"] = s.get("state")
|
|
||||||
ev["switches"] = s.get("switches")
|
|
||||||
ctx.log("/status state=%s switches=%s", s.get("state"), s.get("switches"))
|
|
||||||
for key in ("lid", "button", "interlock_ok", "head", "hv_enable"):
|
|
||||||
ctx.check(key in (s.get("switches") or {}), "/status switches lacks %r", key)
|
|
||||||
|
|
||||||
# SoC utilization rides /status next to the temperatures. The CPU
|
|
||||||
# number is a delta over the interval since the previous read, so
|
|
||||||
# the read above primes it; after a beat both percents must be
|
|
||||||
# numbers in range.
|
|
||||||
ctx.sleep(1)
|
|
||||||
sys_ = ctx.forgectrl.status().get("sys") or {}
|
|
||||||
ev["sys"] = sys_
|
|
||||||
ctx.log("/status sys=%s", sys_)
|
|
||||||
ctx.check(isinstance(sys_.get("cpu_pct"), (int, float)) and 0.0 <= sys_["cpu_pct"] <= 100.0,
|
|
||||||
"/status sys.cpu_pct is not a percent: %s", sys_)
|
|
||||||
ctx.check(isinstance(sys_.get("mem_pct"), (int, float)) and 0.0 < sys_["mem_pct"] < 100.0,
|
|
||||||
"/status sys.mem_pct is not a percent: %s", sys_)
|
|
||||||
|
|
||||||
# The lens takes its own reference on the hall edge at every
|
|
||||||
# controller start, so with a controller running Z is referenced on
|
|
||||||
# its own while X and Y wait for a home: homed_axes names the axes
|
|
||||||
# that carry one, and Z reads inside the lens reach rather than the
|
|
||||||
# zero an unreferenced axis would show.
|
|
||||||
axes = s.get("homed_axes")
|
|
||||||
ev["homed_axes"] = axes
|
|
||||||
ctx.check(isinstance(axes, int) and 0 <= axes <= 7,
|
|
||||||
"/status homed_axes is not an axis mask: %s", axes)
|
|
||||||
ctx.check(bool(s.get("homed")) == (axes == 7),
|
|
||||||
"/status homed (%s) disagrees with homed_axes (%s)",
|
|
||||||
s.get("homed"), axes)
|
|
||||||
st, mode0 = fc.get("/mode")
|
|
||||||
if isinstance(mode0, dict) and mode0.get("controller") == "running":
|
|
||||||
lens = s.get("lens") or {}
|
|
||||||
pos = s.get("pos") or {}
|
|
||||||
ctx.log("/status homed_axes=%s pos.z=%s reach=%s..%s", axes,
|
|
||||||
pos.get("z"), lens.get("reach_min"), lens.get("reach_max"))
|
|
||||||
ctx.check(axes is not None and axes & 4,
|
|
||||||
"Z is not referenced with a controller running: %s", axes)
|
|
||||||
ctx.check(isinstance(pos.get("z"), (int, float))
|
|
||||||
and lens.get("reach_min") is not None
|
|
||||||
and lens["reach_min"] <= pos["z"] <= lens["reach_max"],
|
|
||||||
"Z %s is outside the lens reach %s..%s", pos.get("z"),
|
|
||||||
lens.get("reach_min"), lens.get("reach_max"))
|
|
||||||
|
|
||||||
st, cam = fc.get("/cam/status")
|
|
||||||
ev["cam_status"] = st
|
|
||||||
ctx.log("GET /cam/status -> %s %s", st, cam)
|
|
||||||
ctx.check(st == 200 and isinstance(cam, dict) and "running" in cam, "GET /cam/status -> %s", st)
|
|
||||||
|
|
||||||
# The controller's published state, echoed only while a live GRBL
|
|
||||||
# controller runs (glowforge_status.c -> /run/forgefirm -> /status).
|
|
||||||
st, mode = fc.get("/mode")
|
|
||||||
if isinstance(mode, dict) and mode.get("mode") == "grbl" and mode.get("controller") == "running":
|
|
||||||
g = ctx.forgectrl.status().get("grbl") or {}
|
|
||||||
ev["grbl"] = g
|
|
||||||
ctx.log("/status grbl=%s", g)
|
|
||||||
ctx.check(isinstance(g.get("age_s"), (int, float)) and g["age_s"] < 30,
|
|
||||||
"/status grbl block missing or stale: %s", g)
|
|
||||||
rep = g.get("report") or {}
|
|
||||||
for key in ("state", "sender", "laser", "modals"):
|
|
||||||
ctx.check(key in rep, "/status grbl.report lacks %r", key)
|
|
||||||
ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"),
|
|
||||||
"grbl.report.laser carries no model: %s", rep.get("laser"))
|
|
||||||
ctx.check((rep.get("laser") or {}).get("curve"),
|
|
||||||
"grbl.report.laser carries no dose curve: %s", rep.get("laser"))
|
|
||||||
st, text = fc.get("/grbl/settings", raw=True)
|
|
||||||
ev["grbl_settings_status"] = st
|
|
||||||
ctx.check(st == 200 and b"$35=" in (text or b""),
|
|
||||||
"GET /grbl/settings -> %s without the $$ view", st)
|
|
||||||
# The $-settings persist in the data directory, never loose in /data.
|
|
||||||
nvs = "/data/forgefirm/EEPROM-glowforge.DAT"
|
|
||||||
ev["grbl_nvs"] = os.path.isfile(nvs)
|
|
||||||
ctx.check(os.path.isfile(nvs), "the controller's settings store is not at %s", nvs)
|
|
||||||
ctx.check(not os.path.exists("/data/EEPROM-glowforge.DAT"),
|
|
||||||
"a settings store remains at the top of /data")
|
|
||||||
else:
|
|
||||||
ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode)
|
|
||||||
|
|
||||||
# The dose-curve recorder's read surface answers in any mode.
|
|
||||||
st, cs = fc.get("/curve/status")
|
|
||||||
ev["curve_status"] = cs
|
|
||||||
ctx.check(st == 200 and isinstance(cs, dict) and cs.get("state") in
|
|
||||||
("idle", "waiting", "recording", "done", "failed"),
|
|
||||||
"GET /curve/status -> %s %s", st, cs)
|
|
||||||
st, text = fc.get("/curve/ladder.gcode", raw=True)
|
|
||||||
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
|
|
||||||
"GET /curve/ladder.gcode -> %s without the ladder", st)
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""The helpers for a test that lets the web service move the head.
|
||||||
|
|
||||||
|
The homing is the web-service session cloud.mode-switch runs. These judge
|
||||||
|
that its motions ran whole, and take the head back to where the test found
|
||||||
|
it by the travel the service's motions logged. No counter reading can say
|
||||||
|
where that was: every
|
||||||
|
service motion zeroes the step counters at its start, and a home and every
|
||||||
|
controller start zero them again.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from ..baseline import XY_STEPS_PER_MM, counter_frame, counter_steps_per_mm, read_position
|
||||||
|
from ..runner import Failed
|
||||||
|
from . import cloud as logs # the log paths, read at each call
|
||||||
|
from .cloud import log_lines_since, log_size
|
||||||
|
from .motion import _drop_reference, clean_slate, machine_idle, wait_idle
|
||||||
|
|
||||||
|
RETURN_MAX_MM = 600.0 # no hand-back jogs farther on an axis: the bed is smaller
|
||||||
|
# The client's own record of a motion's end: the kernel counters, which
|
||||||
|
# the motion zeroed at its start (the "(actual/expected)" line of the same
|
||||||
|
# motion is not this one). A print's park keeps the counters and drives
|
||||||
|
# them back, so the line after it is the print's net travel.
|
||||||
|
MOTION_END = re.compile(r"end positions \((-?\d+), (-?\d+), (-?\d+)\)")
|
||||||
|
|
||||||
|
|
||||||
|
def session_mark(path=None):
|
||||||
|
"""Where a client's log ends now (gfhome's by default): the start of
|
||||||
|
the next session's record."""
|
||||||
|
return log_size(path or logs.GFHOME_LOG)
|
||||||
|
|
||||||
|
|
||||||
|
def session_travel(offset, path=None):
|
||||||
|
"""The X/Y travel of the service motions logged in a client's log
|
||||||
|
(gfhome's by default) since offset, in steps, or None when it cannot be
|
||||||
|
known. Every service motion zeroes the kernel counters at its start and
|
||||||
|
logs where they ended, so the travel is the sum of those ends. A service
|
||||||
|
motion runs at x8 or not at all (the client refuses any other mode), so
|
||||||
|
the steps are x8 steps. A motion refused before it moved logs no end
|
||||||
|
and moved nothing; a motion with no "end motion" (one that raised after
|
||||||
|
it moved, or a client killed inside it) leaves the travel unknown, and
|
||||||
|
so does a log rotated under the run."""
|
||||||
|
path = path or logs.GFHOME_LOG
|
||||||
|
if log_size(path) < offset:
|
||||||
|
return None
|
||||||
|
x = y = 0
|
||||||
|
inside, end = False, None
|
||||||
|
for ln in log_lines_since(path, offset):
|
||||||
|
ln = ln.rstrip()
|
||||||
|
m = MOTION_END.search(ln)
|
||||||
|
if ln.endswith(" start motion"):
|
||||||
|
if inside:
|
||||||
|
return None
|
||||||
|
inside, end = True, None
|
||||||
|
elif m and inside:
|
||||||
|
end = (int(m.group(1)), int(m.group(2)))
|
||||||
|
elif ln.endswith(" end motion") and inside:
|
||||||
|
if end:
|
||||||
|
x, y = x + end[0], y + end[1]
|
||||||
|
inside = False
|
||||||
|
return None if inside else (x, y)
|
||||||
|
|
||||||
|
|
||||||
|
def judge_whole_motions(ctx, ev, offset):
|
||||||
|
"""Every service motion of the homing session since offset ran whole.
|
||||||
|
One that stopped short (the cooling engine's stop, a fault) left the
|
||||||
|
head short of the camera home, and the position declared there is
|
||||||
|
false."""
|
||||||
|
short = [ln.strip()[:160] for ln in log_lines_since(logs.GFHOME_LOG, offset)
|
||||||
|
if "run stopped short" in ln
|
||||||
|
or ("_finish_action motion [" in ln and 'event ":completed"' not in ln)]
|
||||||
|
ev["short_motions"] = short
|
||||||
|
ctx.check(not short, "a service motion of the homing did not run whole, so the head stopped short of "
|
||||||
|
"the camera home and the declared position is false: %s", short)
|
||||||
|
|
||||||
|
|
||||||
|
def camera_home_return(ctx, ev, offset):
|
||||||
|
"""Hand the head back to where the test found it after gfhome_homing
|
||||||
|
ran from gfhome's log offset, whatever the session decided. The head is
|
||||||
|
the session's logged travel from there, plus, once homed, whatever the
|
||||||
|
counters read since: the home zeroed them where the session left the
|
||||||
|
head and wrote the anchor, and a controller start since would have
|
||||||
|
removed it. Unhomed, the counters still hold the last motion's end,
|
||||||
|
which the travel already counts. The camera home is dropped first,
|
||||||
|
since its envelope need not hold the spot the head came from."""
|
||||||
|
def back():
|
||||||
|
now = [0, 0, 0]
|
||||||
|
if ev.get("homed"):
|
||||||
|
now = read_position() if counter_frame() is not None else None
|
||||||
|
_drop_reference(ctx, ctx.forgectrl)
|
||||||
|
# Read after the stop: a runner the stop ended has written its last line.
|
||||||
|
travel = session_travel(offset)
|
||||||
|
rec = ev["return"] = {"session_travel_steps": travel, "counters_since_home": now}
|
||||||
|
ctx.check(travel is not None and now is not None,
|
||||||
|
"the homing session's travel cannot be known (gfhome log %s, counters since the home %s): "
|
||||||
|
"the head is left where it stands and the hand-back moves nothing", travel, now)
|
||||||
|
_jog_back(ctx, rec, travel, now)
|
||||||
|
_hand_back(ctx, ev, "return", back)
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_mode_return(ctx, ev, offset):
|
||||||
|
"""Hand the head back to where the test found it after a stay in cloud
|
||||||
|
mode that began at the cloud client's log offset, on the GRBL
|
||||||
|
controller the switch back started: that start zeroed the counters
|
||||||
|
where the service left the head, and they hold whatever moved since.
|
||||||
|
The client's record of its motions is the rest (a hunt's lens travel is
|
||||||
|
Z, which a hand-back never touches)."""
|
||||||
|
def back():
|
||||||
|
travel = session_travel(offset, logs.GFCLOUD_LOG)
|
||||||
|
now = read_position()
|
||||||
|
rec = ev["cloud_return"] = {"session_travel_steps": travel, "counters_since_start": now}
|
||||||
|
ctx.check(travel is not None and now is not None,
|
||||||
|
"the travel in cloud mode cannot be known (gfcloud log %s, counters %s): the head is left "
|
||||||
|
"where it stands and the hand-back moves nothing", travel, now)
|
||||||
|
_jog_back(ctx, rec, travel, now)
|
||||||
|
_hand_back(ctx, ev, "cloud_return", back)
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_back(ctx, ev, key, back):
|
||||||
|
"""Run a hand-back from a test's finally. The first failure wins: when
|
||||||
|
the test has failed already (the exception in flight), a hand-back that
|
||||||
|
fails as well is logged and recorded, and the test's own failure is the
|
||||||
|
one reported."""
|
||||||
|
first = sys.exc_info()[1]
|
||||||
|
try:
|
||||||
|
back()
|
||||||
|
except Failed as e:
|
||||||
|
if first is None:
|
||||||
|
raise
|
||||||
|
ev.setdefault(key, {})["failed"] = str(e)
|
||||||
|
ctx.log("the head is not back, and the run has failed already: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _jog_back(ctx, rec, travel, since):
|
||||||
|
"""Jog the head back by the service's travel (x8 steps) and what the
|
||||||
|
counters read since (the controller's own steps), on a controller
|
||||||
|
without a home. One more start then zeroes the counters where the head
|
||||||
|
began, which the rest of the catalog counts on."""
|
||||||
|
spm = counter_steps_per_mm()
|
||||||
|
dx = travel[0] / XY_STEPS_PER_MM + since[0] / spm
|
||||||
|
dy = travel[1] / XY_STEPS_PER_MM + since[1] / spm
|
||||||
|
rec["mm"] = [round(dx, 3), round(dy, 3)]
|
||||||
|
ctx.log("the head is %.3f/%.3f mm from where the test found it", dx, dy)
|
||||||
|
ctx.check(abs(dx) <= RETURN_MAX_MM and abs(dy) <= RETURN_MAX_MM,
|
||||||
|
"a travel of %.1f/%.1f mm exceeds %.0f mm - not jogging back", dx, dy, RETURN_MAX_MM)
|
||||||
|
if abs(dx) >= 0.05 or abs(dy) >= 0.05:
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
clean_slate(ctx, g)
|
||||||
|
start = read_position()
|
||||||
|
r = g.command("$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy))
|
||||||
|
ctx.check(not any(x.startswith("error") for x in r), "the return jog was refused: %s", r)
|
||||||
|
states = wait_idle(ctx, g, timeout=60)[1]
|
||||||
|
ctx.check(states[-1:] != ["TIMEOUT"], "the return jog did not end: %s", states)
|
||||||
|
machine_idle(ctx)
|
||||||
|
end = read_position()
|
||||||
|
rec["counters_across_jog"] = [start, end]
|
||||||
|
ctx.check(start is not None and end is not None and abs((end[0] - start[0]) / spm + dx) < 0.1
|
||||||
|
and abs((end[1] - start[1]) / spm + dy) < 0.1,
|
||||||
|
"the return jog did not travel %.3f/%.3f mm: counters %s -> %s", -dx, -dy, start, end)
|
||||||
|
_drop_reference(ctx, ctx.forgectrl)
|
||||||
|
ctx.counters_rezeroed()
|
||||||
|
ctx.log("head returned to where the test found it (%.3f/%.3f mm)", -dx, -dy)
|
||||||
@@ -1,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
|
||||||
@@ -105,7 +111,8 @@ 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", "src/status.*"),
|
||||||
("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", "**")],
|
||||||
@@ -113,10 +120,20 @@ def fds_of(pid):
|
|||||||
"version stamped in /etc/forgefirm-version and under the machine mark in the "
|
"version stamped in /etc/forgefirm-version and under the machine mark in the "
|
||||||
"console banner and the motd, "
|
"console banner and the motd, "
|
||||||
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
|
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
|
||||||
"the daemon ownership, "
|
"the daemon ownership, the board temperatures /status reports (the chassis and "
|
||||||
"the init ordering, the file modes the release depends on, and the mounts: the "
|
"the SoC die in a plausible range, the supply as a 10-bit count, the CPU "
|
||||||
|
"unthrottled), "
|
||||||
|
"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. "
|
||||||
|
"And the boot does not wait on the network: ifup has returned for wlan0 (it is "
|
||||||
|
"in the state file and no ifup is running) and the console login is up; each "
|
||||||
|
"DHCP client runs with -b and has left ifup (its parent is pid 1); no DHCPv6 "
|
||||||
|
"client is running or on the image, nor its hook script; and IPv6 is the "
|
||||||
|
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
|
||||||
|
"address up (a global address is logged, not checked).")
|
||||||
def image_health(ctx):
|
def image_health(ctx):
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
manifest = ctx.runner.manifest
|
manifest = ctx.runner.manifest
|
||||||
@@ -252,6 +269,21 @@ def image_health(ctx):
|
|||||||
"controller is %r (expected running or standby)", mode.get("controller"))
|
"controller is %r (expected running or standby)", mode.get("controller"))
|
||||||
ctx.check(mode.get("motion") != "fault", "supervisor reports a motion fault")
|
ctx.check(mode.get("motion") != "fault", "supervisor reports a motion fault")
|
||||||
|
|
||||||
|
# 4b. the board temperatures /status carries, watched and not gated:
|
||||||
|
# the chassis and the SoC die in degrees, the supply as its raw count,
|
||||||
|
# and the CPU unthrottled at idle.
|
||||||
|
temps = (ctx.forgectrl.status() or {}).get("temps") or {}
|
||||||
|
ev["temps"] = temps
|
||||||
|
ctx.log("temps: %s", temps)
|
||||||
|
ctx.check(isinstance(temps.get("chassis_c"), (int, float)) and 5.0 <= temps["chassis_c"] <= 80.0,
|
||||||
|
"/status temps.chassis_c is not a plausible chassis temperature: %s", temps)
|
||||||
|
ctx.check(isinstance(temps.get("soc_c"), (int, float)) and 10.0 <= temps["soc_c"] <= 100.0,
|
||||||
|
"/status temps.soc_c is not a plausible die temperature: %s", temps)
|
||||||
|
ctx.check(isinstance(temps.get("supply_raw"), int) and 0 <= temps["supply_raw"] <= 1023,
|
||||||
|
"/status temps.supply_raw is not a 10-bit count: %s", temps)
|
||||||
|
ctx.check(temps.get("soc_throttle") == 0,
|
||||||
|
"the CPU is throttled (or its cooling device is missing) at idle: %s", temps)
|
||||||
|
|
||||||
# 5. init ordering: controllers stop before the daemon
|
# 5. init ordering: controllers stop before the daemon
|
||||||
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*"))
|
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*"))
|
||||||
ev["rc6_kill"] = k
|
ev["rc6_kill"] = k
|
||||||
@@ -261,6 +293,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
|
||||||
@@ -278,6 +333,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)
|
||||||
@@ -342,56 +405,121 @@ def image_health(ctx):
|
|||||||
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
|
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
|
||||||
ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components))
|
ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components))
|
||||||
|
|
||||||
|
# 10. the network boot. init starts the rest of the boot, the console
|
||||||
|
# login last, only after the networking script returns, so a client
|
||||||
|
# that waits in ifup's foreground on a server's answer holds the
|
||||||
|
# whole machine. The interfaces file starts no DHCPv6 client
|
||||||
|
# (test_image.py reads the layer's file); the bounded DHCP client
|
||||||
|
# (-b, which leaves ifup after three unanswered discovers) is checked
|
||||||
|
# here, on the client that runs.
|
||||||
|
procs = _procs()
|
||||||
|
up = ifstate_names(_read(IFSTATE, ""))
|
||||||
|
ev["ifstate"] = sorted(up)
|
||||||
|
ctx.check("wlan0" in up, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(up))
|
||||||
|
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
|
||||||
|
ev["ifup_running"] = [p[3] for p in stuck]
|
||||||
|
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
|
||||||
|
logins = [p[3] for p in procs if p[2] == "getty"]
|
||||||
|
ev["getty"] = logins
|
||||||
|
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
|
||||||
|
v4 = [p for p in procs if p[2] == "udhcpc"]
|
||||||
|
ev["udhcpc"] = [p[3] for p in v4]
|
||||||
|
ctx.check(v4, "no DHCP client is running")
|
||||||
|
for pid, ppid, _comm, cmd in v4:
|
||||||
|
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
|
||||||
|
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
|
||||||
|
# no DHCPv6 client: not running, not on the image
|
||||||
|
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")
|
||||||
|
# IPv6 is the kernel's own. A global address is evidence only: a
|
||||||
|
# network whose router advertisement offers no SLAAC prefix gives none.
|
||||||
|
conf = "/proc/sys/net/ipv6/conf/wlan0/"
|
||||||
|
ev["disable_ipv6"] = (_read(conf + "disable_ipv6", "") or "").strip()
|
||||||
|
ev["accept_ra"] = (_read(conf + "accept_ra", "") or "").strip()
|
||||||
|
ctx.check(ev["disable_ipv6"] == "0", "IPv6 is off on wlan0 (disable_ipv6=%r)", ev["disable_ipv6"])
|
||||||
|
ctx.check(ev["accept_ra"] not in ("", "0"),
|
||||||
|
"wlan0 does not accept router advertisements (accept_ra=%r)", ev["accept_ra"])
|
||||||
|
local, world = link_addrs(_read("/proc/net/if_inet6", ""), "wlan0")
|
||||||
|
ev["link_local"] = local
|
||||||
|
ev["global"] = world
|
||||||
|
ctx.check(local, "wlan0 has no link-local IPv6 address")
|
||||||
|
ctx.log("wlan0: %d link-local, %d global IPv6 address(es)", len(local), len(world))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- the network boot
|
||||||
|
|
||||||
|
IFSTATE = "/var/run/ifstate" # busybox ifupdown's state file
|
||||||
|
INTERFACES = "/etc/network/interfaces"
|
||||||
|
DHCP6_CLIENTS = ("udhcpc6", "dhcp6c", "odhcp6c")
|
||||||
|
|
||||||
|
|
||||||
|
def ifstate_names(text):
|
||||||
|
"""The interfaces ifup finished configuring: one name=name line each
|
||||||
|
in busybox's state file, written when ifup returns for the name."""
|
||||||
|
return {line.split("=", 1)[0].strip() for line in (text or "").splitlines() if "=" in line}
|
||||||
|
|
||||||
|
|
||||||
|
def dhcp6_in_interfaces(text):
|
||||||
|
"""The lines of an interfaces file that start a DHCPv6 client: an
|
||||||
|
inet6 stanza with a method that runs one, or a command that names
|
||||||
|
one. A comment starts nothing."""
|
||||||
|
bad = []
|
||||||
|
for raw in (text or "").splitlines():
|
||||||
|
words = raw.split("#", 1)[0].split()
|
||||||
|
if not words:
|
||||||
|
continue
|
||||||
|
if words[0] == "iface":
|
||||||
|
if len(words) >= 4 and words[2] == "inet6" and words[3] in ("dhcp", "auto"):
|
||||||
|
bad.append(" ".join(words))
|
||||||
|
elif any(w.split("/")[-1] in DHCP6_CLIENTS for w in words[1:]):
|
||||||
|
bad.append(" ".join(words))
|
||||||
|
return bad
|
||||||
|
|
||||||
|
|
||||||
|
def parse_stat(text):
|
||||||
|
"""(pid, comm, ppid) from a /proc/<pid>/stat line, or None. The comm
|
||||||
|
sits in parentheses and may itself hold spaces and parentheses."""
|
||||||
|
try:
|
||||||
|
head, tail = text.split("(", 1)
|
||||||
|
comm, rest = tail.rsplit(")", 1)
|
||||||
|
return int(head), comm, int(rest.split()[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _procs():
|
||||||
|
"""(pid, ppid, comm, command line) of every process."""
|
||||||
|
out = []
|
||||||
|
for name in os.listdir("/proc"):
|
||||||
|
if not name.isdigit():
|
||||||
|
continue
|
||||||
|
st = parse_stat(_read("/proc/%s/stat" % name, ""))
|
||||||
|
if st is None:
|
||||||
|
continue
|
||||||
|
cmd = (_read("/proc/%s/cmdline" % name, "") or "").replace("\0", " ").strip()
|
||||||
|
out.append((st[0], st[2], st[1], cmd))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def link_addrs(text, iface):
|
||||||
|
"""(link-local, global) IPv6 addresses of one interface, as the hex
|
||||||
|
strings of /proc/net/if_inet6 (address, index, prefix length, scope,
|
||||||
|
flags, name; scope 20 is the link, 00 the world)."""
|
||||||
|
local, world = [], []
|
||||||
|
for line in (text or "").splitlines():
|
||||||
|
f = line.split()
|
||||||
|
if len(f) == 6 and f[5] == iface:
|
||||||
|
if f[3] == "20":
|
||||||
|
local.append(f[0])
|
||||||
|
elif f[3] == "00":
|
||||||
|
world.append(f[0])
|
||||||
|
return local, world
|
||||||
|
|
||||||
@test("image.license-bundle", title="The license texts travel with the image",
|
|
||||||
subsystem="image", kind="auto", est_min=1,
|
|
||||||
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
|
|
||||||
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/login.html")],
|
|
||||||
requires=["forgectrl.panel-serves"],
|
|
||||||
description="The image carries /usr/share/forgefirm/licenses.tar.gz, a gzip tar with "
|
|
||||||
"the license manifest (every installed package with its license) and the "
|
|
||||||
"license texts, and no loose common-licenses tree; the panel serves the "
|
|
||||||
"bundle, the manifest as text, and the Licenses page every footer links, "
|
|
||||||
"each without a login.")
|
|
||||||
def license_bundle(ctx):
|
|
||||||
import io
|
|
||||||
import tarfile
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
path = "/usr/share/forgefirm/licenses.tar.gz"
|
|
||||||
ctx.check(os.path.isfile(path), "%s is missing", path)
|
|
||||||
ctx.check(not os.path.isdir("/usr/share/common-licenses"),
|
|
||||||
"the loose common-licenses tree is still on the rootfs")
|
|
||||||
size = os.path.getsize(path)
|
|
||||||
ev["bundle_bytes"] = size
|
|
||||||
ctx.log("%s: %d bytes", path, size)
|
|
||||||
with tarfile.open(path, "r:gz") as t:
|
|
||||||
names = t.getnames()
|
|
||||||
ctx.check("common-licenses/license.manifest" in names, "no license.manifest in the bundle")
|
|
||||||
manifest = t.extractfile("common-licenses/license.manifest").read().decode("utf-8", "replace")
|
|
||||||
packages = manifest.count("PACKAGE NAME: ")
|
|
||||||
texts = sum(1 for n in names if "/generic_" in n and not n.endswith("/"))
|
|
||||||
ev["packages"] = packages
|
|
||||||
ev["generic_texts"] = texts
|
|
||||||
ctx.log("manifest lists %d packages; %d generic license entries", packages, texts)
|
|
||||||
ctx.check(packages >= 40, "only %d packages in the manifest", packages)
|
|
||||||
ctx.check(texts >= 5, "only %d generic license entries", texts)
|
|
||||||
for name in ("forgectrl", "grblhal-glowforge", "kernel-module-glowforge"):
|
|
||||||
ctx.check("RECIPE NAME: %s\n" % name in manifest, "%s is not in the manifest", name)
|
|
||||||
|
|
||||||
st, body = fc.get("/system/licenses/manifest", raw=True)
|
|
||||||
ctx.check(st == 200, "GET /system/licenses/manifest -> %s", st)
|
|
||||||
ctx.check(isinstance(body, (bytes, bytearray)) and b"PACKAGE NAME: " in body,
|
|
||||||
"the served manifest is not the manifest")
|
|
||||||
st, body = fc.get("/system/licenses", raw=True)
|
|
||||||
ctx.check(st == 200, "GET /system/licenses -> %s", st)
|
|
||||||
ctx.check(isinstance(body, (bytes, bytearray)) and len(body) == size,
|
|
||||||
"the served bundle is %s bytes, the file %d", len(body) if body else None, size)
|
|
||||||
with tarfile.open(fileobj=io.BytesIO(bytes(body)), mode="r:gz") as t:
|
|
||||||
ctx.check("common-licenses/license.manifest" in t.getnames(),
|
|
||||||
"the served bundle holds no manifest")
|
|
||||||
st, body = fc.get("/licenses", raw=True)
|
|
||||||
ctx.check(st == 200, "GET /licenses -> %s", st)
|
|
||||||
text = bytes(body).decode("utf-8", "replace") if body else ""
|
|
||||||
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
|
|
||||||
"the Licenses page lacks the download link or the manifest")
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
+163
-333
@@ -1,14 +1,18 @@
|
|||||||
|
# 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
|
The live tests here emit. The page starts a firing one only with the
|
||||||
settings. The page starts a firing one only with the operator's
|
operator's eye-protection / fire-watch / exhaust acknowledgment; the
|
||||||
eye-protection / fire-watch / exhaust acknowledgment; the test then
|
test then prompts for the scrap and the button, streams a small job
|
||||||
prompts for the scrap and the button, streams a small job through the
|
through the controller, and the machine fires only after the operator
|
||||||
controller, and the machine fires only after the operator presses the
|
presses the physical arm button - nothing here defeats that gate, and
|
||||||
physical arm button - nothing here defeats that gate, and forgetest
|
forgetest never touches the laser latch. One emission per test; on abort
|
||||||
never touches the laser latch. One emission per test; on abort or error
|
or error the job is soft-reset (`^X`: controlled stop, latch relocked).
|
||||||
the job is soft-reset (`^X`: controlled stop, latch relocked). The
|
The witnesses are forgectrl's `/status` (`laser.emission_samples` = the
|
||||||
witnesses are forgectrl's `/status` (`laser.emission_samples` = the
|
|
||||||
kernel's LASER_ON sample count, `hv_current_raw`, `lid_ir`),
|
kernel's LASER_ON sample count, `hv_current_raw`, `lid_ir`),
|
||||||
`/cool/status` (`armed`), the head's beam detector
|
`/cool/status` (`armed`), the head's beam detector
|
||||||
(`head/beam_detect_analog`, the tube's own emission seen from the head:
|
(`head/beam_detect_analog`, the tube's own emission seen from the head:
|
||||||
@@ -24,41 +28,12 @@ import time
|
|||||||
from ..catalog import test
|
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_start, check_kernel_returned, Watch
|
||||||
wait_state_text, wait_left_state, wait_idle, drain_text, Watch)
|
|
||||||
|
|
||||||
_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"),
|
||||||
("forgectrl", "src/status.c"), ("forgectrl", "src/main.c")]
|
("forgectrl", "src/status.c"), ("forgectrl", "src/main.c")]
|
||||||
|
|
||||||
# The S-range floor the controller derives $35 from at every precompute
|
|
||||||
# (glowforge_laser.c: laser_floor_density, this default when the config
|
|
||||||
# carries no key): the lowest pulse density that still marks, against
|
|
||||||
# the hardware's 127-count PWM period. Density is the only dose model.
|
|
||||||
FLOOR_DEFAULT = 10.0
|
|
||||||
PWM_PERIOD = 127
|
|
||||||
PWMSAR_FLOOR_MIN = 12
|
|
||||||
|
|
||||||
|
|
||||||
def _floor(fc):
|
|
||||||
"""The configured floor percent, default applied."""
|
|
||||||
raw = (fc.settings().get("laser_floor_density") or "").strip()
|
|
||||||
try:
|
|
||||||
return float(raw) if raw else FLOOR_DEFAULT
|
|
||||||
except ValueError:
|
|
||||||
return FLOOR_DEFAULT
|
|
||||||
|
|
||||||
|
|
||||||
def _grbl_settings(lines):
|
|
||||||
vals = {}
|
|
||||||
for ln in lines:
|
|
||||||
key, _, val = ln.partition("=")
|
|
||||||
if key.startswith("$"):
|
|
||||||
try:
|
|
||||||
vals[key] = float(val)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return vals
|
|
||||||
|
|
||||||
ARM_CUE = ("LIVE FIRE. Eye protection on. Exhaust on. Extinguisher in reach. "
|
ARM_CUE = ("LIVE FIRE. Eye protection on. Exhaust on. Extinguisher in reach. "
|
||||||
"Put scrap under the head with %s clear, and close the lid.")
|
"Put scrap under the head with %s clear, and close the lid.")
|
||||||
@@ -252,6 +227,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:
|
||||||
@@ -379,45 +374,6 @@ def wait_disarm(ctx, timeout):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@test("laser.power-floor", title="The derived floor is the one in force",
|
|
||||||
subsystem="laser", kind="auto", mode="grbl", est_min=1,
|
|
||||||
covers=[("grblhal-glowforge", "src/**"), ("forgectrl", "src/main.c")],
|
|
||||||
description="The controller derives $35 from the floor key (laser_floor_density) at "
|
|
||||||
"every spindle precompute, boot included, and never from a typed value. "
|
|
||||||
"Unfloored, the low end of S asks for pulses too far apart to re-strike, "
|
|
||||||
"so a commanded 1 % would emit nothing. Reads the floor key from forgectrl "
|
|
||||||
"and $$ from the controller, and checks $35 is that floor, that it lands at "
|
|
||||||
"or above the measured minimum, and that $31 is 0 (the floor, not $31, sets "
|
|
||||||
"the bottom of the range).")
|
|
||||||
def power_floor(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
want = _floor(ctx.forgectrl)
|
|
||||||
ev["configured_floor"] = want
|
|
||||||
with ctx.grbl() as g:
|
|
||||||
lines = g.command("$$", timeout=5)
|
|
||||||
vals = _grbl_settings(lines)
|
|
||||||
ctx.check(vals, "no $-settings in the response to $$")
|
|
||||||
|
|
||||||
floor_pct = vals.get("$35")
|
|
||||||
rpm_min = vals.get("$31")
|
|
||||||
ev["settings"] = {k: vals.get(k) for k in ("$30", "$31", "$32", "$35", "$36")}
|
|
||||||
ctx.check(floor_pct is not None, "$35 missing from the settings report")
|
|
||||||
counts = int(PWM_PERIOD * floor_pct / 100.0)
|
|
||||||
ev["pwmsar_floor"] = counts
|
|
||||||
ctx.log("$35=%.1f%% -> PWMSAR %d of %d ($31=%s, $32=%s, $36=%s)", floor_pct, counts,
|
|
||||||
PWM_PERIOD, rpm_min, vals.get("$32"), vals.get("$36"))
|
|
||||||
|
|
||||||
ctx.check(abs(floor_pct - want) < 0.05,
|
|
||||||
"$35 is %.1f %%, expected the derived floor %.1f %% from the machine config",
|
|
||||||
floor_pct, want)
|
|
||||||
ctx.check(counts >= PWMSAR_FLOOR_MIN,
|
|
||||||
"the floor lands at PWMSAR %d, below the %d the tube needs",
|
|
||||||
counts, PWMSAR_FLOOR_MIN)
|
|
||||||
ctx.check(rpm_min == 0, "$31 is %s, not 0: the bottom of the S range is no longer the floor",
|
|
||||||
rpm_min)
|
|
||||||
ctx.log("PASS: nonzero S never commands less than PWMSAR %d", counts)
|
|
||||||
|
|
||||||
|
|
||||||
@test("laser.emission-witness", title="Live emission witness (S400 vector mark) and job-based disarm",
|
@test("laser.emission-witness", title="Live emission witness (S400 vector mark) and job-based disarm",
|
||||||
subsystem="laser", kind="live", mode="grbl", always=True, est_min=5,
|
subsystem="laser", kind="live", mode="grbl", always=True, est_min=5,
|
||||||
covers=_LASER_COVERS,
|
covers=_LASER_COVERS,
|
||||||
@@ -431,15 +387,45 @@ def power_floor(ctx):
|
|||||||
"(G4) between its second and third sides: emission_samples (the kernel's "
|
"(G4) between its second and third sides: emission_samples (the kernel's "
|
||||||
"LASER_ON sample count) goes nonzero during the fire window and returns to 0 "
|
"LASER_ON sample count) goes nonzero during the fire window and returns to 0 "
|
||||||
"at Idle, HV current rises during the burn, the head's beam detector sees the "
|
"at Idle, HV current rises during the burn, the head's beam detector sees the "
|
||||||
"beam, the armed window is observed, the M2 program end disarms promptly at "
|
"beam, and the armed window is observed. Across the dwell the kernel run ends "
|
||||||
"Idle (job-based, not the 60 s idle grace) and the button goes dark. Across "
|
"and restarts: HV_ENABLE drops with the charge-pump watchdog and is back for "
|
||||||
"the dwell the kernel run ends and restarts: HV_ENABLE drops with the "
|
"the third side, while the hardware button latch stays clear (cnc/button_latch "
|
||||||
"charge-pump watchdog and is back for the third side, while the hardware "
|
"0 in every armed sample), so the second half of the square marks without a "
|
||||||
"button latch stays clear (cnc/button_latch 0 in every armed sample), so the "
|
"second press. The square ends at M5 with the window still open; once the "
|
||||||
"second half of the square marks without a second press. The operator "
|
"LASER_ON count reads 0 (the dark point), a rapid runs 20 mm out over the "
|
||||||
"confirms all four sides.")
|
"first side and back, then the program ends. From the dark point until the "
|
||||||
|
"window closes, every sample reads no emission and the HV current at its idle "
|
||||||
|
"reading, and the head's reports place the rapid inside that stretch: LASER_ON "
|
||||||
|
"stays off with FIRE clear while the chain can fire. The M2 program end "
|
||||||
|
"disarms promptly (job-based, not the 60 s idle grace) and the button goes "
|
||||||
|
"dark. The operator confirms all four sides.")
|
||||||
def emission_witness(ctx):
|
def emission_witness(ctx):
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
|
# The rapid after the M5 (G91 is modal from the square): out over the
|
||||||
|
# first side and back, then the program end.
|
||||||
|
rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P0.5", "G90", "M2"]
|
||||||
|
|
||||||
|
def sample_to_disarm(g, timeout=20.0, sample_hz=8):
|
||||||
|
"""Sample until the armed window closes, or the timeout, with the
|
||||||
|
controller's state and machine X beside each sample ("x"). A G4
|
||||||
|
dwell reports Idle, so Idle cannot end this; the window closing at
|
||||||
|
the program end does."""
|
||||||
|
samples = []
|
||||||
|
period = 1.0 / sample_hz
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < timeout:
|
||||||
|
ctx.checkpoint()
|
||||||
|
smp = sample(ctx)
|
||||||
|
rep = g.status_report()
|
||||||
|
if smp:
|
||||||
|
smp["gstate"] = rep.get("state")
|
||||||
|
smp["x"] = (rep.get("MPos") or (None,))[0]
|
||||||
|
samples.append(smp)
|
||||||
|
if not smp["armed"]:
|
||||||
|
break
|
||||||
|
time.sleep(period)
|
||||||
|
return samples
|
||||||
|
|
||||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||||
prepare(ctx, g)
|
prepare(ctx, g)
|
||||||
base = sample(ctx)
|
base = sample(ctx)
|
||||||
@@ -449,21 +435,46 @@ def emission_witness(ctx):
|
|||||||
base["armed"], base["verdict"])
|
base["armed"], base["verdict"])
|
||||||
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 and +Y")
|
ctx.ready(ARM_CUE % "20 mm +X and +Y")
|
||||||
|
# The square ends at M5 with no program end: the window stays open
|
||||||
|
# for the rapid that follows.
|
||||||
job = ["G91", "G21", "M4", "S400",
|
job = ["G91", "G21", "M4", "S400",
|
||||||
"G1 X20 F600", "G1 Y20 F600", "G4 P2", "G1 X-20 F600", "G1 Y-20 F600",
|
"G1 X20 F600", "G1 Y20 F600", "G4 P2", "G1 X-20 F600", "G1 Y-20 F600",
|
||||||
"M5", "G90", "M2"]
|
"M5"]
|
||||||
ctx.arm_press()
|
ctx.arm_press()
|
||||||
try:
|
try:
|
||||||
samples = run_and_sample(ctx, g, job)
|
cut = run_and_sample(ctx, g, job)
|
||||||
|
# The dark point: the M5 has run, the LASER_ON count reads 0, and
|
||||||
|
# the window is still open. The rapid is sent only after it, so
|
||||||
|
# the dark stretch covers the rapid by construction, and the
|
||||||
|
# mid-square dwell's own zero cannot be taken for it.
|
||||||
|
ctx.wait_for(lambda: (sample(ctx) or {}).get("emission") == 0, 10, poll=0.125)
|
||||||
|
dark = sample(ctx)
|
||||||
|
ctx.check(dark and dark["emission"] == 0,
|
||||||
|
"the LASER_ON count did not return to 0 after the M5 (%s)", dark and dark["emission"])
|
||||||
|
ctx.check(dark["armed"], "the armed window closed after the M5, before the rapid "
|
||||||
|
"(phase %s): the rapid would not run under it", dark["phase"])
|
||||||
|
# The engine's armed flag can still read true after the latch has
|
||||||
|
# locked, and a dark reading with the latch locked is the unarmed
|
||||||
|
# case this sample is here to exclude: the SoC lock line must read
|
||||||
|
# released.
|
||||||
|
ctx.check(dark["locked"] is False, "the laser latch is locked at the dark point "
|
||||||
|
"(laser_locked %s): the rapid would run unarmed", dark["locked"])
|
||||||
|
rep = g.status_report()
|
||||||
|
dark["gstate"] = rep.get("state")
|
||||||
|
dark["x"] = (rep.get("MPos") or (None,))[0]
|
||||||
|
ctx.check(dark["x"] is not None, "the controller reports no machine position: %s", rep)
|
||||||
|
stream(g, rapid_tail)
|
||||||
|
tail = sample_to_disarm(g)
|
||||||
finally:
|
finally:
|
||||||
ctx.clear_notice()
|
ctx.clear_notice()
|
||||||
|
samples = cut + [dark] + tail
|
||||||
ev["trail_fields"] = list(TRAIL_FIELDS)
|
ev["trail_fields"] = list(TRAIL_FIELDS)
|
||||||
ev["trail"] = trail(samples)
|
ev["trail"] = trail(samples)
|
||||||
beam = beam_witness(ctx, ev, samples, base)
|
beam = beam_witness(ctx, ev, cut, base)
|
||||||
emis = [s["emission"] for s in samples if s["emission"] is not None]
|
emis = [s["emission"] for s in cut if s["emission"] is not None]
|
||||||
peak = max(emis) if emis else 0
|
peak = max(emis) if emis else 0
|
||||||
end = emis[-1] if emis else None
|
end = next((s["emission"] for s in reversed(samples) if s["emission"] is not None), None)
|
||||||
hv = [s["hv"] for s in samples if s["hv"] is not None]
|
hv = [s["hv"] for s in cut if s["hv"] is not None]
|
||||||
ir_peak = [0, 0, 0, 0]
|
ir_peak = [0, 0, 0, 0]
|
||||||
for s in samples:
|
for s in samples:
|
||||||
if s["ir"] and len(s["ir"]) == 4:
|
if s["ir"] and len(s["ir"]) == 4:
|
||||||
@@ -507,12 +518,34 @@ def emission_witness(ctx):
|
|||||||
ev["exhaust_floor"] = floor
|
ev["exhaust_floor"] = floor
|
||||||
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
|
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
|
||||||
ev["exhaust_at_first_fire"], floor)
|
ev["exhaust_at_first_fire"], floor)
|
||||||
# X-3: job-based disarm at Idle after M2
|
# The rapid after the M5, with the window open and FIRE clear: every
|
||||||
dt = wait_disarm(ctx, 75)
|
# sample from the dark point until the window closes reads no emission
|
||||||
|
# and the HV current at its idle reading. The head's reports place the
|
||||||
|
# rapid inside that stretch: out to +20 mm, then back at the start.
|
||||||
|
stretch = [dark] + tail
|
||||||
|
x0 = dark["x"]
|
||||||
|
i_out = next((i for i, s in enumerate(stretch) if s.get("x") is not None and s["x"] - x0 >= 19.5), None)
|
||||||
|
i_back = (next((i for i in range(i_out + 1, len(stretch))
|
||||||
|
if stretch[i].get("x") is not None and abs(stretch[i]["x"] - x0) < 0.05), None)
|
||||||
|
if i_out is not None else None)
|
||||||
|
closed = stretch[-1] if not stretch[-1]["armed"] else None
|
||||||
|
relit = [s for s in stretch if s["emission"]]
|
||||||
|
hv_dark = max((s["hv"] for s in stretch if s["hv"] is not None), default=0)
|
||||||
|
dt = (closed["t"] - stretch[i_back]["t"]) if (closed and i_back is not None) else None
|
||||||
|
ev["rapid"] = {"samples": len(stretch), "span_s": round(stretch[-1]["t"] - dark["t"], 2),
|
||||||
|
"x_start": x0, "x_max": max((s["x"] for s in stretch if s.get("x") is not None), default=None),
|
||||||
|
"out_at_s": round(stretch[i_out]["t"] - dark["t"], 2) if i_out is not None else None,
|
||||||
|
"back_at_s": round(stretch[i_back]["t"] - dark["t"], 2) if i_back is not None else None,
|
||||||
|
"relit": [(round(s["t"] - dark["t"], 2), s["emission"]) for s in relit][:8],
|
||||||
|
"hv_max": hv_dark, "locked_at_dark": dark["locked"],
|
||||||
|
"locked_at_out": stretch[i_out]["locked"] if i_out is not None else None}
|
||||||
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
||||||
ctx.log("time-to-disarm after Idle: %s s", ev["disarm_after_idle_s"])
|
ctx.log("the rapid after the M5: %d samples over %.1f s from the dark point, out at +%s s, back at +%s s, "
|
||||||
|
"%d with emission, HV max %s; the window closed %s s after the head was back", len(stretch),
|
||||||
|
ev["rapid"]["span_s"], ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"], len(relit), hv_dark,
|
||||||
|
ev["disarm_after_idle_s"])
|
||||||
ctx.check(armed_seen, "the armed window was never observed (arm refused, or no button press)")
|
ctx.check(armed_seen, "the armed window was never observed (arm refused, or no button press)")
|
||||||
ctx.check(peak > 0, "no emission witnessed (emission_samples stayed 0)")
|
ctx.check(peak > 0, "no emission witnessed on the square (emission_samples stayed 0)")
|
||||||
first_idle_fire = ev["idle_airflow_fire"][0] if idle_fire else (None, None, None)
|
first_idle_fire = ev["idle_airflow_fire"][0] if idle_fire else (None, None, None)
|
||||||
ctx.check(not idle_fire,
|
ctx.check(not idle_fire,
|
||||||
"the laser fired while the cooling engine had no run session, so the fans were at "
|
"the laser fired while the cooling engine had no run session, so the fans were at "
|
||||||
@@ -524,8 +557,18 @@ def emission_witness(ctx):
|
|||||||
"emission sample, floor %.0f)", ev["exhaust_at_first_fire"], floor)
|
"emission sample, floor %.0f)", ev["exhaust_at_first_fire"], floor)
|
||||||
ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)",
|
ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)",
|
||||||
ev["hv_min"], ev["hv_max"])
|
ev["hv_min"], ev["hv_max"])
|
||||||
|
ctx.check(i_out is not None and i_back is not None,
|
||||||
|
"the rapid did not run inside the dark stretch: out at %s, back at %s (X from %s, max %s)",
|
||||||
|
ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"], x0, ev["rapid"]["x_max"])
|
||||||
|
ctx.check(stretch[i_out]["locked"] is False, "the laser latch was locked with the head at +20 mm "
|
||||||
|
"(laser_locked %s): the rapid ran unarmed", stretch[i_out]["locked"])
|
||||||
|
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%s s (emission_samples %s) "
|
||||||
|
"- a rapid after M5 fired", len(relit), relit[0]["t"] - dark["t"] if relit else None,
|
||||||
|
relit[0]["emission"] if relit else None)
|
||||||
|
ctx.check(hv_dark <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge ran "
|
||||||
|
"through the rapid", hv_dark)
|
||||||
ctx.check(dt is not None and dt < 10.0,
|
ctx.check(dt is not None and dt < 10.0,
|
||||||
"the M2 job did not disarm promptly at Idle (%s s; the idle grace is ~60 s)",
|
"the M2 job did not disarm promptly (%s s after the head was back; the idle grace is ~60 s)",
|
||||||
ev["disarm_after_idle_s"])
|
ev["disarm_after_idle_s"])
|
||||||
ctx.check(gap["button_latch_unlocked_samples"] and gap["button_latch_unlocked_max"] == 0,
|
ctx.check(gap["button_latch_unlocked_samples"] and gap["button_latch_unlocked_max"] == 0,
|
||||||
"the hardware button latch read SET while the laser latch was unlocked (max %s over %d "
|
"the hardware button latch read SET while the laser latch was unlocked (max %s over %d "
|
||||||
@@ -537,172 +580,14 @@ def emission_witness(ctx):
|
|||||||
judge_beam(ctx, beam)
|
judge_beam(ctx, beam)
|
||||||
check_button_dark(ctx, ev)
|
check_button_dark(ctx, ev)
|
||||||
ctx.confirm("Did the laser mark a 20 mm square outline on the scrap, all four sides?")
|
ctx.confirm("Did the laser mark a 20 mm square outline on the scrap, all four sides?")
|
||||||
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s, disarmed %.1f s after Idle, button "
|
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s; the rapid after the M5 dark (HV max %s); "
|
||||||
"dark, mark confirmed", peak, ev["hv_min"], ev["hv_max"], beam["delta"], dt)
|
"disarmed %.1f s after the head was back, button dark, mark confirmed", peak, ev["hv_min"],
|
||||||
|
ev["hv_max"], beam["delta"], hv_dark, dt)
|
||||||
|
|
||||||
|
|
||||||
HV_DARK_MAX = 20 # hv_current_raw reads 0 with the tube off; hundreds under fire
|
HV_DARK_MAX = 20 # hv_current_raw reads 0 with the tube off; hundreds under fire
|
||||||
|
|
||||||
|
|
||||||
@test("laser.m5-rapid-dark", title="The rapids after an M5 ship dark",
|
|
||||||
subsystem="laser", kind="live", mode="grbl", est_min=3,
|
|
||||||
covers=_LASER_COVERS,
|
|
||||||
requires=["laser.emission-witness"], 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 constant power (M3 S400/F600), then M5, a dwell, a rapid back "
|
|
||||||
"over the line, a dwell, a rapid forward, a dwell, and the program end. M5 "
|
|
||||||
"executes with the planner drained and the kernel run over, and the core "
|
|
||||||
"issues no per-segment laser update for moves made with the spindle off, so "
|
|
||||||
"only the stream's own wanted state decides whether those rapids fire; a stale "
|
|
||||||
"true there lights them at the last level, full duty under the density model. "
|
|
||||||
"The kernel's LASER_ON sample count must go to 0 after the M5 and stay 0 "
|
|
||||||
"through both rapids, and the HV current must stay at its idle reading.")
|
|
||||||
def m5_rapid_dark(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
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")
|
|
||||||
# +20 out on the cut, then the two rapids the test is about, then
|
|
||||||
# a third that puts the head back where the job found it: the
|
|
||||||
# first two net to +20 and a job that walks the head 20 mm and
|
|
||||||
# leaves it there is dirt the hand-back has to jog out. The
|
|
||||||
# return is inside the sampling window and after the M5, so it is
|
|
||||||
# one more rapid that must be dark, which is what the test asks
|
|
||||||
# of the other two.
|
|
||||||
job = ["G91", "G21", "M3", "S400",
|
|
||||||
"G1 X20 F600",
|
|
||||||
"M5", "G4 P2.5",
|
|
||||||
"G0 X-20", "G4 P2.5",
|
|
||||||
"G0 X20", "G4 P2.5",
|
|
||||||
"G0 X-20", "G4 P0.5",
|
|
||||||
"G90", "M2"]
|
|
||||||
ctx.arm_press()
|
|
||||||
try:
|
|
||||||
samples = run_and_sample(ctx, g, job)
|
|
||||||
# The controller reports Idle inside a G4 dwell, so the sampler
|
|
||||||
# above can return before the rapids; the window closing at M2
|
|
||||||
# is the end of the job. Keep sampling until then.
|
|
||||||
t0 = time.time()
|
|
||||||
while time.time() - t0 < 30:
|
|
||||||
ctx.checkpoint()
|
|
||||||
smp = sample(ctx)
|
|
||||||
if smp:
|
|
||||||
samples.append(smp)
|
|
||||||
if not smp["armed"]:
|
|
||||||
break
|
|
||||||
time.sleep(0.125)
|
|
||||||
finally:
|
|
||||||
ctx.clear_notice()
|
|
||||||
emis = [(s["t"], s["emission"], s["hv"]) for s in samples if s["emission"] is not None]
|
|
||||||
peak = max((e for _t, e, _hv in emis), default=0)
|
|
||||||
ctx.check(peak > 0, "no emission witnessed on the G1 (emission_samples stayed 0)")
|
|
||||||
# The first zero after the peak is the dark window the M5 and its dwell
|
|
||||||
# produce; everything after it is the two rapids and their dwells.
|
|
||||||
i_peak = max(range(len(emis)), key=lambda i: emis[i][1])
|
|
||||||
after = [x for x in emis[i_peak:] if x[1] == 0]
|
|
||||||
ctx.check(after, "emission_samples never returned to 0 after the M5")
|
|
||||||
t_dark = after[0][0]
|
|
||||||
tail = [x for x in emis if x[0] >= t_dark]
|
|
||||||
relit = [x for x in tail if x[1]]
|
|
||||||
hv_tail = max((hv for _t, _e, hv in tail if hv is not None), default=0)
|
|
||||||
ev.update({"samples": len(samples), "emission_peak": peak, "tail_samples": len(tail),
|
|
||||||
"relit_samples": len(relit), "hv_tail_max": hv_tail,
|
|
||||||
"relit_first": relit[0] if relit else None})
|
|
||||||
ctx.log("emission peak %s; dark from +%.1f s; %d samples after it spanning %.1f s, %d with "
|
|
||||||
"emission, HV max after dark %s", peak, t_dark - emis[0][0], len(tail),
|
|
||||||
tail[-1][0] - t_dark, len(relit), hv_tail)
|
|
||||||
ctx.check(tail[-1][0] - t_dark >= 5.0, "sampling ended %.1f s after the dark point, before "
|
|
||||||
"both rapids and their dwells (~5.2 s) had run", tail[-1][0] - t_dark)
|
|
||||||
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%.1f s "
|
|
||||||
"(emission_samples %s) - a rapid after M5 fired at the last level",
|
|
||||||
len(relit), (relit[0][0] - t_dark) if relit else 0, relit[0][1] if relit else None)
|
|
||||||
ctx.check(hv_tail <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge "
|
|
||||||
"ran through a rapid", hv_tail)
|
|
||||||
ctx.log("PASS: emission peak %s on the G1, 0 through both rapids, HV %s after the M5",
|
|
||||||
peak, hv_tail)
|
|
||||||
|
|
||||||
|
|
||||||
@test("laser.disarm-in-hold", title="Disarm grace counts down in Hold", 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. Nothing else: the test holds the "
|
|
||||||
"job itself and waits about a minute for the disarm."],
|
|
||||||
description="Arm and start a +X move at S400/F300, feed-hold it after ~2 s of motion, and "
|
|
||||||
"hold: the disarm grace must count down while held and close the armed window "
|
|
||||||
"(armed -> false) without the job resuming, and the button goes dark with it.")
|
|
||||||
def disarm_in_hold(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
|
||||||
prepare(ctx, g)
|
|
||||||
k0 = kernel_start(ctx)
|
|
||||||
ctx.ready(ARM_CUE % "40 mm +X")
|
|
||||||
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
|
|
||||||
ctx.log("armed; waiting for motion to start (arm + your button press)...")
|
|
||||||
w = Watch(g)
|
|
||||||
ctx.act("button", "press", text="The button is lit white: the press arms the job and the "
|
|
||||||
"move starts.", until=w.in_state("Run"), timeout=180, fail=False)
|
|
||||||
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.log("moving under laser: %s; feed-hold in 2 s", st)
|
|
||||||
ctx.sleep(2)
|
|
||||||
g.realtime(ord("!"))
|
|
||||||
t1 = time.time()
|
|
||||||
while time.time() - t1 < 5:
|
|
||||||
st = g.status_report()["state"]
|
|
||||||
if st.startswith("Hold"):
|
|
||||||
break
|
|
||||||
time.sleep(0.1)
|
|
||||||
ev["held_state"] = st
|
|
||||||
ctx.log("feed-held mid-move: %s; watching the disarm grace count down IN HOLD", st)
|
|
||||||
ctx.check(st.startswith("Hold"), "feed hold did not park (state %s)", st)
|
|
||||||
t0 = time.time()
|
|
||||||
disarmed_at = None
|
|
||||||
left_hold = None
|
|
||||||
while time.time() - t0 < 120:
|
|
||||||
ctx.checkpoint()
|
|
||||||
s = sample(ctx)
|
|
||||||
held = g.status_report()["state"].startswith("Hold")
|
|
||||||
if s and not s["armed"]:
|
|
||||||
disarmed_at = time.time() - t0
|
|
||||||
break
|
|
||||||
if not held and left_hold is None:
|
|
||||||
left_hold = g.status_report()["state"]
|
|
||||||
ctx.log("note: left Hold (state=%s) before disarm", left_hold)
|
|
||||||
time.sleep(1)
|
|
||||||
ev["disarmed_after_s"] = round(disarmed_at, 1) if disarmed_at is not None else None
|
|
||||||
ev["left_hold"] = left_hold
|
|
||||||
# recover: laser off, abort out of hold
|
|
||||||
g.command("M5", timeout=1)
|
|
||||||
g.realtime(0x18)
|
|
||||||
ctx.sleep(1)
|
|
||||||
if "Alarm" in g.status_report()["state"]:
|
|
||||||
g.command("$X")
|
|
||||||
# The hold parks the head part-way through the move and the reset
|
|
||||||
# abandons the rest of it, so the job ends wherever the feed hold
|
|
||||||
# caught it - about 11 mm in, on the bench reference. That is the
|
|
||||||
# test's own doing and it goes back: the kernel counters say how
|
|
||||||
# far the head actually went, which is what a jog has to undo.
|
|
||||||
wait_idle(ctx, g, 15)
|
|
||||||
dx, dy = kernel_xy_mm(ctx)
|
|
||||||
back_x, back_y = k0[0] - dx, k0[1] - dy
|
|
||||||
ev["left_at_mm"] = [round(dx - k0[0], 3), round(dy - k0[1], 3)]
|
|
||||||
if abs(back_x) >= 0.02 or abs(back_y) >= 0.02:
|
|
||||||
ctx.log("returning the head: %+.3f/%+.3f mm", back_x, back_y)
|
|
||||||
g.command("$J=G91X%.3fY%.3fF1200" % (back_x, back_y))
|
|
||||||
wait_idle(ctx, g, 30)
|
|
||||||
ev["returned_mm"] = list(kernel_xy_mm(ctx))
|
|
||||||
ctx.check(disarmed_at is not None, "still armed after 120 s in Hold")
|
|
||||||
ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold)
|
|
||||||
check_button_dark(ctx, ev)
|
|
||||||
ctx.log("PASS: disarmed in Hold after %.1f s, button dark", disarmed_at)
|
|
||||||
|
|
||||||
|
|
||||||
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
|
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
|
||||||
subsystem="laser", kind="live", mode="grbl", est_min=6,
|
subsystem="laser", kind="live", mode="grbl", est_min=6,
|
||||||
covers=_LASER_COVERS + [("forgectrl", "src/main.c")],
|
covers=_LASER_COVERS + [("forgectrl", "src/main.c")],
|
||||||
@@ -715,7 +600,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
|
||||||
@@ -769,13 +656,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")
|
||||||
@@ -792,82 +686,18 @@ 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.arm-wait-lid", title="Lid open during the arm wait cancels the job",
|
|
||||||
subsystem="laser", kind="operator", mode="grbl", est_min=3,
|
|
||||||
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_laser.c"),
|
|
||||||
("grblhal-glowforge", "src/glowforge_switches.c"),
|
|
||||||
("grblhal-glowforge", "src/glowforge_switch_map.h")],
|
|
||||||
requires=["kernel.latch-locked-idle", "motion.jog-roundtrip"], actions=["lid"],
|
|
||||||
steps=["Lid closed; nothing under the head needs to be in place - the machine will not fire.",
|
|
||||||
"When the white button lights, do NOT press it: open the lid instead, then close it "
|
|
||||||
"when told."],
|
|
||||||
description="Start a laser job so the controller unlocks the latch and lights the button, "
|
|
||||||
"then open the lid while it waits. The wait must abort with the lid named as the "
|
|
||||||
"reason, canceled with a soft reset (no alarm - nothing to unlock), the armed "
|
|
||||||
"window closed (armed -> false), the kernel latch back to locked, and no emission; "
|
|
||||||
"the controller ends Idle. No press is given, so nothing can fire.")
|
|
||||||
def arm_wait_lid(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
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["armed"], "armed window already open before the job")
|
|
||||||
stream(g, ["G91", "G21", "M4", "S400", "G1 X5 F600"])
|
|
||||||
# The prompt: the latch is unlocked and the button lit from here on.
|
|
||||||
t0 = time.time()
|
|
||||||
text = ""
|
|
||||||
while time.time() - t0 < 30 and "press the button" not in text:
|
|
||||||
ctx.checkpoint()
|
|
||||||
text += g.drain()
|
|
||||||
time.sleep(0.1)
|
|
||||||
ctx.check("press the button" in text, "no arm prompt within 30 s (job did not reach the arm)")
|
|
||||||
s = sample(ctx)
|
|
||||||
ev["armed_during_wait"] = s["armed"] if s else None
|
|
||||||
ctx.log("arm prompt seen; armed=%s; asking the operator to open the lid", ev["armed_during_wait"])
|
|
||||||
ctx.act("lid", "open", text="The button is lit white: do NOT press it.", timeout=60)
|
|
||||||
t1 = time.time()
|
|
||||||
while time.time() - t1 < 15:
|
|
||||||
ctx.checkpoint()
|
|
||||||
text += g.drain()
|
|
||||||
if "job canceled" in text and "help]" in text:
|
|
||||||
break
|
|
||||||
time.sleep(0.1)
|
|
||||||
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
|
|
||||||
ctx.log("controller: %s", ev["messages"])
|
|
||||||
ctx.check("lid opened during arm - job canceled" in text,
|
|
||||||
"the lid open was not reported as canceling the arm")
|
|
||||||
ctx.check("help]" in text, "no reset banner after the lid-open cancel (it must be a clean cancel)")
|
|
||||||
ctx.check("ALARM" not in text, "an alarm was raised on the lid-open cancel")
|
|
||||||
# Armed window closed and the kernel latch locked.
|
|
||||||
t2 = time.time()
|
|
||||||
s = None
|
|
||||||
while time.time() - t2 < 10:
|
|
||||||
s = sample(ctx)
|
|
||||||
if s and not s["armed"]:
|
|
||||||
break
|
|
||||||
time.sleep(0.25)
|
|
||||||
ev["armed_after"] = s["armed"] if s else None
|
|
||||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
|
||||||
locked = ilk is not None and bool(ilk & (1 << 3))
|
|
||||||
ev["latch_locked"] = locked
|
|
||||||
ev["emission"] = s["emission"] if s else None
|
|
||||||
ctx.check(s and not s["armed"], "the armed window stayed open after the lid-open cancel")
|
|
||||||
ctx.check(locked, "kernel latch not locked after the lid-open cancel (interlock_circuit=%s)", ilk)
|
|
||||||
ctx.check(not ev["emission"], "emission_samples nonzero (%s) - nothing may have fired", ev["emission"])
|
|
||||||
check_button_dark(ctx, ev)
|
|
||||||
ctx.act("lid", "close")
|
|
||||||
ctx.sleep(1)
|
|
||||||
st = g.status_report()["state"]
|
|
||||||
ev["state_after"] = st
|
|
||||||
ctx.check(st.startswith("Idle"), "controller is %s after the cancel, expected Idle (no unlock needed)", st)
|
|
||||||
ctx.log("PASS: lid open during the arm wait canceled the job (clean reset, no alarm), armed=false, latch locked")
|
|
||||||
|
|
||||||
|
|
||||||
@test("laser.pause-resume-lid-cancel", title="One live cut: the button pauses and resumes it, the lid "
|
@test("laser.pause-resume-lid-cancel", title="One live cut: the button pauses and resumes it, the lid "
|
||||||
@@ -875,7 +705,7 @@ def arm_wait_lid(ctx):
|
|||||||
subsystem="laser", kind="live", mode="grbl", est_min=7,
|
subsystem="laser", kind="live", mode="grbl", est_min=7,
|
||||||
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
|
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
|
||||||
("grblhal-glowforge", "src/glowforge_switch_map.h")],
|
("grblhal-glowforge", "src/glowforge_switch_map.h")],
|
||||||
requires=["laser.emission-witness", "motion.lid-cancel-home", "motion.button-hold-resume"],
|
requires=["laser.emission-witness"],
|
||||||
actions=["button", "lid"],
|
actions=["button", "lid"],
|
||||||
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
|
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
|
||||||
"Press the physical button when it lights white (arm). Once the cut is under way the "
|
"Press the physical button when it lights white (arm). Once the cut is under way the "
|
||||||
|
|||||||
@@ -1,306 +0,0 @@
|
|||||||
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
|
|
||||||
the routing path (emitters, relays, rendered rules); the level settings."""
|
|
||||||
import gzip
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import tarfile
|
|
||||||
import time
|
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .. import hw
|
|
||||||
|
|
||||||
|
|
||||||
def _read(path, default=None):
|
|
||||||
try:
|
|
||||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
return f.read()
|
|
||||||
except OSError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
|
|
||||||
("forgectrl", "src/main.c")]
|
|
||||||
|
|
||||||
|
|
||||||
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
|
|
||||||
kind="auto", est_min=1,
|
|
||||||
covers=_LOG_COVERS, requires=["forgectrl.auth"],
|
|
||||||
description="/logs lists the loggers with their levels and files, /logs/tail returns the "
|
|
||||||
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
|
|
||||||
"bundle that contains neither the panel token nor the camera key.")
|
|
||||||
def tree_tail_export(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
st, body = fc.get("/logs")
|
|
||||||
ctx.log("GET /logs -> %s", st)
|
|
||||||
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
|
|
||||||
loggers = body.get("loggers") or []
|
|
||||||
names = [l.get("name") for l in loggers] if isinstance(loggers, list) else list(loggers)
|
|
||||||
ev["loggers"] = names
|
|
||||||
ctx.log("loggers: %s", names)
|
|
||||||
ctx.check("forgectrl" in names, "/logs lacks the forgectrl logger: %s", names)
|
|
||||||
|
|
||||||
st, tail = fc.get("/logs/tail", params={"name": "forgectrl", "lines": "20"})
|
|
||||||
ctx.log("GET /logs/tail?name=forgectrl -> %s", st)
|
|
||||||
ctx.check(st == 200, "GET /logs/tail -> %s %s", st, tail if isinstance(tail, dict) else "")
|
|
||||||
st, tail = fc.get("/logs/tail", params={"name": "no-such-logger"})
|
|
||||||
ctx.check(st == 404, "unknown logger -> %s, expected 404", st)
|
|
||||||
|
|
||||||
# the sanitizer walks every log file; a full tree takes tens of
|
|
||||||
# seconds on the target, so this call gets its own client timeout
|
|
||||||
slow = hw.Forgectrl(token=fc.token, timeout=300.0)
|
|
||||||
t0 = time.time()
|
|
||||||
st, data = slow.post("/logs/export", raw=True)
|
|
||||||
ev["export_s"] = round(time.time() - t0, 1)
|
|
||||||
ctx.log("POST /logs/export -> %s (%d bytes, %.1f s)", st, len(data) if data else 0, ev["export_s"])
|
|
||||||
ctx.check(st == 200, "POST /logs/export -> %s", st)
|
|
||||||
ev["export_bytes"] = len(data)
|
|
||||||
try:
|
|
||||||
raw = gzip.decompress(data)
|
|
||||||
tf = tarfile.open(fileobj=io.BytesIO(raw))
|
|
||||||
members = tf.getnames()
|
|
||||||
except (OSError, tarfile.TarError, EOFError) as e:
|
|
||||||
ctx.fail("export is not a readable tar.gz: %s", e)
|
|
||||||
ev["members"] = len(members)
|
|
||||||
ctx.log("bundle: %d members, e.g. %s", len(members), members[:5])
|
|
||||||
ctx.check(members, "empty bundle")
|
|
||||||
ctx.check(any(m.endswith("README.txt") for m in members), "sanitized bundle lacks README.txt")
|
|
||||||
# the two secrets the sanitizer knows by value and a bundle could
|
|
||||||
# carry: the panel token and the camera key (a URL parameter that
|
|
||||||
# lands in sender logs)
|
|
||||||
secrets = {}
|
|
||||||
if fc.token:
|
|
||||||
secrets["panel token"] = fc.token
|
|
||||||
st, body = fc.get("/system/camera-key")
|
|
||||||
if st == 200 and isinstance(body, dict) and body.get("key"):
|
|
||||||
secrets["camera key"] = body["key"]
|
|
||||||
ctx.check("camera key" in secrets, "GET /system/camera-key -> %s: no key to check for", st)
|
|
||||||
contents = [(m.name, tf.extractfile(m).read()) for m in tf.getmembers() if m.isfile()]
|
|
||||||
for what, value in secrets.items():
|
|
||||||
leaked = [name for name, content in contents if value.encode() in content]
|
|
||||||
ev[what.replace(" ", "_") + "_leaks"] = leaked
|
|
||||||
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
|
|
||||||
|
|
||||||
|
|
||||||
# The routing test proves the whole path every logger takes: emitter (or
|
|
||||||
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
|
|
||||||
# logger's own file in the ff_line format. It stands for the emitters in
|
|
||||||
# every component (fflog in forgectrl and grblHAL, the SysLogHandler in the
|
|
||||||
# Python apps), the supervisor's per-controller relay, the daemon's fifo
|
|
||||||
# relay in its init script, and the render step.
|
|
||||||
_ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/super.c"),
|
|
||||||
("forgectrl", "init/**"),
|
|
||||||
("grblhal-glowforge", "src/fflog.*"), ("grblhal-glowforge", "src/main.c"),
|
|
||||||
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
|
|
||||||
("forgefirm-app", "forgefirm-app/gfhome.py")]
|
|
||||||
|
|
||||||
LOGS_ROOT = "/data/log/forgefirm"
|
|
||||||
LOGGERS = ("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
|
|
||||||
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
|
|
||||||
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
|
|
||||||
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
|
|
||||||
|
|
||||||
|
|
||||||
def _tail_lines(path, n=400):
|
|
||||||
try:
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
f.seek(0, 2)
|
|
||||||
size = f.tell()
|
|
||||||
f.seek(max(0, size - 256 * 1024))
|
|
||||||
data = f.read()
|
|
||||||
except OSError:
|
|
||||||
return []
|
|
||||||
return data.decode("utf-8", "replace").splitlines()[-n:]
|
|
||||||
|
|
||||||
|
|
||||||
def _wait_line(path, needle, timeout=6.0):
|
|
||||||
"""Poll a log file for a line containing needle; the matched line or None."""
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
for line in _tail_lines(path, 200):
|
|
||||||
if needle in line:
|
|
||||||
return line
|
|
||||||
time.sleep(0.25)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@test("logs.routing", title="Log routing, relays, and the rendered rules", subsystem="logs",
|
|
||||||
kind="auto", est_min=1,
|
|
||||||
covers=_ROUTING_COVERS, requires=["logs.tree-tail-export"],
|
|
||||||
description="rsyslog is the only logger (no busybox syslogd/klogd), the rules rendered from "
|
|
||||||
"the settings and the effective-levels record exist and agree with /logs, every "
|
|
||||||
"logger has its directory, the daemon's own emitter and the per-controller "
|
|
||||||
"`logger` relay both land in the right file in the ff_line format, a stray "
|
|
||||||
"program's line goes to system/ and nowhere else, kernel lines reach kernel/, "
|
|
||||||
"and nothing of ours is written outside the tree.")
|
|
||||||
def routing(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
|
|
||||||
# 1. one logger daemon
|
|
||||||
rs = hw.pidof("rsyslogd")
|
|
||||||
ev["rsyslogd_pids"] = rs
|
|
||||||
ctx.check(rs, "rsyslogd is not running")
|
|
||||||
for legacy in ("syslogd", "klogd"):
|
|
||||||
pids = hw.pidof(legacy)
|
|
||||||
ctx.check(not pids, "busybox %s is running (%s) next to rsyslog", legacy, pids)
|
|
||||||
ctx.log("rsyslogd %s; no syslogd/klogd", rs)
|
|
||||||
|
|
||||||
# 2. the render step: rules + effective levels, consistent with what
|
|
||||||
# the API reports as effective
|
|
||||||
ctx.check(os.path.isfile("/data/forgefirm/rsyslog-forgefirm.conf"),
|
|
||||||
"rendered rules missing: /data/forgefirm/rsyslog-forgefirm.conf")
|
|
||||||
rules = _read("/data/forgefirm/rsyslog-forgefirm.conf") or ""
|
|
||||||
for name in LOGGERS:
|
|
||||||
ctx.check(("# %s:" % name) in rules, "rendered rules carry no block for %s", name)
|
|
||||||
ctx.check(os.path.isfile("/var/run/forgefirm-loglevels"), "effective-levels record missing")
|
|
||||||
st, body = fc.get("/logs")
|
|
||||||
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
|
|
||||||
ctx.check(body.get("effective_known") is True, "/logs does not know the effective levels")
|
|
||||||
eff = {}
|
|
||||||
for l in body.get("loggers") or []:
|
|
||||||
eff[l["name"]] = (l.get("effective_disk"), l.get("effective_remote"))
|
|
||||||
ev["effective"] = eff
|
|
||||||
ctx.log("effective levels: %s", eff)
|
|
||||||
ctx.check(set(eff) == set(LOGGERS), "loggers reported: %s", sorted(eff))
|
|
||||||
levels = _read("/var/run/forgefirm-loglevels") or ""
|
|
||||||
for name, (disk, _remote) in eff.items():
|
|
||||||
ctx.check(("log_%s_disk=%s" % (name, disk)) in levels,
|
|
||||||
"effective record and /logs disagree on %s (%s)", name, disk)
|
|
||||||
|
|
||||||
# 3. the tree
|
|
||||||
for name in LOGGERS:
|
|
||||||
ctx.check(os.path.isdir(os.path.join(LOGS_ROOT, name)), "no directory for %s", name)
|
|
||||||
|
|
||||||
# 4. the daemon's own emitter (fflog): a line from the running pid, if
|
|
||||||
# its level admits info
|
|
||||||
pids = hw.pidof("forgectrl")
|
|
||||||
ctx.check(pids, "forgectrl is not running")
|
|
||||||
disk_rank = _SEV_RANK.get(eff.get("forgectrl", ("info", "off"))[0], 6)
|
|
||||||
fpath = os.path.join(LOGS_ROOT, "forgectrl", "forgectrl.log")
|
|
||||||
if disk_rank >= 6:
|
|
||||||
own = [l for l in _tail_lines(fpath, 2000)
|
|
||||||
if any((" forgectrl[%d] " % p) in l for p in pids)]
|
|
||||||
ev["own_lines"] = len(own)
|
|
||||||
ctx.log("forgectrl[%s] lines in its file: %d", pids, len(own))
|
|
||||||
ctx.check(own, "no line from the running daemon in %s (emitter path broken?)", fpath)
|
|
||||||
m = _LINE_RE.match(own[-1])
|
|
||||||
ctx.check(m and m.group("prog") == "forgectrl", "not in the ff_line format: %r", own[-1])
|
|
||||||
else:
|
|
||||||
ctx.log("forgectrl disk level %s: own-emitter check skipped", eff["forgectrl"][0])
|
|
||||||
|
|
||||||
# 5. the relay path: a `logger` probe under a controller's name lands
|
|
||||||
# in that controller's file with the ff_line format
|
|
||||||
nonce = "forgetest-routing-%d" % int(time.time())
|
|
||||||
for name in ("grblhal", "gfhome"):
|
|
||||||
rank = _SEV_RANK.get(eff.get(name, ("info", "off"))[0], 6)
|
|
||||||
if rank < 3:
|
|
||||||
ctx.log("%s disk level off: relay probe skipped", name)
|
|
||||||
continue
|
|
||||||
rc, out = hw.run(["logger", "-t", name, "-p", "daemon.err", "%s %s" % (nonce, name)])
|
|
||||||
ctx.check(rc == 0, "logger -t %s failed: %s", name, out)
|
|
||||||
path = os.path.join(LOGS_ROOT, name, name + ".log")
|
|
||||||
line = _wait_line(path, nonce)
|
|
||||||
ev["probe_" + name] = line
|
|
||||||
ctx.check(line, "%s probe did not reach %s", name, path)
|
|
||||||
m = _LINE_RE.match(line)
|
|
||||||
ctx.check(m and m.group("prog") == name and m.group("sev") == "ERR",
|
|
||||||
"%s probe line not in the ff_line format: %r", name, line)
|
|
||||||
ctx.log("%s: %s", name, line)
|
|
||||||
# a probe under another name must NOT leak into these files
|
|
||||||
rc, out = hw.run(["logger", "-t", "forgetest-stray", "-p", "daemon.err", "%s stray" % nonce])
|
|
||||||
time.sleep(1.5)
|
|
||||||
for name in ("grblhal", "gfhome", "forgectrl"):
|
|
||||||
path = os.path.join(LOGS_ROOT, name, name + ".log")
|
|
||||||
ctx.check(not any((nonce + " stray") in l for l in _tail_lines(path, 100)),
|
|
||||||
"a stray program's line landed in %s", path)
|
|
||||||
sys_rank = _SEV_RANK.get(eff.get("system", ("info", "off"))[0], 6)
|
|
||||||
if sys_rank >= 3:
|
|
||||||
line = _wait_line(os.path.join(LOGS_ROOT, "system", "system.log"), nonce + " stray")
|
|
||||||
ctx.check(line, "the stray program's line did not reach system/")
|
|
||||||
|
|
||||||
# 6. relay processes: the supervisor spawns one `logger` per controller
|
|
||||||
# (its stdin is the controller's output pipe); the daemon's init
|
|
||||||
# script keeps one on the fifo
|
|
||||||
st, mode = fc.get("/mode")
|
|
||||||
relays = hw.pidof("logger")
|
|
||||||
ev["logger_relays"] = relays
|
|
||||||
ev["controller"] = mode.get("controller") if isinstance(mode, dict) else None
|
|
||||||
ctx.log("logger relays: %s (controller %s)", relays, ev["controller"])
|
|
||||||
if isinstance(mode, dict) and mode.get("controller") == "running":
|
|
||||||
ctx.check(relays, "controller running but no `logger` relay process")
|
|
||||||
if os.path.exists("/var/run/forgectrl.stderr"):
|
|
||||||
ctx.check(len(relays) >= 2 or ev["controller"] != "running",
|
|
||||||
"fifo relay present but only %s logger process(es)", relays)
|
|
||||||
|
|
||||||
# 7. the kernel reaches kernel/ (unless filtered off)
|
|
||||||
if _SEV_RANK.get(eff.get("kernel", ("info", "off"))[0], 6) >= 6:
|
|
||||||
klines = _tail_lines(os.path.join(LOGS_ROOT, "kernel", "kernel.log"), 50)
|
|
||||||
ev["kernel_lines"] = len(klines)
|
|
||||||
ctx.check(klines and any(" kernel[" in l for l in klines), "no kernel lines in kernel/")
|
|
||||||
|
|
||||||
# 8. nothing outside the tree
|
|
||||||
for stray in ("/data/forgectrl.log", "/data/gfcloud.log", "/data/gfhome.log",
|
|
||||||
"/data/log/gfcloud", "/data/log/gfhome", "/var/log/messages"):
|
|
||||||
ctx.check(not os.path.exists(stray), "pre-syslog log path still present: %s", stray)
|
|
||||||
ctx.log("no ForgeFIRM log outside %s", LOGS_ROOT)
|
|
||||||
|
|
||||||
|
|
||||||
@test("logs.level-settings", title="Log level and remote-target settings", subsystem="logs",
|
|
||||||
kind="auto", est_min=1,
|
|
||||||
covers=[("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/settings.*")],
|
|
||||||
requires=["logs.tree-tail-export", "forgectrl.settings-bounds"],
|
|
||||||
description="A log level outside off..debug, a bad remote port, a bad protocol and a bad "
|
|
||||||
"server name are refused (400); a valid remote level is accepted and /logs shows "
|
|
||||||
"it as configured but not effective (pending_reboot); the setting is restored to "
|
|
||||||
"its prior value.")
|
|
||||||
def level_settings(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
|
||||||
|
|
||||||
for key, val in (("log_grblhal_disk", "verbose"), ("log_kernel_remote", "7"),
|
|
||||||
("syslog_port", "70000"), ("syslog_proto", "tls"),
|
|
||||||
("syslog_server", "bad host name")):
|
|
||||||
st, body = fc.post("/settings", data={key: val})
|
|
||||||
ev["refused %s=%s" % (key, val)] = st
|
|
||||||
ctx.log("POST /settings %s=%s -> %s", key, val, st)
|
|
||||||
ctx.check(st == 400, "%s=%s -> %s, expected 400", key, val, st)
|
|
||||||
|
|
||||||
st, before = fc.get("/logs")
|
|
||||||
ctx.check(st == 200 and isinstance(before, dict), "GET /logs -> %s", st)
|
|
||||||
prior = {l["name"]: l for l in before.get("loggers") or []}
|
|
||||||
ctx.check("system" in prior, "/logs lacks the system logger")
|
|
||||||
was = prior["system"].get("remote") or "off"
|
|
||||||
was_pending = bool(before.get("pending_reboot"))
|
|
||||||
new = "warning" if was != "warning" else "error"
|
|
||||||
ev["system_remote_before"] = was
|
|
||||||
ev["pending_before"] = was_pending
|
|
||||||
try:
|
|
||||||
st, body = fc.post("/settings", data={"log_system_remote": new})
|
|
||||||
ctx.log("POST /settings log_system_remote=%s -> %s", new, st)
|
|
||||||
ctx.check(st == 200, "valid level refused: %s", st)
|
|
||||||
st, after = fc.get("/logs")
|
|
||||||
now = {l["name"]: l for l in after.get("loggers") or []}
|
|
||||||
ctx.check(now["system"].get("remote") == new, "configured level reads %r", now["system"].get("remote"))
|
|
||||||
ctx.check(now["system"].get("effective_remote") == prior["system"].get("effective_remote"),
|
|
||||||
"effective level changed without a reboot")
|
|
||||||
ctx.check(after.get("pending_reboot") is True, "pending_reboot not raised by a level change")
|
|
||||||
ctx.log("configured %s, effective %s, pending_reboot %s",
|
|
||||||
now["system"].get("remote"), now["system"].get("effective_remote"), after.get("pending_reboot"))
|
|
||||||
finally:
|
|
||||||
# restore: a value that was stored goes back as a value; a default
|
|
||||||
# is restored by clearing the key (a query parameter - empty
|
|
||||||
# values never reach the form body)
|
|
||||||
stored = fc.settings().get("log_system_remote", "")
|
|
||||||
if was == "off" and stored in ("", None, new):
|
|
||||||
st, body = fc.post("/settings", params={"log_system_remote": ""})
|
|
||||||
else:
|
|
||||||
st, body = fc.post("/settings", data={"log_system_remote": was})
|
|
||||||
ctx.log("restore log_system_remote -> %s", st)
|
|
||||||
st, final = fc.get("/logs")
|
|
||||||
fin = {l["name"]: l for l in final.get("loggers") or []}
|
|
||||||
ctx.check(fin["system"].get("remote") == was, "system remote level not restored (%r)", fin["system"].get("remote"))
|
|
||||||
ctx.check(bool(final.get("pending_reboot")) == was_pending,
|
|
||||||
"pending_reboot did not return to %s after the restore", was_pending)
|
|
||||||
+369
-477
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""motion.* - the motion controller under grblHAL: dry motion, no emission.
|
"""motion.* - the motion controller under grblHAL: dry motion, no emission.
|
||||||
|
|
||||||
Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
|
Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
|
||||||
@@ -5,7 +10,9 @@ Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
|
|||||||
and round-trip; the laser stays latched (the tests never touch it); the
|
and round-trip; the laser stays latched (the tests never touch it); the
|
||||||
suite is the only Grbl client while a test runs.
|
suite is the only Grbl client while a test runs.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import struct
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
@@ -56,39 +63,6 @@ def wait_state(ctx, g, prefix, timeout):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def wait_state_text(ctx, g, prefix, timeout):
|
|
||||||
"""wait_state, keeping everything the controller said while polling.
|
|
||||||
The driver reports a press with a [MSG:] line the moment it acts on it -
|
|
||||||
inside the poll window - so a test that wants both the state and the
|
|
||||||
message has to collect them together."""
|
|
||||||
end = time.time() + timeout
|
|
||||||
text = ""
|
|
||||||
while time.time() < end:
|
|
||||||
ctx.checkpoint()
|
|
||||||
st = g.status_report()
|
|
||||||
text += g.drain()
|
|
||||||
if st["state"].startswith(prefix):
|
|
||||||
return st, text
|
|
||||||
time.sleep(0.1)
|
|
||||||
return None, text + g.drain()
|
|
||||||
|
|
||||||
|
|
||||||
def wait_left_state(ctx, g, prefix, timeout):
|
|
||||||
"""The first report whose state is no longer `prefix`, with the text.
|
|
||||||
Leaving a state is what proves a command was acted on; catching the
|
|
||||||
state it moves INTO is a race whenever the remaining work is short."""
|
|
||||||
end = time.time() + timeout
|
|
||||||
text = ""
|
|
||||||
while time.time() < end:
|
|
||||||
ctx.checkpoint()
|
|
||||||
st = g.status_report()
|
|
||||||
text += g.drain()
|
|
||||||
if not st["state"].startswith(prefix):
|
|
||||||
return st, text
|
|
||||||
time.sleep(0.1)
|
|
||||||
return None, text + g.drain()
|
|
||||||
|
|
||||||
|
|
||||||
def wait_idle(ctx, g, timeout=30.0, poll=0.05, grace=0.3):
|
def wait_idle(ctx, g, timeout=30.0, poll=0.05, grace=0.3):
|
||||||
"""Poll until Idle; returns (peak_feed_mm_min, states_seen, final_report).
|
"""Poll until Idle; returns (peak_feed_mm_min, states_seen, final_report).
|
||||||
An Idle report inside the first `grace` seconds counts only once a
|
An Idle report inside the first `grace` seconds counts only once a
|
||||||
@@ -136,9 +110,27 @@ def clean_slate(ctx, g):
|
|||||||
ctx.log("unlock: %s", g.command("$X"))
|
ctx.log("unlock: %s", g.command("$X"))
|
||||||
st = g.status_report()
|
st = g.status_report()
|
||||||
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
|
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
|
||||||
|
clear_error(ctx, g)
|
||||||
return st
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def clear_error(ctx, g):
|
||||||
|
"""Clear a latched line error before the test's first move.
|
||||||
|
|
||||||
|
Soft limits are armed after a home (the driver's, since the bed has
|
||||||
|
no switches), so a jog past the bed is refused with error:15 - and
|
||||||
|
grblHAL then answers error:15 to every following G-code line, across
|
||||||
|
a fresh connection, until a blank line acknowledges it ($ commands
|
||||||
|
and the ? report are unaffected, so a sender that queries on connect
|
||||||
|
self-clears; a bare G-code stream does not). A prior test's or the
|
||||||
|
baseline hand-back's rejected jog would otherwise make this test's
|
||||||
|
first move fail with a stale error unrelated to the move. A blank
|
||||||
|
line clears it and returns ok. Harmless and idempotent at Idle."""
|
||||||
|
r = g.command("")
|
||||||
|
ctx.check(r and r[-1] == "ok" and not any(x.startswith("error") for x in r),
|
||||||
|
"the controller answered %s to a blank line at the start - not a clean parser", r)
|
||||||
|
|
||||||
|
|
||||||
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
|
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
|
||||||
subsystem="motion", kind="auto", mode="grbl", est_min=1,
|
subsystem="motion", kind="auto", mode="grbl", est_min=1,
|
||||||
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
|
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
|
||||||
@@ -567,99 +559,6 @@ def _wait_controller(ctx, fc, states, timeout, motion=None):
|
|||||||
return m if isinstance(m, dict) else {}
|
return m if isinstance(m, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def _button_led(ctx, window_s=1.2):
|
|
||||||
"""The commanded level of the button's red, green, and blue channels
|
|
||||||
(ledtrig_smooth's target), sampled over a blink period or two and
|
|
||||||
reduced to the maximum per channel: with a pulse set, target reads 0
|
|
||||||
through the off half of every blink. None when the LEDs are not
|
|
||||||
readable."""
|
|
||||||
best = None
|
|
||||||
t0 = time.time()
|
|
||||||
while time.time() - t0 < window_s:
|
|
||||||
out = []
|
|
||||||
for ch in (1, 2, 3):
|
|
||||||
try:
|
|
||||||
with open("/sys/class/leds/button_led_%d/target" % ch, "r", encoding="utf-8") as f:
|
|
||||||
out.append(int(f.read().strip()))
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return None
|
|
||||||
best = out if best is None else [max(a, b) for a, b in zip(best, out)]
|
|
||||||
ctx.sleep(0.1)
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
@test("motion.gate-waits-for-lid", title="The motion gate waits for the lid", subsystem="motion",
|
|
||||||
kind="operator", est_min=2,
|
|
||||||
covers=[("forgectrl", "src/super.*"), ("forgectrl", "src/liveness.*"), ("forgectrl", "src/led.*"),
|
|
||||||
("forgectrl", "src/ui/panel.js")],
|
|
||||||
requires=["motion.liveness-probe"], actions=["lid"],
|
|
||||||
steps=["The lid opens, forgectrl is restarted (the gate runs at the first spawn of a "
|
|
||||||
"session: power-on, a daemon restart), the lid closes: the head does not move "
|
|
||||||
"while the lid is open, then makes the 15 mm probe move (+X first)."],
|
|
||||||
description="With the lid open when forgectrl starts (power-on with the lid up, or a "
|
|
||||||
"daemon restart) the supervisor starts nothing: GET /mode reports controller "
|
|
||||||
"waiting with why naming the lid, motion unverified, no pid, the button blinks "
|
|
||||||
"amber, and no probe line reaches the log. When the lid closes the probe runs "
|
|
||||||
"(MOTION OK), the lens takes its reference, and the controller comes up "
|
|
||||||
"verified with the button handed back.")
|
|
||||||
def gate_waits_for_lid(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
|
|
||||||
try:
|
|
||||||
ctx.act("lid", "open")
|
|
||||||
off = _log_offset(FORGECTRL_LOG)
|
|
||||||
rc, out = hw.initd("forgectrl", "restart")
|
|
||||||
ctx.check(rc == 0, "forgectrl restart -> rc %s", rc)
|
|
||||||
m = _wait_controller(ctx, fc, ("waiting", "running", "motion-fault"), 60)
|
|
||||||
ev["waiting"] = m
|
|
||||||
ctx.log("mode with the lid open: %s", m)
|
|
||||||
ctx.check(m.get("controller") == "waiting",
|
|
||||||
"controller is %r with the lid open, expected waiting", m.get("controller"))
|
|
||||||
ctx.check("lid" in (m.get("why") or ""), "why does not name the lid: %r", m.get("why"))
|
|
||||||
ctx.check(m.get("motion") == "unverified", "motion reads %r while waiting", m.get("motion"))
|
|
||||||
ctx.check(not m.get("pid"), "a controller (pid %s) runs while the gate waits", m.get("pid"))
|
|
||||||
led = _button_led(ctx)
|
|
||||||
ev["led_waiting"] = led
|
|
||||||
ctx.log("button LED targets while waiting (r, g, b): %s", led)
|
|
||||||
ctx.check(led is not None and led[0] > 0 and led[1] > 0 and led[2] == 0,
|
|
||||||
"the button does not blink amber while the gate waits: %s", led)
|
|
||||||
ctx.sleep(3)
|
|
||||||
st, m2 = fc.get("/mode")
|
|
||||||
ctx.check(isinstance(m2, dict) and m2.get("controller") == "waiting", "the wait did not hold: %s", m2)
|
|
||||||
lines = _probe_lines(FORGECTRL_LOG, off)
|
|
||||||
ctx.check(not lines, "the probe ran with the lid open: %s", lines[:1])
|
|
||||||
|
|
||||||
t_close = time.time()
|
|
||||||
ctx.act("lid", "close")
|
|
||||||
m = _wait_controller(ctx, fc, ("running", "motion-fault"), 150, motion="verified")
|
|
||||||
ev["after_close"] = m
|
|
||||||
ev["close_to_running_s"] = round(time.time() - t_close, 1)
|
|
||||||
ctx.log("mode %.1f s after the lid closed: %s", time.time() - t_close, m)
|
|
||||||
ctx.check(m.get("controller") == "running" and m.get("motion") == "verified",
|
|
||||||
"the controller did not come up verified after the lid closed: %s", m)
|
|
||||||
t1 = time.time()
|
|
||||||
lines = _probe_lines(FORGECTRL_LOG, off)
|
|
||||||
while not lines and time.time() - t1 < 10:
|
|
||||||
ctx.sleep(0.5)
|
|
||||||
lines = _probe_lines(FORGECTRL_LOG, off)
|
|
||||||
ev["probe_lines"] = lines[-3:]
|
|
||||||
ctx.check(lines and "MOTION OK" in lines[0],
|
|
||||||
"the probe after the lid closed was not MOTION OK: %s", lines[:1])
|
|
||||||
led = _button_led(ctx)
|
|
||||||
ev["led_running"] = led
|
|
||||||
ctx.check(led is None or led[0] == 0 or led[2] > 0,
|
|
||||||
"the amber wait pattern is still on the button after the start: %s", led)
|
|
||||||
finally:
|
|
||||||
if ctx.switch("lid") is False:
|
|
||||||
ctx.act("lid", "close", fail=False)
|
|
||||||
m = _wait_controller(ctx, fc, ("running", "motion-fault"), 150)
|
|
||||||
if m.get("controller") != "running":
|
|
||||||
fc.post("/controller/start")
|
|
||||||
_wait_controller(ctx, fc, ("running", "motion-fault"), 150)
|
|
||||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the drill")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- cancel / abort
|
# ---------------------------------------------------------------- cancel / abort
|
||||||
|
|
||||||
@test("motion.cancel-abort", title="Jog cancel and controlled abort recover cleanly", subsystem="motion",
|
@test("motion.cancel-abort", title="Jog cancel and controlled abort recover cleanly", subsystem="motion",
|
||||||
@@ -753,21 +652,30 @@ def _return_x(ctx, delta_mm):
|
|||||||
machine_idle(ctx)
|
machine_idle(ctx)
|
||||||
|
|
||||||
|
|
||||||
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move",
|
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move, "
|
||||||
subsystem="motion", kind="auto", mode="grbl", est_min=4,
|
"a kill during the web-service homing",
|
||||||
|
subsystem="motion", kind="auto", mode="grbl", est_min=6,
|
||||||
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
|
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
|
||||||
requires=["motion.cancel-abort", "kernel.k1-k2"],
|
requires=["motion.cancel-abort", "kernel.k1-k2"],
|
||||||
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail."],
|
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail. "
|
||||||
|
"With cloud mode enabled the test also homes through the web service and kills the "
|
||||||
|
"controller mid-homing (about a minute); the head ends wherever the homing was."],
|
||||||
description="SIGKILL of the controller mid-move: the supervisor reaps it, safes (cnc/stop, "
|
description="SIGKILL of the controller mid-move: the supervisor reaps it, safes (cnc/stop, "
|
||||||
"latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a "
|
"latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a "
|
||||||
"hang) mid-move: the ring drains into a kernel underrun (fast halt, latch "
|
"hang) mid-move: the ring drains into a kernel underrun (fast halt, latch "
|
||||||
"locked); the process resumed from the hang recovers on a soft reset and an unlock "
|
"locked); the process resumed from the hang recovers on a soft reset and an unlock "
|
||||||
"(the alarm is critical, so $X alone is refused; the reset acknowledges the "
|
"(the alarm is critical, so $X alone is refused; the reset acknowledges the "
|
||||||
"fault to the stream and clears the stale ring) and moves again without a "
|
"fault to the stream and clears the stale ring) and moves again without a "
|
||||||
"restart. forgectrl "
|
"restart. A short SIGSTOP (100 ms, inside the kernel's 200 ms queue) mid-move: "
|
||||||
"restart mid-move: the busy controller finishes the move unmanaged and the new "
|
"no underrun, but the step producer comes back later than its lead and its "
|
||||||
"daemon retakes supervision at idle. After each drill the head is jogged back "
|
"late events clamp; unarmed that is a warning in the controller's log and the "
|
||||||
"by the kernel-measured distance.")
|
"move completes with every step (the armed case faults, proven on the host). "
|
||||||
|
"forgectrl restart mid-move: the busy controller finishes the move unmanaged "
|
||||||
|
"and the new daemon retakes supervision at idle. After each drill the head is "
|
||||||
|
"jogged back by the kernel-measured distance. SIGKILL during $H (the web-service "
|
||||||
|
"homing, with cloud mode enabled): the homing runner the controller left behind "
|
||||||
|
"is ended before the respawn, the kernel is idle when the new controller starts, "
|
||||||
|
"and the pulse device has one controller on it.")
|
||||||
def deadman(ctx):
|
def deadman(ctx):
|
||||||
import os as _os
|
import os as _os
|
||||||
import signal as _signal
|
import signal as _signal
|
||||||
@@ -897,6 +805,47 @@ def deadman(ctx):
|
|||||||
x1 = _kernel_x_mm(ctx)
|
x1 = _kernel_x_mm(ctx)
|
||||||
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||||
|
|
||||||
|
# ---- 2b. a short hang (inside the kernel queue): late events clamp, a warning, the move completes
|
||||||
|
m2 = wait_running(10)
|
||||||
|
pid2 = m2["pid"]
|
||||||
|
x0 = _kernel_x_mm(ctx)
|
||||||
|
underruns0 = hw.sysfs_int("cnc/underruns", 0)
|
||||||
|
log0 = _log_offset(GRBLHAL_LOG)
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
clean_slate(ctx, g)
|
||||||
|
g.command("G91")
|
||||||
|
g.command("G1X30F300", timeout=0.5) # ~6 s of motion
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
_os.kill(pid2, _signal.SIGSTOP)
|
||||||
|
time.sleep(0.1)
|
||||||
|
_os.kill(pid2, _signal.SIGCONT)
|
||||||
|
ctx.log("SIGSTOP 100 ms sent to controller pid %d mid-move", pid2)
|
||||||
|
peak, states, st = wait_idle(ctx, g, 30)
|
||||||
|
g.command("G90")
|
||||||
|
machine_idle(ctx) # the kernel's tail plays out behind grbl's Idle
|
||||||
|
warned = None
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < 5 and not warned:
|
||||||
|
lines = _log_lines(GRBLHAL_LOG, log0, "late events clamped")
|
||||||
|
warned = lines[-1] if lines else None
|
||||||
|
if not warned:
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
x1 = _kernel_x_mm(ctx)
|
||||||
|
ev["short_stall"] = {"states": states, "underruns": hw.sysfs_int("cnc/underruns", 0) - underruns0,
|
||||||
|
"warning": warned, "kernel_dx_mm": round((x1 - x0), 3) if (x0 is not None and x1 is not None) else None,
|
||||||
|
"state_after": (st or {}).get("state")}
|
||||||
|
ctx.log("short stall: states %s, underruns +%s, warning %r, kernel dx %s mm", states,
|
||||||
|
ev["short_stall"]["underruns"], warned, ev["short_stall"]["kernel_dx_mm"])
|
||||||
|
ctx.check("TIMEOUT" not in states and str((st or {}).get("state", "")).startswith("Idle"),
|
||||||
|
"the move did not complete after the short stall (states %s)", states)
|
||||||
|
ctx.check(ev["short_stall"]["underruns"] == 0, "the short stall drained the ring (underruns +%s)",
|
||||||
|
ev["short_stall"]["underruns"])
|
||||||
|
ctx.check(warned, "the controller did not warn about the clamped events after the short stall")
|
||||||
|
ctx.check(ev["short_stall"]["kernel_dx_mm"] is not None and abs(ev["short_stall"]["kernel_dx_mm"] - 30.0) < 0.3,
|
||||||
|
"the kernel counted %s mm for a 30 mm move after the short stall", ev["short_stall"]["kernel_dx_mm"])
|
||||||
|
ctx.check(latch_locked(), "latch unlocked after the short stall")
|
||||||
|
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||||
|
|
||||||
# ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle
|
# ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle
|
||||||
m2 = wait_running(10)
|
m2 = wait_running(10)
|
||||||
pid2 = m2["pid"]
|
pid2 = m2["pid"]
|
||||||
@@ -934,17 +883,104 @@ def deadman(ctx):
|
|||||||
x1 = _kernel_x_mm(ctx)
|
x1 = _kernel_x_mm(ctx)
|
||||||
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||||
machine_idle(ctx)
|
machine_idle(ctx)
|
||||||
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, restart retook supervision (pid %s)",
|
|
||||||
respawn_s, halt_s, m3.get("pid"))
|
# ---- 4. SIGKILL during $H: the homing runner is ended, the kernel idle, one writer
|
||||||
|
homing_kill(ctx, wait_running)
|
||||||
|
|
||||||
|
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, short stall warned and completed, "
|
||||||
|
"restart retook supervision (pid %s), the kill during $H ended the runner (%s)",
|
||||||
|
respawn_s, halt_s, m3.get("pid"), ev.get("homing_kill", {}).get("runner_gone_s", "skipped"))
|
||||||
|
|
||||||
|
|
||||||
|
def _pulse_holders():
|
||||||
|
"""The processes holding the pulse device open, by name."""
|
||||||
|
out = []
|
||||||
|
for pid in os.listdir("/proc"):
|
||||||
|
if not pid.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
for fd in os.listdir("/proc/%s/fd" % pid):
|
||||||
|
if os.readlink("/proc/%s/fd/%s" % (pid, fd)) == "/dev/glowforge":
|
||||||
|
with open("/proc/%s/comm" % pid) as f:
|
||||||
|
out.append(f.read().strip())
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def homing_kill(ctx, wait_running):
|
||||||
|
"""SIGKILL the GRBL controller while gfhome (the web-service homing
|
||||||
|
runner, its own process group on the inherited pulse fd) is moving
|
||||||
|
the head. The supervisor must end the runner before it starts another
|
||||||
|
controller, or two processes write the ring. Skipped when cloud mode
|
||||||
|
is not enabled on the machine (the runner needs the service)."""
|
||||||
|
import os as _os
|
||||||
|
import signal as _signal
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
before = fc.settings()
|
||||||
|
if str(before.get("cloud_enabled", "")).lower() not in ("1", "true", "yes", "on"):
|
||||||
|
ctx.log("kill during $H: skipped, cloud mode is not enabled on this machine")
|
||||||
|
return
|
||||||
|
hm = before.get("homing_mode", "")
|
||||||
|
ev["homing_kill"] = {"homing_mode": hm}
|
||||||
|
if hm != "gfcloud":
|
||||||
|
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
|
||||||
|
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
|
||||||
|
try:
|
||||||
|
m4 = wait_running(10)
|
||||||
|
ctx.check(m4, "controller not running before the homing drill")
|
||||||
|
pid4 = m4["pid"]
|
||||||
|
off = _log_offset(FORGECTRL_LOG)
|
||||||
|
runner_up = None
|
||||||
|
try:
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
clean_slate(ctx, g)
|
||||||
|
g.send_raw(b"$H\n")
|
||||||
|
runner_up = ctx.wait_for(lambda: bool(hw.pidof("gfhome.py")), 30)
|
||||||
|
ctx.check(runner_up is not None, "the homing runner never started for $H")
|
||||||
|
ctx.sleep(3.0) # into the session: the runner drives the head
|
||||||
|
ctx.log("SIGKILL sent to controller pid %d during $H (runner pid %s)",
|
||||||
|
pid4, hw.pidof("gfhome.py"))
|
||||||
|
_os.kill(pid4, _signal.SIGKILL)
|
||||||
|
except (hw.HwError, OSError) as e:
|
||||||
|
ctx.log("the Grbl connection ended with the kill: %s", e)
|
||||||
|
t_kill = time.time()
|
||||||
|
gone_s = ctx.wait_for(lambda: not hw.pidof("gfhome.py"), 15)
|
||||||
|
ev["homing_kill"]["runner_gone_s"] = gone_s
|
||||||
|
ctx.check(gone_s is not None, "the homing runner outlived its controller")
|
||||||
|
m5 = None
|
||||||
|
while time.time() - t_kill < 60:
|
||||||
|
st, m5 = fc.get("/mode")
|
||||||
|
if isinstance(m5, dict) and m5.get("controller") == "running" and m5.get("pid") != pid4:
|
||||||
|
break
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
kstate = hw.sysfs_read("cnc/state")
|
||||||
|
holders = _pulse_holders()
|
||||||
|
lines = _log_lines(FORGECTRL_LOG, off, "homing runner")
|
||||||
|
halted = _log_lines(FORGECTRL_LOG, off, "halting it")
|
||||||
|
ev["homing_kill"].update({"respawn_s": round(time.time() - t_kill, 1), "mode_after": m5,
|
||||||
|
"kernel_state_at_respawn": kstate, "pulse_holders": holders,
|
||||||
|
"super_lines": [ln[-160:] for ln in lines[-3:]]})
|
||||||
|
ctx.log("kill during $H: runner gone in %s s, respawned as %s, kernel %s, pulse held by %s",
|
||||||
|
gone_s, m5 and m5.get("pid"), kstate, holders)
|
||||||
|
ctx.check(m5 and m5.get("pid") != pid4, "no respawn after the kill during $H: %s", m5)
|
||||||
|
ctx.check(lines, "the supervisor did not log the runner's end")
|
||||||
|
ctx.check(not halted, "the kernel had to be halted for the respawn: %s", halted[-1:])
|
||||||
|
ctx.check(kstate in ("idle", "disabled"), "the kernel was %s when the new controller started", kstate)
|
||||||
|
ctx.check("gfhome.py" not in holders and holders.count("grblHAL_glowfor") <= 1,
|
||||||
|
"more than one controller on the pulse device: %s", holders)
|
||||||
|
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the drill")
|
||||||
|
finally:
|
||||||
|
if hm != "gfcloud":
|
||||||
|
st, body = (fc.post("/settings", params={"homing_mode": ""}) if not hm
|
||||||
|
else fc.post("/settings", data={"homing_mode": hm}))
|
||||||
|
ctx.log("restore homing_mode=%r -> %s", hm, st)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------- lid / button (the factory's)
|
# ------------------------------------------------- lid / button (the factory's)
|
||||||
|
|
||||||
_LID_COVERS = _MOTION_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
|
|
||||||
("grblhal-glowforge", "src/glowforge_switch_map.h"),
|
|
||||||
("grblhal-glowforge", "src/glowforge_laser.c")]
|
|
||||||
|
|
||||||
|
|
||||||
def kernel_xy_mm(ctx):
|
def kernel_xy_mm(ctx):
|
||||||
"""The kernel's own position counters, in mm (forgectrl /status pos):
|
"""The kernel's own position counters, in mm (forgectrl /status pos):
|
||||||
what the machine physically did, independent of what grbl believes."""
|
what the machine physically did, independent of what grbl believes."""
|
||||||
@@ -1012,343 +1048,6 @@ def drain_text(g, seconds):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def expect_cancel_and_return(ctx, g, ev, start, k0, why, tag):
|
|
||||||
"""The cancel policy's whole tail, shared by every trigger that ends a
|
|
||||||
job this way (lid or interlock, from Run or from a hold): the reason is
|
|
||||||
reported, the controller resets without an alarm and with the position
|
|
||||||
kept, and the head goes back to where the job started - which the KERNEL
|
|
||||||
counters have to confirm, not grbl's belief about them."""
|
|
||||||
text = drain_text(g, 3.0)
|
|
||||||
msgs = [ln for ln in text.splitlines()
|
|
||||||
if ln.startswith("[MSG:") or "help]" in ln or ln.startswith("ALARM")]
|
|
||||||
ev[tag + "_messages"] = msgs
|
|
||||||
ctx.log("[%s] controller: %s", tag, msgs)
|
|
||||||
cancel_msg = "%s - job canceled" % why
|
|
||||||
ctx.check(cancel_msg in text, "[%s] the job was not canceled with %r as the reason", tag, why)
|
|
||||||
ctx.check("help]" in text, "[%s] no reset banner after the cancel (the sender must see the job end)", tag)
|
|
||||||
ctx.check("ALARM" not in text, "[%s] an alarm was raised on the cancel (position should be kept)", tag)
|
|
||||||
t0 = time.time()
|
|
||||||
returned = "returned to the job start" in text
|
|
||||||
while not returned and time.time() - t0 < 30:
|
|
||||||
ctx.checkpoint()
|
|
||||||
text += g.drain()
|
|
||||||
returned = "returned to the job start" in text
|
|
||||||
time.sleep(0.2)
|
|
||||||
ev[tag + "_returned_message"] = returned
|
|
||||||
ctx.check(returned, "[%s] the head did not report returning to the job start within 30 s", tag)
|
|
||||||
st = wait_state(ctx, g, "Idle", 5)
|
|
||||||
ctx.check(st is not None, "[%s] not Idle after the return (state %s)", tag,
|
|
||||||
g.status_report()["state"])
|
|
||||||
drift = max(abs(st["MPos"][i] - start[i]) for i in range(2))
|
|
||||||
ev[tag + "_drift_mm"] = round(drift, 3)
|
|
||||||
ctx.log("[%s] back at the job start: drift %.3f mm", tag, drift)
|
|
||||||
ctx.check(drift <= 0.05, "[%s] head not back at the job start (drift %.3f mm)", tag, drift)
|
|
||||||
machine_idle(ctx, 10)
|
|
||||||
check_kernel_returned(ctx, ev, k0, tag=tag)
|
|
||||||
return drift
|
|
||||||
|
|
||||||
|
|
||||||
@test("motion.button-hold-resume", title="The button pauses and resumes a job",
|
|
||||||
subsystem="motion", kind="operator", mode="grbl", est_min=2,
|
|
||||||
covers=_LID_COVERS, requires=["motion.pacing"], actions=["button"],
|
|
||||||
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
|
|
||||||
"On Ready the head starts an 8 s move: press the button once while it moves (the "
|
|
||||||
"job holds), then once more when told (it resumes and finishes)."],
|
|
||||||
description="A travel job is running; one press of the big button feed-holds it (the sender "
|
|
||||||
"sees Hold), the next press resumes it (Run) and the move completes with its "
|
|
||||||
"position intact - the factory's pause/resume on the machine.")
|
|
||||||
def button_hold_resume(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
with ctx.grbl() as g:
|
|
||||||
clean_slate(ctx, g)
|
|
||||||
start = g.status_report()["MPos"]
|
|
||||||
ctx.ready("On Ready the head starts an 8 s move along +X. Press the button ONCE while it "
|
|
||||||
"moves; the job holds and the test sees it.")
|
|
||||||
g.command("M5")
|
|
||||||
g.command("G91")
|
|
||||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain() # the message window opens here
|
|
||||||
w = Watch(g)
|
|
||||||
ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
|
|
||||||
st, text = w.last, w.text
|
|
||||||
ctx.check(st is not None and st["state"].startswith("Hold"),
|
|
||||||
"the press did not hold the job (state %s)", st["state"] if st else "?")
|
|
||||||
ev["held_state"] = st["state"]
|
|
||||||
ev["held_at_mm"] = round(st["MPos"][0] - start[0], 3)
|
|
||||||
ev["pause_message"] = "job paused" in text
|
|
||||||
ctx.log("held: %s at %.3f mm of 40; message seen: %s", st["state"], ev["held_at_mm"],
|
|
||||||
ev["pause_message"])
|
|
||||||
# The press is proven by the job LEAVING the hold. Catching it in Run
|
|
||||||
# is a race: a pause late in the move leaves a fraction of a second of
|
|
||||||
# travel, which can be over before the next poll - the machine did
|
|
||||||
# exactly the right thing and the test would still have called it a
|
|
||||||
# failure.
|
|
||||||
w = Watch(g)
|
|
||||||
ctx.act("button", "press", text="The head is stopped: the press resumes the move.",
|
|
||||||
until=w.left_state("Hold"), timeout=60, fail=False)
|
|
||||||
st, text = w.last, w.text
|
|
||||||
if st is not None and st["state"].startswith("Hold"):
|
|
||||||
st = None
|
|
||||||
ev["state_after_resume"] = st["state"] if st else None
|
|
||||||
ev["resume_message"] = "job resumed" in text
|
|
||||||
ctx.check(st is not None, "the second press did not resume the job (still held: %s)",
|
|
||||||
g.status_report()["state"])
|
|
||||||
# Leaving the hold for Run or Idle is the resume; leaving it for Alarm
|
|
||||||
# or Door is something else entirely, and must not read as a pass.
|
|
||||||
ctx.check(st["state"].startswith(("Run", "Idle")),
|
|
||||||
"the job left the hold into %s, not into motion", st["state"])
|
|
||||||
ctx.log("resumed: %s; message seen: %s", ev["state_after_resume"], ev["resume_message"])
|
|
||||||
peak, states, st = wait_idle(ctx, g, 40)
|
|
||||||
ctx.check("TIMEOUT" not in states, "the resumed move did not complete: %s", states)
|
|
||||||
moved = st["MPos"][0] - start[0]
|
|
||||||
ev["moved_mm"] = round(moved, 3)
|
|
||||||
ctx.log("move completed after pause/resume: %.3f mm of 40", moved)
|
|
||||||
ctx.check(abs(moved - 40.0) <= 0.05, "the resumed move did not land on its target (%.3f mm)", moved)
|
|
||||||
g.command("$J=G91X-40F2400")
|
|
||||||
wait_idle(ctx, g, 30)
|
|
||||||
g.command("G90")
|
|
||||||
machine_idle(ctx)
|
|
||||||
ctx.log("PASS: button press held the job (%s), the next press resumed it, target reached", ev["held_state"])
|
|
||||||
|
|
||||||
|
|
||||||
@test("motion.lid-cancel-home", title="Lid open during a job - running or paused - cancels it and returns "
|
|
||||||
"to the job start",
|
|
||||||
subsystem="motion", kind="operator", mode="grbl", est_min=5,
|
|
||||||
covers=_LID_COVERS, requires=["motion.pacing", "motion.cancel-abort"],
|
|
||||||
actions=["lid", "button"],
|
|
||||||
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
|
|
||||||
"Twice, on Ready the head starts an 8 s move. First: open the lid while it moves and "
|
|
||||||
"leave it open until the head has come back, then close it. Second: press the button "
|
|
||||||
"once while it moves (pause), then open the lid, leave it open until the head is back, "
|
|
||||||
"and close it."],
|
|
||||||
description="A travel job is running when the lid opens: the job parks (planned deceleration), "
|
|
||||||
"the reason is reported, the controller resets (position kept, no alarm - the "
|
|
||||||
"sender's job is over), and the head returns on its own to where the job started "
|
|
||||||
"with the lid still open; the controller ends Idle at the start position. The same "
|
|
||||||
"job paused on the button takes the same path - a lid open from the hold cancels "
|
|
||||||
"and returns, it never resumes. With lid_policy=cancel (the default).")
|
|
||||||
def lid_cancel_home(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
policy = (ctx.forgectrl.settings() or {}).get("lid_policy") or "cancel"
|
|
||||||
ev["lid_policy"] = policy
|
|
||||||
ctx.check(policy == "cancel", "lid_policy is %r; this test needs cancel", policy)
|
|
||||||
with ctx.grbl() as g:
|
|
||||||
clean_slate(ctx, g)
|
|
||||||
k0 = kernel_start(ctx)
|
|
||||||
start = g.status_report()["MPos"]
|
|
||||||
ev["kernel_start"] = k0
|
|
||||||
ev["start"] = start
|
|
||||||
ctx.ready("On Ready the head starts an 8 s move along +X. Open the lid while it moves and "
|
|
||||||
"leave it open until the head has come back on its own.")
|
|
||||||
g.command("M5")
|
|
||||||
g.command("G91")
|
|
||||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain() # the message window opens here
|
|
||||||
ctx.act("lid", "open", text="Leave it open until the head has come back.", timeout=12)
|
|
||||||
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "lid opened", "running")
|
|
||||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
|
||||||
ev["lid_at_return"] = sw.get("lid")
|
|
||||||
ctx.act("lid", "close")
|
|
||||||
ctx.sleep(1)
|
|
||||||
# a jog afterward proves the controller is usable without $X
|
|
||||||
r = g.command("$J=G91X5F1200")
|
|
||||||
ctx.check(not any(x.startswith("error") for x in r), "jog refused after the cancel: %s", r)
|
|
||||||
wait_idle(ctx, g, 15)
|
|
||||||
g.command("$J=G91X-5F1200")
|
|
||||||
wait_idle(ctx, g, 15)
|
|
||||||
|
|
||||||
# -- the same cancel, entered from a hold ---------------------------
|
|
||||||
# A job paused on the button must not be resumable past a lid open:
|
|
||||||
# the armed window and the hardware button latch have to agree, so
|
|
||||||
# the lid ends the job here exactly as it does from Run.
|
|
||||||
# The reference for this phase is taken only once the MACHINE is at
|
|
||||||
# rest: the jogs above waited for grblHAL's Idle, which arrives while
|
|
||||||
# the kernel is still playing their tail.
|
|
||||||
k1 = kernel_start(ctx)
|
|
||||||
start2 = g.status_report()["MPos"]
|
|
||||||
ev["hold_start"] = start2
|
|
||||||
ctx.ready("On Ready the head starts the 8 s move again. Press the button ONCE while it "
|
|
||||||
"moves (the job pauses); then open the lid and leave it open until the head has "
|
|
||||||
"come back.")
|
|
||||||
g.command("G91") # the reset restored G90
|
|
||||||
g.command("G1X40F300", timeout=0.5)
|
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the second move did not start")
|
|
||||||
g.drain()
|
|
||||||
w = Watch(g)
|
|
||||||
ctx.act("button", "press", text="The job pauses.", until=w.in_state("Hold"), timeout=12,
|
|
||||||
fail=False)
|
|
||||||
st, held = w.last, w.text
|
|
||||||
ctx.check(st is not None and st["state"].startswith("Hold"),
|
|
||||||
"the press did not hold the job (state %s)", st["state"] if st else "?")
|
|
||||||
ev["hold_state"] = st["state"]
|
|
||||||
ev["hold_pause_message"] = "job paused" in held
|
|
||||||
ctx.log("paused: %s; message seen: %s", st["state"], ev["hold_pause_message"])
|
|
||||||
g.drain()
|
|
||||||
ctx.act("lid", "open", text="The job is paused: leave the lid open until the head has come "
|
|
||||||
"back.", timeout=60)
|
|
||||||
hold_drift = expect_cancel_and_return(ctx, g, ev, start2, k1, "lid opened", "hold")
|
|
||||||
ctx.check(not g.status_report()["state"].startswith("Hold"),
|
|
||||||
"the controller is still holding after the lid canceled the paused job")
|
|
||||||
ctx.act("lid", "close")
|
|
||||||
ctx.sleep(1)
|
|
||||||
r = g.command("$J=G91X5F1200")
|
|
||||||
ctx.check(not any(x.startswith("error") for x in r), "jog refused after the paused cancel: %s", r)
|
|
||||||
wait_idle(ctx, g, 15)
|
|
||||||
g.command("$J=G91X-5F1200")
|
|
||||||
wait_idle(ctx, g, 15)
|
|
||||||
g.command("G90")
|
|
||||||
machine_idle(ctx)
|
|
||||||
ctx.log("PASS: lid open canceled the job from Run (drift %.3f mm) and from the hold (drift %.3f mm), "
|
|
||||||
"reset without alarm, head returned to the start both times", drift, hold_drift)
|
|
||||||
|
|
||||||
|
|
||||||
@test("motion.interlock-cancel-home", title="The interlock loop cancels a job like the lid and returns to "
|
|
||||||
"the job start",
|
|
||||||
subsystem="motion", kind="operator", mode="grbl", est_min=4,
|
|
||||||
covers=_LID_COVERS, requires=["motion.lid-cancel-home"], actions=["interlock"],
|
|
||||||
steps=["Bed clear; the head needs 60 mm of free +X travel. No laser is involved.",
|
|
||||||
"Be able to open the remote-interlock loop: unplug the Pro's interlock plug, or pull the "
|
|
||||||
"jumper at J8 on a Basic/Plus.",
|
|
||||||
"On Ready the head starts a 12 s move: open the interlock while it moves and leave it "
|
|
||||||
"open until the head has come back, then restore it."],
|
|
||||||
description="The remote-interlock loop is the lid's equal in the cancel policy: opening it mid-job "
|
|
||||||
"cancels the job with 'interlock open' named as the reason - the lid's own message would "
|
|
||||||
"be wrong here - and sends the head back to the job start with the loop still open. "
|
|
||||||
"(An open lid not stopping the park is the lid test's park, which runs with the lid open "
|
|
||||||
"throughout; in GRBL mode the park is a rapid too short to open a lid during, so the "
|
|
||||||
"mid-park lid edge is the cloud test's.)")
|
|
||||||
def interlock_cancel_home(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
policy = (ctx.forgectrl.settings() or {}).get("lid_policy") or "cancel"
|
|
||||||
ev["lid_policy"] = policy
|
|
||||||
ctx.check(policy == "cancel", "lid_policy is %r; this test needs cancel", policy)
|
|
||||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
|
||||||
ctx.check(sw.get("interlock_ok"), "the interlock loop already reads open - close it before this test")
|
|
||||||
with ctx.grbl() as g:
|
|
||||||
clean_slate(ctx, g)
|
|
||||||
k0 = kernel_start(ctx)
|
|
||||||
start = g.status_report()["MPos"]
|
|
||||||
ev["start"] = start
|
|
||||||
ev["kernel_start"] = k0
|
|
||||||
ctx.ready("On Ready the head starts a 12 s move along +X. Open the interlock loop while it "
|
|
||||||
"moves and leave it open until the head has come back on its own.")
|
|
||||||
g.command("M5")
|
|
||||||
g.command("G91")
|
|
||||||
g.command("G1X60F300", timeout=0.5) # a 12 s move
|
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain()
|
|
||||||
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
|
|
||||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
|
||||||
ev["interlock_ok_after_pull"] = sw.get("interlock_ok")
|
|
||||||
ctx.check(sw.get("interlock_ok") is False,
|
|
||||||
"the interlock still reads closed - the loop was not opened (switches: %s)", sw)
|
|
||||||
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "interlock open", "interlock")
|
|
||||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
|
||||||
ev["switches_at_return"] = {"lid": sw.get("lid"), "interlock_ok": sw.get("interlock_ok")}
|
|
||||||
ctx.log("at the end of the park: %s", ev["switches_at_return"])
|
|
||||||
ctx.check(sw.get("interlock_ok") is False,
|
|
||||||
"the interlock was closed again before the park finished - the park ran with the loop "
|
|
||||||
"restored, not open")
|
|
||||||
ctx.act("interlock", "close")
|
|
||||||
ctx.sleep(1)
|
|
||||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
|
||||||
ev["restored"] = {"lid": sw.get("lid"), "interlock_ok": sw.get("interlock_ok")}
|
|
||||||
ctx.check(sw.get("interlock_ok"), "the interlock loop is still open - restore it before continuing")
|
|
||||||
r = g.command("$J=G91X5F1200")
|
|
||||||
ctx.check(not any(x.startswith("error") for x in r), "jog refused after the cancel: %s", r)
|
|
||||||
wait_idle(ctx, g, 15)
|
|
||||||
g.command("$J=G91X-5F1200")
|
|
||||||
wait_idle(ctx, g, 15)
|
|
||||||
g.command("G90")
|
|
||||||
machine_idle(ctx)
|
|
||||||
ctx.log("PASS: interlock open canceled the job with its own reason and the head returned to the "
|
|
||||||
"start (drift %.3f mm) with the loop still open", drift)
|
|
||||||
|
|
||||||
|
|
||||||
@test("motion.lid-policy-hold", title="lid_policy=hold parks the job in Door and a cycle start resumes it",
|
|
||||||
subsystem="motion", kind="operator", mode="grbl", est_min=4,
|
|
||||||
covers=_LID_COVERS + [("forgectrl", "src/settings.*")], requires=["motion.lid-cancel-home"],
|
|
||||||
actions=["lid"],
|
|
||||||
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
|
|
||||||
"On Ready the head starts an 8 s move: open the lid while it moves (the job parks in "
|
|
||||||
"Door), then close it when told; the job finishes after that."],
|
|
||||||
description="The other lid policy, kept for senders that expect stock grblHAL: with "
|
|
||||||
"lid_policy=hold a lid open parks the job in the door state and holds it there - "
|
|
||||||
"no cancel, no return home - and once the lid is closed a cycle start finishes the "
|
|
||||||
"move with its position intact. The setting is restored to cancel at the end.")
|
|
||||||
def lid_policy_hold(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
# What the machine had, exactly: an unset lid_policy reads as the empty
|
|
||||||
# string and behaves as cancel, and writing the word back where the
|
|
||||||
# machine had nothing is a leftover the hand-back reports. The default
|
|
||||||
# is for reading the value, never for restoring it.
|
|
||||||
was = (fc.settings() or {}).get("lid_policy", "")
|
|
||||||
ev["lid_policy_before"] = was
|
|
||||||
ev["lid_policy_in_force"] = was or "cancel"
|
|
||||||
st, _b = fc.post("/settings", data={"lid_policy": "hold"})
|
|
||||||
ctx.check(st == 200, "could not set lid_policy=hold (%s)", st)
|
|
||||||
ctx.check(((fc.settings() or {}).get("lid_policy")) == "hold", "lid_policy did not take")
|
|
||||||
try:
|
|
||||||
with ctx.grbl() as g:
|
|
||||||
clean_slate(ctx, g)
|
|
||||||
start = g.status_report()["MPos"]
|
|
||||||
ctx.ready("On Ready the head starts an 8 s move along +X. Open the lid while it moves; "
|
|
||||||
"the job parks in the door state and waits.")
|
|
||||||
g.command("M5")
|
|
||||||
g.command("G91")
|
|
||||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain()
|
|
||||||
ctx.act("lid", "open", timeout=12)
|
|
||||||
st, text = wait_state_text(ctx, g, "Door", 8)
|
|
||||||
ev["door_state"] = st["state"] if st else g.status_report()["state"]
|
|
||||||
ctx.check(st is not None, "the lid did not park the job in Door (state %s)", ev["door_state"])
|
|
||||||
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:")]
|
|
||||||
ctx.check("job canceled" not in text, "the job was canceled under lid_policy=hold: %s", ev["messages"])
|
|
||||||
ctx.check("returned to the job start" not in text,
|
|
||||||
"the head returned to the job start under lid_policy=hold")
|
|
||||||
held = g.status_report()["MPos"]
|
|
||||||
ev["parked_at"] = held
|
|
||||||
ctx.log("parked in %s at %s", ev["door_state"], held)
|
|
||||||
ctx.act("lid", "close")
|
|
||||||
ctx.sleep(1)
|
|
||||||
ev["state_after_close"] = g.status_report()["state"]
|
|
||||||
ctx.log("after the lid closed: %s (a cycle start is needed)", ev["state_after_close"])
|
|
||||||
g.realtime(0x7E) # ~ cycle start
|
|
||||||
peak, states, st = wait_idle(ctx, g, 30)
|
|
||||||
ev["states_after_resume"] = states
|
|
||||||
ctx.check("TIMEOUT" not in states, "the job did not finish after the resume: %s", states)
|
|
||||||
final = st["MPos"]
|
|
||||||
moved = final[0] - start[0]
|
|
||||||
ev["moved_mm"] = round(moved, 3)
|
|
||||||
ctx.log("finished: moved %.3f mm of 40 (states %s)", moved, states)
|
|
||||||
ctx.check(abs(moved - 40.0) <= 0.05,
|
|
||||||
"the resumed job did not finish its move (%.3f mm of 40)", moved)
|
|
||||||
g.command("$J=G91X-40F1200")
|
|
||||||
wait_idle(ctx, g, 30)
|
|
||||||
g.command("G90")
|
|
||||||
machine_idle(ctx)
|
|
||||||
finally:
|
|
||||||
# An empty value clears the key, and only the query-string form
|
|
||||||
# carries one: an empty form field never reaches the request, and
|
|
||||||
# the write is refused with "no known setting in request".
|
|
||||||
st, _b = (fc.post("/settings", params={"lid_policy": ""}) if not was
|
|
||||||
else fc.post("/settings", data={"lid_policy": was}))
|
|
||||||
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy", "")
|
|
||||||
ctx.log("lid_policy restored to %s", ev["lid_policy_restored"])
|
|
||||||
ctx.check(ev["lid_policy_restored"] == was, "lid_policy was not restored to %r", was)
|
|
||||||
ctx.log("PASS: lid_policy=hold parked the job in Door and the cycle start finished it (%.3f mm)",
|
|
||||||
ev["moved_mm"])
|
|
||||||
|
|
||||||
|
|
||||||
GRBLHAL_LOG = "/data/log/forgefirm/grblhal/grblhal.log"
|
GRBLHAL_LOG = "/data/log/forgefirm/grblhal/grblhal.log"
|
||||||
SCHED_FIFO = 1
|
SCHED_FIFO = 1
|
||||||
|
|
||||||
@@ -1454,3 +1153,196 @@ def step_timing_under_load(ctx):
|
|||||||
"; ".join(clamped))
|
"; ".join(clamped))
|
||||||
ctx.log("PASS: %d legs at 2000 mm/min against a nice-5 CPU hog, no clamped events",
|
ctx.log("PASS: %d legs at 2000 mm/min against a nice-5 CPU hog, no clamped events",
|
||||||
ev["legs"])
|
ev["legs"])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- motor release
|
||||||
|
|
||||||
|
HOLD_CURRENTS = ("33", "5") # pic/x_step_current, pic/y_step_current at idle
|
||||||
|
|
||||||
|
|
||||||
|
def _step_currents():
|
||||||
|
return (hw.sysfs_read("pic/x_step_current"), hw.sysfs_read("pic/y_step_current"))
|
||||||
|
|
||||||
|
|
||||||
|
def _rail_lines():
|
||||||
|
"""How many times the kernel has logged the 40 V supply going on or off."""
|
||||||
|
rc, out = hw.run(["dmesg"])
|
||||||
|
if rc != 0:
|
||||||
|
return -1
|
||||||
|
return sum(1 for line in out.splitlines() if "40V on" in line or "40V off" in line)
|
||||||
|
|
||||||
|
|
||||||
|
def _kernel_position():
|
||||||
|
"""The kernel's position record: (x, y, z) in steps, then the bytes it
|
||||||
|
has played and the bytes it was handed. Nothing moves without the last
|
||||||
|
two moving, which makes them the witness for "nothing was shipped": the
|
||||||
|
head accelerometer cannot say it, since the rotors relax off their held
|
||||||
|
microstep when the drivers let go, when the currents drop to hold, and at
|
||||||
|
a reset, and the head feels each of those."""
|
||||||
|
with open(hw.sysfs_root() + "cnc/position", "rb") as f:
|
||||||
|
raw = f.read(32)
|
||||||
|
return struct.unpack_from("<3i", raw, 0) + struct.unpack_from("<2I", raw, 12)
|
||||||
|
|
||||||
|
|
||||||
|
def _refused(ctx, g, line):
|
||||||
|
"""Send a line that must be refused; acknowledge the parser error it
|
||||||
|
leaves (clear_error has the why) and return the error."""
|
||||||
|
r = g.command(line)
|
||||||
|
err = next((x for x in r if x.startswith("error")), None)
|
||||||
|
if err is not None:
|
||||||
|
g.command("")
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_reference(ctx, fc):
|
||||||
|
"""A false home is not left behind: a fresh controller starts unhomed."""
|
||||||
|
fc.post("/controller/stop")
|
||||||
|
_wait_controller(ctx, fc, ("stopped", "standby"), 30)
|
||||||
|
fc.post("/controller/start")
|
||||||
|
m = _wait_controller(ctx, fc, ("running",), 120, motion="verified")
|
||||||
|
ctx.check(m.get("controller") == "running", "the controller did not come back: %s", m)
|
||||||
|
|
||||||
|
|
||||||
|
@test("motion.release", title="Motor release: nothing moves a released gantry, only the operator energizes it",
|
||||||
|
subsystem="motion", kind="auto", hardware="takeover", mode="grbl", est_min=8,
|
||||||
|
covers=_MOTION_COVERS + [("forgectrl", "src/status.*"), ("forgectrl", "src/wizdark.*")],
|
||||||
|
requires=["motion.jog-roundtrip"],
|
||||||
|
steps=["Bed clear; the head needs 50 mm of free travel each way on X and Y. Nobody touches the gantry."],
|
||||||
|
description="$MD releases X and Y by taking their step currents to 0, with the 40 V rail "
|
||||||
|
"untouched (no supply line in the kernel log, cnc/state idle, no fault), drops the "
|
||||||
|
"X and Y reference and locks the machine in alarm. While released a jog, a G0 and "
|
||||||
|
"$X are refused, a soft reset does not unlock it, and forgectrl refuses a switch "
|
||||||
|
"to cloud mode; the kernel's position record "
|
||||||
|
"is the witness that nothing was shipped (no step and no byte played), and the head "
|
||||||
|
"accelerometer stays under the moving threshold in the window after the release. "
|
||||||
|
"$ME energizes with no fault. The drivers are then proven alive the way the machine "
|
||||||
|
"proves it to itself: the Setup motion check runs the liveness probe on the held "
|
||||||
|
"device and the witnessed 50 mm jogs.")
|
||||||
|
def motor_release(ctx):
|
||||||
|
from .setup_dark import run_check
|
||||||
|
|
||||||
|
ev = ctx.evidence
|
||||||
|
fc = hw.Forgectrl()
|
||||||
|
accel = hw.AccelSampler()
|
||||||
|
ctx.check(accel.available, "no head accelerometer found (i2c %s): the motion witness is missing",
|
||||||
|
hw.HEAD_ACCEL_I2C)
|
||||||
|
rail0 = _rail_lines()
|
||||||
|
ctx.check(rail0 >= 0, "the kernel log is unreadable: the rail witness is missing")
|
||||||
|
with ctx.grbl() as g, accel:
|
||||||
|
clean_slate(ctx, g)
|
||||||
|
r = g.command("$MD")
|
||||||
|
ctx.check(not any(x.startswith("error") for x in r), "$MD refused at idle: %s", r)
|
||||||
|
text = "\n".join(r) + drain_text(g, 1.0)
|
||||||
|
ev["released_currents"] = list(_step_currents())
|
||||||
|
ev["state_released"] = g.status_report()["state"]
|
||||||
|
ev["kernel_state_released"] = hw.sysfs_read("cnc/state")
|
||||||
|
ev["faults_released"] = hw.sysfs_int("cnc/faults")
|
||||||
|
ev["homed_axes_released"] = fc.status().get("homed_axes")
|
||||||
|
ctx.log("$MD: step currents %s, %s, cnc/state %s, faults %s, homed_axes %s",
|
||||||
|
ev["released_currents"], ev["state_released"], ev["kernel_state_released"],
|
||||||
|
ev["faults_released"], ev["homed_axes_released"])
|
||||||
|
ctx.check(ev["released_currents"] == ["0", "0"],
|
||||||
|
"the step currents read %s after $MD, not 0 and 0", ev["released_currents"])
|
||||||
|
ctx.check(ev["state_released"].startswith("Alarm"), "not locked in alarm: %s", ev["state_released"])
|
||||||
|
ctx.check(ev["kernel_state_released"] == "idle", "the kernel left idle: %s", ev["kernel_state_released"])
|
||||||
|
ctx.check(ev["faults_released"] == 0, "a driver fault on release: %s", ev["faults_released"])
|
||||||
|
ctx.check(not (ev["homed_axes_released"] or 0) & 3, "X or Y still referenced while released")
|
||||||
|
ctx.check("Motors released" in text, "the sender was not told the motors are released")
|
||||||
|
|
||||||
|
# The rotors have relaxed by now: from here on the head is still.
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
k0 = _kernel_position()
|
||||||
|
t0 = time.time()
|
||||||
|
refusals = {}
|
||||||
|
for line in ("$J=G91X10F2400", "G0X10", "$X"):
|
||||||
|
refusals[line] = _refused(ctx, g, line)
|
||||||
|
ctx.check(refusals[line], "%s was accepted while the motors are released", line)
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
p2px, p2py, n = accel.p2p(t0)
|
||||||
|
ev["released_accel_p2p"] = [p2px, p2py, n]
|
||||||
|
ctx.check(n > 0, "no accelerometer samples in the released window")
|
||||||
|
ctx.check(max(p2px, p2py) < hw.ACCEL_P2P_MOVING,
|
||||||
|
"the head moved while released (accel p2p x=%d y=%d)", p2px, p2py)
|
||||||
|
g.realtime(0x18)
|
||||||
|
ctx.sleep(2)
|
||||||
|
g.drain()
|
||||||
|
refusals["^X then $X"] = _refused(ctx, g, "$X")
|
||||||
|
ctx.check(refusals["^X then $X"], "a soft reset and $X unlocked a released machine")
|
||||||
|
ev["state_after_reset"] = g.status_report()["state"]
|
||||||
|
ctx.check(ev["state_after_reset"].startswith("Alarm"),
|
||||||
|
"a soft reset unlocked a released machine: %s", ev["state_after_reset"])
|
||||||
|
k1 = _kernel_position()
|
||||||
|
ev["refusals"] = refusals
|
||||||
|
ev["kernel_position"] = [list(k0), list(k1)]
|
||||||
|
ctx.log("refused while released: %s; accel p2p x=%d y=%d over %d samples; kernel record %s -> %s",
|
||||||
|
refusals, p2px, p2py, n, k0, k1)
|
||||||
|
ctx.check(k1 == k0, "the kernel played something while released: %s -> %s", k0, k1)
|
||||||
|
|
||||||
|
# forgectrl reads the release too: cloud homing moves the head, so the
|
||||||
|
# switch is refused (for the release where cloud mode is enabled, for
|
||||||
|
# cloud mode being off where it is not; the mode stays either way).
|
||||||
|
st, body = fc.post("/mode", data={"controller": "cloud"})
|
||||||
|
text = body if isinstance(body, str) else json.dumps(body)
|
||||||
|
cloud_on = str(fc.settings().get("cloud_enabled", "")).lower() in ("1", "true", "yes", "on")
|
||||||
|
ev["mode_cloud_released"] = {"status": st, "body": text, "cloud_enabled": cloud_on}
|
||||||
|
ctx.log("POST /mode controller=cloud while released -> %s %s", st, text)
|
||||||
|
ctx.check(st == 409, "a switch to cloud mode while released -> %s, expected 409", st)
|
||||||
|
if cloud_on:
|
||||||
|
ctx.check("released" in text, "the refusal does not name the release: %r", text)
|
||||||
|
ctx.check(fc.get("/mode")[1].get("mode") == "grbl", "the mode changed under a released gantry")
|
||||||
|
ctx.check(_step_currents() == ("0", "0"),
|
||||||
|
"something energized the motors: step currents %s", _step_currents())
|
||||||
|
|
||||||
|
r = g.command("$ME")
|
||||||
|
ctx.check(not any(x.startswith("error") for x in r), "$ME refused: %s", r)
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
ev["energized_currents"] = list(_step_currents())
|
||||||
|
ev["faults_energized"] = hw.sysfs_int("cnc/faults")
|
||||||
|
ev["state_energized"] = g.status_report()["state"]
|
||||||
|
ctx.check(tuple(ev["energized_currents"]) == HOLD_CURRENTS,
|
||||||
|
"the step currents read %s after $ME, not the hold currents", ev["energized_currents"])
|
||||||
|
ctx.check(ev["faults_energized"] == 0, "a driver fault on energize: %s", ev["faults_energized"])
|
||||||
|
ctx.check(ev["state_energized"].startswith("Idle"), "not idle after $ME: %s", ev["state_energized"])
|
||||||
|
|
||||||
|
# The drivers are alive: the machine's own witness says so. The liveness
|
||||||
|
# probe is built for the accelerometer (a slow, rough move sampled for
|
||||||
|
# seconds); a quick smooth jog is not, and reads as nothing half the time.
|
||||||
|
ctx.notice("The head moves 50 mm each way on X and on Y. Keep the bed clear.")
|
||||||
|
last = run_check(ctx, "motion", lambda p: "Continue" if p.get("id") == "jogs" else None, 420)
|
||||||
|
ctx.clear_notice()
|
||||||
|
res = last.get("result") or {}
|
||||||
|
ev["probe"] = res.get("probe")
|
||||||
|
ev["moves"] = res.get("moves")
|
||||||
|
ctx.log("after energize: probe '%s', moves %s", res.get("probe"),
|
||||||
|
{k: v.get("witnessed") for k, v in (res.get("moves") or {}).items()})
|
||||||
|
ctx.check(res.get("probe") and last.get("result") is not None and not last.get("error"),
|
||||||
|
"the motion check did not complete after the energize: %s", last.get("error") or last)
|
||||||
|
for name in ("+X", "-X", "+Y", "-Y"):
|
||||||
|
m = (res.get("moves") or {}).get(name) or {}
|
||||||
|
ctx.check(m.get("witnessed") is True,
|
||||||
|
"the %s jog was not witnessed after the energize: the drivers did not come back (%s)", name, m)
|
||||||
|
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("local") is False
|
||||||
|
and (fc.get("/mode")[1] or {}).get("controller") == "running", 90)
|
||||||
|
ctx.check(ok is not None, "the controller is not back in its normal posture")
|
||||||
|
ev["rail_lines"] = [rail0, _rail_lines()]
|
||||||
|
ctx.check(ev["rail_lines"][0] == ev["rail_lines"][1],
|
||||||
|
"the kernel logged the 40 V supply changing across a release: %s", ev["rail_lines"])
|
||||||
|
machine_idle(ctx)
|
||||||
|
ctx.log("PASS: released with the rail untouched, every motion refused and nothing shipped, "
|
||||||
|
"energized with no fault, and the liveness probe and the witnessed jogs say the drivers are alive")
|
||||||
|
|
||||||
|
|
||||||
|
def _port_state(fc):
|
||||||
|
st, body = fc.get("/motion/state")
|
||||||
|
if st != 200 or not isinstance(body, dict):
|
||||||
|
raise hw.HwError("forgectrl /motion/state -> %s %s" % (st, body))
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _port_idle(ctx, fc, timeout=30.0):
|
||||||
|
"""The port's own word that its jog is over. The suite's Grbl client
|
||||||
|
sends no line while a port jog runs: a line from it is what cancels one."""
|
||||||
|
ok = ctx.wait_for(lambda: (lambda s: s["state"] == "Idle" and not s["port_jog"])(_port_state(fc)),
|
||||||
|
timeout, poll=0.1)
|
||||||
|
ctx.check(ok is not None, "the port jog did not end: %s", _port_state(fc))
|
||||||
|
machine_idle(ctx)
|
||||||
|
|||||||
@@ -0,0 +1,535 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""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
|
||||||
|
keeps them in memory; only its own routes write them. A test that needs
|
||||||
|
a different record installs the file under a forgectrl restart (a
|
||||||
|
takeover) and puts the previous file back the same way. Thus the daemon
|
||||||
|
never saves a test record over the real one, and a restore is complete
|
||||||
|
when the daemon is up again.
|
||||||
|
|
||||||
|
The layer content of this work (the account replay init script, the
|
||||||
|
sshd policy, the hostname and console banner scripts) is part of the
|
||||||
|
platform identity that every fingerprint carries, not of a component.
|
||||||
|
No coverage map names it: a change there makes every test necessary
|
||||||
|
again.
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
import posixpath
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .. import hw
|
||||||
|
|
||||||
|
RECORD = "setup.json"
|
||||||
|
USERS = "users"
|
||||||
|
SSH_FLAG = "ssh-enabled"
|
||||||
|
TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests)
|
||||||
|
|
||||||
|
# The first run's form wizards, all at version 1 (wiz.c).
|
||||||
|
FORM_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
|
||||||
|
# The steps the gate requires after the account, all at version 1, in
|
||||||
|
# the order the gate's reason names them (setup.c): the form steps, then
|
||||||
|
# the coolant flow calibration. The first-run tests seed the checks done,
|
||||||
|
# the calibration among them, so the form steps are what they walk.
|
||||||
|
REQUIRED_FORM_STEPS = ("preferences", "machine", "cloud")
|
||||||
|
REQUIRED_STEPS = REQUIRED_FORM_STEPS + ("cooling.flow",)
|
||||||
|
# The setting that counts the intake fans: the gate holds without it.
|
||||||
|
INTAKE_KEY = "cool_intake_fans"
|
||||||
|
SAFETY_PHRASE = "I UNDERSTAND"
|
||||||
|
LOGIN_FAILS = 5
|
||||||
|
LOGIN_LOCK_S = 30
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- files
|
||||||
|
|
||||||
|
def data_dir():
|
||||||
|
return os.environ.get("FORGECTRL_DATA_DIR") or "/data/forgefirm"
|
||||||
|
|
||||||
|
|
||||||
|
def run_dir():
|
||||||
|
return os.environ.get("GF_RUN_DIR") or "/run/forgefirm"
|
||||||
|
|
||||||
|
|
||||||
|
# The daemon's own paths on the board, POSIX on every host that names them.
|
||||||
|
def record_path():
|
||||||
|
return posixpath.join(data_dir(), RECORD)
|
||||||
|
|
||||||
|
|
||||||
|
def users_path():
|
||||||
|
return posixpath.join(data_dir(), USERS)
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_flag_path():
|
||||||
|
return posixpath.join(run_dir(), SSH_FLAG)
|
||||||
|
|
||||||
|
|
||||||
|
def read_file(path):
|
||||||
|
"""The bytes of a file, or None when it does not exist."""
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_file(path, data):
|
||||||
|
"""Write data to path (temp file and rename, mode 0600), or remove
|
||||||
|
the file when data is None."""
|
||||||
|
if data is None:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
tmp = path + ".forgetest"
|
||||||
|
# the bytes as given: O_BINARY keeps a Windows host (the unit tests)
|
||||||
|
# from translating newlines; it does not exist elsewhere
|
||||||
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0), 0o600)
|
||||||
|
try:
|
||||||
|
os.write(fd, data)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- records
|
||||||
|
|
||||||
|
class Restore:
|
||||||
|
"""Settings a test or a wizard writes, put back as found at the end,
|
||||||
|
in the order of `keys` (a value that depends on another goes after
|
||||||
|
it)."""
|
||||||
|
|
||||||
|
def __init__(self, ctx, keys):
|
||||||
|
self.ctx = ctx
|
||||||
|
self.keys = keys
|
||||||
|
self.before = {}
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
s = self.ctx.forgectrl.settings() or {}
|
||||||
|
self.before = {k: s.get(k) for k in self.keys}
|
||||||
|
self.ctx.evidence["settings_before"] = dict(self.before)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
fc = self.ctx.forgectrl
|
||||||
|
for k, v in self.before.items():
|
||||||
|
if v is None or v == "":
|
||||||
|
st, _ = fc.post("/settings", params={k: ""})
|
||||||
|
else:
|
||||||
|
st, _ = fc.post("/settings", data={k: v})
|
||||||
|
self.ctx.log("restore %s=%r -> %s", k, v, st)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso():
|
||||||
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
|
||||||
|
|
||||||
|
def record_without_wizards(record):
|
||||||
|
"""A copy of a record with no completed wizard and no flag: the
|
||||||
|
consent and the account stay, so the gate closes on the required
|
||||||
|
steps."""
|
||||||
|
out = json.loads(json.dumps(record))
|
||||||
|
out["wizards"] = {}
|
||||||
|
out["flags"] = {}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def record_with_required_steps(record):
|
||||||
|
"""A copy of a record with the required steps done (kept from the
|
||||||
|
record, or written at version 1) and nothing else, and no flag: on a
|
||||||
|
machine whose intake fans are counted the gate is open and every
|
||||||
|
other step is recommended as not run yet."""
|
||||||
|
out = record_without_wizards(record)
|
||||||
|
have = record.get("wizards") or {}
|
||||||
|
for wid in REQUIRED_STEPS:
|
||||||
|
out["wizards"][wid] = have.get(wid) or {"version": 1, "completed": now_iso(), "result": {},
|
||||||
|
"applied": {}}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def record_without_consent(record):
|
||||||
|
"""A copy of a record with no advisory accepted and no press: the
|
||||||
|
gate closes on the consent, whatever else the record holds."""
|
||||||
|
out = json.loads(json.dumps(record))
|
||||||
|
out["advisories"] = {}
|
||||||
|
out.pop("acceptance", None)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def record_without_account(record):
|
||||||
|
"""A copy of a record with the consent and no account: the gate
|
||||||
|
closes on the account."""
|
||||||
|
out = json.loads(json.dumps(record))
|
||||||
|
out.pop("account", None)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def complete_record(status, base=None, account_name="bench"):
|
||||||
|
"""A complete setup record for a machine that must count as
|
||||||
|
set up: every advisory at the hash GET /wiz reports, the
|
||||||
|
press recorded, an account (the one of `base` when it has one),
|
||||||
|
every wizard of the catalog at its version, and the run complete.
|
||||||
|
`status` is a GET /wiz body; `base` is an existing record to keep
|
||||||
|
the account, the machine facts, and the sheet id from."""
|
||||||
|
base = base or {}
|
||||||
|
ts = now_iso()
|
||||||
|
rec = {"schema": 1, "created": base.get("created") or ts, "completed": ts,
|
||||||
|
"advisories": {}, "wizards": {}, "flags": {}}
|
||||||
|
if base.get("sheet_id"):
|
||||||
|
rec["sheet_id"] = base["sheet_id"]
|
||||||
|
elif status.get("sheet_id"):
|
||||||
|
rec["sheet_id"] = status["sheet_id"]
|
||||||
|
for d in status.get("documents") or []:
|
||||||
|
rec["advisories"][d["id"]] = {"hash": d["hash"], "accepted": ts,
|
||||||
|
"method": d.get("consent") or "check"}
|
||||||
|
rec["acceptance"] = {"pressed_at": ts}
|
||||||
|
acct = base.get("account") if isinstance(base.get("account"), dict) else None
|
||||||
|
rec["account"] = acct or {"name": account_name, "uid": 1000, "created": ts}
|
||||||
|
if isinstance(base.get("machine"), dict):
|
||||||
|
rec["machine"] = base["machine"]
|
||||||
|
wizards = status.get("wizards") or [{"id": w, "version": 1} for w in FORM_WIZARDS]
|
||||||
|
for w in wizards:
|
||||||
|
rec["wizards"][w["id"]] = {"version": int(w.get("version") or 1), "completed": ts,
|
||||||
|
"result": {}, "applied": {}}
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def installed(ctx, files):
|
||||||
|
"""Install files ({path: bytes, or None to remove}) under a forgectrl
|
||||||
|
restart, and put the previous content back under another one when
|
||||||
|
the block ends, on every exit path. Yields the saved content. The
|
||||||
|
restart is the runner's takeover: the controller is stopped through
|
||||||
|
the supervisor, the marker makes a crash recoverable, and the exit
|
||||||
|
waits for the supervisor to settle."""
|
||||||
|
saved = {p: read_file(p) for p in files}
|
||||||
|
with ctx.takeover():
|
||||||
|
for p, data in files.items():
|
||||||
|
write_file(p, data)
|
||||||
|
ctx.log("installed under a restart: %s",
|
||||||
|
", ".join("%s=%s" % (os.path.basename(p), "removed" if d is None else "%d B" % len(d))
|
||||||
|
for p, d in files.items()))
|
||||||
|
try:
|
||||||
|
yield saved
|
||||||
|
finally:
|
||||||
|
with ctx.takeover():
|
||||||
|
for p, data in saved.items():
|
||||||
|
write_file(p, data)
|
||||||
|
ctx.log("restored under a restart: %s", ", ".join(os.path.basename(p) for p in saved))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- client
|
||||||
|
|
||||||
|
def request(base, method, path, data=None, headers=None, timeout=10.0):
|
||||||
|
"""One HTTP or HTTPS request without redirect following: (status,
|
||||||
|
body bytes, {header: value}). HTTPS is served with a self-signed
|
||||||
|
certificate, so the peer is not verified. The Host header is the
|
||||||
|
address literal of base, which the origin check requires."""
|
||||||
|
u = urllib.parse.urlsplit(base)
|
||||||
|
port = u.port or (443 if u.scheme == "https" else 80)
|
||||||
|
if u.scheme == "https":
|
||||||
|
cx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
cx.check_hostname = False
|
||||||
|
cx.verify_mode = ssl.CERT_NONE
|
||||||
|
conn = http.client.HTTPSConnection(u.hostname, port, timeout=timeout, context=cx)
|
||||||
|
else:
|
||||||
|
conn = http.client.HTTPConnection(u.hostname, port, timeout=timeout)
|
||||||
|
hdrs = {"Host": u.netloc}
|
||||||
|
hdrs.update(headers or {})
|
||||||
|
body = None
|
||||||
|
if isinstance(data, dict):
|
||||||
|
body = urllib.parse.urlencode(data).encode()
|
||||||
|
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
elif isinstance(data, str):
|
||||||
|
body = data.encode()
|
||||||
|
elif data is not None:
|
||||||
|
body = data
|
||||||
|
if body is not None:
|
||||||
|
hdrs.setdefault("Content-Length", str(len(body)))
|
||||||
|
try:
|
||||||
|
conn.request(method, path, body=body, headers=hdrs)
|
||||||
|
r = conn.getresponse()
|
||||||
|
content = r.read()
|
||||||
|
return r.status, content, {k.lower(): v for k, v in r.getheaders()}
|
||||||
|
except (OSError, http.client.HTTPException) as e:
|
||||||
|
raise hw.HwError("%s %s%s: %s" % (method, base, path, e))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def decode(content):
|
||||||
|
"""JSON when the body parses as JSON, else text."""
|
||||||
|
text = content.decode("utf-8", "replace")
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except ValueError:
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def tls_base():
|
||||||
|
return (os.environ.get("FORGECTRL_TLS_URL") or TLS_BASE).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def tcp_open(host, port, timeout=2.0):
|
||||||
|
try:
|
||||||
|
s = socket.create_connection((host, port), timeout=timeout)
|
||||||
|
s.close()
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def session_from_cookie(value):
|
||||||
|
"""The ffsid session id out of a Set-Cookie value, or None."""
|
||||||
|
for part in (value or "").split(";"):
|
||||||
|
part = part.strip()
|
||||||
|
if part.startswith("ffsid="):
|
||||||
|
sid = part[len("ffsid="):]
|
||||||
|
return sid or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_flags(value):
|
||||||
|
return set(p.strip().split("=", 1)[0].lower() for p in (value or "").split(";")[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def wiz(fc):
|
||||||
|
st, body = fc.get("/wiz")
|
||||||
|
if st != 200 or not isinstance(body, dict):
|
||||||
|
raise hw.HwError("GET /wiz -> %s" % st)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def mode(fc):
|
||||||
|
st, body = fc.get("/mode")
|
||||||
|
return body if (st == 200 and isinstance(body, dict)) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def press_status(fc):
|
||||||
|
"""GET /wiz/advisories/press, the page's poll at the press step: the
|
||||||
|
daemon takes a pressed button and records the acceptance on this
|
||||||
|
read, so a press nobody polls for stays 'pressed' and unaccepted."""
|
||||||
|
st, body = fc.get("/wiz/advisories/press")
|
||||||
|
return body if (st == 200 and isinstance(body, dict)) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def wiz_summary(w):
|
||||||
|
return {k: w.get(k) for k in ("first_run", "gate", "why", "recommended",
|
||||||
|
"advisories_complete", "acceptance_done", "account",
|
||||||
|
"fans_counted", "completed", "versions")}
|
||||||
|
|
||||||
|
|
||||||
|
def recommended(w):
|
||||||
|
"""The steps a GET /wiz body recommends: {id: reason}."""
|
||||||
|
return {x.get("id"): x.get("reason") or "" for x in w.get("recommended") or [] if isinstance(x, dict)}
|
||||||
|
|
||||||
|
|
||||||
|
def wait_settled(ctx):
|
||||||
|
"""The supervisor settled (the runner's own wait): the /mode body."""
|
||||||
|
return ctx.takeover().wait_settled()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- the login
|
||||||
|
|
||||||
|
@test("setup.account-login", title="The panel login over HTTPS", subsystem="setup",
|
||||||
|
kind="auto", est_min=2,
|
||||||
|
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
||||||
|
("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
||||||
|
("forgectrl", "src/ui/login.*")],
|
||||||
|
requires=["forgectrl.auth"], hardware="takeover",
|
||||||
|
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 "
|
||||||
|
"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. 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 "
|
||||||
|
"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 "
|
||||||
|
"is refused inside the lock too; after the lock a wrong name is 401 and the "
|
||||||
|
"right pair sets the ffsid cookie (HttpOnly, Secure, SameSite=Strict); GET /wiz "
|
||||||
|
"with the cookie reports the session; POST /logout ends it.")
|
||||||
|
def account_login(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
name = "forgetest"
|
||||||
|
pw = "".join(random.choice("abcdefghjkmnpqrstuvwxyz23456789") for _ in range(16))
|
||||||
|
before = wiz(fc)
|
||||||
|
ev["account_before"] = (before.get("users") or {}).get("name")
|
||||||
|
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}):
|
||||||
|
w = wiz(fc)
|
||||||
|
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})
|
||||||
|
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)
|
||||||
|
w = wiz(fc)
|
||||||
|
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)
|
||||||
|
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")
|
||||||
|
ev["users_reload_rc"] = rc
|
||||||
|
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
|
||||||
|
if name != ev["account_before"]:
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(os.path.join(homes, name), ignore_errors=True)
|
||||||
|
after = wiz(fc)
|
||||||
|
ctx.check((after.get("users") or {}).get("name") == ev["account_before"],
|
||||||
|
"the account reads %r after the restore, was %r", (after.get("users") or {}).get("name"),
|
||||||
|
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):
|
||||||
|
"""The login rules against an account whose password the test knows."""
|
||||||
|
ev = ctx.evidence
|
||||||
|
base = tls_base()
|
||||||
|
|
||||||
|
st, body, hdrs = request(base, "GET", "/wiz")
|
||||||
|
w = decode(body)
|
||||||
|
ctx.log("GET %s/wiz -> %s", base, st)
|
||||||
|
ctx.check(st == 200 and isinstance(w, dict), "GET /wiz over HTTPS -> %s", st)
|
||||||
|
ctx.check(w.get("session") is False, "a session is reported without a cookie")
|
||||||
|
ev["tls_fingerprint"] = w.get("tls_fingerprint")
|
||||||
|
ctx.check(w.get("tls_fingerprint"), "GET /wiz carries no certificate fingerprint")
|
||||||
|
|
||||||
|
def attempt(n, p):
|
||||||
|
st, body, hdrs = request(base, "POST", "/login", data={"name": n, "password": p})
|
||||||
|
return st, decode(body), hdrs
|
||||||
|
|
||||||
|
# a lock left by an earlier run: wait it out first
|
||||||
|
st, body, hdrs = attempt(name, "not-" + pw)
|
||||||
|
if st == 429:
|
||||||
|
wait = int((body or {}).get("wait") or LOGIN_LOCK_S) if isinstance(body, dict) else LOGIN_LOCK_S
|
||||||
|
ctx.log("the address is locked from before (%s s): waiting", wait)
|
||||||
|
ctx.sleep(min(wait, LOGIN_LOCK_S) + 1)
|
||||||
|
st, body, hdrs = attempt(name, "not-" + pw)
|
||||||
|
codes = [st]
|
||||||
|
ctx.check(st == 401, "a wrong password -> %s, expected 401", st)
|
||||||
|
for i in range(LOGIN_FAILS + 1):
|
||||||
|
st, body, hdrs = attempt(name, "not-%s-%d" % (pw, i))
|
||||||
|
codes.append(st)
|
||||||
|
if st == 429:
|
||||||
|
break
|
||||||
|
ev["wrong_codes"] = codes
|
||||||
|
ctx.log("wrong password attempts -> %s", codes)
|
||||||
|
ctx.check(codes[-1] == 429 and all(c == 401 for c in codes[:-1]),
|
||||||
|
"the throttle did not lock after wrong attempts: %s", codes)
|
||||||
|
ctx.check(len(codes) - 1 <= LOGIN_FAILS, "the lock came after %d failures, the rule is %d",
|
||||||
|
len(codes) - 1, LOGIN_FAILS)
|
||||||
|
wait = body.get("wait") if isinstance(body, dict) else None
|
||||||
|
ev["wait"] = wait
|
||||||
|
ctx.check(isinstance(wait, int) and 0 < wait <= LOGIN_LOCK_S, "the lock reports wait=%r", wait)
|
||||||
|
st, body, hdrs = attempt(name, pw)
|
||||||
|
ev["right_inside_lock"] = st
|
||||||
|
ctx.check(st == 429, "the right password inside the lock -> %s, expected 429", st)
|
||||||
|
ctx.log("locked for %s s; the right password is refused inside the lock", wait)
|
||||||
|
ctx.sleep(wait + 1)
|
||||||
|
|
||||||
|
st, body, hdrs = attempt("not-" + name, pw)
|
||||||
|
ev["wrong_name"] = st
|
||||||
|
ctx.check(st == 401, "a wrong name with the right password -> %s, expected 401", st)
|
||||||
|
st, body, hdrs = attempt(name, pw)
|
||||||
|
cookie = hdrs.get("set-cookie") or ""
|
||||||
|
sid = session_from_cookie(cookie)
|
||||||
|
ev["login"] = st
|
||||||
|
ev["cookie_flags"] = sorted(cookie_flags(cookie))
|
||||||
|
ctx.log("POST /login (right) -> %s, cookie flags %s", st, ev["cookie_flags"])
|
||||||
|
ctx.check(st == 200, "the right name and password -> %s, expected 200", st)
|
||||||
|
ctx.check(sid and len(sid) == 64 and all(c in "0123456789abcdef" for c in sid),
|
||||||
|
"no ffsid session id in Set-Cookie: %r", cookie)
|
||||||
|
ctx.check({"httponly", "secure", "samesite"} <= cookie_flags(cookie),
|
||||||
|
"the cookie lacks HttpOnly, Secure, or SameSite: %r", cookie)
|
||||||
|
ctx.check("samesite=strict" in cookie.lower(), "the cookie is not SameSite=Strict: %r", cookie)
|
||||||
|
st, body, hdrs = request(base, "GET", "/wiz", headers={"Cookie": "ffsid=" + sid})
|
||||||
|
w = decode(body)
|
||||||
|
ctx.check(st == 200 and isinstance(w, dict) and w.get("session") is True,
|
||||||
|
"GET /wiz with the cookie does not report the session (%s %s)", st,
|
||||||
|
w.get("session") if isinstance(w, dict) else w)
|
||||||
|
st, body, hdrs = request(base, "POST", "/logout", headers={"Cookie": "ffsid=" + sid})
|
||||||
|
ev["logout"] = st
|
||||||
|
ctx.check(st == 200, "POST /logout -> %s", st)
|
||||||
|
ctx.check("max-age=0" in (hdrs.get("set-cookie") or "").lower(), "the logout did not clear the cookie")
|
||||||
|
st, body, hdrs = request(base, "GET", "/wiz", headers={"Cookie": "ffsid=" + sid})
|
||||||
|
w = decode(body)
|
||||||
|
ctx.check(isinstance(w, dict) and w.get("session") is False, "the session survives the logout")
|
||||||
|
ctx.log("session set by the login, seen by GET /wiz, ended by the logout")
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""setup.focus - the setup's focus burn (a live wizard), driven the way
|
||||||
|
the page drives it: POST /wiz/<id>/start, GET /wiz/dark polled, the
|
||||||
|
prompts answered from here, the burn armed by a press, the result
|
||||||
|
judged, and the setting the burn wrote put back as found. The burn
|
||||||
|
stands alone on its own scrap of wood in the back-left corner, drawn
|
||||||
|
from the head's home against the gantry stops, and re-runs on the real
|
||||||
|
record, so a completed burn completes again at the same version.
|
||||||
|
|
||||||
|
The operator's part is the scrap and one press on the machine at the
|
||||||
|
start (the presence check of the ready gate; with the bench actuator up
|
||||||
|
it then makes the arm press). The judgment a person makes by eye on the
|
||||||
|
scrap (the narrowest line) is answered here with a fixed pick: the test
|
||||||
|
proves the wizard's mechanics and the emission witnesses, not the
|
||||||
|
operator's eye, and the setting it writes is restored. The page is where
|
||||||
|
the real numbers go in.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .. import hw
|
||||||
|
from ..catalog import test
|
||||||
|
from .setup_dark import run_check, Restore, FALLBACK_WINDOW, LENS_WINDOW_KEYS, dark
|
||||||
|
|
||||||
|
BURN_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
||||||
|
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
|
||||||
|
("forgectrl", "src/lenshome.*"),
|
||||||
|
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
||||||
|
("forgectrl", "src/jobrun.*"), ("forgectrl", "src/lease.*"),
|
||||||
|
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
||||||
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||||
|
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
||||||
|
("forgectrl", "src/super.c"),
|
||||||
|
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
||||||
|
("grblhal-glowforge", "src/glowforge_laser.c"),
|
||||||
|
("grblhal-glowforge", "src/glowforge_status.c"),
|
||||||
|
("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||||
|
("grblhal-glowforge", "src/glowforge_io.*")]
|
||||||
|
|
||||||
|
# Every burn asks first that the gantry stops are installed and the head
|
||||||
|
# is at its home against them; the bench's head stands there, and every
|
||||||
|
# burn's tail brings it back.
|
||||||
|
HOME = "The head is home"
|
||||||
|
EDGE_KEY = "lens_hall_edge_z_mm"
|
||||||
|
FOCUS_KEYS = ["thickness_mm", "z_passes", "ladder_z", "window", "emission", "pick", "picked_lines",
|
||||||
|
"pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm", "focus_range_mm", "summary"]
|
||||||
|
|
||||||
|
|
||||||
|
def preview_ok(ctx, wid):
|
||||||
|
"""The page's preview and the program for the burn are served."""
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
|
||||||
|
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
|
||||||
|
ctx.evidence["preview_bytes"] = len(body)
|
||||||
|
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
|
||||||
|
# The text is M4 at the mark dose; the ladders bring their own M3 or
|
||||||
|
# M4 lines. A program is one with a laser-on command.
|
||||||
|
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
||||||
|
"no program for %s (%s)", wid, st)
|
||||||
|
ctx.evidence["program_lines"] = body.count(b"\n")
|
||||||
|
# Every Z the program commands is a height the head reaches in the
|
||||||
|
# window the machine holds now: the Lens travel check's, or the
|
||||||
|
# fallback on a head whose travel is not known yet. The controller
|
||||||
|
# refuses a Z outside it with ALARM:2, and it refuses it while the
|
||||||
|
# body is still burning, so the burn dies where the parser is and not
|
||||||
|
# where the bad line is.
|
||||||
|
lens = (fc.status() or {}).get("lens") or {}
|
||||||
|
lo, hi = lens.get("reach_min"), lens.get("reach_max")
|
||||||
|
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
|
||||||
|
ctx.evidence.setdefault("program_z", {})[wid] = {
|
||||||
|
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
|
||||||
|
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
|
||||||
|
if lo is not None and hi is not None:
|
||||||
|
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
|
||||||
|
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
|
||||||
|
wid, lo, hi, out)
|
||||||
|
# The tail the wizard streams after the body: the lens back on the
|
||||||
|
# reference the program's head declared, the head back to X0 Y0, the
|
||||||
|
# G92 origin cleared (the controller keeps a G92 offset and restores it
|
||||||
|
# at its next start, where it would shift a sender's own work offsets),
|
||||||
|
# and the program end.
|
||||||
|
lines = [ln.strip() for ln in (body or b"").decode("utf-8", "replace").splitlines()]
|
||||||
|
tail = [ln for ln in lines if ln and not ln.startswith(";")][-4:]
|
||||||
|
edge = lens.get("edge_z")
|
||||||
|
want = ["G0 Z%.2f" % edge, "G0 X0 Y0", "G92.1", "M2"] if isinstance(edge, (int, float)) else None
|
||||||
|
ctx.evidence.setdefault("program_tail", {})[wid] = tail
|
||||||
|
ctx.check(tail == want, "the %s program ends %s, not the tail %s", wid, tail, want)
|
||||||
|
|
||||||
|
|
||||||
|
def emission_ok(ctx, last):
|
||||||
|
"""The two witnesses of the burn, from the wizard's result. The head's
|
||||||
|
beam sensor is recorded, never required: the result reports its rise
|
||||||
|
and whether it read as the beam seen."""
|
||||||
|
e = (last.get("result") or {}).get("emission") or {}
|
||||||
|
ctx.evidence["emission"] = e
|
||||||
|
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
|
||||||
|
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
|
||||||
|
ctx.check(isinstance(e.get("thermopile_delta"), int) and isinstance(e.get("beam_sensor_seen"), bool),
|
||||||
|
"the result does not record the beam sensor: %s", e)
|
||||||
|
ctx.log("the beam sensor rose %s (seen: %s)", e.get("thermopile_delta"), e.get("beam_sensor_seen"))
|
||||||
|
|
||||||
|
|
||||||
|
def thickness_answer(ctx):
|
||||||
|
"""The thickness the burn is answered with, in the machine's units
|
||||||
|
(the number prompts read ui_units), and the millimeters the record
|
||||||
|
must carry for it."""
|
||||||
|
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
|
||||||
|
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
|
||||||
|
|
||||||
|
|
||||||
|
def burn(ctx, wid, arm, pick, want):
|
||||||
|
"""One burn: the preview, then the burn after the press with every
|
||||||
|
prompt answered (the gantry stops installed, the head at home, the
|
||||||
|
scrap in the corner, its thickness in the machine's units, the arm,
|
||||||
|
and the pick in `pick`), the witnesses, and the keys of the result in
|
||||||
|
`want`. run_check makes the arm press when the burn asks for it. A
|
||||||
|
prompt the test does not answer, or one asked again because its
|
||||||
|
answer was refused, ends the burn. Returns the result."""
|
||||||
|
answer, want_mm = thickness_answer(ctx)
|
||||||
|
ctx.evidence["thickness_answer"] = answer
|
||||||
|
answers = {"gantry-stops": "Yes", "home": HOME, "scrap": "Continue", "thickness": answer,
|
||||||
|
arm: "Continue"}
|
||||||
|
answers.update(pick)
|
||||||
|
preview_ok(ctx, wid)
|
||||||
|
asked = set()
|
||||||
|
|
||||||
|
def on_prompt(p):
|
||||||
|
pid = p.get("id")
|
||||||
|
if pid == "press":
|
||||||
|
return None
|
||||||
|
if pid not in answers:
|
||||||
|
# the motors found released ask for the head pushed home by
|
||||||
|
# hand, which the bench's head never needs
|
||||||
|
ctx.fail("the %s burn asked %s, which the test does not answer: %s", wid, pid, p.get("text"))
|
||||||
|
if pid in asked:
|
||||||
|
ctx.fail("the %s burn asked %s again: it refused %r", wid, pid, answers[pid])
|
||||||
|
asked.add(pid)
|
||||||
|
return answers[pid]
|
||||||
|
|
||||||
|
try:
|
||||||
|
last = run_check(ctx, wid, on_prompt, 900)
|
||||||
|
finally:
|
||||||
|
ctx.clear_notice()
|
||||||
|
emission_ok(ctx, last)
|
||||||
|
r = last.get("result") or {}
|
||||||
|
missing = [k for k in want if k not in r]
|
||||||
|
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
|
||||||
|
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
|
||||||
|
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
|
||||||
|
ctx.check(len(r.get("z_passes") or []) >= 1, "the lens was not referenced: %s", r.get("z_passes"))
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def window_held(ctx):
|
||||||
|
"""The lens window the machine holds: the two settings, and the lens
|
||||||
|
block of /status, which reads them (the fallback where they are
|
||||||
|
unset)."""
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
s = fc.settings() or {}
|
||||||
|
lens = (fc.status() or {}).get("lens") or {}
|
||||||
|
return {"settings": {k: s.get(k) for k in LENS_WINDOW_KEYS},
|
||||||
|
"window": (lens.get("below"), lens.get("above")), "stops_found": lens.get("stops_found")}
|
||||||
|
|
||||||
|
|
||||||
|
def focus_window_ok(ctx, r, before):
|
||||||
|
"""The ladder burned in the window the machine held before the burn
|
||||||
|
(`before`, from window_held): the Lens travel check's, or the fallback
|
||||||
|
while the settings are unset. The burn left that window as it found
|
||||||
|
it and wrote the edge alone, the one its result reports; every ladder
|
||||||
|
height lies in the reach, and the program served now agrees with
|
||||||
|
/status."""
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
w = r.get("window") or {}
|
||||||
|
window = (w.get("below"), w.get("above"))
|
||||||
|
after = window_held(ctx)
|
||||||
|
ctx.check(after["settings"] == before["settings"], "the focus burn wrote the lens window: %s, was %s",
|
||||||
|
after["settings"], before["settings"])
|
||||||
|
ctx.check(window == before["window"], "the ladder ran in %s/%s, the machine holds %s",
|
||||||
|
window[0], window[1], before["window"])
|
||||||
|
held = before["settings"]
|
||||||
|
if before["stops_found"]:
|
||||||
|
ctx.check(tuple(str(held[k]) for k in LENS_WINDOW_KEYS) == tuple(str(v) for v in window),
|
||||||
|
"the ladder ran in %s/%s, the settings hold %s", window[0], window[1], held)
|
||||||
|
else:
|
||||||
|
ctx.check(window == FALLBACK_WINDOW, "the settings hold no window (%s), but the ladder ran in %s/%s",
|
||||||
|
held, window[0], window[1])
|
||||||
|
edge = (fc.settings() or {}).get(EDGE_KEY)
|
||||||
|
try:
|
||||||
|
edge_ok = abs(float(edge) - float(r.get("edge_z_mm"))) < 0.006
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
edge_ok = False
|
||||||
|
ctx.check(edge_ok, "%s reads %r, the burn reports %s", EDGE_KEY, edge, r.get("edge_z_mm"))
|
||||||
|
zs = r.get("ladder_z") or []
|
||||||
|
lo, hi = w.get("reach_min"), w.get("reach_max")
|
||||||
|
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
|
||||||
|
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
|
||||||
|
first = dict(ev.get("program_z") or {}).get("laser.focus")
|
||||||
|
preview_ok(ctx, "laser.focus")
|
||||||
|
ev["focus_window"] = {"window": w, "ladder_z": zs, "held": before, "edge": edge,
|
||||||
|
"program_z_before": first,
|
||||||
|
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
|
||||||
|
"preview_bytes": ev.pop("preview_bytes", None),
|
||||||
|
"program_lines": ev.pop("program_lines", None)}
|
||||||
|
|
||||||
|
|
||||||
|
@test("setup.focus", title="The focus burn: the ladder in the lens window, and the edge it writes",
|
||||||
|
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=10,
|
||||||
|
covers=BURN_COVERS,
|
||||||
|
requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "setup.check-lens"],
|
||||||
|
actions=["button"], hands=["scrap"],
|
||||||
|
steps=["The gantry stops installed and the head at its home against them. A fresh flat scrap "
|
||||||
|
"of wood about 3 mm (1/8 in) thick and at least 78 x 68 mm (3.1 x 2.7 in), pushed into "
|
||||||
|
"the back-left corner of the cut area: as far left as it goes, its top edge at the top "
|
||||||
|
"of the cut area; lid closed. Eye protection on, exhaust on, extinguisher in reach.",
|
||||||
|
"Start the test and press the machine's button once. That press says you are at the "
|
||||||
|
"machine, and the bench actuator makes the arm press after it, when the burn itself "
|
||||||
|
"asks for it. With no actuator the start is the Ready answer instead, and you press "
|
||||||
|
"the button again at the arm, as the page asks.",
|
||||||
|
"Nothing else: the test answers every prompt itself and puts back the setting the "
|
||||||
|
"burn writes."],
|
||||||
|
description="The focus burn as a fresh head runs it: the edge setting is cleared first, so "
|
||||||
|
"the burn starts from the bench reference's edge, while the lens window stays "
|
||||||
|
"the one the Lens travel check wrote (or the fallback). POST "
|
||||||
|
"/wiz/laser.focus/start is answered that the gantry stops are installed, "
|
||||||
|
"references the lens, takes the controller in loopback posture, is answered that "
|
||||||
|
"the head is home and that the scrap is in the corner, and takes the thickness "
|
||||||
|
"in the machine's units (3.2 mm or 0.125 in); its program declares X0 Y0 at the "
|
||||||
|
"head, and its tail puts the lens back on the reference, returns the head there, "
|
||||||
|
"clears the G92 origin (G92.1), and ends (M2). With its preview and program "
|
||||||
|
"served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the program commands "
|
||||||
|
"inside the reach /status reports, and the program ending in that tail at the "
|
||||||
|
"edge /status reports, it burns twelve lines after the press, from the top of "
|
||||||
|
"the window to the bottom, seen by the two witnesses (the tube current and the "
|
||||||
|
"kernel's LASER_ON samples), with the beam sensor's rise recorded and not "
|
||||||
|
"required. The pick is line 11. The "
|
||||||
|
"result carries its keys; the ladder ran in the window the machine held, which "
|
||||||
|
"the burn leaves as found (it writes the edge alone, the one its result "
|
||||||
|
"reports); every ladder height lies in the reach; the program served afterward "
|
||||||
|
"agrees with /status; the record carries laser.focus at version 1. The edge and "
|
||||||
|
"the window are restored at the end. grblHAL reads them only at its start, and "
|
||||||
|
"the burn started it before the restore, so the controller is stopped and "
|
||||||
|
"started again on the settings as found (on every exit path), and it must be "
|
||||||
|
"running with motion verified under a new process.")
|
||||||
|
def focus(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
restart = {}
|
||||||
|
|
||||||
|
def restart_on_found():
|
||||||
|
"""A fresh controller start on the settings as found, once the
|
||||||
|
burn has ended. Plain waits, not the run's, so an abort gets it
|
||||||
|
too; a burn still ending starts its own controller after the
|
||||||
|
restore, which reads them already."""
|
||||||
|
try:
|
||||||
|
t_end = time.time() + 120
|
||||||
|
while time.time() < t_end and dark(fc).get("running"):
|
||||||
|
time.sleep(1)
|
||||||
|
if dark(fc).get("running"):
|
||||||
|
restart["skipped"] = "the burn is still ending"
|
||||||
|
ctx.log("the burn is still ending: its own controller start reads the settings as found")
|
||||||
|
return
|
||||||
|
st, m = fc.get("/mode")
|
||||||
|
m = m if isinstance(m, dict) else {}
|
||||||
|
restart.update(mode=m.get("mode"), pid=m.get("pid"))
|
||||||
|
if m.get("mode") != "grbl":
|
||||||
|
return # the cloud client reads neither the edge nor the window
|
||||||
|
restart["stop"], body = fc.post("/controller/stop")
|
||||||
|
ctx.log("restarting the controller on the edge and the window as found: POST "
|
||||||
|
"/controller/stop -> %s %s", restart["stop"], body)
|
||||||
|
for _ in range(10):
|
||||||
|
restart["start"], body = fc.post("/controller/start")
|
||||||
|
if restart["start"] != 409:
|
||||||
|
break
|
||||||
|
time.sleep(1) # the burn's lease goes just after its end
|
||||||
|
ctx.log("POST /controller/start -> %s %s", restart["start"], body)
|
||||||
|
except hw.HwError as e:
|
||||||
|
restart["error"] = str(e)
|
||||||
|
ctx.log("the controller could not be restarted on the settings as found: %s", e)
|
||||||
|
ctx.ready("The focus burn: twelve lines on the scrap after a press. The bench presses when the "
|
||||||
|
"button lights.")
|
||||||
|
try:
|
||||||
|
with Restore(ctx, [EDGE_KEY] + LENS_WINDOW_KEYS):
|
||||||
|
# A fresh head's focus: the edge unknown, so the burn runs from the
|
||||||
|
# bench reference's number and writes the head's own. The window
|
||||||
|
# stays: it is the Lens travel check's.
|
||||||
|
st, _ = fc.post("/settings", params={EDGE_KEY: ""})
|
||||||
|
ctx.check(st == 200, "clearing %s -> %s", EDGE_KEY, st)
|
||||||
|
before = window_held(ctx)
|
||||||
|
ev["lens_fresh"] = (fc.status() or {}).get("lens") or {}
|
||||||
|
r = burn(ctx, "laser.focus", "focus-arm", {"focus-pick": "11"}, FOCUS_KEYS)
|
||||||
|
ctx.check(r.get("pick") == 11 and r.get("picked_lines") == [11, 11],
|
||||||
|
"the pick reads %s (lines %s), answered 11", r.get("pick"), r.get("picked_lines"))
|
||||||
|
ctx.check("stops" not in r, "the focus burn searched for the stops: %s", r.get("stops"))
|
||||||
|
focus_window_ok(ctx, r, before)
|
||||||
|
ctx.log("focus: line %s, the edge at %s mm, the window %s/%s", r.get("pick"), r.get("edge_z_mm"),
|
||||||
|
(r.get("window") or {}).get("below"), (r.get("window") or {}).get("above"))
|
||||||
|
finally:
|
||||||
|
restart_on_found()
|
||||||
|
ev["restart"] = dict(restart)
|
||||||
|
if restart.get("mode") == "grbl":
|
||||||
|
ctx.check(restart.get("stop") == 200 and restart.get("start") == 200,
|
||||||
|
"the controller was not restarted on the settings as found: %s", restart)
|
||||||
|
|
||||||
|
def back():
|
||||||
|
st, m = fc.get("/mode")
|
||||||
|
m = m if isinstance(m, dict) else {}
|
||||||
|
return (m.get("controller") == "running" and m.get("motion") == "verified"
|
||||||
|
and (restart.get("start") != 200 or m.get("pid") != restart.get("pid")))
|
||||||
|
ok = ctx.wait_for(back, 150)
|
||||||
|
ctx.check(ok is not None, "the controller did not come back on the settings as found")
|
||||||
|
ctx.log("PASS: the focus burn ran in the window the machine holds and wrote the edge alone; "
|
||||||
|
"settings restored and the controller restarted on them")
|
||||||
+169
-91
@@ -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,7 +17,7 @@ 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, INTAKE_KEY
|
||||||
|
|
||||||
POLL_S = 1.0
|
POLL_S = 1.0
|
||||||
# What the machine's own press prompt says, used when it sends no text.
|
# What the machine's own press prompt says, used when it sends no text.
|
||||||
@@ -22,8 +27,29 @@ PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your
|
|||||||
# (74) for the first seconds and settles near 630.
|
# (74) for the first seconds and settles near 630.
|
||||||
PURGE_SPINUP_S = 30
|
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.*")]
|
||||||
|
# The lens window: the free half-steps below and above the hall edge the
|
||||||
|
# Lens travel check writes, and the fallback the lens frame holds while
|
||||||
|
# they are unset (lens.h).
|
||||||
|
LENS_WINDOW_KEYS = ["lens_stop_below_steps", "lens_stop_above_steps"]
|
||||||
|
FALLBACK_WINDOW = (10, 12)
|
||||||
|
|
||||||
|
|
||||||
|
def intakes_counted():
|
||||||
|
"""Precheck: the intake fan count is set. The setup gate holds every
|
||||||
|
controller without it, and a test that puts the count back as found
|
||||||
|
would put an unset count back and close the gate on the tests after
|
||||||
|
it."""
|
||||||
|
try:
|
||||||
|
s = hw.Forgectrl().settings() or {}
|
||||||
|
except hw.HwError as e:
|
||||||
|
return "forgectrl unreachable: %s" % e
|
||||||
|
if not s.get(INTAKE_KEY):
|
||||||
|
return ("the intake fans are not counted, so the setup gate holds every controller; this "
|
||||||
|
"test puts the count back as it finds it, and an uncounted machine handed back "
|
||||||
|
"would hold the controllers of every test after it")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def dark(fc):
|
def dark(fc):
|
||||||
@@ -113,10 +139,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 "
|
||||||
@@ -148,48 +174,48 @@ 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-airflow", title="The airflow check measures the fans and sets the floors",
|
||||||
subsystem="commission", kind="auto", est_min=2,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
|
|
||||||
("forgectrl", "src/cool.c")],
|
|
||||||
requires=["forgectrl.auth"],
|
|
||||||
description="POST /wiz/sensors/start; ten seconds of readings, then the room-temperature "
|
|
||||||
"prompt, answered Skip so no offset is written. The result carries both coolant "
|
|
||||||
"temperatures, the chassis and SoC, the lid IR maxima, the accelerometer event "
|
|
||||||
"count, the supply power-good, the HV current, and the idle fan speeds; the "
|
|
||||||
"record carries sensors at version 1 and cool_temp_offset_c reads as before.")
|
|
||||||
def check_sensors(ctx):
|
|
||||||
with Restore(ctx, ["cool_temp_offset_c"]):
|
|
||||||
last = run_check(ctx, "sensors", lambda p: "Skip" if p.get("id") == "room-temp" else None, 120)
|
|
||||||
r = last.get("result") or {}
|
|
||||||
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
|
||||||
"exhaust_rpm_idle", "intake_rpm_idle"):
|
|
||||||
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
|
||||||
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
|
|
||||||
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
|
|
||||||
r.get("lid_ir_max"), r.get("hv_current_max"))
|
|
||||||
|
|
||||||
|
|
||||||
@test("commission.check-airflow", title="The airflow check measures the fans and sets the floors",
|
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
|
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
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"], precheck=intakes_counted,
|
||||||
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 the five floors are written: the "
|
||||||
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
|
"four at 55 percent of steady and the grace from the slowest spin-up. The intake "
|
||||||
"fan must read at least 1000 rpm. The five settings are put back as found at the "
|
"fan count is the machine's: intake 2 turning proves two fans, both intakes are "
|
||||||
"end, the purge fan is on again and drawing over the floor the check just wrote, "
|
"judged, and the count is written as 2; intake 2 silent on a machine counted with "
|
||||||
"and the controller must be running again.")
|
"one is reported not fitted and not turning, intake 1 alone sets the intake "
|
||||||
|
"floor, and the count is not written; intake 2 silent on a machine counted with "
|
||||||
|
"two fails the check. Both intakes turn on the bench reference, so the bench "
|
||||||
|
"path writes 2. Each fan judged must read at least 1000 rpm. The settings are put "
|
||||||
|
"back as found at the end, the purge fan is on again and drawing over the floor "
|
||||||
|
"the check just wrote, and the controller must be running again.")
|
||||||
def check_airflow(ctx):
|
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"]) as held:
|
||||||
|
counted = held.before.get("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")
|
||||||
|
silent2 = (fans.get("intake 2") or {}).get("turning") is False
|
||||||
|
ctx.evidence["intakes"] = {"counted": counted, "found": intakes, "intake_2_silent": silent2}
|
||||||
|
ctx.check(intakes in (1, 2), "the result carries no intake fan count: %r", intakes)
|
||||||
|
if silent2:
|
||||||
|
# one intake fan, as the machine is counted (on a machine
|
||||||
|
# counted with two the check itself fails, and run_check says so)
|
||||||
|
ctx.check(counted == "1" and intakes == 1,
|
||||||
|
"intake 2 gave no reading on a machine counted with %r intake fans, and the check "
|
||||||
|
"found %r", counted, intakes)
|
||||||
|
else:
|
||||||
|
ctx.check(intakes == 2, "intake 2 turned, and the check found %r intake fans", intakes)
|
||||||
|
ctx.log("intake fans: %s (the machine counted %s)", intakes, counted)
|
||||||
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
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 silent2:
|
||||||
|
ctx.check(f == {"fitted": False, "turning": False}, "intake 2 with one intake fan: %s", f)
|
||||||
|
continue
|
||||||
ctx.check(f.get("ok") is True, "%s: %s", name, f)
|
ctx.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 {}
|
||||||
@@ -198,6 +224,12 @@ 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))
|
||||||
|
# The count: 2 written when intake 2 turned; left as counted when it
|
||||||
|
# was silent.
|
||||||
|
want = counted if silent2 else "2"
|
||||||
|
ctx.check(s.get("cool_intake_fans") == want,
|
||||||
|
"cool_intake_fans reads %r, expected %r (intake 2 %s, the machine counted %r)",
|
||||||
|
s.get("cool_intake_fans"), want, "silent" if silent2 else "turning", counted)
|
||||||
ctx.log("floors: %s", json.dumps(floors))
|
ctx.log("floors: %s", json.dumps(floors))
|
||||||
# The check switches purge air off to read its off current. Leaving
|
# 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
|
# it off costs the next job an airflow hold mid-cut, judged against
|
||||||
@@ -229,43 +261,8 @@ def check_airflow(ctx):
|
|||||||
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-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
||||||
subsystem="commission", kind="operator", est_min=2,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
|
|
||||||
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
|
|
||||||
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
|
|
||||||
"head snapshot, the question. Each question is answered yes once GET /wiz/shot "
|
|
||||||
"serves the snapshot as image/jpeg. The result names the sensor and says both "
|
|
||||||
"views were accepted.")
|
|
||||||
def check_cameras(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
ctx.act("lid", "close")
|
|
||||||
shots = {}
|
|
||||||
|
|
||||||
def on_prompt(p):
|
|
||||||
pid = p.get("id", "")
|
|
||||||
if pid in ("lid-view", "head-view"):
|
|
||||||
cam = "lid" if pid == "lid-view" else "head"
|
|
||||||
st, body = fc.get("/wiz/shot", params={"cam": cam}, raw=True)
|
|
||||||
shots[cam] = {"status": st, "bytes": len(body) if body else 0,
|
|
||||||
"jpeg": bool(body) and body[:2] == b"\xff\xd8"}
|
|
||||||
ctx.check(st == 200 and shots[cam]["jpeg"], "no %s snapshot to judge: %s", cam, shots[cam])
|
|
||||||
return "Yes"
|
|
||||||
if pid == "lid-close":
|
|
||||||
ctx.act("lid", "close", until=lambda: (dark(fc).get("prompt") or {}).get("seq") != p["seq"])
|
|
||||||
return None
|
|
||||||
last = run_check(ctx, "cameras", on_prompt, 180)
|
|
||||||
ev["shots"] = shots
|
|
||||||
r = last.get("result") or {}
|
|
||||||
ctx.check(r.get("lid_ok") is True and r.get("head_ok") is True, "a view was not accepted: %s", r)
|
|
||||||
ctx.check(bool(r.get("sensor")) and r.get("sensor") != "unknown", "no sensor named: %s", r)
|
|
||||||
ctx.log("cameras: %s, lid %d bytes, head %d bytes", r.get("sensor"),
|
|
||||||
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
|
|
||||||
|
|
||||||
|
|
||||||
@test("commission.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
||||||
("forgectrl", "src/lenshome.*"),
|
("forgectrl", "src/lenshome.*"),
|
||||||
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
|
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
|
||||||
@@ -305,26 +302,107 @@ 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-lens", title="The lens travel check finds the stops and writes the window",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=4,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
covers=DARK_COVERS + [("forgectrl", "src/lensfind.*"), ("forgectrl", "src/lens.*"),
|
||||||
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
("forgectrl", "src/lenshome.*"), ("forgectrl", "src/accel.c"),
|
||||||
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
("forgectrl", "src/cool.c"), ("forgectrl", "src/super.c")],
|
||||||
"check runner: the diagnostic's phases show as the check's, its result becomes the "
|
requires=["forgectrl.auth", "setup.check-motion"],
|
||||||
"check's result with pass true, and the record carries cooling.flow-verify at "
|
description="POST /wiz/lens.travel/start: the controller stops, the lens takes its hall-edge "
|
||||||
"version 1. A thin margin recommends the calibration in the record's flags. The "
|
"reference, the fans stop and the head accelerometer listens while the lens "
|
||||||
"air-assist offset calibration is named first among the prerequisites so a queue "
|
"steps to each stop and back (nothing fires), the two window settings are "
|
||||||
"keeps it ahead of every heater tool this check pulls forward.")
|
"written, and the controller comes back. No question is asked. The result "
|
||||||
def check_flow_verify(ctx):
|
"carries the reference passes, the stops (found, the free half-steps each way, "
|
||||||
last = run_check(ctx, "cooling.flow-verify", lambda p: None, 600)
|
"the contact counts, or why not), and the window: the stops found, or the "
|
||||||
|
"fallback (10 half-steps below, 12 above) when they were not. The settings and "
|
||||||
|
"the lens block of /status hold that window, the record carries lens.travel at "
|
||||||
|
"version 1, and the two settings are put back as found. grblHAL reads the "
|
||||||
|
"window only at its start, and the check started it before the put-back, so in "
|
||||||
|
"GRBL mode the controller is stopped and started again on the window as found "
|
||||||
|
"(on every exit path), and it must be running with motion verified under a new "
|
||||||
|
"process.")
|
||||||
|
def check_lens(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
restart = {}
|
||||||
|
|
||||||
|
def on_prompt(p):
|
||||||
|
ctx.fail("the lens travel check asked a question: %s", p)
|
||||||
|
|
||||||
|
def restart_on_found():
|
||||||
|
"""A fresh controller start on the settings as found, once the
|
||||||
|
check has ended. Plain waits, not the run's, so an abort gets it
|
||||||
|
too; a check still ending starts its own controller after the
|
||||||
|
put-back, which reads them already."""
|
||||||
|
try:
|
||||||
|
t_end = time.time() + 120
|
||||||
|
while time.time() < t_end and dark(fc).get("running"):
|
||||||
|
time.sleep(1)
|
||||||
|
if dark(fc).get("running"):
|
||||||
|
restart["skipped"] = "the check is still ending"
|
||||||
|
ctx.log("the check is still ending: its own controller start reads the settings as found")
|
||||||
|
return
|
||||||
|
st, m = fc.get("/mode")
|
||||||
|
m = m if isinstance(m, dict) else {}
|
||||||
|
restart.update(mode=m.get("mode"), pid=m.get("pid"))
|
||||||
|
if m.get("mode") != "grbl":
|
||||||
|
return # the cloud client reads no lens window
|
||||||
|
restart["stop"], body = fc.post("/controller/stop")
|
||||||
|
ctx.log("restarting the controller on the window as found: POST /controller/stop -> %s %s",
|
||||||
|
restart["stop"], body)
|
||||||
|
for _ in range(10):
|
||||||
|
restart["start"], body = fc.post("/controller/start")
|
||||||
|
if restart["start"] != 409:
|
||||||
|
break
|
||||||
|
time.sleep(1) # the check's lease goes just after its end
|
||||||
|
ctx.log("POST /controller/start -> %s %s", restart["start"], body)
|
||||||
|
except hw.HwError as e:
|
||||||
|
restart["error"] = str(e)
|
||||||
|
ctx.log("the controller could not be restarted on the window as found: %s", e)
|
||||||
|
try:
|
||||||
|
with Restore(ctx, LENS_WINDOW_KEYS):
|
||||||
|
last = run_check(ctx, "lens.travel", on_prompt, 300)
|
||||||
r = last.get("result") or {}
|
r = last.get("result") or {}
|
||||||
ctx.check(r.get("pass") is True, "the flow check did not pass: %s", r)
|
ctx.check(len(r.get("z_passes") or []) >= 1, "the lens was not referenced: %s", r.get("z_passes"))
|
||||||
ctx.log("flow check: threshold %s, flow %s, no-flow %s, thin %s", r.get("threshold"),
|
stops, w = r.get("stops") or {}, r.get("window") or {}
|
||||||
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
|
ctx.check(isinstance(stops.get("found"), bool), "the result files no stops: %s", r)
|
||||||
|
window = (w.get("below"), w.get("above"))
|
||||||
|
if stops.get("found"):
|
||||||
|
ctx.check(window == (stops.get("below"), stops.get("above")) and w.get("found") is True,
|
||||||
|
"the window %s is not the stops found %s", w, stops)
|
||||||
|
else:
|
||||||
|
ctx.check(window == FALLBACK_WINDOW and w.get("found") is False,
|
||||||
|
"the stops were not found (%s), but the window is %s", stops.get("why"), w)
|
||||||
|
s = fc.settings() or {}
|
||||||
|
held = tuple(str(s.get(k)) for k in LENS_WINDOW_KEYS)
|
||||||
|
lens = (fc.status() or {}).get("lens") or {}
|
||||||
|
ev["window"] = {"stops": stops, "window": w, "settings": held, "status": lens}
|
||||||
|
ctx.check(held == tuple(str(v) for v in window), "the settings hold %s, the check wrote %s",
|
||||||
|
held, window)
|
||||||
|
ctx.check((lens.get("below"), lens.get("above")) == window,
|
||||||
|
"/status reads the window %s/%s, the check wrote %s", lens.get("below"), lens.get("above"),
|
||||||
|
window)
|
||||||
|
ctx.log("lens travel: %s half-steps below the edge, %s above (%s); contact %s/%s", window[0],
|
||||||
|
window[1], "the stops found" if stops.get("found") else "the fallback: %s" % stops.get("why"),
|
||||||
|
stops.get("contact_below"), stops.get("contact_above"))
|
||||||
|
finally:
|
||||||
|
restart_on_found()
|
||||||
|
ev["restart"] = dict(restart)
|
||||||
|
if restart.get("mode") == "grbl":
|
||||||
|
ctx.check(restart.get("stop") == 200 and restart.get("start") == 200,
|
||||||
|
"the controller was not restarted on the window as found: %s", restart)
|
||||||
|
|
||||||
|
def back():
|
||||||
|
st, m = fc.get("/mode")
|
||||||
|
m = m if isinstance(m, dict) else {}
|
||||||
|
return (m.get("controller") == "running" and m.get("motion") == "verified"
|
||||||
|
and (restart.get("start") != 200 or m.get("pid") != restart.get("pid")))
|
||||||
|
ok = ctx.wait_for(back, 150)
|
||||||
|
ctx.check(ok is not None, "the controller did not come back after the check")
|
||||||
|
|
||||||
|
|
||||||
@test("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"),
|
||||||
@@ -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)
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# 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 tempfile
|
import tempfile
|
||||||
@@ -8,6 +13,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", "**")]
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +24,8 @@ _UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
|
|||||||
"answers; `fwup` refuses a garbage archive. A tiny archive signed with a "
|
"answers; `fwup` refuses a garbage archive. A tiny archive signed with a "
|
||||||
"throwaway key made on the spot verifies with its own key and fails against the "
|
"throwaway key made on the spot verifies with its own key and fails against the "
|
||||||
"shipped release key, and an apply of it without confirm_unsigned is refused by "
|
"shipped release key, and an apply of it without confirm_unsigned is refused by "
|
||||||
"the update job before anything is written. Nothing is written to any slot.")
|
"the update job before anything is written, and the forgectrl log records the "
|
||||||
|
"job's start and its refusal. Nothing is written to any slot.")
|
||||||
def slots_and_signature(ctx):
|
def slots_and_signature(ctx):
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
@@ -89,6 +96,11 @@ def slots_and_signature(ctx):
|
|||||||
target = name
|
target = name
|
||||||
ctx.check(target is not None, "no inactive firmware slot in /slots: %s", slots)
|
ctx.check(target is not None, "no inactive firmware slot in /slots: %s", slots)
|
||||||
shutil.copyfile(signed, staged)
|
shutil.copyfile(signed, staged)
|
||||||
|
log_path = "/data/log/forgefirm/forgectrl/forgectrl.log"
|
||||||
|
try:
|
||||||
|
log_from = os.path.getsize(log_path)
|
||||||
|
except OSError:
|
||||||
|
log_from = 0
|
||||||
st, body = fc.post("/update/apply", params={"slot": target, "file": "upload"})
|
st, body = fc.post("/update/apply", params={"slot": target, "file": "upload"})
|
||||||
ev["apply"] = {"status": st, "body": body}
|
ev["apply"] = {"status": st, "body": body}
|
||||||
ctx.log("POST /update/apply slot=%s file=upload (no confirm_unsigned) -> %s %s", target, st, body)
|
ctx.log("POST /update/apply slot=%s file=upload (no confirm_unsigned) -> %s %s", target, st, body)
|
||||||
@@ -111,6 +123,24 @@ def slots_and_signature(ctx):
|
|||||||
ctx.check(isinstance(result, dict) and result.get("ok") is False
|
ctx.check(isinstance(result, dict) and result.get("ok") is False
|
||||||
and "not signed" in str(result.get("error", "")),
|
and "not signed" in str(result.get("error", "")),
|
||||||
"the apply of a foreign-signed archive was not refused for its signature: %s", result)
|
"the apply of a foreign-signed archive was not refused for its signature: %s", result)
|
||||||
|
|
||||||
|
# The forgectrl log records the job: what it set out to write
|
||||||
|
# and why it stopped.
|
||||||
|
want = ["update: writing the uploaded archive into slot %s" % target,
|
||||||
|
"update: the apply failed: archive is not signed"]
|
||||||
|
|
||||||
|
def log_text():
|
||||||
|
try:
|
||||||
|
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
f.seek(log_from)
|
||||||
|
return f.read()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
ctx.wait_for(lambda: all(w in log_text() for w in want), 10, poll=0.5)
|
||||||
|
text = log_text()
|
||||||
|
ev["apply_log"] = [ln for ln in text.splitlines() if "update: " in ln][-4:]
|
||||||
|
for w in want:
|
||||||
|
ctx.check(w in text, "the forgectrl log lacks %r: %s", w, ev["apply_log"])
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
os.unlink(staged)
|
os.unlink(staged)
|
||||||
|
|||||||
@@ -206,6 +206,100 @@
|
|||||||
border-left: 4px solid var(--og-red);
|
border-left: 4px solid var(--og-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- The release dialog -------------------------------------------- */
|
||||||
|
#rel-view h3 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--og-dim);
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
}
|
||||||
|
.relnotes {
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--og-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.relnotes h1,
|
||||||
|
.relnotes h2,
|
||||||
|
.relnotes h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
}
|
||||||
|
.relnotes h1:first-child,
|
||||||
|
.relnotes h2:first-child,
|
||||||
|
.relnotes h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.relnotes p,
|
||||||
|
.relnotes ul {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.relnotes ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.relnotes code {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.relnotes table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
}
|
||||||
|
.relnotes th,
|
||||||
|
.relnotes td {
|
||||||
|
border: 1px solid var(--og-line);
|
||||||
|
padding: 2px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.relwarn {
|
||||||
|
background: var(--og-warn-bg);
|
||||||
|
border: 1px solid var(--og-warn);
|
||||||
|
color: var(--og-warn-txt);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.relsteps {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-left: 22px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.relsteps li {
|
||||||
|
color: var(--og-dim);
|
||||||
|
}
|
||||||
|
.relsteps li.cur {
|
||||||
|
color: var(--og-txt);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.relsteps li.done {
|
||||||
|
color: var(--og-ok);
|
||||||
|
}
|
||||||
|
.relsteps li.done::after {
|
||||||
|
content: ' \2713';
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
--bs-progress-bg: var(--og-line-soft);
|
||||||
|
--bs-progress-bar-bg: var(--og-action);
|
||||||
|
--bs-progress-height: 10px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
#rel-phase {
|
||||||
|
min-height: 1.4em;
|
||||||
|
font-size: 12.5px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
#rel-err {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Chrome: header, tabs, footer ---------------------------------- */
|
/* ---- Chrome: header, tabs, footer ---------------------------------- */
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -357,6 +451,23 @@ footer a {
|
|||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
}
|
}
|
||||||
|
.extadvdoc {
|
||||||
|
max-height: 22rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.extadvdoc h1,
|
||||||
|
.extadvdoc h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0.6rem 0 0.3rem;
|
||||||
|
}
|
||||||
|
.extcaps {
|
||||||
|
margin: 0.2rem 0 0.2rem 1.1rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.extcaps li {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
.hint {
|
.hint {
|
||||||
color: var(--og-dim);
|
color: var(--og-dim);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
@@ -704,3 +815,26 @@ footer a {
|
|||||||
min-height: 16px;
|
min-height: 16px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A package's card on the Extensions tab: the card and its heading (the
|
||||||
|
label) are the panel's, the frame is the package's, and the frame is
|
||||||
|
given a size rather than taking one. The frame takes the panel's color
|
||||||
|
scheme, which the page inside reads as its prefers-color-scheme; it is
|
||||||
|
transparent, so a page that paints no background sits on the card. */
|
||||||
|
.extcard h2 .mono {
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--og-dim);
|
||||||
|
}
|
||||||
|
.extframe {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 360px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
[data-bs-theme='dark'] .extframe {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1
|
||||||
-41
@@ -1,41 +0,0 @@
|
|||||||
2026-08-17T09:44:00.417337+00:00 gfcloud[1927] INFO cnc:set_step_freq 28160
|
|
||||||
2026-08-17T09:44:00.418627+00:00 gfcloud[1927] INFO cnc:set_x_decay 1
|
|
||||||
2026-08-17T09:44:00.419858+00:00 gfcloud[1927] INFO cnc:set_x_mode 8
|
|
||||||
2026-08-17T09:44:00.421083+00:00 gfcloud[1927] INFO cnc:set_x_current 135
|
|
||||||
2026-08-17T09:44:00.422535+00:00 gfcloud[1927] INFO cnc:set_y_decay 1
|
|
||||||
2026-08-17T09:44:00.423794+00:00 gfcloud[1927] INFO cnc:set_y_mode 8
|
|
||||||
2026-08-17T09:44:00.425014+00:00 gfcloud[1927] INFO cnc:set_y_current 22
|
|
||||||
2026-08-17T09:44:00.432591+00:00 gfcloud[1927] INFO machine:_run_loop starting run
|
|
||||||
2026-08-17T09:44:00.434005+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
|
|
||||||
2026-08-17T09:44:00.438277+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.RUNNING
|
|
||||||
2026-08-17T09:44:01.785230+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
|
|
||||||
2026-08-17T09:44:01.797728+00:00 gfcloud[1927] INFO machine:_run_loop finished run
|
|
||||||
2026-08-17T09:44:01.800661+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:44:01.808504+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (actual/expected): X (-10313/-10313), Y (-649/-649), Z (0/0)
|
|
||||||
2026-08-17T09:44:01.809326+00:00 gfcloud[1927] INFO machine:_motion_locked motion bytes actual:34952, expected: 34952
|
|
||||||
2026-08-17T09:44:01.810066+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
|
|
||||||
2026-08-17T09:44:01.811352+00:00 gfcloud[1927] INFO cnc:set_x_current 33
|
|
||||||
2026-08-17T09:44:01.812874+00:00 gfcloud[1927] INFO cnc:set_y_current 5
|
|
||||||
2026-08-17T09:44:01.820631+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (-10313, -649, 0)
|
|
||||||
2026-08-17T09:44:01.822062+00:00 gfcloud[1927] INFO machine:_motion end motion
|
|
||||||
2026-08-17T09:44:01.822829+00:00 gfcloud[1927] INFO basemachine:_finish_action motion [1576550614]: finished with event ":completed"
|
|
||||||
2026-08-17T09:44:01.824200+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:44:07.993647+00:00 gfcloud[1927] INFO gfuiservice:run service action request: print (ready)
|
|
||||||
2026-08-17T09:44:07.998655+00:00 gfcloud[1927] INFO machine:_motion start motion
|
|
||||||
2026-08-17T09:44:07.999643+00:00 gfcloud[1927] INFO gfuiservice:run print (ready) service action print
|
|
||||||
2026-08-17T09:44:14.199666+00:00 gfcloud[1927] INFO cnc:laser_latch 0
|
|
||||||
2026-08-17T09:44:14.208291+00:00 gfcloud[1927] INFO machine:_button_wait waiting for button
|
|
||||||
2026-08-17T09:44:21.270846+00:00 gfcloud[1927] INFO machine:_switch_event lid opened
|
|
||||||
2026-08-17T09:44:21.275703+00:00 gfcloud[1927] WARNING machine:_button_wait button wait lid opened - relocking the laser
|
|
||||||
2026-08-17T09:44:21.278615+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:44:21.288305+00:00 gfcloud[1927] INFO machine:_return_home start return home
|
|
||||||
2026-08-17T09:44:21.291373+00:00 gfcloud[1927] INFO machine:_return_home return home: already at the job start
|
|
||||||
2026-08-17T09:44:21.292501+00:00 gfcloud[1927] INFO machine:_motion_locked start cool down
|
|
||||||
2026-08-17T09:44:21.305578+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
|
|
||||||
2026-08-17T09:44:21.307322+00:00 gfcloud[1927] INFO cnc:set_x_current 33
|
|
||||||
2026-08-17T09:44:21.309185+00:00 gfcloud[1927] INFO cnc:set_y_current 5
|
|
||||||
2026-08-17T09:44:21.316340+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (0, 0, 0)
|
|
||||||
2026-08-17T09:44:21.318139+00:00 gfcloud[1927] INFO machine:_motion end motion
|
|
||||||
2026-08-17T09:44:21.318860+00:00 gfcloud[1927] INFO basemachine:_finish_action print [1576550621]: finished with event ":cancelled"
|
|
||||||
2026-08-17T09:44:21.320214+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:44:34.058320+00:00 gfcloud[1927] INFO machine:_switch_event lid closed
|
|
||||||
-207
@@ -1,207 +0,0 @@
|
|||||||
2026-08-17T09:44:35.789556+00:00 gfcloud[1927] INFO websocket:img_upload START
|
|
||||||
2026-08-17T09:44:36.105516+00:00 gfcloud[1927] INFO websocket:img_upload COMPLETE
|
|
||||||
2026-08-17T09:44:36.109421+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:44:38.461442+00:00 gfcloud[1927] INFO gfcloud:_shutdown shutdown requested
|
|
||||||
2026-08-17T09:44:38.910494+00:00 gfcloud[1927] INFO machine:_shutdown shutting down
|
|
||||||
2026-08-17T09:44:38.913049+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:44:38.950354+00:00 gfcloud[1927] INFO machine:_shutdown joining switch thread
|
|
||||||
2026-08-17T09:44:38.956909+00:00 gfcloud[1927] INFO machine:_shutdown shut down complete
|
|
||||||
2026-08-17T09:44:38.986287+00:00 gfcloud[1927] INFO websocket:_on_close RX-EVENT: closed (None, None)
|
|
||||||
2026-08-17T09:44:38.989003+00:00 gfcloud[1927] INFO websocket:run CLOSING
|
|
||||||
2026-08-17T09:44:38.992724+00:00 gfcloud[1927] INFO gfcloud:main gfcloud exit
|
|
||||||
2026-08-17T09:45:08.449553+00:00 gfcloud[2278] INFO gfuiservice:__init__ INITIALIZED
|
|
||||||
2026-08-17T09:45:08.451929+00:00 gfcloud[2278] INFO authentication:authenticate_machine START
|
|
||||||
2026-08-17T09:45:08.965893+00:00 gfcloud[2278] INFO authentication:authenticate_machine SUCCESS
|
|
||||||
2026-08-17T09:45:08.967947+00:00 gfcloud[2278] INFO websocket:ws_connect CONNECTING
|
|
||||||
2026-08-17T09:45:09.421918+00:00 gfcloud[2278] INFO _logging:info Websocket connected
|
|
||||||
2026-08-17T09:45:09.424224+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready
|
|
||||||
2026-08-17T09:45:09.973615+00:00 gfcloud[2278] INFO websocket:ws_connect ESTABLISHED
|
|
||||||
2026-08-17T09:45:09.994287+00:00 gfcloud[2278] INFO cnc:set_ignored_faults 0
|
|
||||||
2026-08-17T09:45:09.996281+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
|
||||||
2026-08-17T09:45:09.998803+00:00 gfcloud[2278] INFO cnc:set_x_mode Microstep.M_8
|
|
||||||
2026-08-17T09:45:09.999688+00:00 gfcloud[2278] INFO cnc:set_y_mode Microstep.M_8
|
|
||||||
2026-08-17T09:45:10.000483+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
|
||||||
2026-08-17T09:45:10.001229+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
|
||||||
2026-08-17T09:45:10.002000+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
|
||||||
2026-08-17T09:45:10.007267+00:00 gfcloud[2278] INFO cnc:set_y_current 33
|
|
||||||
2026-08-17T09:45:10.008695+00:00 gfcloud[2278] INFO z_axis:reset resetting z
|
|
||||||
2026-08-17T09:45:10.009124+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: False, current: ZCur.LOW, mode: Microstep.HALF
|
|
||||||
2026-08-17T09:45:10.021592+00:00 gfcloud[2278] INFO gfuiservice:run service action request: settings (ready)
|
|
||||||
2026-08-17T09:45:10.024928+00:00 gfcloud[2278] INFO settings:send_report START
|
|
||||||
2026-08-17T09:45:10.039856+00:00 gfcloud[2278] INFO settings:send_report COMPLETE
|
|
||||||
2026-08-17T09:45:10.040650+00:00 gfcloud[2278] INFO gfuiservice:run settings (ready) service action dispatched
|
|
||||||
2026-08-17T09:45:10.591813+00:00 gfcloud[2278] INFO gfuiservice:run service action request: hunt (ready)
|
|
||||||
2026-08-17T09:45:10.596213+00:00 gfcloud[2278] INFO z_axis:home starting z homing cycle
|
|
||||||
2026-08-17T09:45:10.599109+00:00 gfcloud[2278] INFO gfuiservice:run hunt (ready) service action hunt
|
|
||||||
2026-08-17T09:45:10.600433+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: True, current: ZCur.HIGH, mode: Microstep.FULL
|
|
||||||
2026-08-17T09:45:10.607559+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
|
||||||
2026-08-17T09:45:11.160422+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
|
||||||
2026-08-17T09:45:11.161195+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
|
||||||
2026-08-17T09:45:11.714663+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
|
||||||
2026-08-17T09:45:11.717907+00:00 gfcloud[2278] INFO z_axis:home pass: 1
|
|
||||||
2026-08-17T09:45:11.719134+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
|
||||||
2026-08-17T09:45:12.272747+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
|
||||||
2026-08-17T09:45:12.274301+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
|
||||||
2026-08-17T09:45:12.829585+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
|
||||||
2026-08-17T09:45:12.831305+00:00 gfcloud[2278] INFO z_axis:home pass: 2
|
|
||||||
2026-08-17T09:45:12.832786+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
|
||||||
2026-08-17T09:45:13.385567+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
|
||||||
2026-08-17T09:45:13.397106+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
|
||||||
2026-08-17T09:45:13.947958+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
|
||||||
2026-08-17T09:45:13.949754+00:00 gfcloud[2278] INFO z_axis:home pass: 3
|
|
||||||
2026-08-17T09:45:13.951244+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
|
||||||
2026-08-17T09:45:14.504840+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
|
||||||
2026-08-17T09:45:14.506743+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
|
||||||
2026-08-17T09:45:15.060854+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
|
||||||
2026-08-17T09:45:15.062536+00:00 gfcloud[2278] INFO z_axis:home pass: 4
|
|
||||||
2026-08-17T09:45:15.063941+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
|
||||||
2026-08-17T09:45:15.617907+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
|
||||||
2026-08-17T09:45:15.619505+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
|
||||||
2026-08-17T09:45:16.175270+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
|
||||||
2026-08-17T09:45:16.177421+00:00 gfcloud[2278] INFO z_axis:home pass: 5
|
|
||||||
2026-08-17T09:45:16.179808+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
|
||||||
2026-08-17T09:45:16.733698+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
|
||||||
2026-08-17T09:45:16.735355+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
|
||||||
2026-08-17T09:45:17.289377+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
|
||||||
2026-08-17T09:45:17.291109+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: None, current: ZCur.LOW, mode: Microstep.HALF
|
|
||||||
2026-08-17T09:45:17.296301+00:00 gfcloud[2278] INFO z_axis:home homing cycle complete: [0, 0, 0, 0, 0]
|
|
||||||
2026-08-17T09:45:17.298209+00:00 gfcloud[2278] INFO machine:_motion start motion
|
|
||||||
2026-08-17T09:45:17.968494+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
|
||||||
2026-08-17T09:45:17.969759+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
|
||||||
2026-08-17T09:45:17.970993+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
|
||||||
2026-08-17T09:45:17.972200+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
|
||||||
2026-08-17T09:45:17.973616+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
|
||||||
2026-08-17T09:45:17.974870+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
|
||||||
2026-08-17T09:45:17.976077+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
|
||||||
2026-08-17T09:45:17.983820+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
|
||||||
2026-08-17T09:45:17.985159+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
|
||||||
2026-08-17T09:45:17.989360+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
|
||||||
2026-08-17T09:45:19.630807+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
|
||||||
2026-08-17T09:45:19.636283+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
|
||||||
2026-08-17T09:45:19.637979+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:45:19.651159+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (0/0), Y (0/0), Z (-4/-4)
|
|
||||||
2026-08-17T09:45:19.652103+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:15874, expected: 15874
|
|
||||||
2026-08-17T09:45:19.652852+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
|
||||||
2026-08-17T09:45:19.653964+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
|
||||||
2026-08-17T09:45:19.655305+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
|
||||||
2026-08-17T09:45:19.664032+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (0, 0, -4)
|
|
||||||
2026-08-17T09:45:19.664853+00:00 gfcloud[2278] INFO machine:_motion end motion
|
|
||||||
2026-08-17T09:45:19.665575+00:00 gfcloud[2278] INFO basemachine:_finish_action hunt [1576550660]: finished with event ":completed"
|
|
||||||
2026-08-17T09:45:19.667114+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:45:20.027338+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
|
||||||
2026-08-17T09:45:20.030024+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
|
||||||
2026-08-17T09:45:20.040337+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
|
||||||
2026-08-17T09:45:21.195560+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
|
||||||
2026-08-17T09:45:21.196529+00:00 gfcloud[2278] INFO websocket:img_upload START
|
|
||||||
2026-08-17T09:45:21.721382+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
|
||||||
2026-08-17T09:45:21.725394+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:45:23.732857+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
|
||||||
2026-08-17T09:45:23.736044+00:00 gfcloud[2278] INFO machine:_motion start motion
|
|
||||||
2026-08-17T09:45:23.737995+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
|
||||||
2026-08-17T09:45:23.738941+00:00 gfcloud[2278] INFO machine:_safe_to_move lid opened, unsafe to move
|
|
||||||
2026-08-17T09:45:23.739814+00:00 gfcloud[2278] INFO machine:_motion end motion
|
|
||||||
2026-08-17T09:45:23.740605+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550667]: finished with event ":cancelled"
|
|
||||||
2026-08-17T09:45:23.742169+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:45:42.629674+00:00 gfcloud[2278] INFO machine:_switch_event lid closed
|
|
||||||
2026-08-17T09:45:42.920194+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
|
||||||
2026-08-17T09:45:42.924152+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
|
||||||
2026-08-17T09:45:42.927934+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
|
||||||
2026-08-17T09:45:43.989152+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
|
||||||
2026-08-17T09:45:43.990022+00:00 gfcloud[2278] INFO websocket:img_upload START
|
|
||||||
2026-08-17T09:45:44.312060+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
|
||||||
2026-08-17T09:45:44.315952+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:45:46.652633+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
|
||||||
2026-08-17T09:45:46.654950+00:00 gfcloud[2278] INFO machine:_motion start motion
|
|
||||||
2026-08-17T09:45:46.657522+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
|
||||||
2026-08-17T09:45:48.089456+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
|
||||||
2026-08-17T09:45:48.090706+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
|
||||||
2026-08-17T09:45:48.091970+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
|
||||||
2026-08-17T09:45:48.093155+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
|
||||||
2026-08-17T09:45:48.094587+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
|
||||||
2026-08-17T09:45:48.095866+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
|
||||||
2026-08-17T09:45:48.097182+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
|
||||||
2026-08-17T09:45:48.104758+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
|
||||||
2026-08-17T09:45:48.106128+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
|
||||||
2026-08-17T09:45:48.110477+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
|
||||||
2026-08-17T09:45:50.393091+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
|
||||||
2026-08-17T09:45:50.398748+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
|
||||||
2026-08-17T09:45:50.399629+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:45:50.423949+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (12939/12939), Y (7402/7402), Z (0/0)
|
|
||||||
2026-08-17T09:45:50.425534+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:22740, expected: 22740
|
|
||||||
2026-08-17T09:45:50.426258+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
|
||||||
2026-08-17T09:45:50.427835+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
|
||||||
2026-08-17T09:45:50.429387+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
|
||||||
2026-08-17T09:45:50.437659+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (12939, 7402, 0)
|
|
||||||
2026-08-17T09:45:50.438474+00:00 gfcloud[2278] INFO machine:_motion end motion
|
|
||||||
2026-08-17T09:45:50.439167+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550679]: finished with event ":completed"
|
|
||||||
2026-08-17T09:45:50.440481+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:45:50.770056+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
|
||||||
2026-08-17T09:45:50.772800+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
|
||||||
2026-08-17T09:45:50.782017+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
|
||||||
2026-08-17T09:45:51.427848+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
|
||||||
2026-08-17T09:45:51.428456+00:00 gfcloud[2278] INFO websocket:img_upload START
|
|
||||||
2026-08-17T09:45:51.757479+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
|
||||||
2026-08-17T09:45:51.760692+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:45:54.222123+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
|
||||||
2026-08-17T09:45:54.224786+00:00 gfcloud[2278] INFO machine:_motion start motion
|
|
||||||
2026-08-17T09:45:54.226855+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
|
||||||
2026-08-17T09:45:54.557705+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
|
||||||
2026-08-17T09:45:54.562210+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
|
||||||
2026-08-17T09:45:54.571212+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
|
||||||
2026-08-17T09:45:54.573373+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
|
||||||
2026-08-17T09:45:54.596668+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
|
||||||
2026-08-17T09:45:54.597612+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
|
||||||
2026-08-17T09:45:54.598479+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
|
||||||
2026-08-17T09:45:54.622488+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
|
||||||
2026-08-17T09:45:54.624223+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
|
||||||
2026-08-17T09:45:54.629307+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
|
||||||
2026-08-17T09:45:54.835600+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
|
||||||
2026-08-17T09:45:54.838848+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
|
||||||
2026-08-17T09:45:54.839726+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:45:54.849011+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (159/159), Y (5/5), Z (0/0)
|
|
||||||
2026-08-17T09:45:54.849946+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:1222, expected: 1222
|
|
||||||
2026-08-17T09:45:54.850790+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
|
||||||
2026-08-17T09:45:54.852317+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
|
||||||
2026-08-17T09:45:54.854073+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
|
||||||
2026-08-17T09:45:54.880450+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (159, 5, 0)
|
|
||||||
2026-08-17T09:45:54.881925+00:00 gfcloud[2278] INFO machine:_motion end motion
|
|
||||||
2026-08-17T09:45:54.882641+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550683]: finished with event ":completed"
|
|
||||||
2026-08-17T09:45:54.884500+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:45:55.137070+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
|
||||||
2026-08-17T09:45:55.139449+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
|
||||||
2026-08-17T09:45:55.142346+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
|
||||||
2026-08-17T09:45:55.809276+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
|
||||||
2026-08-17T09:45:55.810155+00:00 gfcloud[2278] INFO websocket:img_upload START
|
|
||||||
2026-08-17T09:45:56.158888+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
|
||||||
2026-08-17T09:45:56.162644+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:45:58.467590+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
|
||||||
2026-08-17T09:45:58.469426+00:00 gfcloud[2278] INFO machine:_motion start motion
|
|
||||||
2026-08-17T09:45:58.471091+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
|
||||||
2026-08-17T09:46:00.721960+00:00 gfcloud[2278] INFO cnc:set_step_freq 28160
|
|
||||||
2026-08-17T09:46:00.725688+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
|
||||||
2026-08-17T09:46:00.727477+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
|
||||||
2026-08-17T09:46:00.728738+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
|
||||||
2026-08-17T09:46:00.730198+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
|
||||||
2026-08-17T09:46:00.731506+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
|
||||||
2026-08-17T09:46:00.732735+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
|
||||||
2026-08-17T09:46:00.740459+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
|
||||||
2026-08-17T09:46:00.741865+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
|
||||||
2026-08-17T09:46:00.745983+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
|
||||||
2026-08-17T09:46:02.491897+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
|
||||||
2026-08-17T09:46:02.494137+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
|
||||||
2026-08-17T09:46:02.495155+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:46:02.530565+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (-13096/-13096), Y (-7400/-7400), Z (0/0)
|
|
||||||
2026-08-17T09:46:02.531413+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:47243, expected: 47243
|
|
||||||
2026-08-17T09:46:02.538168+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
|
||||||
2026-08-17T09:46:02.539603+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
|
||||||
2026-08-17T09:46:02.541147+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
|
||||||
2026-08-17T09:46:02.549578+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (-13096, -7400, 0)
|
|
||||||
2026-08-17T09:46:02.550385+00:00 gfcloud[2278] INFO machine:_motion end motion
|
|
||||||
2026-08-17T09:46:02.551153+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550685]: finished with event ":completed"
|
|
||||||
2026-08-17T09:46:02.552496+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
|
||||||
2026-08-17T09:46:03.007623+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
|
||||||
2026-08-17T09:46:03.009488+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
|
||||||
2026-08-17T09:46:03.037155+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
|
||||||
2026-08-17T09:46:03.758520+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
|
||||||
2026-08-17T09:46:03.760049+00:00 gfcloud[2278] INFO websocket:img_upload START
|
|
||||||
2026-08-17T09:46:04.128126+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Shared fixtures for the forgetest unit tests: synthetic manifests and
|
"""Shared fixtures for the forgetest unit tests: synthetic manifests and
|
||||||
catalog entries that never touch hardware."""
|
catalog entries that never touch hardware."""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user