mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-16 01:29:55 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3da9fa1bd | ||
|
|
56aa164708 | ||
|
|
df5ae151ab | ||
|
|
10ae7bf39c | ||
|
|
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 |
@@ -11,6 +11,7 @@ name: fixture-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'fixture/**'
|
||||
- '.github/workflows/fixture-ci.yml'
|
||||
|
||||
@@ -11,15 +11,24 @@
|
||||
# release with source missing
|
||||
# - shared UI files: theme.css and the vendored Bootstrap the page
|
||||
# carries are byte-identical to forgectrl's at its pinned revision
|
||||
# - 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
|
||||
# recipes must be selected by some catalog test's coverage globs (the
|
||||
# tree manifest is generated from the pins with git - no Yocto build);
|
||||
# enforced: an uncovered path fails the job (https://docs.forgefirm.org/developers/acceptance/).
|
||||
# recipes must be selected by some catalog test's coverage globs or by a
|
||||
# host-tested row naming the host test that proves it (the tree manifest
|
||||
# is generated from the pins with git - no Yocto build); enforced: an
|
||||
# uncovered path fails the job (https://docs.forgefirm.org/developers/testing/).
|
||||
|
||||
name: forgetest-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'forgetest/**'
|
||||
- 'scripts/acceptance-gate.py'
|
||||
@@ -48,6 +57,13 @@ jobs:
|
||||
ref: scarthgap
|
||||
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
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -57,6 +73,68 @@ jobs:
|
||||
working-directory: forgefirm
|
||||
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
|
||||
working-directory: forgefirm/forgetest
|
||||
# -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
|
||||
@@ -325,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
|
||||
`Written by Scott Wiederhold`. Keep the year, or the year range, that
|
||||
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
|
||||
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:
|
||||
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
|
||||
or defaults to GPL version 2.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> ### BETA
|
||||
>
|
||||
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
|
||||
> Expect problems, and expect frequent updates. Upgrade whenever a newer
|
||||
> 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
|
||||
> [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
|
||||
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
|
||||
# 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.
|
||||
#
|
||||
# ./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 /. */
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/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.
|
||||
set -e
|
||||
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
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
### BEGIN INIT INFO
|
||||
# Provides: forgetest
|
||||
# Required-Start: $network forgectrl
|
||||
@@ -22,13 +26,6 @@ case "$1" in
|
||||
start)
|
||||
echo "Starting forgetest"
|
||||
mkdir -p "$DATA"
|
||||
# The bench seed: the setup override lifts the wizard part of
|
||||
# the controller gate until the next reboot (/run is tmpfs), so a
|
||||
# campaign runs on a machine whose required wizards are not complete.
|
||||
# Nothing lifts the agreements or the account: the bench accepts them
|
||||
# once, and that stays in /data.
|
||||
mkdir -p /run/forgefirm
|
||||
: > /run/forgefirm/setup-override
|
||||
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
|
||||
"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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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 winning result record (same-campaign PASS or inherited PASS, with its
|
||||
origin) and the fingerprint it was recorded under. The gate
|
||||
@@ -15,6 +25,7 @@ from . import VERSION
|
||||
from . import campaign as _campaign
|
||||
from . import manifest as _manifest
|
||||
from .log import now_ts
|
||||
from .redact import scrub
|
||||
|
||||
FORMAT = 1
|
||||
_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"],
|
||||
"satisfied": st["satisfied"],
|
||||
"inherited": st["status"] == "inherited",
|
||||
"record": _record(rec) if rec else None,
|
||||
"record": scrub(_record(rec)) if rec else None,
|
||||
})
|
||||
tests_out.append(entry)
|
||||
|
||||
|
||||
+166
-14
@@ -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 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.
|
||||
|
||||
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
|
||||
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 os
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
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
|
||||
# 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
|
||||
# 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
|
||||
# loop heater and TEC are the diagnostics' tools, off at idle.
|
||||
FIXED_SYSFS = [
|
||||
@@ -111,6 +119,12 @@ UNPRESERVED_SETTINGS = ("controller_mode",)
|
||||
# service starts its own client with the hunt.
|
||||
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
|
||||
# comes right through forgectrl - see restore_forgectrl - or is fatal).
|
||||
IDLE_READBACKS = [
|
||||
@@ -128,11 +142,21 @@ PRESERVED_SYSFS = [] # sysfs attrs captured before, restored afte
|
||||
|
||||
LID_LAMP_ATTR = "pic/lid_led"
|
||||
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
|
||||
SETTLE_S = 150 # the supervisor's probe + rail-off ladder
|
||||
CAM_IDLE_S = 20 # camera engine idle stop is 10 s
|
||||
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
|
||||
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
|
||||
|
||||
# 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):
|
||||
# the fixed values of x/y_mode, step_freq and ramp_rate are the mode's.
|
||||
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_TICK_X8_HZ = 28160 # the x8 machine tick
|
||||
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:
|
||||
the tick and the ramp scale with the mode (x16 doubles both, x32
|
||||
quadruples them)."""
|
||||
k = mode // XY_MODE_DEFAULT
|
||||
k = mode // XY_MODE_BASE
|
||||
own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode),
|
||||
"cnc/step_freq": str(XY_TICK_X8_HZ * 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))
|
||||
|
||||
|
||||
# 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():
|
||||
"""(x, y, z) step counters, or None when unreadable."""
|
||||
try:
|
||||
@@ -265,6 +307,11 @@ def read_program_total():
|
||||
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:
|
||||
def __init__(self, item, found, expected, action):
|
||||
self.item = item
|
||||
@@ -438,7 +485,8 @@ class Baseline:
|
||||
# -- capture -------------------------------------------------------
|
||||
def capture(self):
|
||||
"""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:
|
||||
cap["sysfs"][attr] = hw.sysfs_read(attr)
|
||||
st, body = self.fc_get("/settings")
|
||||
@@ -464,6 +512,7 @@ class Baseline:
|
||||
self._forgectrl_side(left, captured)
|
||||
self._kernel_side(left)
|
||||
self._lamp_side(left)
|
||||
self._ext_side(left)
|
||||
self._preserved(left, captured)
|
||||
if left:
|
||||
self.log("%s: %d leftover(s): %s" % (phase, len(left), "; ".join(str(x) for x in left)))
|
||||
@@ -607,13 +656,37 @@ class Baseline:
|
||||
except OSError as e:
|
||||
act = "failed: %s" % e
|
||||
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")
|
||||
if st == 200 and isinstance(c, dict):
|
||||
if c.get("phase") != "idle" or c.get("armed") or c.get("hold"):
|
||||
if st != 200 or not isinstance(c, dict):
|
||||
return
|
||||
if c.get("phase") == "idle" and not c.get("armed") and not c.get("hold"):
|
||||
return
|
||||
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
|
||||
idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731
|
||||
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
||||
# The engine publishes once a tick (1 Hz). A read inside the tick
|
||||
# after a run ended still shows the run: a diagnostic's hold until
|
||||
# the engine has the hardware back, a fail tier's hold until the
|
||||
# session ends with the controller. What the engine's next tick
|
||||
# clears was never left behind, so an arm or a hold is read again
|
||||
# past that tick before it is called the run's doing. One that a
|
||||
# run did leave stands for a job that is never coming back, and it
|
||||
# is still there.
|
||||
if c.get("armed") or c.get("hold"):
|
||||
free = lambda: (lambda x: bool(x) and not x.get("armed") # noqa: E731
|
||||
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
||||
if self._wait("cool publish", free, COOL_PUBLISH_S) is not None:
|
||||
st, c2 = self.fc_get("/cool/status")
|
||||
c = c2 if st == 200 and isinstance(c2, dict) else {}
|
||||
self.log("cool: %s was the engine's last word on the run; its next tick reads "
|
||||
"%s/armed=%s/hold=%s" % (found, c.get("phase"), c.get("armed"), c.get("hold")))
|
||||
if c.get("phase") == "idle":
|
||||
return
|
||||
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
|
||||
# Armed, or holding, is the run's doing. A phase alone is
|
||||
# not: the engine clears smoke at run duty after an armed
|
||||
# session and cools down after a hot one, both timed and
|
||||
@@ -625,7 +698,7 @@ class Baseline:
|
||||
if w is not None and not dirt:
|
||||
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
|
||||
"work, not a leftover" % (found, w))
|
||||
else:
|
||||
return
|
||||
# The engine holds for a job. A run that ended without
|
||||
# ending its job leaves one alive, and no amount of
|
||||
# waiting ends it: stand the machine down and let the
|
||||
@@ -638,6 +711,37 @@ class Baseline:
|
||||
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
||||
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):
|
||||
"""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)."""
|
||||
@@ -650,13 +754,34 @@ class Baseline:
|
||||
got = hw.sysfs_read(LID_LAMP_ATTR)
|
||||
if got is None or got == want:
|
||||
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:
|
||||
hw.sysfs_write(LID_LAMP_ATTR, want)
|
||||
back = hw.sysfs_read(LID_LAMP_ATTR)
|
||||
act = "restored" if back == want else "failed: reads %s" % back
|
||||
held, back = self._lamp_holds(want)
|
||||
act = "restored" if held else "failed: reads %s" % back
|
||||
except OSError as 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):
|
||||
if hw.sysfs_read("cnc/state") is None:
|
||||
@@ -798,7 +923,19 @@ class Baseline:
|
||||
# action re-zeroes them at its start, so they preserve nothing.
|
||||
was = captured.get("position")
|
||||
now = read_position()
|
||||
if was is not None and now is not None and now != was and not self.cloud_mode():
|
||||
# A controller start or a home during the run re-zeroed the counters
|
||||
# wherever the head then stood. Unless the test said where that was
|
||||
# (ctx.counters_rezeroed), the two readings share no frame: on the
|
||||
# bench reference a head that had not moved was "returned" 30 mm into
|
||||
# the gantry stops this way, twice. Nothing is moved on a guess.
|
||||
frame_lost = ("frame" in captured and captured["frame"] != counter_frame()
|
||||
and not captured.get("rezero_declared"))
|
||||
if was is not None and now is not None and now != was and frame_lost and not self.cloud_mode():
|
||||
self.log("position: %s, and %s when the run began, but the controller re-zeroed its "
|
||||
"counters during the run (a controller start or a home): the two are not in "
|
||||
"one frame, so the head's position cannot be judged and the head is not moved"
|
||||
% (now, was))
|
||||
elif was is not None and now is not None and now != was and not self.cloud_mode():
|
||||
spm = self._counter_spm
|
||||
act = self._return_head(was, now, spm)
|
||||
if position_quantized(was, now, spm):
|
||||
@@ -806,6 +943,21 @@ class Baseline:
|
||||
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
|
||||
else:
|
||||
left.append(Leftover("position", now, was, act))
|
||||
# A controller restart during the run (a takeover's) re-zeroed the
|
||||
# counters wherever the head then stood, so a head left out before
|
||||
# it reads as home from here. The takeover judged it on the spot
|
||||
# (runner.Takeover); what it recorded is a leftover, never moved.
|
||||
for r in captured.get("restart_positions") or []:
|
||||
left.append(Leftover("position at the %s" % r["where"], r["found"], r["expected"],
|
||||
"unrestorable: a controller restart re-zeroed the counters there, "
|
||||
"so the head is not moved"))
|
||||
# The cloud client forgectrl's start brought back at the end of a
|
||||
# takeover, when it is not the one the takeover found (the same
|
||||
# judge, runner.Takeover). The client that came up is left as it
|
||||
# is: the cloud test that needs a client starts its own.
|
||||
for r in captured.get("restart_clients") or []:
|
||||
left.append(Leftover("cloud client at the %s" % r["where"], r["found"], r["expected"],
|
||||
"not restored: a cloud test starts the client it needs"))
|
||||
was = captured.get("settings")
|
||||
if was:
|
||||
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
|
||||
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 "
|
||||
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
|
||||
"bench-page tool."},
|
||||
{"id": "ctlport-test", "title": "Controller port and line multiplexer harness", "script": "ctlport_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "A scripted sender that counts every response, beside the controller port's one client, on the "
|
||||
"null-sink controller: a port jog's status goes to the port and never to the sender, a port "
|
||||
"error does not reach the sender's parser, a sender line cancels a port jog and gets its own ok "
|
||||
"(a line queued right behind the port's included), the refusals, the single client, a CR LF "
|
||||
"sender, a soft reset, and the client as the dead-man. A CI harness (the grblHAL repo): needs "
|
||||
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
{"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",
|
||||
"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"])],
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
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
|
||||
invalidates the tests of that module. A test the module does not
|
||||
define in the @test form hashes its whole file."""
|
||||
if self._source_sha is None:
|
||||
# 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:
|
||||
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
|
||||
self._source_sha = implementation_sha(path, self.id)
|
||||
@@ -135,8 +144,38 @@ def source_file_sha(path):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
_PARTS = {} # path -> (shared sha, {test id: own sha}); the files never change under a run
|
||||
_PARTS_LOCK = threading.Lock()
|
||||
# The files never change under a run, so each is read and parsed once:
|
||||
# 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):
|
||||
@@ -149,14 +188,9 @@ def module_parts(path):
|
||||
hit = _PARTS.get(path)
|
||||
if hit is not None:
|
||||
return hit
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
||||
text, tree = _parsed(path)
|
||||
lines = text.split("\n")
|
||||
spans = {}
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
tree = None
|
||||
for node in (tree.body if tree is not None else []):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
@@ -183,22 +217,30 @@ def sibling_imports(path, seen=None):
|
||||
test's fingerprint."""
|
||||
if seen is None:
|
||||
seen = []
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
return seen
|
||||
here = os.path.dirname(path)
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
|
||||
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
|
||||
if os.path.isfile(sib) and sib not in seen and sib != path:
|
||||
for sib in _direct_siblings(path):
|
||||
if sib not in seen and sib != path:
|
||||
seen.append(sib)
|
||||
sibling_imports(sib, 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):
|
||||
"""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
|
||||
|
||||
@@ -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
|
||||
some test's coverage globs, except the allowlisted non-behavioral paths,
|
||||
and every coverage glob must select at least one path of its component.
|
||||
some test's coverage globs or by a host-tested row (manifest.HOST_TESTED),
|
||||
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
|
||||
untested one - a change there leaves every inherited PASS valid when it
|
||||
should have invalidated them. The lint is the floor (the file is
|
||||
fingerprinted by at least one test); whether that test exercises the
|
||||
change stays with the change author. An entry that selects nothing is
|
||||
the same defect from the other side: the test's fingerprint ignores the
|
||||
file it named.
|
||||
fingerprinted by at least one test, or a named host test that CI runs
|
||||
answers for it); whether that test exercises the change stays with the
|
||||
change author. An entry that selects nothing is the same defect from the
|
||||
other side: the test's fingerprint ignores the file it named.
|
||||
|
||||
python3 -m forgetest.coverage [--manifest PATH] [--enforce] [--json]
|
||||
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
|
||||
--enforce with uncovered paths, 2 on a usage or load error.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from . import catalog as _catalog
|
||||
@@ -28,16 +42,22 @@ ALLOW = _manifest.NON_BEHAVIORAL
|
||||
|
||||
|
||||
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):
|
||||
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):
|
||||
ap = argparse.ArgumentParser(description="forgetest coverage lint")
|
||||
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("--json", action="store_true", help="machine-readable report")
|
||||
args = ap.parse_args(argv)
|
||||
@@ -51,19 +71,30 @@ def main(argv=None):
|
||||
report = run(manifest, tests)
|
||||
total = sum(len(v) for v in report.values())
|
||||
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:
|
||||
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:
|
||||
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):
|
||||
print("%s: %d uncovered path(s)" % (comp, len(report[comp])))
|
||||
for p in report[comp]:
|
||||
print(" %s" % p)
|
||||
for tid, comp, pat in hollow:
|
||||
print("%s: covers (%s, %s) selects nothing" % (tid, comp, pat))
|
||||
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, %d tests"
|
||||
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(tests)))
|
||||
if args.enforce and (total or hollow):
|
||||
for comp, pat, ref, why in rows:
|
||||
print("host-tested (%s, %s) by %s: %s" % (comp, pat, ref, why))
|
||||
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 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
|
||||
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.
|
||||
@@ -292,11 +297,11 @@ class Fixture:
|
||||
return bool(st and st.get("button_enabled"))
|
||||
return True
|
||||
|
||||
def act(self, channel, state):
|
||||
def act(self, channel, state, ms=None):
|
||||
if channel == "button":
|
||||
if state != "press":
|
||||
raise FixtureError("the button is only ever pressed")
|
||||
st, body = self._press()
|
||||
st, body = self._press(ms)
|
||||
else:
|
||||
if state not in ("open", "close"):
|
||||
raise FixtureError("%s: unknown state %r" % (channel, state))
|
||||
@@ -307,8 +312,11 @@ class Fixture:
|
||||
self.last_state = body
|
||||
return body
|
||||
|
||||
def _press(self):
|
||||
"""One press, spaced from the last one. The controller detects
|
||||
def _press(self, ms=None):
|
||||
"""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
|
||||
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
|
||||
@@ -317,7 +325,7 @@ class Fixture:
|
||||
wait = self._pulse_end + BUTTON_GAP_S - time.time()
|
||||
if wait > 0:
|
||||
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", "")):
|
||||
deadline = time.time() + PULSE_WAIT_S
|
||||
while time.time() < deadline:
|
||||
@@ -326,7 +334,7 @@ class Fixture:
|
||||
if s == 200 and not b.get("button_pulsing"):
|
||||
break
|
||||
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:
|
||||
self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0
|
||||
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
|
||||
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
|
||||
FORGETEST_INITD default /etc/init.d
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -305,6 +313,89 @@ def button_lit():
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
|
||||
# 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):
|
||||
"""PIDs whose /proc/<pid>/comm equals comm (15-char kernel limit applies)."""
|
||||
out = []
|
||||
@@ -356,6 +481,44 @@ def pgrep_f(needle):
|
||||
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):
|
||||
"""Run a command list; returns (rc, combined output)."""
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
|
||||
@@ -14,6 +19,7 @@ import functools
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
|
||||
DEFAULT_PATH = "/etc/forgefirm-manifest.json"
|
||||
@@ -88,10 +94,166 @@ NON_BEHAVIORAL = [
|
||||
("*", ".vscode/**"),
|
||||
("*", ".env.example"),
|
||||
("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; and the on-demand Extensions
|
||||
# advisory, accepted at its hash with the typed phrase.
|
||||
("forgectrl", "src/advisories.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "docs/advisories/extensions.md", "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"),
|
||||
# The logging tree's paths, the level and target validators, the
|
||||
# configured levels.
|
||||
("forgectrl", "src/logs.*", "forgectrl:tests/logs_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The extension host, each part to the test that drives it (the
|
||||
# freeze, the supervisor, the machine client and the groups stay with
|
||||
# exthost.armed-freeze, which covers them).
|
||||
("forgeext", "src/api.*", "forgeext:tests/api_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/caps.*", "forgeext:tests/caps_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/evfeed.*", "forgeext:tests/evfeed_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/holdkeep.*", "forgeext:tests/holdkeep_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/httpreq.*", "forgeext:tests/httpreq_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/manifest.*", "forgeext:tests/manifest_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/quota.*", "forgeext:tests/quota_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/sandbox.*", "forgeext:tests/sandbox_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/settings.*", "forgeext:tests/settings_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/state.*", "forgeext:tests/state_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/netrules.*", "forgeext:tests/netrules_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
# A package's page asking its own service (forgeext call).
|
||||
("forgeext", "src/call.*", "forgeext:tests/sdk_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
# Archives in and refused, and the signed index.
|
||||
("forgeext", "src/install.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/pkg.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/index.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
# The daemon as built, end to end: its command line, its log, the
|
||||
# killed host under the init script's wrapper.
|
||||
("forgeext", "src/main.c", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/fflog.*", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "CMakeLists.txt", "forgeext:tests/run_test.py", "forgeext:.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):
|
||||
for c, pat in BEHAVIORAL:
|
||||
if c == comp and glob_to_regex(pat).match(path):
|
||||
return False
|
||||
for c, pat in allow:
|
||||
if c in ("*", comp) and glob_to_regex(pat).match(path):
|
||||
return True
|
||||
@@ -194,17 +356,332 @@ def empty_covers(manifest, tests):
|
||||
return out
|
||||
|
||||
|
||||
def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
|
||||
"""Which manifest paths no test covers.
|
||||
def _ref_problem(manifest, ref, what, root):
|
||||
"""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)
|
||||
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.
|
||||
"""
|
||||
covered = {}
|
||||
for t in tests:
|
||||
for comp, pat in t.covers:
|
||||
covered.setdefault(comp, []).append(glob_to_regex(pat))
|
||||
for comp, pat, _ref, _ci in host:
|
||||
covered.setdefault(comp, []).append(glob_to_regex(pat))
|
||||
allowed = {}
|
||||
for comp, pat in allow:
|
||||
allowed.setdefault(comp, []).append(glob_to_regex(pat))
|
||||
@@ -212,11 +689,14 @@ def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
|
||||
for comp in manifest.component_names():
|
||||
if comp in DEV_ONLY_COMPONENTS:
|
||||
continue
|
||||
rxs = covered.get(comp, []) + allowed.get(comp, [])
|
||||
star = allowed.get("*", [])
|
||||
rxs = covered.get(comp, [])
|
||||
free = allowed.get(comp, []) + allowed.get("*", [])
|
||||
behavior = [glob_to_regex(pat) for c, pat in BEHAVIORAL if c == comp]
|
||||
missing = []
|
||||
for p, _b in manifest.files(comp):
|
||||
if any(rx.match(p) for rx in rxs) or any(rx.match(p) for rx in star):
|
||||
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
|
||||
missing.append(p)
|
||||
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
|
||||
one self-contained response (no external assets). Bootstrap and the
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
# scrap, and nothing fires until it lands.
|
||||
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:
|
||||
@@ -311,17 +318,31 @@ class Context:
|
||||
|
||||
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
|
||||
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.log("READY: waiting for the operator's press on the machine "
|
||||
"(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()
|
||||
if pressed is None:
|
||||
raise Failed("no press on the machine within %d s: %s"
|
||||
% (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.evidence.setdefault("actions", []).append(
|
||||
{"channel": "button", "state": "presence", "by": "operator",
|
||||
@@ -361,7 +382,7 @@ class Context:
|
||||
"""One of forgectrl's switch readings (lid, interlock_ok, ...)."""
|
||||
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"),
|
||||
("interlock", "open"|"close"), ("button", "press"). The bench'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)
|
||||
rec["by"] = "fixture"
|
||||
try:
|
||||
fixture.act(channel, state)
|
||||
fixture.act(channel, state, ms)
|
||||
except _fixture.FixtureError as e:
|
||||
rec["fixture_error"] = str(e)
|
||||
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 "
|
||||
"gone - asking the operator for the arm press")
|
||||
self.notice(text)
|
||||
self._clear_when_taken()
|
||||
return False
|
||||
try:
|
||||
fixture.act("button", "press")
|
||||
@@ -468,6 +490,36 @@ class Context:
|
||||
self.log("ARM press by the fixture, at the machine's own press prompt")
|
||||
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.",
|
||||
lit_timeout=60):
|
||||
"""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 "
|
||||
"gone - asking the operator for the arm press")
|
||||
self.notice(text)
|
||||
self._clear_when_taken()
|
||||
return False
|
||||
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()
|
||||
|
||||
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):
|
||||
"""Tell the baseline the kernel position counters were re-zeroed
|
||||
@@ -544,6 +597,10 @@ class Context:
|
||||
cap = self.run.baseline_captured
|
||||
if cap and cap.get("position") is not None:
|
||||
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 "
|
||||
"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",
|
||||
"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.who = who
|
||||
self.marker = marker_path()
|
||||
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):
|
||||
return _baseline.Baseline(self.log).wait_settled()
|
||||
@@ -604,6 +666,18 @@ class Takeover:
|
||||
self.saved[attr] = v
|
||||
if self.saved:
|
||||
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")
|
||||
try:
|
||||
st, body = hw.Forgectrl().post("/controller/stop")
|
||||
@@ -626,10 +700,146 @@ class Takeover:
|
||||
self.__exit__(None, None, None)
|
||||
raise Failed("takeover: processes still alive after stop: %s" % left)
|
||||
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
|
||||
|
||||
# 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):
|
||||
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.markers_on()
|
||||
rc, out = hw.initd("forgectrl", "start")
|
||||
self.log("takeover: forgectrl start -> rc %s" % rc)
|
||||
try:
|
||||
@@ -638,7 +848,12 @@ class Takeover:
|
||||
pass
|
||||
# leave the machine settled for whatever runs next: the probe
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Access follows forgectrl's panel (auth.c): the Host header must be an
|
||||
|
||||
@@ -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 suite: one module per subsystem, imported in display
|
||||
order. Each module registers its tests with @catalog.test."""
|
||||
|
||||
@@ -6,11 +11,14 @@ from . import kernel # noqa: F401,E402
|
||||
from . import forgectrl # noqa: F401,E402
|
||||
from . import setup # noqa: F401,E402
|
||||
from . import setup_dark # noqa: F401,E402
|
||||
from . import setup_sheet # noqa: F401,E402
|
||||
from . import logs # noqa: F401,E402
|
||||
from . import setup_burns # noqa: F401,E402
|
||||
from . import motion # noqa: F401,E402
|
||||
from . import cooling # noqa: F401,E402
|
||||
from . import laser # noqa: F401,E402
|
||||
from . import camera # noqa: F401,E402
|
||||
from . import update # noqa: F401,E402
|
||||
from . import cloud # noqa: F401,E402
|
||||
# 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."""
|
||||
import json
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""camera.* - the lid camera pipeline through forgectrl."""
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
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",
|
||||
kind="auto", est_min=2,
|
||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
||||
covers=_CAM_COVERS,
|
||||
steps=["Setup: lid closed."],
|
||||
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
|
||||
"far smaller), a second snapshot with the lid lamp switched off differs from it "
|
||||
"and is smaller (the capture is live, not a frame the pipeline is re-serving, "
|
||||
"and the lamp lights what the camera sees), and the MJPEG stream starts and "
|
||||
"stops.")
|
||||
"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):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
@@ -111,6 +117,48 @@ def snapshot(ctx):
|
||||
ev["stream_ctype"] = ctype
|
||||
ctx.log("stream: %s, first %d bytes", ctype, len(chunk))
|
||||
ctx.check("multipart" in ctype and b"\xff\xd8" in chunk, "stream is not an MJPEG multipart")
|
||||
|
||||
# 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)
|
||||
st, body = fc.get("/cam/status")
|
||||
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",
|
||||
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 "
|
||||
"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 "
|
||||
@@ -164,7 +212,7 @@ def sensor_profile(ctx):
|
||||
|
||||
@test("camera.h264-stream", title="H.264 live stream", subsystem="camera",
|
||||
kind="auto", est_min=2,
|
||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
||||
covers=_CAM_COVERS,
|
||||
steps=["Setup: lid closed."],
|
||||
description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an "
|
||||
"init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few "
|
||||
@@ -226,13 +274,15 @@ def h264_stream(ctx):
|
||||
|
||||
@test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera",
|
||||
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 "
|
||||
"after the CSI-2 receiver lost sync; forgectrl drops those rather than "
|
||||
"demosaicing them, and restarts the stream if they persist. A healthy machine "
|
||||
"captures a burst with none of them. A nonzero corrupt count here is a real "
|
||||
"signal - a marginal camera ribbon, a mistimed D-PHY - even though the frames "
|
||||
"themselves never reach a client.")
|
||||
"demosaicing them, restarts the stream if they persist, and withholds the "
|
||||
"stream frames on either side of one. A healthy machine captures a burst of "
|
||||
"snapshots and a few seconds of stream with none of them flagged and none "
|
||||
"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):
|
||||
fc = ctx.forgectrl
|
||||
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(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,
|
||||
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.",
|
||||
"Nothing moves and the laser is not involved."],
|
||||
description="The privacy gate: with the lid open neither camera captures. A running stream "
|
||||
"stops within a frame or so of the lid opening, /cam/status reports capture as "
|
||||
"not allowed, and both the snapshot and the stream are refused with 409 and a "
|
||||
"reason naming the lid. Closing the lid restores all of it. This is what stops "
|
||||
"the machine - and in cloud mode the Glowforge service - from imaging the room "
|
||||
"through an open lid.")
|
||||
description="The privacy gate: with the lid open the lid camera does not capture, and the head "
|
||||
"camera captures only for a local viewer. A running lid-camera stream stops within a "
|
||||
"frame or so of the lid opening, /cam/status reports capture as not allowed, and the "
|
||||
"lid camera's snapshot and stream are refused with 409 and a reason naming the lid, "
|
||||
"to the panel too. The head camera, which looks down at the bed, still answers the "
|
||||
"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):
|
||||
import urllib.error
|
||||
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(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)
|
||||
ctx.log("lid open: head snapshot -> %s", st)
|
||||
ctx.check(st == 409, "the head camera should be refused too, got %s", st)
|
||||
ctx.log("lid open: the panel's head snapshot -> %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:
|
||||
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",
|
||||
"snapshot after closing the lid -> %s", st)
|
||||
ctx.log("lid closed again: snapshot %d bytes", len(data))
|
||||
|
||||
|
||||
@test("camera.key-read", title="The camera key reads without a login", subsystem="camera",
|
||||
kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/camkey.*"), ("forgectrl", "src/auth.c"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/panel.js"),
|
||||
("forgectrl", "src/ui/index.html"), ("forgectrl", "src/ui/help.js")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="With the read routes closed to the network (panel_open_reads=0), a camera "
|
||||
"status read from the board's LAN address is refused without the key and "
|
||||
"served with it, on HTTP and on HTTPS; a wrong key is refused; the key never "
|
||||
"authorizes a write; a rotation from the panel route stops the old key and "
|
||||
"the new one reads. The setting is put back after.")
|
||||
def key_read(ctx):
|
||||
from .setup 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})
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -90,7 +95,6 @@ SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
|
||||
# The service's connect-time hunt, as the client logs its request: a few
|
||||
# seconds after the controller starts, right behind the session.
|
||||
HUNT_REQUEST = "service action request: hunt"
|
||||
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
|
||||
|
||||
|
||||
def log_size(path):
|
||||
@@ -115,39 +119,6 @@ def session_established(lines):
|
||||
return all(any(m in ln for ln in lines) for m in SESSION_MARKS)
|
||||
|
||||
|
||||
def return_head(ctx, feed=2400):
|
||||
"""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, poll=1.0):
|
||||
t0 = time.time()
|
||||
last = None
|
||||
@@ -249,8 +220,8 @@ def gfhome_homing(ctx, ev, g):
|
||||
"Open the lid the moment you are told, with a hand ready on it: the cloud client's "
|
||||
"first hunt begins a few seconds after its controller starts and must find the lid "
|
||||
"open. Leave it open through the connect and the hunt; close it when told. Nothing "
|
||||
"else: the switch back and the $H homing run on their own, and the head ends parked "
|
||||
"at the home corner."],
|
||||
"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 "
|
||||
"to the cloud controller with the lid closed (no controller starts with the "
|
||||
"enclosure open): gfcloud comes up under supervision, authenticates and "
|
||||
@@ -263,11 +234,18 @@ def gfhome_homing(ctx, ev, g):
|
||||
"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 "
|
||||
"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 "
|
||||
"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):
|
||||
# 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
|
||||
ev = ctx.evidence
|
||||
st, m0 = fc.get("/mode")
|
||||
@@ -378,10 +356,9 @@ def mode_switch(ctx):
|
||||
st, cam2 = fc.get("/cam/status")
|
||||
ev["cam_after"] = cam2
|
||||
ctx.check(st == 200, "camera status lost after the switch back")
|
||||
# cloud mode's connect cleared the kernel counters at the starting position
|
||||
# and its hunt homed the head: bring it back
|
||||
ctx.counters_rezeroed()
|
||||
return_head(ctx)
|
||||
# The service moved the head in cloud mode, and the controller's start
|
||||
# zeroed the counters where it left it: the client's record brings it back.
|
||||
cloud_mode_return(ctx, ev, log_offset)
|
||||
# cloud mode sets its own lid-lamp level (LLvl) and leaves it: hand back the level found
|
||||
lamp1 = hw.sysfs_read("pic/lid_led")
|
||||
ev["lid_lamp"] = {"before": lamp0, "after_cloud": lamp1}
|
||||
@@ -395,9 +372,11 @@ def mode_switch(ctx):
|
||||
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.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
|
||||
session_at = session_mark()
|
||||
try:
|
||||
with ctx.grbl() as g:
|
||||
gfhome_homing(ctx, ev, g)
|
||||
judge_whole_motions(ctx, ev, session_at)
|
||||
finally:
|
||||
if ev["homing_mode"] != "gfcloud":
|
||||
# Back to exactly what the machine had: unset is the empty
|
||||
@@ -408,6 +387,7 @@ def mode_switch(ctx):
|
||||
if not ev["homing_mode"]
|
||||
else fc.post("/settings", data={"homing_mode": ev["homing_mode"]}))
|
||||
ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st)
|
||||
camera_home_return(ctx, ev, session_at)
|
||||
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"])
|
||||
|
||||
@@ -522,9 +502,18 @@ NOHUNT_MARK = "NO-HUNT:"
|
||||
NOHUNT_MARKER = "/run/gfcloud-nohunt"
|
||||
HUNT_DONE = "hunt ["
|
||||
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
|
||||
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
|
||||
HUNT_TIMEOUT_S = 180
|
||||
|
||||
@@ -581,6 +570,42 @@ def wait_log(ctx, offset, needles, timeout, poll=0.5):
|
||||
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):
|
||||
"""True when the gfcloud log carries needle since offset (one read;
|
||||
the condition an `act` waits on)."""
|
||||
@@ -713,9 +738,9 @@ def client_offline(pid):
|
||||
|
||||
|
||||
def wait_quiet(ctx, offset, quiet_s=None, timeout=None):
|
||||
"""The service's moves are over: the machine idle and no new service
|
||||
activity in the log (a motion, a park, a run, a lens homing) for
|
||||
quiet_s (default QUIET_S; timeout QUIET_TIMEOUT_S). False on timeout."""
|
||||
"""The service's moves are over: the machine idle and no new line of any
|
||||
service action in the log (ACTIVITY_MARKS) for quiet_s (default QUIET_S;
|
||||
timeout QUIET_TIMEOUT_S). False on timeout."""
|
||||
quiet_s = QUIET_S if quiet_s is None else quiet_s
|
||||
timeout = QUIET_TIMEOUT_S if timeout is None else timeout
|
||||
fc = ctx.forgectrl
|
||||
@@ -1038,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
|
||||
press arms it and the run starts. Returns the 'starting run' line."""
|
||||
off.print_ready(action_id, path, settings)
|
||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||
ctx.check(got["waiting for button"], "the offline print never reached the button wait (refused, "
|
||||
"or the job did not load)")
|
||||
wait_button_wait(ctx, offset, 120)
|
||||
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.",
|
||||
until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=180)
|
||||
@@ -1239,129 +1262,11 @@ def lid_interlock_abort_body(ctx, ev, off, job, offset):
|
||||
ev.get("edge_to_stop_ms"))
|
||||
|
||||
|
||||
@test("cloud.lid-during-button-wait", title="Lid open at the cloud button prompt cancels the print",
|
||||
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 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")
|
||||
REFUSE_KEYS = HOLD_KEYS + ("cloud_hold_max_s",)
|
||||
HOLD_REFUSE_S = 60 # the shortest bound the client takes
|
||||
HOLD_REFUSE_LINE = "cooling hold held 60 s - relocking the laser"
|
||||
WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP"
|
||||
|
||||
|
||||
@test("cloud.dark-print", title="A dark cloud print: the latch locked until the run, unlocked "
|
||||
"for it, locked after it",
|
||||
subsystem="cloud", kind="operator", est_min=3,
|
||||
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
||||
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
|
||||
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
||||
"when it lights: the print waits for the engine, runs, and finishes."],
|
||||
description="The cooling-engine contract in cloud mode at its ordinary end, on the engine "
|
||||
"itself: the print arms on the press, waits for the engine's acknowledgment (the "
|
||||
"fans at their floors), and runs. The laser latch is locked at the button and "
|
||||
"through the wait, unlocks only for the run (immediately before it starts), and "
|
||||
"is locked again when the job ends; the print completes. A hold the engine never "
|
||||
"clears is cloud.verdict-refuse; the engine's own warm-up release is "
|
||||
"cooling.floor-and-warm-up.")
|
||||
def dark_print(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "dark.puls", seconds=30)
|
||||
off = Offline().__enter__()
|
||||
try:
|
||||
dark_print_body(ctx, ev, off, job, offset, fc)
|
||||
finally:
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
# ---------------------------------------------------------------- the dark print (exthost.armed-freeze)
|
||||
|
||||
def dark_print_body(ctx, ev, off, job, offset, fc):
|
||||
off.print_ready(9005, job)
|
||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
||||
wait_button_wait(ctx, offset, 120)
|
||||
ev["latch_locked_at_button"] = latch_locked()
|
||||
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
|
||||
ctx.act("button", "press", text="The button is lit: press it. The print runs dark and finishes.",
|
||||
@@ -1383,92 +1288,6 @@ def dark_print_body(ctx, ev, off, job, offset, fc):
|
||||
ctx.log("PASS: locked at the button, unlocked for the run, locked after it; the print completed")
|
||||
|
||||
|
||||
@test("cloud.verdict-refuse", title="A cloud print held by the engine past the bound is canceled with "
|
||||
"the latch never unlocked",
|
||||
subsystem="cloud", kind="operator", est_min=4,
|
||||
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
||||
("forgectrl", "src/cool.*")],
|
||||
requires=["cloud.dark-print"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate well above "
|
||||
"the coolant and the hold bound to its minimum, and restores both. Press the button "
|
||||
"when it lights: nothing runs; a minute later the print cancels by itself."],
|
||||
description="The other end of the cooling-engine contract before a run: a verdict that "
|
||||
"refuses fire and never clears. The start gate far above the coolant keeps the "
|
||||
"armed session under the warm-up for longer than the hold bound (cloud_hold_max_s "
|
||||
"at its minimum, 60 s). The client waits with the laser latch locked the whole "
|
||||
"time - it is never unlocked before the verdict passes, so there is nothing to "
|
||||
"relock - then cancels the print at the bound: no run, the armed window closed, "
|
||||
"the print ':cancelled'. Dark by construction.")
|
||||
def verdict_refuse(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
before = fc.settings()
|
||||
orig = {k: before.get(k, "") for k in REFUSE_KEYS}
|
||||
ev["orig"] = orig
|
||||
up = (fc.status().get("coolant") or {}).get("up_c")
|
||||
ctx.check(up is not None and 10.0 <= up <= 30.0, "coolant outside the test's range (up_c %s)", up)
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "refuse.puls", seconds=30)
|
||||
off = Offline().__enter__()
|
||||
try:
|
||||
verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig)
|
||||
finally:
|
||||
st, body = fc.post("/settings", params=orig)
|
||||
ctx.log("restore: POST /settings %s -> %s", orig, st)
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
|
||||
def verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig):
|
||||
gate = round(up + 8.0, 1) # eight degrees: the heater cannot get there in a minute
|
||||
st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
|
||||
"cool_temp_min": orig["cool_temp_min"],
|
||||
"cloud_hold_max_s": str(HOLD_REFUSE_S)})
|
||||
ctx.check(st == 200, "POST /settings cool_temp_start=%s cloud_hold_max_s=%s -> %s %s",
|
||||
gate, HOLD_REFUSE_S, st, body)
|
||||
ev["gate"] = gate
|
||||
off.print_ready(9006, job)
|
||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
||||
ev["latch_locked_at_button"] = latch_locked()
|
||||
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
|
||||
ctx.act("button", "press", text="The button is lit: press it. Nothing runs; the print cancels "
|
||||
"itself a minute later.",
|
||||
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
|
||||
got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30)
|
||||
ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up")
|
||||
t0 = time.time()
|
||||
samples = []
|
||||
ctx.notice("The engine holds the print; the client gives up at the bound. Do nothing.")
|
||||
try:
|
||||
while time.time() - t0 < HOLD_REFUSE_S + 30:
|
||||
ctx.checkpoint()
|
||||
samples.append(latch_locked())
|
||||
if log_has(offset, HOLD_REFUSE_LINE):
|
||||
break
|
||||
ctx.sleep(2)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
ev["hold_s"] = round(time.time() - t0)
|
||||
ev["latch_samples"] = {"n": len(samples), "unlocked": samples.count(False)}
|
||||
ctx.check(log_has(offset, HOLD_REFUSE_LINE), "the client did not cancel at the hold bound (%d s)",
|
||||
ev["hold_s"])
|
||||
ctx.check(all(samples), "the latch unlocked during the hold (%d of %d samples)",
|
||||
samples.count(False), len(samples))
|
||||
ctx.check(wait_print_running(ctx, offset, 1) is None, "the run started under the refused verdict")
|
||||
fin = wait_action_finished(ctx, offset, "print", 120)
|
||||
ev["print finished"] = message(fin)
|
||||
ctx.check(fin and CANCELLED in fin, "the held print did not end ':cancelled': %s",
|
||||
message(fin) or "no finish line")
|
||||
st, cs = fc.get("/cool/status")
|
||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
||||
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
|
||||
ctx.check(latch_locked(), "the latch is unlocked after the cancel")
|
||||
ev["events"] = offline_events(off)
|
||||
ctx.log("PASS: held %s s with the latch locked throughout, then canceled at the bound", ev["hold_s"])
|
||||
|
||||
|
||||
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
|
||||
subsystem="cloud", kind="live", est_min=8,
|
||||
covers=_CLOUD_ALL,
|
||||
@@ -1702,68 +1521,3 @@ def oversize_stream_body(ctx, ev, off, job, offset, before):
|
||||
"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
@@ -0,0 +1,985 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""exthost.* - what holds an extension package: the image's sandbox platform."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .forgectrl import lan_ip
|
||||
from .image import kernel_config
|
||||
from .cloud import OFFLINE_STEP, Offline, dark_print_body, enter_offline, latch_locked, offline_cleanup, offline_job
|
||||
from .setup import SAFETY_PHRASE, read_file, record_path, request, write_file
|
||||
|
||||
CG = "/sys/fs/cgroup"
|
||||
POOL_GROUP = CG + "/ffx"
|
||||
PROBE_GROUP = POOL_GROUP + "/forgetest-probe"
|
||||
POOL_FIRST, POOL_SIZE = 800, 32
|
||||
CONTROLLERS = ("cpu", "memory", "pids")
|
||||
NFT = "/usr/sbin/nft"
|
||||
|
||||
# The probe: one short program, started as root. It puts itself into the
|
||||
# group named in argv, becomes the uid named there (0 stays root), gives up
|
||||
# new privileges, and then does one thing and prints one JSON line.
|
||||
PROBE = r'''
|
||||
import ctypes, errno, json, os, platform, socket, sys, time
|
||||
group, uid, mode, arg = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4]
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
if group != "-":
|
||||
with open(group + "/cgroup.procs", "w") as f:
|
||||
f.write(str(os.getpid()))
|
||||
if uid:
|
||||
os.setgroups([])
|
||||
os.setgid(uid)
|
||||
os.setuid(uid)
|
||||
libc.prctl(38, 1, 0, 0, 0) # PR_SET_NO_NEW_PRIVS
|
||||
|
||||
|
||||
def say(**kw):
|
||||
print(json.dumps(kw), flush=True)
|
||||
|
||||
|
||||
def word(e):
|
||||
return {errno.ECONNREFUSED: "refused", errno.EPERM: "eperm", errno.EACCES: "eacces"}.get(
|
||||
e.errno, errno.errorcode.get(e.errno, str(e.errno)))
|
||||
|
||||
|
||||
def reach(family, addr, port, udp):
|
||||
s = socket.socket(socket.AF_INET6 if family == 6 else socket.AF_INET,
|
||||
socket.SOCK_DGRAM if udp else socket.SOCK_STREAM)
|
||||
s.settimeout(4.0)
|
||||
t0 = time.time()
|
||||
try:
|
||||
if udp:
|
||||
s.sendto(b"x", (addr, port))
|
||||
else:
|
||||
s.connect((addr, port))
|
||||
out = "ok"
|
||||
except socket.timeout:
|
||||
out = "timeout"
|
||||
except OSError as e:
|
||||
out = word(e)
|
||||
s.close()
|
||||
return [out, round(time.time() - t0, 2)]
|
||||
|
||||
|
||||
if mode == "spin":
|
||||
say(started=True)
|
||||
while True:
|
||||
pass
|
||||
elif mode == "forks":
|
||||
kids = []
|
||||
err = ""
|
||||
for _ in range(int(arg)):
|
||||
try:
|
||||
pid = os.fork()
|
||||
except OSError as e:
|
||||
err = word(e)
|
||||
break
|
||||
if pid == 0:
|
||||
time.sleep(30)
|
||||
os._exit(0)
|
||||
kids.append(pid)
|
||||
for pid in kids:
|
||||
os.kill(pid, 9)
|
||||
os.waitpid(pid, 0)
|
||||
say(forked=len(kids), stopped_by=err)
|
||||
elif mode == "eat":
|
||||
say(started=True)
|
||||
held = []
|
||||
for _ in range(int(arg)):
|
||||
held.append(bytearray(os.urandom(4096)) * 256) # 1 MiB, every page written
|
||||
say(survived=True)
|
||||
elif mode == "net":
|
||||
say(results=[[t, reach(*t)] for t in json.loads(arg)])
|
||||
elif mode == "unix":
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(4.0)
|
||||
try:
|
||||
s.connect(arg)
|
||||
say(result="connected")
|
||||
except OSError as e:
|
||||
say(result=word(e))
|
||||
s.close()
|
||||
elif mode == "landlock":
|
||||
target = json.loads(arg)
|
||||
before = {"etc": None, "usr": None, "tcp": reach(*target)[0]}
|
||||
for key, path in (("etc", "/etc/hostname"), ("usr", sys.executable)):
|
||||
try:
|
||||
open(path, "rb").close()
|
||||
before[key] = "ok"
|
||||
except OSError as e:
|
||||
before[key] = word(e)
|
||||
abi = libc.syscall(444, None, 0, 1) # LANDLOCK_CREATE_RULESET_VERSION
|
||||
after = {}
|
||||
if abi >= 1:
|
||||
# write, read file, read dir; and from ABI 4 on, connect TCP (an older kernel takes the first field alone)
|
||||
attr = (ctypes.c_uint64 * 2)((1 << 1) | (1 << 2) | (1 << 3), 1 << 1)
|
||||
ruleset = libc.syscall(444, ctypes.byref(attr), 16 if abi >= 4 else 8, 0)
|
||||
usr = os.open("/usr", os.O_PATH | os.O_CLOEXEC)
|
||||
|
||||
class PathBeneath(ctypes.Structure):
|
||||
_pack_ = 1
|
||||
_fields_ = [("allowed_access", ctypes.c_uint64), ("parent_fd", ctypes.c_int32)]
|
||||
rule = PathBeneath((1 << 2) | (1 << 3), usr)
|
||||
added = libc.syscall(445, ruleset, 1, ctypes.byref(rule), 0) # LANDLOCK_RULE_PATH_BENEATH
|
||||
applied = libc.syscall(446, ruleset, 0)
|
||||
after = {"ruleset": ruleset >= 0, "added": added, "applied": applied, "tcp": reach(*target)[0]}
|
||||
for key, path in (("etc", "/etc/hostname"), ("usr", sys.executable)):
|
||||
try:
|
||||
open(path, "rb").close()
|
||||
after[key] = "ok"
|
||||
except OSError as e:
|
||||
after[key] = word(e)
|
||||
say(abi=abi, before=before, after=after)
|
||||
elif mode == "seccomp":
|
||||
arch, nr = {"armv7l": (0x40000028, 122), "aarch64": (0xC00000B7, 160),
|
||||
"x86_64": (0xC000003E, 63)}[platform.machine()] # uname
|
||||
|
||||
|
||||
class Filter(ctypes.Structure):
|
||||
_fields_ = [("code", ctypes.c_uint16), ("jt", ctypes.c_uint8), ("jf", ctypes.c_uint8),
|
||||
("k", ctypes.c_uint32)]
|
||||
|
||||
|
||||
class Prog(ctypes.Structure):
|
||||
_fields_ = [("len", ctypes.c_ushort), ("filter", ctypes.POINTER(Filter))]
|
||||
before = os.uname().sysname
|
||||
ops = (Filter * 7)(Filter(0x20, 0, 0, 4), # load arch
|
||||
Filter(0x15, 1, 0, arch), # ours: go on
|
||||
Filter(0x06, 0, 0, 0x80000000), # another ABI: kill the process
|
||||
Filter(0x20, 0, 0, 0), # load the syscall number
|
||||
Filter(0x15, 0, 1, nr), # uname?
|
||||
Filter(0x06, 0, 0, 0x00050000 | errno.EPERM),
|
||||
Filter(0x06, 0, 0, 0x7FFF0000)) # everything else: allow
|
||||
prog = Prog(7, ops)
|
||||
rc = libc.prctl(22, 2, ctypes.byref(prog), 0, 0) # PR_SET_SECCOMP, SECCOMP_MODE_FILTER
|
||||
try:
|
||||
os.uname()
|
||||
after = "ok"
|
||||
except OSError as e:
|
||||
after = word(e)
|
||||
mode_now = [l.split()[1] for l in open("/proc/self/status") if l.startswith("Seccomp:")]
|
||||
say(installed=rc, before=before, after=after, status=mode_now)
|
||||
'''
|
||||
|
||||
|
||||
def _read(path, default=""):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return default
|
||||
|
||||
|
||||
def _write(path, text):
|
||||
with open(path, "w") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
def _flat(path):
|
||||
"""A cgroup flat-keyed file as {key: int}."""
|
||||
out = {}
|
||||
for line in _read(path).splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 2 and parts[1].isdigit():
|
||||
out[parts[0]] = int(parts[1])
|
||||
return out
|
||||
|
||||
|
||||
def _probe(group, uid, mode, arg="", wait=30):
|
||||
"""Runs the probe to its end; (returncode, its JSON lines)."""
|
||||
p = subprocess.run([sys.executable, "-c", PROBE, group, str(uid), mode, arg],
|
||||
capture_output=True, text=True, timeout=wait)
|
||||
lines = []
|
||||
for line in p.stdout.splitlines():
|
||||
try:
|
||||
lines.append(json.loads(line))
|
||||
except ValueError:
|
||||
pass
|
||||
return p.returncode, lines, p.stderr.strip()[-300:]
|
||||
|
||||
|
||||
def _pids_of(comm):
|
||||
out = []
|
||||
for d in os.listdir("/proc"):
|
||||
if d.isdigit() and _read("/proc/%s/comm" % d).strip() == comm:
|
||||
out.append(int(d))
|
||||
return out
|
||||
|
||||
|
||||
def _pool_counters():
|
||||
"""The packet counts of chain refuse's two rules: [tcp reset, drop]."""
|
||||
p = subprocess.run([NFT, "-j", "list", "chain", "inet", "ffx", "refuse"], capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
return None
|
||||
return [e["counter"]["packets"] for c in json.loads(p.stdout)["nftables"] if "rule" in c
|
||||
for e in c["rule"]["expr"] if "counter" in e]
|
||||
|
||||
|
||||
@test("exthost.platform", title="The extension sandbox platform holds a process",
|
||||
subsystem="exthost", kind="auto", est_min=2,
|
||||
covers=[("linux-fslc", "**")],
|
||||
description="What the image holds ready before any extension package exists, proven on a "
|
||||
"probe process rather than read off a config. The kernel has the cpu, memory, "
|
||||
"and pids controllers, seccomp filters, and landlock, and has no realtime "
|
||||
"group scheduler (the pulse feeder is SCHED_FIFO and must not need a group's "
|
||||
"leave to run). cgroup v2 is mounted with the three controllers handed down "
|
||||
"to /sys/fs/cgroup/ffx, which is idle-class, and forgectrl and the controller "
|
||||
"sit in the root group. In a probe group under it, as the last pool uid: a "
|
||||
"spinning process is held to its cpu.max, stops when the group is frozen and "
|
||||
"runs again when thawed; a fork loop stops at pids.max; and a process that "
|
||||
"takes more than memory.max is killed by the group's own OOM while forgectrl "
|
||||
"keeps its pid. The account pool is ffx0 to ffx31, uid and gid 800 to 831, no "
|
||||
"shell, no home, locked, in the account files as forgefirm-users rendered "
|
||||
"them. The deny rules are in the kernel: a pool uid's TCP connect to "
|
||||
"forgectrl on loopback (both ports, IPv4 and IPv6), to the machine's LAN "
|
||||
"address, and to the Grbl port is refused at once, its UDP send gets EPERM, "
|
||||
"and the rules' counters saw every attempt, while root reaches the same "
|
||||
"listeners. A process that restricts itself with landlock loses /etc and TCP "
|
||||
"connects and keeps /usr; one that installs a seccomp filter gets EPERM from "
|
||||
"the filtered call. The layer content (the recipe, the rules, the distro "
|
||||
"option) is in the platform identity of every fingerprint.")
|
||||
def platform(ctx):
|
||||
ev = ctx.evidence
|
||||
|
||||
# 1. the kernel
|
||||
cfg = kernel_config()
|
||||
ctx.check(cfg is not None, "kernel config unavailable (/proc/config.gz, /boot/config-*)")
|
||||
want = {"CONFIG_CGROUPS": "y", "CONFIG_CGROUP_SCHED": "y", "CONFIG_FAIR_GROUP_SCHED": "y",
|
||||
"CONFIG_CFS_BANDWIDTH": "y", "CONFIG_MEMCG": "y", "CONFIG_CGROUP_PIDS": "y",
|
||||
"CONFIG_SECCOMP_FILTER": "y", "CONFIG_SECURITY_LANDLOCK": "y", "CONFIG_NF_TABLES": "y",
|
||||
"CONFIG_NF_TABLES_INET": "y", "CONFIG_NFT_REJECT_INET": "y", "CONFIG_NFT_LIMIT": "y",
|
||||
"CONFIG_RT_GROUP_SCHED": "n", "CONFIG_NF_CONNTRACK": "n"}
|
||||
got = {k: cfg.get(k, "n") for k in want}
|
||||
ev["kernel"] = got
|
||||
wrong = {k: v for k, v in got.items() if v != want[k]}
|
||||
ctx.check(not wrong, "kernel options: %s (wanted %s)", wrong, {k: want[k] for k in wrong})
|
||||
ctx.check("landlock" in cfg.get("CONFIG_LSM", ""), "landlock is not in CONFIG_LSM: %s", cfg.get("CONFIG_LSM"))
|
||||
|
||||
# 2. the cgroup tree
|
||||
mounts = [l.split() for l in _read("/proc/mounts").splitlines()]
|
||||
ctx.check(any(m[1] == CG and m[2] == "cgroup2" for m in mounts if len(m) > 2), "cgroup2 is not mounted at %s", CG)
|
||||
tree = {"controllers": _read(CG + "/cgroup.controllers").split(),
|
||||
"root_subtree": _read(CG + "/cgroup.subtree_control").split(),
|
||||
"ffx_subtree": _read(POOL_GROUP + "/cgroup.subtree_control").split(),
|
||||
"ffx_cpu_idle": _read(POOL_GROUP + "/cpu.idle").strip()}
|
||||
ev["cgroup_tree"] = tree
|
||||
ctx.log("cgroup tree: %s", tree)
|
||||
for key in ("controllers", "root_subtree", "ffx_subtree"):
|
||||
ctx.check(set(CONTROLLERS) <= set(tree[key]), "%s lacks a controller: %s", key, tree[key])
|
||||
ctx.check(tree["ffx_cpu_idle"] == "1", "the ffx group is not idle-class: cpu.idle=%r", tree["ffx_cpu_idle"])
|
||||
homes = {}
|
||||
for comm in ("forgectrl", "grblHAL_glowfor", "forgetest"):
|
||||
for pid in _pids_of(comm):
|
||||
homes["%s/%d" % (comm, pid)] = _read("/proc/%d/cgroup" % pid).strip()
|
||||
ev["firmware_groups"] = homes
|
||||
ctx.check(any(k.startswith("forgectrl/") for k in homes), "forgectrl is not running")
|
||||
ctx.check(all(v == "0::/" for v in homes.values()), "a firmware process is outside the root group: %s", homes)
|
||||
|
||||
# 3. the account pool, as the boot's render left it
|
||||
passwd = {l.split(":")[0]: l.split(":") for l in _read("/etc/passwd").splitlines() if l.count(":") >= 6}
|
||||
shadow = {l.split(":")[0]: l.split(":") for l in _read("/etc/shadow").splitlines() if ":" in l}
|
||||
group = {l.split(":")[0]: l.split(":") for l in _read("/etc/group").splitlines() if l.count(":") >= 3}
|
||||
bad = []
|
||||
for n in range(POOL_SIZE):
|
||||
name, uid = "ffx%d" % n, str(POOL_FIRST + n)
|
||||
p, s, g = passwd.get(name), shadow.get(name), group.get(name)
|
||||
if not p or p[2] != uid or p[3] != uid or p[5] != "/nonexistent" or p[6] != "/bin/false":
|
||||
bad.append("passwd %s: %s" % (name, p))
|
||||
if not s or s[1][:1] not in ("!", "*"):
|
||||
bad.append("shadow %s is not locked" % name)
|
||||
if not g or g[2] != uid or g[3] != "":
|
||||
bad.append("group %s: %s" % (name, g))
|
||||
others = [p[0] for p in passwd.values() if p[2].isdigit()
|
||||
and POOL_FIRST <= int(p[2]) < POOL_FIRST + POOL_SIZE and not re.match(r"^ffx\d+$", p[0])]
|
||||
ev["pool"] = {"accounts": sum(1 for n in passwd if re.match(r"^ffx\d+$", n)), "problems": bad, "others": others}
|
||||
ctx.check(not bad, "the account pool: %s", bad[:6])
|
||||
ctx.check(not others, "another account sits in the pool's uid range: %s", others)
|
||||
ctx.check(ev["pool"]["accounts"] == POOL_SIZE, "%d ffx accounts, expected %d", ev["pool"]["accounts"], POOL_SIZE)
|
||||
|
||||
uid = POOL_FIRST + POOL_SIZE - 1
|
||||
fc_pid = _pids_of("forgectrl")
|
||||
spin = None
|
||||
try:
|
||||
# 4. a probe group holds a process: cpu.max, the freezer, pids.max, memory.max
|
||||
os.makedirs(PROBE_GROUP, exist_ok=True)
|
||||
_write(PROBE_GROUP + "/cpu.max", "5000 100000")
|
||||
_write(PROBE_GROUP + "/pids.max", "6")
|
||||
_write(PROBE_GROUP + "/memory.max", str(24 << 20))
|
||||
if os.path.exists(PROBE_GROUP + "/memory.swap.max"):
|
||||
_write(PROBE_GROUP + "/memory.swap.max", "0")
|
||||
|
||||
spin = subprocess.Popen([sys.executable, "-c", PROBE, PROBE_GROUP, str(uid), "spin", ""],
|
||||
stdout=subprocess.PIPE, text=True)
|
||||
ctx.check("started" in (spin.stdout.readline() or ""), "the spinning probe did not start")
|
||||
ctx.check(_read("/proc/%d/cgroup" % spin.pid).strip() == "0::/ffx/forgetest-probe",
|
||||
"the probe is in %r", _read("/proc/%d/cgroup" % spin.pid).strip())
|
||||
ctx.check("Uid:\t%d\t%d" % (uid, uid) in _read("/proc/%d/status" % spin.pid), "the probe is not uid %d", uid)
|
||||
ctx.sleep(1.0)
|
||||
u0, t0 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"], time.time()
|
||||
ctx.sleep(3.0)
|
||||
u1, t1 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"], time.time()
|
||||
share = (u1 - u0) / 1e6 / (t1 - t0)
|
||||
throttled = _flat(PROBE_GROUP + "/cpu.stat").get("nr_throttled", 0)
|
||||
ctx.log("a spinning probe under cpu.max 5%%: %.1f%% of the core, throttled %d times", share * 100, throttled)
|
||||
ctx.check(0.01 < share < 0.08 and throttled > 0, "cpu.max 5%% held the probe to %.1f%% (throttled %d)",
|
||||
share * 100, throttled)
|
||||
|
||||
_write(PROBE_GROUP + "/cgroup.freeze", "1")
|
||||
ok = ctx.wait_for(lambda: _flat(PROBE_GROUP + "/cgroup.events").get("frozen") == 1, 3, poll=0.05)
|
||||
ctx.check(ok is not None, "the group did not freeze: %s", _flat(PROBE_GROUP + "/cgroup.events"))
|
||||
# The kernel reports a task frozen before its last way into
|
||||
# schedule() is charged, so the window opens once that has landed.
|
||||
ctx.sleep(0.2)
|
||||
f0 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"]
|
||||
ctx.sleep(1.5)
|
||||
f1 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"]
|
||||
_write(PROBE_GROUP + "/cgroup.freeze", "0")
|
||||
ok = ctx.wait_for(lambda: _flat(PROBE_GROUP + "/cgroup.events").get("frozen") == 0, 3, poll=0.05)
|
||||
ctx.check(ok is not None, "the group did not thaw")
|
||||
ctx.sleep(1.5)
|
||||
f2 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"]
|
||||
ctx.log("frozen for 1.5 s the probe ran %d us; thawed for 1.5 s it ran %d us", f1 - f0, f2 - f1)
|
||||
ctx.check(f1 - f0 == 0, "a frozen probe ran %d us", f1 - f0)
|
||||
ctx.check(f2 - f1 > 10000, "a thawed probe ran only %d us", f2 - f1)
|
||||
_write(PROBE_GROUP + "/cgroup.kill", "1")
|
||||
spin.wait(timeout=5)
|
||||
ctx.check(spin.returncode == -signal.SIGKILL, "cgroup.kill left the probe with %s", spin.returncode)
|
||||
spin = None
|
||||
ev["cpu"] = {"share": round(share, 4), "throttled": throttled, "frozen_us": f1 - f0, "thawed_us": f2 - f1}
|
||||
|
||||
rc, lines, err = _probe(PROBE_GROUP, uid, "forks", "12")
|
||||
forks = lines[-1] if lines else {}
|
||||
hits = _flat(PROBE_GROUP + "/pids.events").get("max", 0)
|
||||
ev["pids"] = {"result": forks, "events_max": hits}
|
||||
ctx.log("pids.max 6: the fork loop made %s of 12, stopped by %s; pids.events max %d",
|
||||
forks.get("forked"), forks.get("stopped_by"), hits)
|
||||
ctx.check(rc == 0 and forks.get("forked") == 5 and forks.get("stopped_by") == "EAGAIN" and hits > 0,
|
||||
"pids.max 6 did not stop the fork loop at 5: rc %s %s %s, events max %d", rc, forks, err, hits)
|
||||
|
||||
rc, lines, err = _probe(PROBE_GROUP, uid, "eat", "96", wait=60)
|
||||
kills = _flat(PROBE_GROUP + "/memory.events").get("oom_kill", 0)
|
||||
ev["memory"] = {"rc": rc, "lines": lines, "oom_kill": kills, "peak": _read(PROBE_GROUP + "/memory.peak").strip()}
|
||||
ctx.log("memory.max 24 MiB against a 96 MiB appetite: rc %s, oom_kill %d, peak %s", rc, kills, ev["memory"]["peak"])
|
||||
ctx.check(rc == -signal.SIGKILL and kills > 0 and not any(l.get("survived") for l in lines),
|
||||
"memory.max did not kill the probe: rc %s, oom_kill %d, %s %s", rc, kills, lines, err)
|
||||
ctx.check(_pids_of("forgectrl") == fc_pid, "forgectrl's pid moved across the group's OOM: %s -> %s",
|
||||
fc_pid, _pids_of("forgectrl"))
|
||||
|
||||
# 5. the deny rules
|
||||
p = subprocess.run([NFT, "list", "table", "inet", "ffx"], capture_output=True, text=True)
|
||||
ctx.check(p.returncode == 0, "table inet ffx is not loaded: %s", p.stderr.strip()[:200])
|
||||
ev["rules"] = p.stdout
|
||||
ctx.check("meta skuid %d-%d jump pool" % (POOL_FIRST, uid) in p.stdout and "hook output" in p.stdout
|
||||
and "policy accept" in p.stdout, "table inet ffx is not the image's: %s", p.stdout[:400])
|
||||
pool = p.stdout[p.stdout.find("chain pool"):].split("}")[0]
|
||||
ctx.check(0 <= pool.find('oifname "lo" jump refuse') < pool.find("vmap @allow"),
|
||||
"the machine itself is not refused ahead of the allow map: %s", pool)
|
||||
lan = lan_ip()
|
||||
ctx.check(lan, "the machine has no LAN address to aim at")
|
||||
tcp = [[4, "127.0.0.1", 443, False], [4, "127.0.0.1", 80, False], [6, "::1", 443, False],
|
||||
[6, "::1", 23, False], [4, lan, 443, False], [4, lan, 23, False]]
|
||||
udp = [[4, "127.0.0.1", 9, True], [4, lan, 9, True]]
|
||||
control = [[4, "127.0.0.1", 443, False], [4, lan, 443, False], [4, "127.0.0.1", 9, True]]
|
||||
rc, lines, err = _probe("-", 0, "net", json.dumps(control))
|
||||
as_root = (lines[-1] if lines else {}).get("results", [])
|
||||
ev["as_root"] = as_root
|
||||
ctx.check(len(as_root) == len(control) and all(r[1][0] == "ok" for r in as_root),
|
||||
"root does not reach its own listeners: %s %s", as_root, err)
|
||||
c0 = _pool_counters()
|
||||
heard = {}
|
||||
for who in (POOL_FIRST, uid):
|
||||
rc, lines, err = _probe("-", who, "net", json.dumps(tcp + udp))
|
||||
res = (lines[-1] if lines else {}).get("results", [])
|
||||
heard[str(who)] = res
|
||||
ctx.check(len(res) == len(tcp + udp), "the probe as uid %d said %s %s", who, lines, err)
|
||||
for target, (word, took) in res:
|
||||
if target[3]:
|
||||
ctx.check(word == "eperm", "uid %d UDP to %s -> %s", who, target[1], word)
|
||||
else:
|
||||
ctx.check(word == "refused" and took < 1.5, "uid %d TCP to %s port %d -> %s in %.2f s",
|
||||
who, target[1], target[2], word, took)
|
||||
c1 = _pool_counters()
|
||||
ev["as_pool_uid"] = heard
|
||||
ev["counters"] = [c0, c1]
|
||||
ctx.log("two pool uids: %d TCP connects each refused at once, %d UDP sends each EPERM; the rules "
|
||||
"counted %s -> %s", len(tcp), len(udp), c0, c1)
|
||||
ctx.check(c0 is not None and c1 is not None and len(c1) == 2 and c1[0] - c0[0] >= 2 * len(tcp)
|
||||
and c1[1] - c0[1] >= 2 * len(udp), "the rules did not count the attempts: %s -> %s", c0, c1)
|
||||
|
||||
# The machine itself is not a destination, whatever the allow map says: an
|
||||
# allow chain that names forgectrl on loopback and on the LAN address
|
||||
# opens neither.
|
||||
chain = "u%d" % uid
|
||||
rules = ["add chain inet ffx %s" % chain,
|
||||
"add rule inet ffx %s ip daddr { 127.0.0.1, %s } tcp dport 443 accept" % (chain, lan),
|
||||
"add element inet ffx allow { %d : jump %s }" % (uid, chain), ""]
|
||||
opened = subprocess.run([NFT, "-f", "-"], capture_output=True, text=True, input=os.linesep.join(rules))
|
||||
try:
|
||||
ctx.check(opened.returncode == 0, "could not add an allow chain: %s", opened.stderr.strip()[:200])
|
||||
rc, lines, err = _probe("-", uid, "net", json.dumps([[4, "127.0.0.1", 443, False], [4, lan, 443, False]]))
|
||||
res = (lines[-1] if lines else {}).get("results", [])
|
||||
ev["allowlisted_self"] = res
|
||||
ctx.log("uid %d with the machine's own addresses on its allowlist: %s", uid, res)
|
||||
ctx.check(len(res) == 2 and all(r[1][0] == "refused" for r in res),
|
||||
"an allowlist opened the machine itself to a pool uid: %s %s", res, err)
|
||||
finally:
|
||||
subprocess.run([NFT, "delete", "element", "inet", "ffx", "allow", "{ %d }" % uid], capture_output=True)
|
||||
subprocess.run([NFT, "flush", "chain", "inet", "ffx", chain], capture_output=True)
|
||||
subprocess.run([NFT, "delete", "chain", "inet", "ffx", chain], capture_output=True)
|
||||
left = subprocess.run([NFT, "list", "chain", "inet", "ffx", chain], capture_output=True)
|
||||
ctx.check(left.returncode != 0, "the test's allow chain stayed behind")
|
||||
|
||||
# 6. landlock and seccomp, on a root process so that neither the uid nor the rules explain the refusal
|
||||
rc, lines, err = _probe("-", 0, "landlock", json.dumps([4, "127.0.0.1", 443, False]))
|
||||
ll = lines[-1] if lines else {}
|
||||
ev["landlock"] = ll
|
||||
ctx.log("landlock ABI %s: before %s, after %s", ll.get("abi"), ll.get("before"), ll.get("after"))
|
||||
ctx.check(rc == 0 and ll.get("abi", 0) >= 4, "landlock ABI %s (4 brings the TCP rules) %s", ll.get("abi"), err)
|
||||
ctx.check(ll["before"] == {"etc": "ok", "usr": "ok", "tcp": "ok"}, "before the restriction: %s", ll["before"])
|
||||
after = ll["after"]
|
||||
ctx.check(after.get("ruleset") and after.get("added") == 0 and after.get("applied") == 0,
|
||||
"the ruleset did not apply: %s", after)
|
||||
ctx.check(after.get("etc") == "eacces" and after.get("tcp") == "eacces" and after.get("usr") == "ok",
|
||||
"after the restriction (/etc and TCP lost, /usr kept): %s", after)
|
||||
|
||||
rc, lines, err = _probe("-", 0, "seccomp")
|
||||
sc = lines[-1] if lines else {}
|
||||
ev["seccomp"] = sc
|
||||
ctx.log("seccomp: %s", sc)
|
||||
ctx.check(rc == 0 and sc.get("installed") == 0 and sc.get("before") == "Linux" and sc.get("after") == "eperm"
|
||||
and sc.get("status") == ["2"], "a seccomp filter did not take: rc %s %s %s", rc, sc, err)
|
||||
finally:
|
||||
if spin is not None:
|
||||
spin.kill()
|
||||
spin.wait(timeout=5)
|
||||
if os.path.isdir(PROBE_GROUP):
|
||||
try:
|
||||
_write(PROBE_GROUP + "/cgroup.freeze", "0")
|
||||
_write(PROBE_GROUP + "/cgroup.kill", "1")
|
||||
except OSError:
|
||||
pass
|
||||
for _ in range(50):
|
||||
if not _read(PROBE_GROUP + "/cgroup.procs").strip():
|
||||
break
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
os.rmdir(PROBE_GROUP)
|
||||
except OSError as e:
|
||||
ctx.log("the probe group stayed behind: %s", e)
|
||||
ctx.check(not os.path.isdir(PROBE_GROUP), "the probe group stayed behind")
|
||||
ctx.log("PASS: the kernel, the cgroup tree, the account pool, and the deny rules are in place, and each "
|
||||
"held a probe process the way it will hold a package")
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the host
|
||||
|
||||
FORGEEXT = "/usr/bin/forgeext"
|
||||
FWUP = "/usr/bin/fwup"
|
||||
EXT_ROOT = "/data/forgefirm/ext"
|
||||
HOST_STATUS = "/run/forgefirm/ext/status.json"
|
||||
SAFE_FILE = "/run/forgefirm/ext-safe"
|
||||
REF_ID = "org.forgetest.reference"
|
||||
REF_KEY = "forgetest-reference"
|
||||
REF_DEST = "192.0.2.1" # TEST-NET-1: declared so that port 443 is, and never dialed
|
||||
|
||||
# The reference package's service. It looks at its own confinement from the
|
||||
# inside, leaves what it found in its data directory, and stays up.
|
||||
REF_SERVICE = r'''
|
||||
import errno, json, os, socket, sys, time
|
||||
lan = sys.argv[1]
|
||||
data, pkg = os.environ["FFX_DATA"], os.environ["FFX_PKG"]
|
||||
if os.path.exists(os.path.join(data, "stop")):
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
def word(e):
|
||||
return errno.errorcode.get(e.errno, str(e.errno))
|
||||
|
||||
|
||||
def api(method, path, body=None):
|
||||
"""One request to the host over the socket named in FFX_API: (status, JSON)."""
|
||||
payload = json.dumps(body).encode() if body is not None else b""
|
||||
head = "%s %s HTTP/1.1\r\nHost: forgeext\r\n" % (method, path)
|
||||
if body is not None:
|
||||
head += "Content-Type: application/json\r\nContent-Length: %d\r\n" % len(payload)
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(5)
|
||||
try:
|
||||
s.connect(os.environ["FFX_API"])
|
||||
s.sendall(head.encode() + b"\r\n" + payload)
|
||||
buf = b""
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf += c
|
||||
top, _, text = buf.partition(b"\r\n\r\n")
|
||||
return int(top.split()[1]), json.loads(text or b"null")
|
||||
except (OSError, ValueError, IndexError) as e:
|
||||
return 599, {"error": str(e)}
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def shot(body):
|
||||
"""POST /v0/camera: (status, bytes or the error's words). A frame is
|
||||
bytes, so this one does not try to read the answer as JSON."""
|
||||
payload = json.dumps(body).encode()
|
||||
head = ("POST /v0/camera HTTP/1.1\r\nHost: forgeext\r\nContent-Type: application/json\r\n"
|
||||
"Content-Length: %d\r\n\r\n" % len(payload))
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(40)
|
||||
try:
|
||||
s.connect(os.environ["FFX_API"])
|
||||
s.sendall(head.encode() + payload)
|
||||
buf = b""
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf += c
|
||||
top, _, rest = buf.partition(b"\r\n\r\n")
|
||||
code = int(top.split()[1])
|
||||
if code == 200:
|
||||
return code, len(rest), rest[:2] == b"\xff\xd8", b"image/jpeg" in top
|
||||
try:
|
||||
return code, json.loads(rest or b"{}").get("error", ""), False, False
|
||||
except ValueError:
|
||||
return code, rest[:80].decode("utf-8", "replace"), False, False
|
||||
except (OSError, ValueError, IndexError) as e:
|
||||
return 599, str(e), False, False
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def opened(path, mode="r"):
|
||||
try:
|
||||
with open(path, mode) as f:
|
||||
if "r" in mode:
|
||||
f.read(1)
|
||||
else:
|
||||
f.write("x")
|
||||
return "ok"
|
||||
except OSError as e:
|
||||
return word(e)
|
||||
|
||||
|
||||
def dial(addr, port):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(4.0)
|
||||
try:
|
||||
s.connect((addr, port))
|
||||
return "connected"
|
||||
except socket.timeout:
|
||||
return "timeout"
|
||||
except OSError as e:
|
||||
return word(e)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def family(af):
|
||||
try:
|
||||
socket.socket(af, socket.SOCK_RAW if af == 16 else socket.SOCK_DGRAM).close()
|
||||
return "ok"
|
||||
except OSError as e:
|
||||
return word(e)
|
||||
|
||||
|
||||
report = {
|
||||
"uid": os.getuid(), "gid": os.getgid(), "groups": os.getgroups(), "cwd": os.getcwd(),
|
||||
"env": sorted(os.environ),
|
||||
"read_settings": opened("/data/forgefirm/forgefirm.conf"),
|
||||
"read_setup": opened("/data/forgefirm/setup.json"),
|
||||
"read_etc": opened("/etc/hostname"),
|
||||
"read_pkg": opened(os.path.join(pkg, "manifest.json")),
|
||||
"write_pkg": opened(os.path.join(pkg, "x"), "w"),
|
||||
"write_data": opened(os.path.join(data, "scratch"), "w"),
|
||||
"write_tmp": opened("/tmp/forgetest-reference", "w"),
|
||||
"pulse_device": opened("/dev/glowforge"),
|
||||
"loopback_declared_port": dial("127.0.0.1", 443),
|
||||
"lan_declared_port": dial(lan, 443) if lan != "-" else "skipped",
|
||||
"loopback_undeclared_port": dial("127.0.0.1", 80),
|
||||
"netlink_socket": family(16),
|
||||
"unix_socket": family(1),
|
||||
"api_camera": shot({"camera": "lid", "resolution": "half"}),
|
||||
"api_camera_head": shot({"camera": "head"}),
|
||||
"api_camera_bad": shot({"camera": "bed"}),
|
||||
"api_camera_dark": shot({"camera": "lid", "resolution": "half", "lamp": 0}),
|
||||
"api_camera_lit": shot({"camera": "lid", "resolution": "half", "lamp": 1023}),
|
||||
"api_camera_lamp_bad": shot({"camera": "lid", "lamp": 5000}),
|
||||
"api_settings": api("GET", "/v0/settings"),
|
||||
"api_settings_set": api("POST", "/v0/settings", {"threshold": 70, "note": "set from inside"}),
|
||||
"api_settings_undeclared": api("POST", "/v0/settings", {"nothere": 1}),
|
||||
"api_settings_out_of_bounds": api("POST", "/v0/settings", {"threshold": 9000}),
|
||||
"api_self": api("GET", "/v0/self"),
|
||||
"api_mode": api("GET", "/v0/machine/mode"),
|
||||
"api_hold": api("GET", "/v0/hold"),
|
||||
"api_nowhere": api("GET", "/v0/nowhere"),
|
||||
"api_traversal": api("GET", "/v0/machine/../../settings"),
|
||||
}
|
||||
with open(os.path.join(data, "report.json.new"), "w") as f:
|
||||
json.dump(report, f)
|
||||
os.rename(os.path.join(data, "report.json.new"), os.path.join(data, "report.json"))
|
||||
print("reference service up", flush=True)
|
||||
mine = api("GET", "/v0/self")[1] or {}
|
||||
watching = "events" in (mine.get("capabilities") or [])
|
||||
place, seen, polls = None, [], []
|
||||
n = 0
|
||||
while True:
|
||||
n += 1
|
||||
with open(os.path.join(data, "beat"), "w") as f:
|
||||
f.write(str(n))
|
||||
if watching:
|
||||
# A poll that waits: it comes back with an event as soon as there
|
||||
# is one, and with nothing when its two seconds are up.
|
||||
ask = {"wait": 2} if place is None else {"since": place, "wait": 2}
|
||||
began = time.time()
|
||||
st, ans = api("POST", "/v0/events", ask)
|
||||
took = round(time.time() - began, 2)
|
||||
if st == 200:
|
||||
place = ans.get("next")
|
||||
seen = (seen + [e.get("event") for e in ans.get("events") or []])[-40:]
|
||||
polls = (polls + [{"status": st, "took": took, "placed": "since" in ask,
|
||||
"got": len(ans.get("events") or []) if isinstance(ans, dict) else 0}])[-20:]
|
||||
with open(os.path.join(data, "events.json.new"), "w") as f:
|
||||
json.dump({"place": place, "seen": seen, "polls": polls, "last": ans,
|
||||
"connected": ans.get("connected") if isinstance(ans, dict) else None}, f)
|
||||
os.rename(os.path.join(data, "events.json.new"), os.path.join(data, "events.json"))
|
||||
# the test's word to the service: write a program and run it
|
||||
runf = os.path.join(data, "run")
|
||||
if os.path.exists(runf):
|
||||
with open(runf) as f:
|
||||
words = json.load(f)
|
||||
os.remove(runf)
|
||||
if words.get("program_text") is not None:
|
||||
with open(os.path.join(data, words.get("program", "job.gcode")), "w") as f:
|
||||
f.write(words["program_text"])
|
||||
body = {k: v for k, v in words.items() if k not in ("program_text",)}
|
||||
with open(os.path.join(data, "ran.new"), "w") as f:
|
||||
json.dump(api("POST", "/v0/motion/job", body), f)
|
||||
os.rename(os.path.join(data, "ran.new"), os.path.join(data, "ran"))
|
||||
# the test's word to the service: jog now
|
||||
jog = os.path.join(data, "jog")
|
||||
if os.path.exists(jog):
|
||||
with open(jog) as f:
|
||||
words = json.load(f)
|
||||
os.remove(jog)
|
||||
with open(os.path.join(data, "jogged.new"), "w") as f:
|
||||
json.dump(api("POST", "/v0/motion/jog", words), f)
|
||||
os.rename(os.path.join(data, "jogged.new"), os.path.join(data, "jogged"))
|
||||
# the test's word to the service: take a capture now
|
||||
ask = os.path.join(data, "shoot")
|
||||
if os.path.exists(ask):
|
||||
os.remove(ask)
|
||||
with open(os.path.join(data, "shot.new"), "w") as f:
|
||||
json.dump(shot({"camera": "lid", "resolution": "half"}), f)
|
||||
os.rename(os.path.join(data, "shot.new"), os.path.join(data, "shot"))
|
||||
# the test's word to the service: what to say of its hold
|
||||
say = os.path.join(data, "say")
|
||||
if os.path.exists(say):
|
||||
with open(say) as f:
|
||||
words = json.load(f)
|
||||
os.remove(say)
|
||||
with open(os.path.join(data, "said.new"), "w") as f:
|
||||
json.dump(api("POST", "/v0/hold", words), f)
|
||||
os.rename(os.path.join(data, "said.new"), os.path.join(data, "said"))
|
||||
time.sleep(0.5)
|
||||
'''
|
||||
|
||||
|
||||
def _host_pids():
|
||||
"""The extension host: /usr/bin/forgeext run, and not an install or a
|
||||
check someone has under way."""
|
||||
out = []
|
||||
for d in os.listdir("/proc"):
|
||||
if not d.isdigit():
|
||||
continue
|
||||
try:
|
||||
if os.readlink("/proc/%s/exe" % d) != FORGEEXT:
|
||||
continue
|
||||
with open("/proc/%s/cmdline" % d, "rb") as f:
|
||||
argv = f.read().split(b"\0")
|
||||
except OSError:
|
||||
continue
|
||||
if argv[:2] == [FORGEEXT.encode(), b"run"]:
|
||||
out.append(int(d))
|
||||
return out
|
||||
|
||||
|
||||
def _host_status():
|
||||
try:
|
||||
with open(HOST_STATUS) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _svc(id_):
|
||||
return next((x for x in _host_status().get("services", []) if x.get("id") == id_), {})
|
||||
|
||||
|
||||
def _forgeext(*args, wait=120):
|
||||
p = subprocess.run([FORGEEXT] + list(args), capture_output=True, text=True, timeout=wait)
|
||||
try:
|
||||
return json.loads(p.stdout)
|
||||
except ValueError:
|
||||
return {"ok": False, "error": "no JSON answer (exit %s): %s %s" % (p.returncode, p.stdout[-200:], p.stderr[-200:])}
|
||||
|
||||
|
||||
def _until(ctx, cond, seconds, poll=0.25):
|
||||
end = time.time() + seconds
|
||||
while time.time() < end:
|
||||
v = cond()
|
||||
if v:
|
||||
return v
|
||||
ctx.checkpoint()
|
||||
time.sleep(poll)
|
||||
return cond()
|
||||
|
||||
|
||||
def _tree(root):
|
||||
"""Every path under the extension root but the host's own bookkeeping."""
|
||||
out = []
|
||||
for base, dirs, files in os.walk(root):
|
||||
for n in dirs + files:
|
||||
rel = os.path.relpath(os.path.join(base, n), root)
|
||||
if rel not in ("state.json", "lock"):
|
||||
out.append(rel)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
REF_UI = ("<!doctype html><title>forgetest reference</title>\n"
|
||||
"<p>the reference package's own page</p>\n")
|
||||
|
||||
|
||||
def _pack_reference(work, lan, more_caps=(), ui=None):
|
||||
"""The reference package, signed with a key made here: (archive, public key)."""
|
||||
import io
|
||||
import tarfile
|
||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest reference", "version": "1.0.0",
|
||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||
"service": {"exec": "bin/reference.py", "args": [lan or "-"]},
|
||||
"settings": {"threshold": {"type": "number", "default": 40, "min": 0, "max": 100},
|
||||
"note": {"type": "string", "default": "", "max": 32, "label": "A note"},
|
||||
"when": {"type": "choice", "default": "end",
|
||||
"choices": ["start", "end", "never"]}},
|
||||
"capabilities": ["net.outbound:%s:443" % REF_DEST, "storage:1", "machine.read",
|
||||
"settings.own", "camera.lid"] + list(more_caps)}
|
||||
payload = os.path.join(work, "payload.tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
files = [("manifest.json", json.dumps(manifest), 0o644),
|
||||
("bin/reference.py", REF_SERVICE, 0o755)]
|
||||
if ui is None:
|
||||
ui = "ui" in (manifest.get("capabilities") or [])
|
||||
if ui:
|
||||
files.append(("ui/index.html", REF_UI, 0o644))
|
||||
for name, text, mode in files:
|
||||
info = tarfile.TarInfo(name)
|
||||
data = text.encode()
|
||||
info.size, info.mode = len(data), mode
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, "fwup.conf")
|
||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||
% (REF_ID, payload))
|
||||
key = os.path.join(work, REF_KEY)
|
||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "reference.ffx")
|
||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||
return signed, key + ".pub"
|
||||
|
||||
|
||||
def _put_back(ctx, fc, work, prior, etag, raw, dir_mode):
|
||||
"""Everything a test of the host put on the machine, taken away again:
|
||||
safe mode, the package, the owner key, the work directory, the
|
||||
setting, the data directory's mode, and the setup record (under a
|
||||
forgectrl restart)."""
|
||||
import shutil
|
||||
if os.path.exists(SAFE_FILE):
|
||||
os.remove(SAFE_FILE)
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
|
||||
r = _forgeext("remove", REF_ID)
|
||||
ctx.log("remove %s -> %s", REF_ID, "ok" if r.get("ok") else r.get("error"))
|
||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||
if os.path.exists(owner_key):
|
||||
os.remove(owner_key)
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
if prior == "1":
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
elif prior == "":
|
||||
st, reply = fc.post("/settings", params={"ext_enabled": ""})
|
||||
ctx.log("restore ext_enabled=%r -> %s", prior, st)
|
||||
if prior != "1":
|
||||
os.chmod(os.path.dirname(EXT_ROOT), dir_mode)
|
||||
with ctx.takeover():
|
||||
write_file(record_path(), raw)
|
||||
ctx.log("the previous record is back under a restart")
|
||||
|
||||
|
||||
def _as_found(ctx, fc, prior, raw, dir_mode, found_tree):
|
||||
ctx.check((fc.settings().get("ext_enabled") or "") == prior, "ext_enabled not restored: %r, was %r",
|
||||
fc.settings().get("ext_enabled"), prior)
|
||||
ctx.check(read_file(record_path()) == raw, "the setup record on disk is not the one found")
|
||||
ctx.check(prior == "1" or os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777 == dir_mode,
|
||||
"the data directory's mode is not the one found")
|
||||
left = _tree(EXT_ROOT)
|
||||
ctx.check(left == found_tree, "the extension root is not as found: %s", sorted(set(left) ^ set(found_tree)))
|
||||
ctx.check(len(_host_pids()) == 1, "the extension host is not running at the end")
|
||||
|
||||
|
||||
# ------------------------------------------------- the armed window
|
||||
|
||||
def _frozen(id_):
|
||||
ev = _read("%s/%s/cgroup.events" % (POOL_GROUP, id_))
|
||||
return "frozen 1" in ev if ev else None
|
||||
|
||||
|
||||
@test("exthost.armed-freeze", title="A package's service is frozen for the armed window",
|
||||
subsystem="exthost", kind="operator", hardware="takeover", est_min=6,
|
||||
covers=[("forgeext", "src/super.*"), ("forgeext", "src/run.*"), ("forgeext", "src/machine.*"),
|
||||
("forgeext", "src/cgroup.*"), ("forgectrl", "src/cool.*"), ("forgectrl", "src/cam.*"),
|
||||
("forgectrl", "src/main.c")],
|
||||
requires=["exthost.platform"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
||||
"when it lights."],
|
||||
description="The reference package runs, its heartbeat advancing twice a second, and a dark "
|
||||
"cloud print opens a real armed window over it: the cloud suite's dark-print "
|
||||
"helper, whose latch checks still run (locked at the button, unlocked for the run, "
|
||||
"locked after it), and the print completes. Sampled five times a second from before the button to after the end: the "
|
||||
"engine's armed flag, the group's frozen state as the kernel reports it, and the "
|
||||
"heartbeat. The window is open for at least 10 s. From 2 s after it opens to its "
|
||||
"close the group reads frozen in every sample and the heartbeat does not move, the "
|
||||
"freeze is in place before the run starts (the latch unlocks only for the run), and "
|
||||
"within 3 s of the close the group is thawed and the heartbeat advances again. The "
|
||||
"service is the same process throughout. A background capture is refused in words "
|
||||
"while the window is open and served again once it closes, which is the other half "
|
||||
"of keeping a package off the step stream: a capture costs kernel-side work that a "
|
||||
"thread priority does not cover. The package, the key, the setting and the setup "
|
||||
"record are put back as found, the record under a forgectrl restart.")
|
||||
def armed_freeze(ctx):
|
||||
import tempfile
|
||||
import threading
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(len(_host_pids()) == 1, "the extension host is not one running process: %s", _host_pids())
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||
prior = fc.settings().get("ext_enabled") or ""
|
||||
raw = read_file(record_path())
|
||||
ctx.check(raw, "no setup record at %s", record_path())
|
||||
ctx.check(not os.path.exists(SAFE_FILE), "%s exists: the machine is in safe mode", SAFE_FILE)
|
||||
found_tree = _tree(EXT_ROOT)
|
||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||
etag = hdrs.get("etag")
|
||||
ctx.check(st == 200 and etag, "GET /advisories/extensions -> %s", st)
|
||||
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
|
||||
beat_path = os.path.join(EXT_ROOT, "data", REF_ID, "beat")
|
||||
samples = []
|
||||
probe = {}
|
||||
stop = threading.Event()
|
||||
|
||||
def sampler():
|
||||
while not stop.is_set():
|
||||
st_, cool = fc.get("/cool/status")
|
||||
armed = bool(cool.get("armed")) if st_ == 200 and isinstance(cool, dict) else None
|
||||
samples.append((time.time(), armed, _frozen(REF_ID), _read(beat_path).strip(),
|
||||
(_svc(REF_ID) or {}).get("pid"), latch_locked()))
|
||||
# One background capture from inside the window. The refusal is
|
||||
# decided before any frame is taken, so it costs the cut nothing;
|
||||
# if it were served instead, that is what this case is here to
|
||||
# catch, and the capture is the one the rule forbids.
|
||||
if armed and "in" not in probe:
|
||||
probe["in"] = fc.get("/cam/snapshot", params={"cam": "lid", "background": "1"}, raw=True)
|
||||
time.sleep(0.2)
|
||||
|
||||
try:
|
||||
archive, pub = _pack_reference(work, lan_ip())
|
||||
with open(pub, "rb") as f:
|
||||
write_file(os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub"), f.read())
|
||||
os.chmod(os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub"), 0o644)
|
||||
r = _forgeext("install", archive, "--consent-community")
|
||||
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
ctx.check(st == 200, "ext_enabled=1 over the advisory -> %s %r", st, reply)
|
||||
seen = set()
|
||||
ctx.check(_until(ctx, lambda: seen.add(_read(beat_path)) or len(seen) >= 4, 120, poll=0.2),
|
||||
"the reference service's heartbeat does not advance: %s", _svc(REF_ID) or _host_status())
|
||||
pid = _svc(REF_ID).get("pid")
|
||||
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "dark.puls", seconds=30)
|
||||
off = Offline().__enter__()
|
||||
t = threading.Thread(target=sampler, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
dark_print_body(ctx, ev, off, job, offset, fc)
|
||||
ctx.sleep(6) # past the close, for the thaw
|
||||
finally:
|
||||
stop.set()
|
||||
t.join(timeout=5)
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
opens = [x for x in samples if x[1]]
|
||||
ctx.check(opens, "the engine never read armed across the print: %d samples", len(samples))
|
||||
t_open, t_close = opens[0][0], opens[-1][0]
|
||||
ev["window_s"] = round(t_close - t_open, 1)
|
||||
ctx.check(t_close - t_open >= 10, "the armed window was open for %.1f s only", t_close - t_open)
|
||||
inside = [x for x in samples if t_open + 2.0 <= x[0] <= t_close]
|
||||
thawed = [x for x in inside if x[2] is not True]
|
||||
ev["samples"] = {"all": len(samples), "inside": len(inside), "not_frozen_inside": len(thawed)}
|
||||
first_frozen = next((x[0] for x in samples if x[0] >= t_open and x[2] is True), None)
|
||||
ev["freeze_lag_s"] = round(first_frozen - t_open, 2) if first_frozen else None
|
||||
ctx.check(inside and not thawed, "inside the armed window the group read not frozen in %d of %d samples "
|
||||
"(the first %.1f s after it opened)", len(thawed), len(inside), (thawed[0][0] - t_open) if thawed else 0)
|
||||
beats = {x[3] for x in inside}
|
||||
ctx.check(len(beats) == 1, "the heartbeat moved inside the armed window: %s", sorted(beats)[:6])
|
||||
# the latch unlocks only for the run: its first unlocked sample is the run's start
|
||||
run_start = next((x[0] for x in samples if x[5] is False), None)
|
||||
ctx.check(run_start is not None, "the latch never read unlocked: the print did not run under the samples")
|
||||
ctx.check(first_frozen is not None and first_frozen <= run_start,
|
||||
"the run started before the freeze was in place (frozen %s, the latch unlocked %s after the window opened)",
|
||||
None if first_frozen is None else round(first_frozen - t_open, 2), round(run_start - t_open, 2))
|
||||
ev["freeze_led_the_run_s"] = round(run_start - first_frozen, 2)
|
||||
after = [x for x in samples if x[0] >= t_close + 3.0]
|
||||
ctx.check(after and all(x[2] is False for x in after), "3 s after the close the group is not thawed: %s",
|
||||
[x[2] for x in after][:8])
|
||||
ctx.check(len({x[3] for x in after}) >= 2, "the heartbeat did not advance again after the close")
|
||||
last_frozen = max((x[0] for x in samples if x[2] is True), default=None)
|
||||
ev["thaw_lag_s"] = round(last_frozen - t_close, 2) if last_frozen else None
|
||||
ctx.check({x[4] for x in samples if x[4]} == {pid}, "the service did not stay the same process: %s",
|
||||
sorted({x[4] for x in samples if x[4]}))
|
||||
|
||||
# The camera, inside the window and after it.
|
||||
ctx.check("in" in probe, "no background capture was tried inside the armed window")
|
||||
cam_st, cam_body = probe.get("in", (None, b""))
|
||||
words = cam_body.decode("utf-8", "replace")[:200] if isinstance(cam_body, bytes) else str(cam_body)[:200]
|
||||
ev["camera_in_window"] = {"status": cam_st, "said": words}
|
||||
ctx.check(cam_st == 409, "a background capture inside the armed window -> %s (%d bytes)", cam_st,
|
||||
len(cam_body or b""))
|
||||
ctx.check("armed" in words, "the refusal does not say why: %s", words)
|
||||
st, after_body = fc.get("/cam/snapshot", params={"cam": "lid", "background": "1"}, raw=True)
|
||||
ev["camera_after_window"] = {"status": st, "bytes": len(after_body or b"")}
|
||||
ctx.check(st == 200 and (after_body or b"")[:2].hex() == "ffd8",
|
||||
"a background capture after the window -> %s (%d bytes)", st, len(after_body or b""))
|
||||
ctx.log("a background capture inside the window -> %s (%s); after it -> %s, %d bytes", cam_st, words, st,
|
||||
len(after_body or b""))
|
||||
ctx.log("the armed window was open %.1f s; frozen %.2f s after it opened and %.2f s before the latch unlocked "
|
||||
"for the run, in every one of %d samples from 2 s in to the close, the heartbeat still; thawed %.2f s "
|
||||
"after the close", ev["window_s"], ev["freeze_lag_s"], ev["freeze_led_the_run_s"], len(inside),
|
||||
ev["thaw_lag_s"] if ev["thaw_lag_s"] is not None else -1)
|
||||
finally:
|
||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||
@@ -1,18 +1,19 @@
|
||||
# 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."""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
|
||||
# The write guard reads the account and the session store (a machine with
|
||||
# an account needs a session from the LAN; this host and the dev image
|
||||
# write with the token), so both are the guard's domain.
|
||||
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
||||
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
||||
("forgectrl", "src/ui/login.js"), ("forgectrl", "src/ui/wizard.js")]
|
||||
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/wizard.js")]
|
||||
|
||||
|
||||
def lan_ip():
|
||||
@@ -137,14 +138,39 @@ def auth(ctx):
|
||||
"GET /fuse-identity with the token but no button -> %s %r (expected the two-factor refusal)",
|
||||
st, body)
|
||||
|
||||
# the cooling report channel: the loopback peer is accepted. An idle
|
||||
# report is what the controller sends every period; the engine is idle
|
||||
# here, so it changes nothing. A dual-stack listener reports this peer
|
||||
# as ::ffff:127.0.0.1, which the check must recognize in full.
|
||||
st, body = fc.post("/cool/state", params={"mode": "idle", "armed": "0"})
|
||||
# the cooling report channel is the running controller's alone. A
|
||||
# loopback peer is not enough: anything on this host is one. The
|
||||
# supervisor hands the controller a secret at its spawn, and the route
|
||||
# asks for it. An idle report is what the controller sends every
|
||||
# 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
|
||||
ctx.log("POST /cool/state from loopback -> %s %s", st, body if isinstance(body, dict) else "")
|
||||
ctx.check(st == 200, "/cool/state refused the loopback peer (%s %r): the controller's "
|
||||
ctx.log("POST /cool/state from loopback with the controller's secret -> %s", st)
|
||||
ctx.check(st == 200, "/cool/state refused the controller's own secret (%s %r): the controller's "
|
||||
"reports never reach the engine", st, body)
|
||||
|
||||
# ...and a non-loopback peer is refused, even with a token. Over HTTP
|
||||
@@ -156,8 +182,7 @@ def auth(ctx):
|
||||
ip = lan_ip()
|
||||
ev["lan_ip"] = ip
|
||||
ctx.check(ip, "cannot determine the board's LAN address")
|
||||
token = {"X-ForgeFIRM-Token": fc.token}
|
||||
report = {"mode": "idle", "armed": "0"}
|
||||
token = {"X-ForgeFIRM-Token": fc.token, "X-ForgeFIRM-Report": secret}
|
||||
st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token)
|
||||
loc = hdrs.get("location", "")
|
||||
ev["cool_state_from_lan_http"] = {"status": st, "location": loc}
|
||||
@@ -183,232 +208,3 @@ def auth(ctx):
|
||||
if st == 302:
|
||||
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
|
||||
"the login redirect does not carry the path: %r", loc)
|
||||
|
||||
|
||||
@test("forgectrl.settings-bounds", title="Settings validation and restore", subsystem="forgectrl",
|
||||
kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/settings.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/cam.c")],
|
||||
description="An over-length value and an out-of-range value are refused (400) and leave the "
|
||||
"settings byte-identical; an in-range value is accepted (200). The lid lamp "
|
||||
"idles at lid_lamp_idle (unset = 236), an out-of-range level is refused, a new "
|
||||
"level applies to the lamp at once, and clearing it returns the default.")
|
||||
def settings_bounds(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings()
|
||||
ev["keys"] = len(before)
|
||||
|
||||
st, body = fc.post("/settings", data={"gf_serial": "X" * 300})
|
||||
ev["overlong"] = st
|
||||
ctx.log("POST /settings gf_serial=<300 chars> -> %s %s", st, body if isinstance(body, dict) else "")
|
||||
ctx.check(st == 400, "over-length value -> %s, expected 400", st)
|
||||
|
||||
st, body = fc.post("/settings", data={"laser_disarm_s": "99999"})
|
||||
ev["out_of_range"] = st
|
||||
ctx.log("POST /settings laser_disarm_s=99999 -> %s", st)
|
||||
ctx.check(st == 400, "out-of-range value -> %s, expected 400", st)
|
||||
|
||||
# The cloud download guard is bytes, so its range is far wider than the
|
||||
# other numeric keys: check the far end is still a wall.
|
||||
st, body = fc.post("/settings", data={"pulse_reject_threshold_bytes": "2000000000"})
|
||||
ev["pulse_bytes_out_of_range"] = st
|
||||
ctx.log("POST /settings pulse_reject_threshold_bytes=2000000000 -> %s", st)
|
||||
ctx.check(st == 400, "out-of-range byte limit -> %s, expected 400", st)
|
||||
|
||||
st, body = fc.post("/settings", data={"no_such_key_forgetest": "1"})
|
||||
ev["unknown_key"] = st
|
||||
ctx.log("POST /settings no_such_key_forgetest=1 -> %s", st)
|
||||
ctx.check(st in (400, 404), "unknown key -> %s, expected 400", st)
|
||||
|
||||
after = fc.settings()
|
||||
ctx.check(json.dumps(after, sort_keys=True) == json.dumps(before, sort_keys=True),
|
||||
"settings changed after refused writes")
|
||||
ctx.log("settings unchanged after the refused writes")
|
||||
|
||||
# an accepted in-range write: rewrite a present key with its own value
|
||||
key = None
|
||||
for k in ("ui_units", "laser_disarm_s", "cool_flow_rise", "rail_settle_s"):
|
||||
v = before.get(k)
|
||||
if isinstance(v, str) and v != "":
|
||||
key = k
|
||||
break
|
||||
if key is None:
|
||||
key, val = "ui_units", "metric"
|
||||
ctx.log("no settable key is present; writing %s=%s (recorded in evidence)", key, val)
|
||||
else:
|
||||
val = before[key]
|
||||
st, body = fc.post("/settings", data={key: val})
|
||||
ev["accepted"] = {"key": key, "value": val, "status": st}
|
||||
ctx.log("POST /settings %s=%s -> %s", key, val, st)
|
||||
ctx.check(st == 200, "in-range write -> %s, expected 200", st)
|
||||
final = fc.settings()
|
||||
others_before = {k: v for k, v in before.items() if k != key}
|
||||
others_after = {k: v for k, v in final.items() if k != key}
|
||||
ctx.check(others_before == others_after, "other settings changed by the write")
|
||||
ctx.check(final.get(key) == val, "%s reads back %r, wrote %r", key, final.get(key), val)
|
||||
|
||||
# the lid lamp's idle level: resting at the setting, bounded, applied live
|
||||
lamp_was = (before.get("lid_lamp_idle") or "").strip()
|
||||
want = lamp_was or "236"
|
||||
got = ctx.sysfs("pic/lid_led")
|
||||
ev["lid_lamp"] = {"setting": lamp_was, "resting": got}
|
||||
ctx.log("lid lamp: setting %r, pic/lid_led=%s (expected %s)", lamp_was, got, want)
|
||||
ctx.check(got == want, "lid lamp rests at %s, lid_lamp_idle is %s", got, want)
|
||||
for bad in ("256", "-1", "bright"):
|
||||
st, body = fc.post("/settings", data={"lid_lamp_idle": bad})
|
||||
ctx.check(st == 400, "lid_lamp_idle=%s -> %s, expected 400", bad, st)
|
||||
ctx.log("lid_lamp_idle 256 / -1 / bright refused")
|
||||
try_level = "100" if want != "100" else "120"
|
||||
st, body = fc.post("/settings", data={"lid_lamp_idle": try_level})
|
||||
ctx.check(st == 200, "lid_lamp_idle=%s -> %s, expected 200", try_level, st)
|
||||
applied = None
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 5:
|
||||
applied = ctx.sysfs("pic/lid_led")
|
||||
if applied == try_level:
|
||||
break
|
||||
ctx.sleep(0.2)
|
||||
ctx.log("lid_lamp_idle=%s -> pic/lid_led=%s after %.1f s", try_level, applied, time.time() - t0)
|
||||
# an empty value clears the key: the query-string form carries it
|
||||
st, body = (fc.post("/settings", params={"lid_lamp_idle": ""}) if not lamp_was
|
||||
else fc.post("/settings", data={"lid_lamp_idle": lamp_was}))
|
||||
ctx.check(st == 200, "restoring lid_lamp_idle=%r -> %s", lamp_was, st)
|
||||
t0 = time.time()
|
||||
back = None
|
||||
while time.time() - t0 < 5:
|
||||
back = ctx.sysfs("pic/lid_led")
|
||||
if back == want:
|
||||
break
|
||||
ctx.sleep(0.2)
|
||||
ev["lid_lamp"].update({"applied": applied, "restored": back})
|
||||
ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied)
|
||||
ctx.check(back == want, "lamp did not return to %s after the restore (reads %s)", want, back)
|
||||
ctx.log("lid lamp follows the setting live and returns to %s", want)
|
||||
|
||||
|
||||
@test("forgectrl.panel-serves", title="Control panel and status endpoints", subsystem="forgectrl",
|
||||
kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"),
|
||||
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
|
||||
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
|
||||
("forgectrl", "src/curverec.*")],
|
||||
description="The panel page is served, /status carries the machine telemetry the panel and "
|
||||
"the acceptance tool read (including the sys block: CPU busy percent over the "
|
||||
"interval since the previous read, memory used percent; and homed_axes, "
|
||||
"the axes that carry a reference, Z alone once the lens has taken its "
|
||||
"own at the controller's start), and /cam/status "
|
||||
"answers. In GRBL mode with a live controller, /status also echoes the "
|
||||
"controller's published state file as the grbl block (fresh age, machine "
|
||||
"state, sender session, laser window and dose model, modal report), "
|
||||
"GET /grbl/settings serves the published $$ view, and the controller's "
|
||||
"settings store is /data/forgefirm/EEPROM-glowforge.DAT with nothing of "
|
||||
"it at the top of /data.")
|
||||
def panel_serves(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
st, body = fc.get("/", raw=True)
|
||||
ev["panel_status"] = st
|
||||
ctx.log("GET / -> %s (%d bytes)", st, len(body) if body else 0)
|
||||
ctx.check(st == 200, "GET / -> %s", st)
|
||||
text = body.decode("utf-8", "replace")
|
||||
ctx.check("<html" in text.lower() and "ForgeFIRM" in text, "the panel does not look like the panel")
|
||||
ctx.check(fc.token and fc.token in text, "the panel does not embed the bearer token")
|
||||
ctx.check("<link " not in text and "<script src=" not in text,
|
||||
"the panel references an external asset (the build did not bundle src/ui/)")
|
||||
# The daemon stores the page gzipped and inflates it once at first
|
||||
# request; what it serves is the plain page with the theme attribute
|
||||
# the head script sets and the one save bar every settings tab shares.
|
||||
ctx.check("data-bs-theme" in text, "the panel lacks the theme attribute (inflate failed?)")
|
||||
ctx.check('id="savebar"' in text, "the panel lacks the save bar")
|
||||
|
||||
s = fc.status()
|
||||
for key in ("state", "switches", "coolant", "fans"):
|
||||
ctx.check(key in s, "/status lacks %r", key)
|
||||
ev["state"] = s.get("state")
|
||||
ev["switches"] = s.get("switches")
|
||||
ctx.log("/status state=%s switches=%s", s.get("state"), s.get("switches"))
|
||||
for key in ("lid", "button", "interlock_ok", "head", "hv_enable"):
|
||||
ctx.check(key in (s.get("switches") or {}), "/status switches lacks %r", key)
|
||||
|
||||
# SoC utilization rides /status next to the temperatures. The CPU
|
||||
# number is a delta over the interval since the previous read, so
|
||||
# the read above primes it; after a beat both percents must be
|
||||
# numbers in range.
|
||||
ctx.sleep(1)
|
||||
sys_ = ctx.forgectrl.status().get("sys") or {}
|
||||
ev["sys"] = sys_
|
||||
ctx.log("/status sys=%s", sys_)
|
||||
ctx.check(isinstance(sys_.get("cpu_pct"), (int, float)) and 0.0 <= sys_["cpu_pct"] <= 100.0,
|
||||
"/status sys.cpu_pct is not a percent: %s", sys_)
|
||||
ctx.check(isinstance(sys_.get("mem_pct"), (int, float)) and 0.0 < sys_["mem_pct"] < 100.0,
|
||||
"/status sys.mem_pct is not a percent: %s", sys_)
|
||||
|
||||
# The lens takes its own reference on the hall edge at every
|
||||
# controller start, so with a controller running Z is referenced on
|
||||
# its own while X and Y wait for a home: homed_axes names the axes
|
||||
# that carry one, and Z reads inside the lens reach rather than the
|
||||
# zero an unreferenced axis would show.
|
||||
axes = s.get("homed_axes")
|
||||
ev["homed_axes"] = axes
|
||||
ctx.check(isinstance(axes, int) and 0 <= axes <= 7,
|
||||
"/status homed_axes is not an axis mask: %s", axes)
|
||||
ctx.check(bool(s.get("homed")) == (axes == 7),
|
||||
"/status homed (%s) disagrees with homed_axes (%s)",
|
||||
s.get("homed"), axes)
|
||||
st, mode0 = fc.get("/mode")
|
||||
if isinstance(mode0, dict) and mode0.get("controller") == "running":
|
||||
lens = s.get("lens") or {}
|
||||
pos = s.get("pos") or {}
|
||||
ctx.log("/status homed_axes=%s pos.z=%s reach=%s..%s", axes,
|
||||
pos.get("z"), lens.get("reach_min"), lens.get("reach_max"))
|
||||
ctx.check(axes is not None and axes & 4,
|
||||
"Z is not referenced with a controller running: %s", axes)
|
||||
ctx.check(isinstance(pos.get("z"), (int, float))
|
||||
and lens.get("reach_min") is not None
|
||||
and lens["reach_min"] <= pos["z"] <= lens["reach_max"],
|
||||
"Z %s is outside the lens reach %s..%s", pos.get("z"),
|
||||
lens.get("reach_min"), lens.get("reach_max"))
|
||||
|
||||
st, cam = fc.get("/cam/status")
|
||||
ev["cam_status"] = st
|
||||
ctx.log("GET /cam/status -> %s %s", st, cam)
|
||||
ctx.check(st == 200 and isinstance(cam, dict) and "running" in cam, "GET /cam/status -> %s", st)
|
||||
|
||||
# The controller's published state, echoed only while a live GRBL
|
||||
# controller runs (glowforge_status.c -> /run/forgefirm -> /status).
|
||||
st, mode = fc.get("/mode")
|
||||
if isinstance(mode, dict) and mode.get("mode") == "grbl" and mode.get("controller") == "running":
|
||||
g = ctx.forgectrl.status().get("grbl") or {}
|
||||
ev["grbl"] = g
|
||||
ctx.log("/status grbl=%s", g)
|
||||
ctx.check(isinstance(g.get("age_s"), (int, float)) and g["age_s"] < 30,
|
||||
"/status grbl block missing or stale: %s", g)
|
||||
rep = g.get("report") or {}
|
||||
for key in ("state", "sender", "laser", "modals"):
|
||||
ctx.check(key in rep, "/status grbl.report lacks %r", key)
|
||||
ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"),
|
||||
"grbl.report.laser carries no model: %s", rep.get("laser"))
|
||||
ctx.check((rep.get("laser") or {}).get("curve"),
|
||||
"grbl.report.laser carries no dose curve: %s", rep.get("laser"))
|
||||
st, text = fc.get("/grbl/settings", raw=True)
|
||||
ev["grbl_settings_status"] = st
|
||||
ctx.check(st == 200 and b"$35=" in (text or b""),
|
||||
"GET /grbl/settings -> %s without the $$ view", st)
|
||||
# The $-settings persist in the data directory, never loose in /data.
|
||||
nvs = "/data/forgefirm/EEPROM-glowforge.DAT"
|
||||
ev["grbl_nvs"] = os.path.isfile(nvs)
|
||||
ctx.check(os.path.isfile(nvs), "the controller's settings store is not at %s", nvs)
|
||||
ctx.check(not os.path.exists("/data/EEPROM-glowforge.DAT"),
|
||||
"a settings store remains at the top of /data")
|
||||
else:
|
||||
ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode)
|
||||
|
||||
# The dose-curve recorder's read surface answers in any mode.
|
||||
st, cs = fc.get("/curve/status")
|
||||
ev["curve_status"] = cs
|
||||
ctx.check(st == 200 and isinstance(cs, dict) and cs.get("state") in
|
||||
("idle", "waiting", "recording", "done", "failed"),
|
||||
"GET /curve/status -> %s %s", st, cs)
|
||||
st, text = fc.get("/curve/ladder.gcode", raw=True)
|
||||
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
|
||||
"GET /curve/ladder.gcode -> %s without the ladder", st)
|
||||
|
||||
@@ -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,5 +1,9 @@
|
||||
# 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)."""
|
||||
import glob
|
||||
import gzip
|
||||
import mmap
|
||||
import os
|
||||
@@ -105,7 +109,8 @@ def fds_of(pid):
|
||||
|
||||
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
|
||||
always=True, est_min=1,
|
||||
covers=[("forgectrl", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
|
||||
covers=[("forgectrl", "init/**"), ("forgeext", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
|
||||
("forgectrl", "src/status.*"),
|
||||
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
|
||||
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
|
||||
("linux-fslc", "**")],
|
||||
@@ -113,10 +118,20 @@ def fds_of(pid):
|
||||
"version stamped in /etc/forgefirm-version and under the machine mark in the "
|
||||
"console banner and the motd, "
|
||||
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
|
||||
"the daemon ownership, "
|
||||
"the init ordering, the file modes the release depends on, and the mounts: the "
|
||||
"the daemon ownership, the board temperatures /status reports (the chassis and "
|
||||
"the SoC die in a plausible range, the supply as a 10-bit count, the CPU "
|
||||
"unthrottled), "
|
||||
"the extension host as one process on a machine with no package installed and "
|
||||
"nothing running under a pool account, no userspace watchdog running, "
|
||||
"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 "
|
||||
"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 dev image's factory slots read-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; 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):
|
||||
ev = ctx.evidence
|
||||
manifest = ctx.runner.manifest
|
||||
@@ -252,21 +267,40 @@ def image_health(ctx):
|
||||
"controller is %r (expected running or standby)", mode.get("controller"))
|
||||
ctx.check(mode.get("motion") != "fault", "supervisor reports a motion fault")
|
||||
|
||||
# 5. init ordering: controllers stop before the daemon
|
||||
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*"))
|
||||
ev["rc6_kill"] = k
|
||||
kg = [x for x in k if "grblhal" in x]
|
||||
kf = [x for x in k if x.endswith("forgectrl")]
|
||||
ctx.log("rc6.d: %s", " ".join(k))
|
||||
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links")
|
||||
ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[0])
|
||||
# 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)
|
||||
|
||||
# 6. logging lever present, no userspace watchdog daemon
|
||||
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
|
||||
ev["logging_init"] = logging
|
||||
ctx.check(logging, "no ForgeFIRM logging/logrotate init script")
|
||||
ctx.check(not os.path.exists("/etc/init.d/watchdog"), "a userspace watchdog init script is present")
|
||||
# kernel threads (the imx2_wdt kthread is expected) have an empty cmdline
|
||||
# 5. the extension host: one process, and a campaign's machine is
|
||||
# extension-free (its init order is the rootfs lint's)
|
||||
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)
|
||||
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. no userspace watchdog daemon running (the logging init and the
|
||||
# absent watchdog init script are the rootfs lint's); kernel threads
|
||||
# (the imx2_wdt kthread is expected) have an empty cmdline
|
||||
user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")]
|
||||
ev["userspace_watchdog_pids"] = user_wd
|
||||
ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", user_wd)
|
||||
@@ -278,6 +312,14 @@ def image_health(ctx):
|
||||
ev[path] = "%o" % m
|
||||
ctx.log("%s mode %o", 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"):
|
||||
s = os.statvfs("/data")
|
||||
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
|
||||
@@ -288,8 +330,9 @@ def image_health(ctx):
|
||||
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
||||
# the state a read-only rootfs hands off (the read-only-rootfs image
|
||||
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
|
||||
# sshd host keys). The
|
||||
# dev image alone mounts the factory slots under /factory.
|
||||
# sshd host keys). The dev image's factory slots under /factory are
|
||||
# read-only; rcS's ROOTFS_READ_ONLY and the release fstab with no
|
||||
# factory slot are the rootfs lint's.
|
||||
mounts = {}
|
||||
for line in (_read("/proc/mounts", "") or "").splitlines():
|
||||
parts = line.split()
|
||||
@@ -307,8 +350,6 @@ def image_health(ctx):
|
||||
ctx.check("rw" in mount_opts("/data"), "/data is not mounted read-write: %s", mounts.get("/data"))
|
||||
ctx.check("rw" in mount_opts("/var/lib"),
|
||||
"/var/lib is not a writable copy (read-only-rootfs-hook.sh): %s", mounts.get("/var/lib"))
|
||||
rcs = _read("/etc/default/rcS", "") or ""
|
||||
ctx.check("ROOTFS_READ_ONLY=yes" in rcs.splitlines(), "/etc/default/rcS lacks ROOTFS_READ_ONLY=yes")
|
||||
factory = sorted(p for p in mounts if p.startswith("/factory/"))
|
||||
ev["factory_mounts"] = factory
|
||||
dev_image = os.path.exists("/etc/forgefirm-dev")
|
||||
@@ -319,8 +360,6 @@ def image_health(ctx):
|
||||
if os.path.exists("/dev/mmcblk2p%d" % n):
|
||||
p = "/factory/img%d" % n
|
||||
ctx.check("ro" in mount_opts(p), "%s is not mounted read-only on the dev image: %s", p, mounts.get(p))
|
||||
else:
|
||||
ctx.check(not factory, "a release image mounts the factory slots: %s", factory)
|
||||
if os.path.isfile("/data/forgefirm/users"):
|
||||
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
|
||||
if l.strip() and not l.startswith("#")]
|
||||
@@ -342,56 +381,117 @@ def image_health(ctx):
|
||||
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))
|
||||
|
||||
# 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 running (none on the image is the rootfs lint's)
|
||||
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)
|
||||
# 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.
|
||||
|
||||
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
|
||||
|
||||
+123
-499
@@ -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`.
|
||||
|
||||
Every test here can emit, except `laser.power-floor`, which only reads
|
||||
settings. The page starts a firing one only with the operator's
|
||||
eye-protection / fire-watch / exhaust acknowledgment; the test then
|
||||
prompts for the scrap and the button, streams a small job through the
|
||||
controller, and the machine fires only after the operator presses the
|
||||
physical arm button - nothing here defeats that gate, and forgetest
|
||||
never touches the laser latch. One emission per test; on abort or error
|
||||
the job is soft-reset (`^X`: controlled stop, latch relocked). The
|
||||
witnesses are forgectrl's `/status` (`laser.emission_samples` = the
|
||||
The live tests here emit. The page starts a firing one only with the
|
||||
operator's eye-protection / fire-watch / exhaust acknowledgment; the
|
||||
test then prompts for the scrap and the button, streams a small job
|
||||
through the controller, and the machine fires only after the operator
|
||||
presses the physical arm button - nothing here defeats that gate, and
|
||||
forgetest never touches the laser latch. One emission per test; on abort
|
||||
or error the job is soft-reset (`^X`: controlled stop, latch relocked).
|
||||
The witnesses are forgectrl's `/status` (`laser.emission_samples` = the
|
||||
kernel's LASER_ON sample count, `hv_current_raw`, `lid_ir`),
|
||||
`/cool/status` (`armed`), the head's beam detector
|
||||
(`head/beam_detect_analog`, the tube's own emission seen from the head:
|
||||
@@ -24,41 +28,12 @@ import time
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
from ..runner import Failed
|
||||
from .motion import (kernel_xy_mm, kernel_start, check_kernel_returned, wait_state,
|
||||
wait_state_text, wait_left_state, wait_idle, drain_text, Watch)
|
||||
from .motion import kernel_start, check_kernel_returned, Watch
|
||||
|
||||
_LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
|
||||
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.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. "
|
||||
"Put scrap under the head with %s clear, and close the lid.")
|
||||
@@ -399,45 +374,6 @@ def wait_disarm(ctx, timeout):
|
||||
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",
|
||||
subsystem="laser", kind="live", mode="grbl", always=True, est_min=5,
|
||||
covers=_LASER_COVERS,
|
||||
@@ -451,15 +387,45 @@ def power_floor(ctx):
|
||||
"(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 "
|
||||
"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 "
|
||||
"Idle (job-based, not the 60 s idle grace) and the button goes dark. Across "
|
||||
"the dwell the kernel run ends and restarts: HV_ENABLE drops with the "
|
||||
"charge-pump watchdog and is back for the third side, while the hardware "
|
||||
"button latch stays clear (cnc/button_latch 0 in every armed sample), so the "
|
||||
"second half of the square marks without a second press. The operator "
|
||||
"confirms all four sides.")
|
||||
"beam, and the armed window is observed. Across the dwell the kernel run ends "
|
||||
"and restarts: HV_ENABLE drops with the charge-pump watchdog and is back for "
|
||||
"the third side, while the hardware button latch stays clear (cnc/button_latch "
|
||||
"0 in every armed sample), so the second half of the square marks without a "
|
||||
"second press. The square ends at M5 with the window still open; once the "
|
||||
"LASER_ON count reads 0 (the dark point), a rapid runs 20 mm out over the "
|
||||
"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):
|
||||
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):
|
||||
prepare(ctx, g)
|
||||
base = sample(ctx)
|
||||
@@ -469,21 +435,46 @@ def emission_witness(ctx):
|
||||
base["armed"], base["verdict"])
|
||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
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",
|
||||
"G1 X20 F600", "G1 Y20 F600", "G4 P2", "G1 X-20 F600", "G1 Y-20 F600",
|
||||
"M5", "G90", "M2"]
|
||||
"M5"]
|
||||
ctx.arm_press()
|
||||
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:
|
||||
ctx.clear_notice()
|
||||
samples = cut + [dark] + tail
|
||||
ev["trail_fields"] = list(TRAIL_FIELDS)
|
||||
ev["trail"] = trail(samples)
|
||||
beam = beam_witness(ctx, ev, samples, base)
|
||||
emis = [s["emission"] for s in samples if s["emission"] is not None]
|
||||
beam = beam_witness(ctx, ev, cut, base)
|
||||
emis = [s["emission"] for s in cut if s["emission"] is not None]
|
||||
peak = max(emis) if emis else 0
|
||||
end = emis[-1] if emis else None
|
||||
hv = [s["hv"] for s in samples if s["hv"] is not None]
|
||||
end = next((s["emission"] for s in reversed(samples) if s["emission"] is not None), None)
|
||||
hv = [s["hv"] for s in cut if s["hv"] is not None]
|
||||
ir_peak = [0, 0, 0, 0]
|
||||
for s in samples:
|
||||
if s["ir"] and len(s["ir"]) == 4:
|
||||
@@ -527,12 +518,34 @@ def emission_witness(ctx):
|
||||
ev["exhaust_floor"] = floor
|
||||
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
|
||||
ev["exhaust_at_first_fire"], floor)
|
||||
# X-3: job-based disarm at Idle after M2
|
||||
dt = wait_disarm(ctx, 75)
|
||||
# The rapid after the M5, with the window open and FIRE clear: every
|
||||
# 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
|
||||
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(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)
|
||||
ctx.check(not idle_fire,
|
||||
"the laser fired while the cooling engine had no run session, so the fans were at "
|
||||
@@ -544,8 +557,18 @@ def emission_witness(ctx):
|
||||
"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)",
|
||||
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,
|
||||
"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"])
|
||||
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 "
|
||||
@@ -557,172 +580,14 @@ def emission_witness(ctx):
|
||||
judge_beam(ctx, beam)
|
||||
check_button_dark(ctx, ev)
|
||||
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 "
|
||||
"dark, mark confirmed", peak, ev["hv_min"], ev["hv_max"], beam["delta"], dt)
|
||||
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s; the rapid after the M5 dark (HV max %s); "
|
||||
"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
|
||||
|
||||
|
||||
@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",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=6,
|
||||
covers=_LASER_COVERS + [("forgectrl", "src/main.c")],
|
||||
@@ -835,253 +700,12 @@ def armed_kill(ctx):
|
||||
"respawned with the latch still locked, button dark", ev["expected"]["zero_at_s"], zero_at)
|
||||
|
||||
|
||||
@test("laser.verdict-cut", title="A blocked verdict mid-cut locks the latch and holds; the clean "
|
||||
"verdict resumes with no press",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=4,
|
||||
covers=_LASER_COVERS,
|
||||
requires=["laser.emission-witness"], actions=["button"],
|
||||
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white (the arm). Nothing else: the test "
|
||||
"takes the cooling verdict away itself and gives it back."],
|
||||
description="A 40 mm line at constant power (M3 S400/F300, 8 s). About 1.5 s in, the test "
|
||||
"pauses the machine-services daemon for 3.5 s, so the cooling verdict the "
|
||||
"controller reads goes stale: the controller's pause tier. The controller must "
|
||||
"hold the job (grbl Hold) under the still-open armed window with the stream "
|
||||
"masked dark, so the kernel's LASER_ON sample count reads 0 before the cut "
|
||||
"resumes, and it must not write the latch: a lock sets the hardware button "
|
||||
"latch, which only a press clears, so both bit 3 (the SoC lock) and bit 2 "
|
||||
"(the button latch) of interlock_circuit stay clear through the whole run. "
|
||||
"When the daemon returns (the verdict fresh, clean, resume_ok), the controller "
|
||||
"must resume the cut lit from where it stopped, with no button press and no "
|
||||
"prompt; the M2 program end then disarms as usual. The daemon's own dead-man "
|
||||
"(5 s of silence) is not reached: the controller keeps reporting through the "
|
||||
"pause. The beam detector witnesses the cut on both sides of the hold.")
|
||||
def verdict_cut(ctx):
|
||||
import os as _os
|
||||
import signal as _signal
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
# The daemon is frozen long enough for the verdict the controller
|
||||
# caches to expire (VERDICT_MAX_AGE 2.0 s), so the driver's pause tier
|
||||
# holds the job - but not so long that the engine's report dead-man
|
||||
# trips on resume. That dead-man locks the laser latch when the
|
||||
# controller's report is older than REPORT_TIMEOUT_S (5.0 s) while
|
||||
# armed, which is exactly the "latch stayed locked" this test refuses.
|
||||
# The last report is up to 1 s old when the freeze starts, so the
|
||||
# freeze must stay under 4.0 s; 3.0 s clears the stale floor with room
|
||||
# to see the hold and stays a full second under the dead-man.
|
||||
PAUSE_S = 3.0
|
||||
pids = hw.pidof("forgectrl")
|
||||
ctx.check(pids, "no forgectrl process found to pause")
|
||||
ev["daemon_pids"] = pids
|
||||
state_path = "/run/forgefirm/grbl.state"
|
||||
|
||||
def grbl_state_file():
|
||||
try:
|
||||
with open(state_path) as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
def resume_daemon():
|
||||
for p in pids:
|
||||
try:
|
||||
_os.kill(p, _signal.SIGCONT)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
trail = []
|
||||
text = ""
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
k0 = kernel_start(ctx)
|
||||
base = sample(ctx)
|
||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
ctx.ready(ARM_CUE % "40 mm +X")
|
||||
job = ["G91", "G21", "M3", "S400", "G1 X40 F300", "M5", "G0 X-40", "G90", "M2"]
|
||||
smp = arm_and_fire(ctx, g, room="40 mm +X", job=job)
|
||||
beams = [(smp.get("beam"), smp.get("beam_d"))]
|
||||
ctx.log("emission live (%s); pausing the daemon in 1 s", smp["emission"])
|
||||
ctx.sleep(1.0)
|
||||
g.drain()
|
||||
t0 = time.time()
|
||||
try:
|
||||
for p in pids:
|
||||
_os.kill(p, _signal.SIGSTOP)
|
||||
ctx.log("daemon paused (SIGSTOP %s) for %.1f s: the verdict goes stale", pids, PAUSE_S)
|
||||
# No HTTP while the daemon is paused: the controller's socket,
|
||||
# sysfs and the controller's state file are the witnesses.
|
||||
while time.time() - t0 < PAUSE_S:
|
||||
st = g.status_report()["state"]
|
||||
text += g.drain()
|
||||
il = hw.sysfs_int("cnc/interlock_circuit")
|
||||
trail.append({"t": round(time.time() - t0, 2), "gstate": st, "il": il,
|
||||
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
|
||||
"armed": '"armed":true' in grbl_state_file()})
|
||||
time.sleep(0.12)
|
||||
finally:
|
||||
resume_daemon()
|
||||
ctx.log("daemon resumed (SIGCONT) at +%.2f s", time.time() - t0)
|
||||
# Through the resume and to the end of the cut, sampling the same
|
||||
# witnesses (the daemon's own once it answers again).
|
||||
resumed_at = None
|
||||
end = time.time() + 30
|
||||
while time.time() < end:
|
||||
ctx.checkpoint()
|
||||
st = g.status_report()["state"]
|
||||
text += g.drain()
|
||||
il = hw.sysfs_int("cnc/interlock_circuit")
|
||||
row = {"t": round(time.time() - t0, 2), "gstate": st, "il": il,
|
||||
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
|
||||
"armed": '"armed":true' in grbl_state_file()}
|
||||
if time.time() - t0 > PAUSE_S + 1.0:
|
||||
s = sample(ctx)
|
||||
if s:
|
||||
row["beam"] = s.get("beam")
|
||||
beams.append((s.get("beam"), s.get("beam_d")))
|
||||
trail.append(row)
|
||||
if resumed_at is None and st.startswith("Run"):
|
||||
resumed_at = time.time() - t0
|
||||
if st.startswith("Idle") and resumed_at is not None and time.time() - t0 > resumed_at + 3.0:
|
||||
break
|
||||
time.sleep(0.12)
|
||||
for r in trail:
|
||||
ctx.log(" %s", r)
|
||||
ev["trail"] = trail
|
||||
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
|
||||
ctx.log("controller: %s", ev["messages"])
|
||||
dt = wait_disarm(ctx, 75)
|
||||
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
||||
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the job")
|
||||
check_kernel_returned(ctx, ev, k0)
|
||||
|
||||
def locked(r):
|
||||
return r["il"] is not None and bool(r["il"] & IL_LASER_LATCH)
|
||||
|
||||
def button_latched(r):
|
||||
return r["il"] is not None and bool(r["il"] & IL_BUTTON_LATCH)
|
||||
|
||||
held = [r for r in trail if r["gstate"].startswith("Hold")]
|
||||
ev.update({"held_at_s": held[0]["t"] if held else None,
|
||||
"resumed_at_s": round(resumed_at, 2) if resumed_at else None,
|
||||
"latch_locked_samples": sum(1 for r in trail if locked(r)),
|
||||
"button_latch_set_samples": sum(1 for r in trail if button_latched(r))})
|
||||
ctx.check(held and held[0]["t"] < PAUSE_S, "the stale verdict did not hold the job during the "
|
||||
"pause (first Hold at %s)", ev["held_at_s"])
|
||||
ctx.check("fire masked, job held" in text, "the controller did not report the pause tier")
|
||||
ctx.check(ev["latch_locked_samples"] == 0,
|
||||
"the pause tier wrote the latch (locked in %d samples): a lock sets the hardware "
|
||||
"button latch and the resume would need a press", ev["latch_locked_samples"])
|
||||
ctx.check(ev["button_latch_set_samples"] == 0,
|
||||
"the hardware button latch read SET in %d samples: the resume needed a press",
|
||||
ev["button_latch_set_samples"])
|
||||
ctx.check(all(r["armed"] for r in trail if r["t"] < (resumed_at or PAUSE_S + 5)),
|
||||
"the armed window closed during the pause")
|
||||
ctx.check("press the button" not in text, "the resume asked for a button press")
|
||||
ctx.check(DISARMED_MSG not in text.split("resuming")[0], "the pause tier disarmed the job")
|
||||
# Dark from the hold until the resume: the gate masked the stream.
|
||||
span = [r for r in trail if held and r["t"] >= held[0]["t"]
|
||||
and (resumed_at is None or r["t"] < resumed_at)]
|
||||
zero = next((r for r in span if r["emission"] == 0), None)
|
||||
ev["emission_zero_after_hold_s"] = round(zero["t"] - held[0]["t"], 2) if zero else None
|
||||
ctx.check(zero is not None and zero["t"] - held[0]["t"] <= 2.5,
|
||||
"emission did not read 0 within 2.5 s of the hold (before the resume)")
|
||||
ctx.check(resumed_at is not None, "the clean verdict did not resume the cut")
|
||||
ctx.check("resuming" in text, "the controller did not report the resume")
|
||||
after = [r for r in trail if resumed_at and r["t"] >= resumed_at]
|
||||
ctx.check(any(r["emission"] for r in after), "the resumed cut ran dark (no emission after the resume)")
|
||||
ctx.check("ALARM" not in text, "an alarm was raised on the pause tier")
|
||||
ctx.check(dt is not None and dt < 10.0, "the M2 job did not disarm promptly at Idle (%s s)", dt)
|
||||
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base)
|
||||
judge_beam(ctx, ev["beam"], "the cut")
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.log("PASS: held at +%s s with the latch untouched, emission 0 %s s after the hold, resumed "
|
||||
"lit at +%s s with no press, disarmed %.1f s after Idle", ev["held_at_s"],
|
||||
ev["emission_zero_after_hold_s"], ev["resumed_at_s"], dt)
|
||||
|
||||
|
||||
DISARMED_MSG = "laser disarmed - latch locked"
|
||||
|
||||
|
||||
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
|
||||
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 "
|
||||
"cancels it and sends the head home",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=7,
|
||||
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
|
||||
("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"],
|
||||
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 "
|
||||
|
||||
@@ -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)
|
||||
+201
-700
@@ -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.
|
||||
|
||||
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
|
||||
suite is the only Grbl client while a test runs.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
@@ -56,39 +63,6 @@ def wait_state(ctx, g, prefix, timeout):
|
||||
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):
|
||||
"""Poll until Idle; returns (peak_feed_mm_min, states_seen, final_report).
|
||||
An Idle report inside the first `grace` seconds counts only once a
|
||||
@@ -157,47 +131,6 @@ def clear_error(ctx, g):
|
||||
"the controller answered %s to a blank line at the start - not a clean parser", r)
|
||||
|
||||
|
||||
MOVE_START_S = 2.0 # Run is there within 100 ms; a loaded board gets this long
|
||||
|
||||
|
||||
def start_move(ctx, g, line):
|
||||
"""Start a travel job with `line` and see the controller in Run. The
|
||||
wait is bounded, not a fixed sleep. When Run never comes, the failure
|
||||
names what happened instead: the reply (a refused block answers
|
||||
error), the state the controller sits in (Hold, Door, Alarm, or an
|
||||
Idle that never took the block), every line it said meanwhile, and
|
||||
the machine as forgectrl sees it - the evidence a bare "did not
|
||||
start" leaves out. Recorded in the evidence either way."""
|
||||
reply = g.command(line, timeout=1.0)
|
||||
end = time.time() + MOVE_START_S
|
||||
t0 = time.time()
|
||||
said = ""
|
||||
st = None
|
||||
while time.time() < end:
|
||||
ctx.checkpoint()
|
||||
st = g.status_report()
|
||||
if st["state"].startswith("Run"):
|
||||
break
|
||||
said += g.drain()
|
||||
time.sleep(0.1)
|
||||
rec = {"line": line, "reply": reply, "state": st["state"] if st else None,
|
||||
"after_s": round(time.time() - t0, 2)}
|
||||
ctx.evidence.setdefault("move_start", []).append(rec)
|
||||
if st is not None and st["state"].startswith("Run"):
|
||||
return st
|
||||
msgs = [ln.strip() for ln in said.splitlines() if ln.strip()]
|
||||
rec["said"] = msgs
|
||||
s = ctx.forgectrl.status() or {}
|
||||
gr = ((s.get("grbl") or {}).get("report") or {})
|
||||
rec["forgectrl"] = {"state": s.get("state"), "switches": s.get("switches"),
|
||||
"laser_locked": s.get("laser_locked"), "grbl": gr.get("state"),
|
||||
"alarm": gr.get("alarm"), "sender": (gr.get("sender") or {}).get("connected")}
|
||||
ctx.check(False, "the move did not start: %s answered %s; controller %s %.1f s later, said %s; "
|
||||
"forgectrl sees kernel %s, grbl %s (alarm %s), switches %s, laser locked %s",
|
||||
line, reply, st["state"] if st else "no report", rec["after_s"], msgs or "nothing",
|
||||
s.get("state"), gr.get("state"), gr.get("alarm"), s.get("switches"), s.get("laser_locked"))
|
||||
|
||||
|
||||
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=1,
|
||||
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
|
||||
@@ -626,171 +559,6 @@ def _wait_controller(ctx, fc, states, timeout, motion=None):
|
||||
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
|
||||
|
||||
@test("motion.cancel-abort", title="Jog cancel and controlled abort recover cleanly", subsystem="motion",
|
||||
kind="auto", mode="grbl", est_min=2,
|
||||
covers=_MOTION_COVERS, requires=["motion.pacing"],
|
||||
steps=["Bed clear; the head needs 40 mm of free +X travel."],
|
||||
description="A jog-cancel (0x85) stops a jog short of its target and returns to Idle with "
|
||||
"position preserved; a ^X abort mid-move (what the sender's Stop sends) "
|
||||
"decelerates under control into Alarm with machine position retained, leaves the "
|
||||
"head where it stopped - no return-to-start, that is the lid policy's move alone - "
|
||||
"and $X recovers to Idle with a subsequent jog running (no driver wedge: the rail "
|
||||
"never cycled).")
|
||||
def cancel_abort(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
start = g.status_report()["MPos"]
|
||||
# jog cancel
|
||||
g.command("$J=G91X40F2400")
|
||||
ctx.sleep(0.4)
|
||||
g.realtime(0x85)
|
||||
st = wait_state(ctx, g, "Idle", 5)
|
||||
ctx.check(st is not None, "not Idle within 5 s of the jog cancel")
|
||||
p1 = st["MPos"]
|
||||
moved1 = p1[0] - start[0]
|
||||
ev["cancel_moved_mm"] = round(moved1, 3)
|
||||
ctx.log("jog cancel: moved %.3f mm of 40 (state %s)", moved1, st["state"])
|
||||
ctx.check(0.5 < moved1 < 39.0, "jog cancel did not stop short of the target (%.3f mm)", moved1)
|
||||
# ^X abort mid-move
|
||||
g.command("G91")
|
||||
g.command("G1X30F600", timeout=0.5)
|
||||
ctx.sleep(1.0)
|
||||
g.realtime(0x18)
|
||||
st = wait_state(ctx, g, "Alarm", 5)
|
||||
ctx.check(st is not None, "^X did not land in Alarm within 5 s")
|
||||
p2 = st["MPos"]
|
||||
moved2 = p2[0] - p1[0]
|
||||
ev["abort_moved_mm"] = round(moved2, 3)
|
||||
ctx.log("^X abort: state %s, moved %.3f mm of 30, position retained %s", st["state"], moved2, p2)
|
||||
ctx.check(0.5 < moved2 < 29.5, "abort position not retained/plausible (%.3f mm)", moved2)
|
||||
r = g.command("$X")
|
||||
ctx.log("$X -> %s", r)
|
||||
st = wait_state(ctx, g, "Idle", 5)
|
||||
ctx.check(st is not None, "$X did not recover to Idle")
|
||||
# A sender abort is not a lid cancel: it stops where it stopped and
|
||||
# the head stays there. Only the lid/interlock policy returns to the
|
||||
# job start, and an unasked-for return move would be a surprise to
|
||||
# whoever pressed Stop.
|
||||
text = drain_text(g, 2.0)
|
||||
ev["abort_returned_home"] = "returned to the job start" in text
|
||||
p3 = g.status_report()["MPos"]
|
||||
ev["abort_drift_after_recovery_mm"] = round(abs(p3[0] - p2[0]), 3)
|
||||
ctx.log("after $X: %s (moved %.3f mm since the abort)", p3, ev["abort_drift_after_recovery_mm"])
|
||||
ctx.check(not ev["abort_returned_home"], "a sender abort triggered the return-to-start motion")
|
||||
ctx.check(ev["abort_drift_after_recovery_mm"] <= 0.05,
|
||||
"the head moved %.3f mm on its own after a sender abort",
|
||||
ev["abort_drift_after_recovery_mm"])
|
||||
# a jog after the abort proves the drivers are alive; return to start
|
||||
back = -(p2[0] - start[0])
|
||||
r = g.command("$J=G91X%.3fF2400" % back)
|
||||
ctx.check(not any(x.startswith("error") for x in r), "return jog refused: %s", r)
|
||||
peak, states, st = wait_idle(ctx, g, 30)
|
||||
ctx.check("TIMEOUT" not in states, "return jog did not complete: %s", states)
|
||||
final = st["MPos"]
|
||||
drift = abs(final[0] - start[0])
|
||||
ev["final_drift_mm"] = round(drift, 3)
|
||||
ctx.log("returned: drift %.3f mm", drift)
|
||||
ctx.check(drift <= 0.05, "position drift %.3f mm after cancel/abort/return", drift)
|
||||
g.command("G90")
|
||||
machine_idle(ctx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- dead-man
|
||||
|
||||
def _kernel_x_mm(ctx):
|
||||
@@ -816,7 +584,7 @@ def _return_x(ctx, delta_mm):
|
||||
"a kill during the web-service homing",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=6,
|
||||
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
|
||||
requires=["motion.cancel-abort", "kernel.k1-k2"],
|
||||
requires=["motion.pacing", "kernel.k1-k2"],
|
||||
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."],
|
||||
@@ -1139,139 +907,8 @@ def homing_kill(ctx, wait_running):
|
||||
ctx.log("restore homing_mode=%r -> %s", hm, st)
|
||||
|
||||
|
||||
@test("motion.respawn-gate", title="A respawn waits for the lid the way a first spawn does",
|
||||
subsystem="motion", kind="operator", mode="grbl", est_min=2,
|
||||
covers=_MOTION_COVERS, requires=["motion.deadman", "motion.gate-waits-for-lid"], actions=["lid"],
|
||||
steps=["Bed clear. Open the lid when told and close it when told; nothing moves."],
|
||||
description="The enclosure check runs before every controller spawn, respawns included. "
|
||||
"With the lid open, the controller is killed: the supervisor safes the machine "
|
||||
"(latch locked), reports waiting with why naming the lid, and starts no controller "
|
||||
"while the lid stays open. When the lid closes the controller comes back verified, "
|
||||
"without a second motion probe (the probe is once per broker hold).")
|
||||
def respawn_gate(ctx):
|
||||
import os as _os
|
||||
import signal as _signal
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
|
||||
st, m0 = fc.get("/mode")
|
||||
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
|
||||
pid0 = m0.get("pid")
|
||||
off = _log_offset(FORGECTRL_LOG)
|
||||
ctx.act("lid", "open")
|
||||
try:
|
||||
ctx.sleep(1.0)
|
||||
_os.kill(pid0, _signal.SIGKILL)
|
||||
t_kill = time.time()
|
||||
ctx.log("SIGKILL sent to controller pid %d with the lid open", pid0)
|
||||
waited = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("controller") == "waiting", 15)
|
||||
st, m1 = fc.get("/mode")
|
||||
ev["after_kill"] = {"waiting_s": waited, "mode": m1}
|
||||
ctx.log("mode %.1f s after the kill: %s", time.time() - t_kill, m1)
|
||||
ctx.check(waited is not None, "the supervisor did not report waiting for the lid: %s", m1)
|
||||
ctx.check("lid" in (m1.get("why") or ""), "why does not name the lid: %r", m1.get("why"))
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
ctx.check(ilk is not None and ilk & (1 << 3), "latch not locked after the kill")
|
||||
ctx.sleep(4.0)
|
||||
st, m2 = fc.get("/mode")
|
||||
ctx.check(isinstance(m2, dict) and m2.get("controller") == "waiting" and not m2.get("pid"),
|
||||
"the wait did not hold with the lid open: %s", m2)
|
||||
finally:
|
||||
ctx.act("lid", "close")
|
||||
t_close = time.time()
|
||||
came = ctx.wait_for(lambda: ((fc.get("/mode")[1] or {}).get("controller") == "running"
|
||||
and (fc.get("/mode")[1] or {}).get("pid") != pid0), 60)
|
||||
st, m3 = fc.get("/mode")
|
||||
ev["after_close"] = {"running_s": came, "mode": m3}
|
||||
ctx.log("mode %.1f s after the lid closed: %s", time.time() - t_close, m3)
|
||||
ctx.check(came is not None and m3.get("motion") == "verified",
|
||||
"the controller did not come back verified after the lid closed: %s", m3)
|
||||
lines = _probe_lines(FORGECTRL_LOG, off)
|
||||
ev["probe_lines"] = lines[-2:]
|
||||
ctx.check(not lines, "the motion probe ran again for the respawn: %s", lines[:1])
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the end")
|
||||
ctx.log("PASS: the respawn waited %.1f s for the lid, then came back verified in %.1f s with no probe",
|
||||
t_close - t_kill, came)
|
||||
|
||||
|
||||
@test("motion.soft-limits", title="After a home the bed is the X/Y envelope",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=3,
|
||||
covers=_MOTION_COVERS, requires=["motion.jog-roundtrip", "cloud.mode-switch"],
|
||||
steps=["Bed clear, lid closed. The test homes the machine through the web service if it is "
|
||||
"not homed (about a minute), then sends moves the controller must refuse."],
|
||||
description="The machine has no limit switches, so the core's $20 cannot be turned on and "
|
||||
"the X/Y soft limits are the driver's: off while the position is not trusted, on "
|
||||
"after a home, when the envelope is the bed ($130 by $131 from the home corner). "
|
||||
"Homed, a program move 5 mm past X max or Y max, or past the near edge, raises "
|
||||
"ALARM:2 before any motion (the kernel counters do not move), a jog past the bed "
|
||||
"is refused with error 15, and a move inside the bed runs. The Z envelope is the "
|
||||
"lens window, as before.")
|
||||
def soft_limits(ctx):
|
||||
from .cloud import gfhome_homing
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
if not fc.status().get("homed"):
|
||||
gfhome_homing(ctx, ev, g)
|
||||
ctx.check(fc.status().get("homed"), "the machine is not homed")
|
||||
x_travel = float(grbl_setting(g, "$130"))
|
||||
y_travel = float(grbl_setting(g, "$131"))
|
||||
ev["travel"] = {"x": x_travel, "y": y_travel}
|
||||
k0 = kernel_xy_mm(ctx)
|
||||
|
||||
def refused(cmd):
|
||||
lines = g.command(cmd, timeout=2)
|
||||
ctx.sleep(0.5)
|
||||
text = "\n".join(lines) + g.drain()
|
||||
k1 = kernel_xy_mm(ctx)
|
||||
moved = max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1]))
|
||||
st = g.status_report()["state"]
|
||||
rec = {"cmd": cmd, "reply": lines, "alarm": "ALARM:2" in text, "moved_mm": round(moved, 3), "state": st}
|
||||
ctx.log("%s", rec)
|
||||
ctx.check(rec["alarm"], "%s was not refused with ALARM:2: %s", cmd, lines)
|
||||
ctx.check(moved < 0.05, "%s moved the kernel %.3f mm before the alarm", cmd, moved)
|
||||
g.realtime(0x18)
|
||||
ctx.sleep(1.5)
|
||||
g.drain()
|
||||
unlock = g.command("$X")
|
||||
ctx.check(unlock and unlock[-1] == "ok", "$X after the soft-limit alarm: %s", unlock)
|
||||
st = g.status_report()["state"]
|
||||
ctx.check(st.startswith("Idle"), "controller is %s after the recovery", st)
|
||||
return rec
|
||||
|
||||
ev["refused"] = [refused("G90 G1 X%.1f F600" % (x_travel + 5)),
|
||||
refused("G90 G1 Y%.1f F600" % (y_travel + 5)),
|
||||
refused("G90 G1 X-1 F600")]
|
||||
# Homed still: the soft-limit alarm and its reset keep the reference.
|
||||
ctx.check(fc.status().get("homed"), "the soft-limit alarm and the reset un-homed the machine")
|
||||
jog = g.command("$J=G91X%.1fF1200" % (x_travel + 5))
|
||||
ev["jog"] = jog
|
||||
ctx.check(any(l.startswith("error:15") for l in jog), "a jog past the bed was not refused with error 15: %s", jog)
|
||||
# The core answers the line after an error with that error again
|
||||
# until an empty line clears it (the sender's acknowledgment).
|
||||
g.command("")
|
||||
g.command("G90 G1 X10 Y10 F600", timeout=0.5)
|
||||
peak, states, st = wait_idle(ctx, g, 20)
|
||||
ev["inside"] = states
|
||||
ctx.check("TIMEOUT" not in states, "a move inside the bed did not run")
|
||||
g.command("G90 G1 X0 Y0 F600", timeout=0.5)
|
||||
wait_idle(ctx, g, 20)
|
||||
k1 = kernel_start(ctx) # at rest: grbl's Idle leads the kernel's tail
|
||||
ev["kernel_back_mm"] = [round(k1[0] - k0[0], 3), round(k1[1] - k0[1], 3)]
|
||||
ctx.check(max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1])) < 0.1, "the head did not come back to the corner: %s",
|
||||
ev["kernel_back_mm"])
|
||||
ctx.log("PASS: X max, Y max and X min refused with ALARM:2 and no motion, the jog refused with "
|
||||
"error 15, a move inside the bed ran")
|
||||
|
||||
|
||||
# ------------------------------------------------- lid / button (the factory's)
|
||||
|
||||
_LID_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):
|
||||
"""The kernel's own position counters, in mm (forgectrl /status pos):
|
||||
what the machine physically did, independent of what grbl believes."""
|
||||
@@ -1339,335 +976,6 @@ def drain_text(g, seconds):
|
||||
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")
|
||||
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||
g.drain() # the message window opens here
|
||||
w = Watch(g)
|
||||
ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
|
||||
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")
|
||||
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||
g.drain() # the message window opens here
|
||||
ctx.act("lid", "open", text="Leave it open until the head has come back.", timeout=12)
|
||||
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "lid opened", "running")
|
||||
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")
|
||||
start_move(ctx, g, "G1X60F300") # a 12 s move
|
||||
g.drain()
|
||||
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
|
||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
||||
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")
|
||||
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||
g.drain()
|
||||
ctx.act("lid", "open", timeout=12)
|
||||
st, text = wait_state_text(ctx, g, "Door", 8)
|
||||
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"
|
||||
SCHED_FIFO = 1
|
||||
|
||||
@@ -1773,3 +1081,196 @@ def step_timing_under_load(ctx):
|
||||
"; ".join(clamped))
|
||||
ctx.log("PASS: %d legs at 2000 mm/min against a nice-5 CPU hog, no clamped events",
|
||||
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)
|
||||
|
||||
+55
-1434
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
@@ -1,3 +1,8 @@
|
||||
# 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,
|
||||
the prompts answered from here (the bench fixture works the lid and the
|
||||
@@ -12,7 +17,7 @@ import time
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
from ..baseline import read_position
|
||||
from .setup import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
|
||||
from .setup import wiz, Restore, INTAKE_KEY
|
||||
|
||||
POLL_S = 1.0
|
||||
# What the machine's own press prompt says, used when it sends no text.
|
||||
@@ -24,6 +29,27 @@ PURGE_SPINUP_S = 30
|
||||
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||
("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):
|
||||
@@ -116,7 +142,7 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
||||
@test("setup.check-switches", title="The switches check follows the lid and the button",
|
||||
subsystem="setup", kind="operator", est_min=3,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
||||
requires=["forgectrl.auth"], actions=["lid", "button"],
|
||||
requires=["forgectrl.auth"], actions=["lid", "button", "interlock"],
|
||||
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
||||
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
|
||||
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
|
||||
@@ -148,58 +174,48 @@ def check_switches(ctx):
|
||||
ctx.log("switches: %s", json.dumps(r))
|
||||
|
||||
|
||||
@test("setup.check-sensors", title="The sensors check reads a plausible machine at rest",
|
||||
subsystem="setup", kind="auto", est_min=2,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
|
||||
("forgectrl", "src/cool.c")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="POST /wiz/sensors/start; ten seconds of readings and no question asked. The "
|
||||
"result carries both coolant temperatures, the chassis and SoC, the lid IR "
|
||||
"maxima, the accelerometer event count, the supply power-good, the HV current, "
|
||||
"and the idle fan speeds; the record carries sensors at version 1 and the "
|
||||
"settings read as before (the check writes none).")
|
||||
def check_sensors(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings() or {}
|
||||
ev["settings_before"] = dict(before)
|
||||
|
||||
def on_prompt(p):
|
||||
ctx.fail("the sensors check asked a question: %s", p)
|
||||
last = run_check(ctx, "sensors", on_prompt, 120)
|
||||
r = last.get("result") or {}
|
||||
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
||||
"exhaust_rpm_idle", "intake_rpm_idle"):
|
||||
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
||||
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
|
||||
after = fc.settings() or {}
|
||||
changed = {k: (before.get(k), after.get(k)) for k in set(before) | set(after)
|
||||
if before.get(k) != after.get(k)}
|
||||
ctx.check(not changed, "the sensors check wrote a setting: %s", changed)
|
||||
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
|
||||
r.get("lid_ir_max"), r.get("hv_current_max"))
|
||||
|
||||
|
||||
@test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
||||
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
||||
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
||||
requires=["forgectrl.auth"], precheck=intakes_counted,
|
||||
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
|
||||
"for 35 s, the purge is measured on and off, and five settings are written: the "
|
||||
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
|
||||
"fan must read at least 1000 rpm. The five settings are put back as found at the "
|
||||
"end, the purge fan is on again and drawing over the floor the check just wrote, "
|
||||
"and the controller must be running again.")
|
||||
"for 35 s, the purge is measured on and off, and the five floors are written: the "
|
||||
"four at 55 percent of steady and the grace from the slowest spin-up. The intake "
|
||||
"fan count is the machine's: intake 2 turning proves two fans, both intakes are "
|
||||
"judged, and the count is written as 2; intake 2 silent on a machine counted with "
|
||||
"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):
|
||||
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
|
||||
"cool_purge_min_current", "cool_fan_grace_s"]
|
||||
with Restore(ctx, keys):
|
||||
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)
|
||||
r = last.get("result") 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"):
|
||||
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.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
|
||||
floors = r.get("floors") or {}
|
||||
@@ -208,6 +224,12 @@ def check_airflow(ctx):
|
||||
s = ctx.forgectrl.settings() or {}
|
||||
for k in keys:
|
||||
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
|
||||
# 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))
|
||||
# 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
|
||||
@@ -239,41 +261,6 @@ def check_airflow(ctx):
|
||||
ctx.check(ok is not None, "the controller did not come back after the check")
|
||||
|
||||
|
||||
@test("setup.check-cameras", title="The cameras check captures both cameras",
|
||||
subsystem="setup", kind="operator", est_min=2,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
|
||||
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
|
||||
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
|
||||
"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("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
||||
@@ -315,22 +302,103 @@ def check_motion(ctx):
|
||||
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
|
||||
|
||||
|
||||
@test("setup.check-flow-verify", title="The flow check runs as a setup check",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
||||
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
||||
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
||||
"check runner: the diagnostic's phases show as the check's, its result becomes the "
|
||||
"check's result with pass true, and the record carries cooling.flow-verify at "
|
||||
"version 1. A thin margin recommends the calibration in the record's flags. The "
|
||||
"air-assist offset calibration is named first among the prerequisites so a queue "
|
||||
"keeps it ahead of every heater tool this check pulls forward.")
|
||||
def check_flow_verify(ctx):
|
||||
last = run_check(ctx, "cooling.flow-verify", lambda p: None, 600)
|
||||
@test("setup.check-lens", title="The lens travel check finds the stops and writes the window",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=4,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/lensfind.*"), ("forgectrl", "src/lens.*"),
|
||||
("forgectrl", "src/lenshome.*"), ("forgectrl", "src/accel.c"),
|
||||
("forgectrl", "src/cool.c"), ("forgectrl", "src/super.c")],
|
||||
requires=["forgectrl.auth", "setup.check-motion"],
|
||||
description="POST /wiz/lens.travel/start: the controller stops, the lens takes its hall-edge "
|
||||
"reference, the fans stop and the head accelerometer listens while the lens "
|
||||
"steps to each stop and back (nothing fires), the two window settings are "
|
||||
"written, and the controller comes back. No question is asked. The result "
|
||||
"carries the reference passes, the stops (found, the free half-steps each way, "
|
||||
"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 {}
|
||||
ctx.check(r.get("pass") is True, "the flow check did not pass: %s", r)
|
||||
ctx.log("flow check: threshold %s, flow %s, no-flow %s, thin %s", r.get("threshold"),
|
||||
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
|
||||
ctx.check(len(r.get("z_passes") or []) >= 1, "the lens was not referenced: %s", r.get("z_passes"))
|
||||
stops, w = r.get("stops") or {}, r.get("window") or {}
|
||||
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("setup.cloud-header-capture", title="The cloud header check takes one print's envelope",
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
"""setup.sheet - the setup's sheet (the live wizards) in one run,
|
||||
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
|
||||
polled, the prompts answered from here, each burn armed by a press, the
|
||||
result judged, and every setting a wizard wrote put back as found. One
|
||||
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 .setup_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/setup.*"),
|
||||
("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("setup.sheet", title="The setup sheet: the placement, the frame, and the five cards",
|
||||
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=35,
|
||||
covers=SHEET_COVERS,
|
||||
requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "cooling.flow-verify"],
|
||||
actions=["button"], hands=["scrap"],
|
||||
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
|
||||
"it goes with its top edge at the top of the cut area (the head's home corner); "
|
||||
"lid closed. Eye protection on, exhaust on, extinguisher in reach.",
|
||||
"Start the test and press the machine's button once. That press says you are at the "
|
||||
"machine, and the bench actuator makes every arm press after it, 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")
|
||||
@@ -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,6 +1,10 @@
|
||||
# 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."""
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import shutil
|
||||
@@ -20,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 "
|
||||
"throwaway key made on the spot verifies with its own key and fails against the "
|
||||
"shipped release key, and an apply of it without confirm_unsigned is refused by "
|
||||
"the update job before anything is written. Nothing is written to any slot.")
|
||||
"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):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
@@ -91,6 +96,11 @@ def slots_and_signature(ctx):
|
||||
target = name
|
||||
ctx.check(target is not None, "no inactive firmware slot in /slots: %s", slots)
|
||||
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"})
|
||||
ev["apply"] = {"status": st, "body": body}
|
||||
ctx.log("POST /update/apply slot=%s file=upload (no confirm_unsigned) -> %s %s", target, st, body)
|
||||
@@ -113,82 +123,27 @@ def slots_and_signature(ctx):
|
||||
ctx.check(isinstance(result, dict) and result.get("ok") is False
|
||||
and "not signed" in str(result.get("error", "")),
|
||||
"the apply of a foreign-signed archive was not refused for its signature: %s", result)
|
||||
|
||||
# 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:
|
||||
try:
|
||||
os.unlink(staged)
|
||||
except OSError:
|
||||
pass
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
_VERSION_RX = re.compile(r"^v?\d+\.\d+\.\d+")
|
||||
|
||||
|
||||
@test("update.release-check", title="Release check answers from the releases API; the alert dismissal is per release",
|
||||
subsystem="update", kind="auto", est_min=1,
|
||||
covers=_UPDATE_COVERS + [("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="GET /update/release answers the kept answer of the daily check (available, version, "
|
||||
"current, new, checked, dismissed). POST /update/check asks the releases API now and "
|
||||
"answers the same shape; a machine with no route to the API answers 502, which the drill "
|
||||
"records and steps over. A published release is a v<semver> tag with the firmware "
|
||||
"file's size and its notes, and `new` follows the version order: a development build "
|
||||
"sees every release as newer. POST /update/dismiss marks that version dismissed, an "
|
||||
"empty version undoes it, and a version that is not a tag is refused. The dismissal "
|
||||
"the machine had before the drill is put back.")
|
||||
def release_check(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
keys = ("available", "version", "current", "new", "checked", "dismissed", "detail", "bytes")
|
||||
|
||||
st, before = fc.get("/update/release")
|
||||
ctx.log("GET /update/release -> %s %s", st, {k: before.get(k) for k in keys} if isinstance(before, dict) else before)
|
||||
ctx.check(st == 200 and isinstance(before, dict) and "available" in before and "checked" in before,
|
||||
"GET /update/release -> %s", st)
|
||||
ev["before"] = {k: before.get(k) for k in keys}
|
||||
prior = before.get("version", "") if before.get("dismissed") else ""
|
||||
|
||||
st, now = fc.post("/update/check")
|
||||
ctx.log("POST /update/check -> %s %s", st, {k: now.get(k) for k in keys} if isinstance(now, dict) else now)
|
||||
if st == 502:
|
||||
ctx.log("the machine has no route to the releases API; the kept answer stands")
|
||||
now = before
|
||||
else:
|
||||
ctx.check(st == 200 and isinstance(now, dict) and "available" in now, "POST /update/check -> %s", st)
|
||||
ctx.check(isinstance(now.get("checked"), int) and now["checked"] >= before.get("checked", 0),
|
||||
"the check did not move `checked`: %s", now.get("checked"))
|
||||
ev["after_check"] = {k: now.get(k) for k in keys}
|
||||
|
||||
if now.get("available"):
|
||||
ctx.check(_VERSION_RX.match(str(now.get("version", ""))) is not None,
|
||||
"the release version is not a v<semver> tag: %r", now.get("version"))
|
||||
ctx.check(isinstance(now.get("bytes"), int) and now["bytes"] > 0,
|
||||
"the firmware file's size is missing: %r", now.get("bytes"))
|
||||
ctx.check(isinstance(now.get("notes"), str), "the notes are not a string")
|
||||
ctx.check(isinstance(now.get("new"), bool), "`new` is not a bool")
|
||||
if not _VERSION_RX.match(str(now.get("current", ""))):
|
||||
ctx.check(now.get("new") is True,
|
||||
"a development build (%r) must see release %s as newer", now.get("current"), now["version"])
|
||||
else:
|
||||
ctx.log("no release available: %s", now.get("detail"))
|
||||
ctx.check(isinstance(now.get("detail"), str) and now["detail"], "an unavailable release names no reason")
|
||||
|
||||
try:
|
||||
st, body = fc.post("/update/dismiss", params={"version": "v0.0.1; rm -rf /"})
|
||||
ctx.log("POST /update/dismiss version=<not a tag> -> %s %s", st, body)
|
||||
ctx.check(st == 400, "a version that is not a tag was not refused: %s", st)
|
||||
|
||||
tag = now["version"] if now.get("available") else "v0.0.0"
|
||||
st, body = fc.post("/update/dismiss", params={"version": tag})
|
||||
ctx.log("POST /update/dismiss version=%s -> %s %s", tag, st,
|
||||
{k: body.get(k) for k in keys} if isinstance(body, dict) else body)
|
||||
ctx.check(st == 200 and isinstance(body, dict), "POST /update/dismiss -> %s", st)
|
||||
if now.get("available"):
|
||||
ctx.check(body.get("dismissed") is True, "the dismissal of %s was not recorded: %r", tag, body.get("dismissed"))
|
||||
st, body = fc.post("/update/dismiss", params={"version": ""})
|
||||
ctx.log("POST /update/dismiss version= -> %s", st)
|
||||
ctx.check(st == 200 and isinstance(body, dict) and body.get("dismissed") is False,
|
||||
"the empty version did not undo the dismissal: %s %r", st, body.get("dismissed") if isinstance(body, dict) else body)
|
||||
finally:
|
||||
st, body = fc.post("/update/dismiss", params={"version": prior})
|
||||
ctx.log("dismissal put back to %r -> %s", prior, st)
|
||||
|
||||
@@ -451,6 +451,23 @@ footer a {
|
||||
font-size: 11.5px;
|
||||
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 {
|
||||
color: var(--og-dim);
|
||||
font-size: 12.5px;
|
||||
@@ -798,3 +815,26 @@ footer a {
|
||||
min-height: 16px;
|
||||
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
|
||||
catalog entries that never touch hardware."""
|
||||
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 json
|
||||
import unittest
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The baseline: fixed resting values are restored, preserved values are
|
||||
handed back, every deviation is a recorded leftover. Runs against a fake
|
||||
sysfs tree; forgectrl is unreachable (service-side checks skip)."""
|
||||
@@ -10,8 +15,16 @@ import unittest
|
||||
|
||||
from forgetest import baseline
|
||||
|
||||
# The fixed machine tick at the mode an unset xy_microsteps reads as.
|
||||
# Taken from baseline rather than typed, so a change to the default mode
|
||||
# moves the expectations with it.
|
||||
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
|
||||
|
||||
|
||||
class BaselineFixture:
|
||||
"""The fake sysfs tree and LED class a baseline runs against. It has
|
||||
no tests of its own, so a class that mixes it in runs only its own."""
|
||||
|
||||
class BaselineTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-bl-")
|
||||
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
|
||||
@@ -21,8 +34,11 @@ class BaselineTests(unittest.TestCase):
|
||||
for name in baseline.BUTTON_LEDS + ("lid_led",):
|
||||
os.makedirs(self.leds + name)
|
||||
self._led(name, "0")
|
||||
# a clean machine
|
||||
for attr, val in baseline.FIXED_SYSFS + baseline.IDLE_READBACKS:
|
||||
# A clean machine at the mode an unset xy_microsteps reads as:
|
||||
# x/y_mode, step_freq and ramp_rate are the mode's, not the x8
|
||||
# literals in FIXED_SYSFS, and that is what enforce() compares
|
||||
# against.
|
||||
for attr, val in baseline.fixed_sysfs() + baseline.IDLE_READBACKS:
|
||||
self._attr(attr, val)
|
||||
self._attr("cnc/interlock_circuit", "45")
|
||||
self._attr("pic/lid_led", "0")
|
||||
@@ -52,13 +68,6 @@ class BaselineTests(unittest.TestCase):
|
||||
# the class interface writes 'target'; the fake mirrors it into brightness
|
||||
# only when the test asks (see _sync_leds)
|
||||
|
||||
def _led2(self, name, target, brightness):
|
||||
"""Both attributes of one button LED: the trigger's commanded
|
||||
level and the fade that follows it."""
|
||||
for a, v in (("target", target), ("brightness", brightness)):
|
||||
with open(self.leds + name + "/" + a, "w") as f:
|
||||
f.write(v)
|
||||
|
||||
def _sync_leds(self):
|
||||
for name in baseline.BUTTON_LEDS:
|
||||
p = self.leds + name + "/target"
|
||||
@@ -75,6 +84,19 @@ class BaselineTests(unittest.TestCase):
|
||||
def bl(self):
|
||||
return baseline.Baseline(self.lines.append)
|
||||
|
||||
def _pos_bytes(self, x, y, z, processed, total):
|
||||
with open(self.sysfs + "cnc/position", "wb") as f:
|
||||
f.write(struct.pack("<3i2I", x, y, z, processed, total))
|
||||
|
||||
def _probe_state(self):
|
||||
# what the kernel shows between the supervisor's motion probe and
|
||||
# the GRBL controller's init writes
|
||||
self._attr("cnc/motor_lock", "0")
|
||||
self._attr("cnc/step_freq", "10000")
|
||||
self._attr("cnc/y_mode", "1")
|
||||
|
||||
|
||||
class BaselineTests(BaselineFixture, unittest.TestCase):
|
||||
def test_clean_machine_has_no_leftovers(self):
|
||||
left = self.bl().enforce("pre", captured=None)
|
||||
self.assertEqual(left, [])
|
||||
@@ -90,7 +112,7 @@ class BaselineTests(unittest.TestCase):
|
||||
for x in left:
|
||||
self.assertEqual(x.action, "restored", str(x))
|
||||
self.assertEqual(self._read("cnc/motor_lock"), "0")
|
||||
self.assertEqual(self._read("cnc/step_freq"), "28160")
|
||||
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
|
||||
self.assertEqual(self._read("cnc/streaming"), "0")
|
||||
self.assertEqual(items["cnc/motor_lock"].found, "15")
|
||||
self.assertEqual(items["cnc/motor_lock"].expected, "0")
|
||||
@@ -126,6 +148,49 @@ class BaselineTests(unittest.TestCase):
|
||||
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
|
||||
self.assertEqual(items["position"].found, [1000, 0, 0])
|
||||
|
||||
def test_a_lost_counter_frame_never_moves_the_head(self):
|
||||
# The controller zeroes the step counters at every start and at
|
||||
# every home, and rewrites its anchor then. A run that began at
|
||||
# -6400 steps and restarted the controller ends at 0 with the head
|
||||
# where it was: the difference is not a distance, and the hand-back
|
||||
# must neither jog the head nor call it a leftover.
|
||||
anchor = os.path.join(self.tmp, "grblhal.homed")
|
||||
old_path, baseline.ANCHOR_PATH = baseline.ANCHOR_PATH, anchor
|
||||
try:
|
||||
with open(anchor, "w") as f:
|
||||
f.write("0 0 0 4 startup")
|
||||
self._pos(-6400, 0, 0)
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
self.assertIsNotNone(cap["frame"])
|
||||
os.unlink(anchor) # a controller start: a new anchor file
|
||||
with open(anchor + ".new", "w") as f:
|
||||
f.write("0 0 0 4 startup")
|
||||
os.replace(anchor + ".new", anchor)
|
||||
os.utime(anchor, ns=(5, 5)) # a freed inode can come straight back, inside one clock tick
|
||||
self._pos(0, 0, 0)
|
||||
left = b.enforce("post", captured=cap)
|
||||
self.assertEqual([x.item for x in left], [])
|
||||
self.assertTrue(any("re-zeroed its counters during the run" in l for l in self.lines), self.lines)
|
||||
# the same counters with the frame intact are a displaced head, as before
|
||||
self.lines.clear()
|
||||
self._pos(-6400, 0, 0)
|
||||
cap = b.capture()
|
||||
self._pos(0, 0, 0)
|
||||
left = b.enforce("post", captured=cap)
|
||||
self.assertEqual([x.item for x in left], ["position"])
|
||||
# and a test that vouches for the new frame is held to it
|
||||
self._pos(-6400, 0, 0)
|
||||
cap = b.capture()
|
||||
os.utime(anchor, ns=(1, 1)) # re-zeroed, and declared (ctx.counters_rezeroed)
|
||||
cap["position"], cap["rezero_declared"] = [0, 0, 0], True
|
||||
self._pos(0, 0, 0)
|
||||
self.assertEqual(b.enforce("post", captured=cap), [])
|
||||
self._pos(1000, 0, 0)
|
||||
self.assertEqual([x.item for x in b.enforce("post", captured=cap)], ["position"])
|
||||
finally:
|
||||
baseline.ANCHOR_PATH = old_path
|
||||
|
||||
def test_the_counters_are_read_at_the_modes_scale(self):
|
||||
# 6400 steps is 30 mm at x32 (a return within the bound; on the
|
||||
# host it stops at the missing controller) and 120 mm at x8
|
||||
@@ -153,14 +218,16 @@ class BaselineTests(unittest.TestCase):
|
||||
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
self._pos(221, 0, 0) # 4.144 mm into the held move
|
||||
# 4.144 mm into the held move, in the steps of the mode in force
|
||||
held_mm = 4.144
|
||||
self._pos(round(held_mm * baseline.xy_steps_per_mm(baseline.XY_MODE_DEFAULT)), 0, 0)
|
||||
left = b.enforce("post", captured=cap)
|
||||
items = {x.item: x for x in left}
|
||||
self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
|
||||
self.assertEqual(dev.sent[0], "^X")
|
||||
jogs = [l for l in dev.sent if l.startswith("$J=")]
|
||||
self.assertEqual(len(jogs), 1)
|
||||
self.assertIn("X-4.144", jogs[0])
|
||||
self.assertIn("X-%.3f" % held_mm, jogs[0])
|
||||
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
|
||||
finally:
|
||||
dev.stop()
|
||||
@@ -168,10 +235,6 @@ class BaselineTests(unittest.TestCase):
|
||||
for k in ("GRBL_HOST", "GRBL_PORT"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
def _pos_bytes(self, x, y, z, processed, total):
|
||||
with open(self.sysfs + "cnc/position", "wb") as f:
|
||||
f.write(struct.pack("<3i2I", x, y, z, processed, total))
|
||||
|
||||
def test_ring_residue_is_a_leftover_and_blocks_the_return_jog(self):
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
@@ -245,18 +308,11 @@ class BaselineTests(unittest.TestCase):
|
||||
def test_fixed_constants_checked_against_a_dump(self):
|
||||
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
|
||||
diffs = baseline.check_fixed_against(ref, self.lines.append)
|
||||
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=28160"])
|
||||
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=%s" % DEFAULT_TICK])
|
||||
|
||||
|
||||
# -- the reference is taken after the controller applied its config -----
|
||||
|
||||
def _probe_state(self):
|
||||
# what the kernel shows between the supervisor's motion probe and
|
||||
# the GRBL controller's init writes
|
||||
self._attr("cnc/motor_lock", "0")
|
||||
self._attr("cnc/step_freq", "10000")
|
||||
self._attr("cnc/y_mode", "1")
|
||||
|
||||
def test_wait_configured_returns_once_the_controller_wrote_its_config(self):
|
||||
self._probe_state()
|
||||
calls = {"n": 0}
|
||||
@@ -264,7 +320,8 @@ class BaselineTests(unittest.TestCase):
|
||||
def sleep(_s):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 3: # the controller's init writes land
|
||||
for attr, val in baseline.CONFIGURED_MARKERS:
|
||||
# at the mode wait_controller_configured watches by default
|
||||
for attr, val in baseline.configured_markers():
|
||||
self._attr(attr, val)
|
||||
ok = baseline.wait_controller_configured(
|
||||
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
|
||||
@@ -302,11 +359,15 @@ class BaselineTests(unittest.TestCase):
|
||||
def test_preconfig_reference_is_recognized(self):
|
||||
self.assertTrue(baseline.reference_preconfig(
|
||||
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
|
||||
# The markers are read at the reference's own mode, so a dump taken
|
||||
# on an x8 machine carries the setting that says so.
|
||||
self.assertFalse(baseline.reference_preconfig(
|
||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"}}))
|
||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"},
|
||||
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
|
||||
# a genuinely different single constant is a machine fact, not pre-config
|
||||
self.assertFalse(baseline.reference_preconfig(
|
||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"}}))
|
||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"},
|
||||
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
|
||||
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
|
||||
|
||||
# -- the XY microstep mode drives the fixed values ------------------------
|
||||
@@ -325,16 +386,18 @@ class BaselineTests(unittest.TestCase):
|
||||
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
|
||||
|
||||
def test_the_mode_is_read_from_the_settings_with_a_default(self):
|
||||
# An unset or unusable key reads as the driver's default, x32.
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 8)
|
||||
self.assertEqual(baseline.xy_mode_of({}), 8)
|
||||
self.assertEqual(baseline.xy_mode_of(None), 8)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 8)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 8)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "8"}), 8)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 32)
|
||||
self.assertEqual(baseline.xy_mode_of({}), 32)
|
||||
self.assertEqual(baseline.xy_mode_of(None), 32)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 32)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 32)
|
||||
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
|
||||
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 8)
|
||||
self.assertEqual(baseline.ref_xy_mode({}), 8)
|
||||
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 32)
|
||||
self.assertEqual(baseline.ref_xy_mode({}), 32)
|
||||
|
||||
def test_fixed_constants_are_checked_at_the_reference_mode(self):
|
||||
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
|
||||
@@ -369,50 +432,95 @@ class BaselineTests(unittest.TestCase):
|
||||
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}))
|
||||
|
||||
def test_stale_preconfig_reference_is_retaken_on_a_fresh_boot(self):
|
||||
# Both sides of BOOT_MAX_AGE_S, with the uptime held: read from the host, it can cross the limit
|
||||
# between boot_reference()'s reading and the test's (it did, on a CI runner 600 s after its boot).
|
||||
os.environ["FORGETEST_BOOT_ID"] = "test-boot-2"
|
||||
real = baseline.uptime_s
|
||||
try:
|
||||
for up, fresh in ((baseline.BOOT_MAX_AGE_S - 60, True), (baseline.BOOT_MAX_AGE_S + 60, False)):
|
||||
self.lines.clear()
|
||||
path = os.path.join(self.tmp, "boot-test-boot-2.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump({"ts": "old", "sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000",
|
||||
"cnc/y_mode": "1"}}, f)
|
||||
baseline.uptime_s = lambda up=up: up
|
||||
ref = baseline.boot_reference(self.lines.append, self.tmp)
|
||||
up = baseline.uptime_s()
|
||||
if up is None or up > baseline.BOOT_MAX_AGE_S:
|
||||
# too old to retake: the stale reference stands, marked
|
||||
self.assertTrue(any("predates the controller's config" in l for l in self.lines))
|
||||
self.assertEqual(ref["ts"], "old")
|
||||
else:
|
||||
self.assertTrue(any("retaking" in l for l in self.lines))
|
||||
if fresh:
|
||||
self.assertTrue(any("retaking" in l for l in self.lines), self.lines)
|
||||
self.assertNotEqual(ref["ts"], "old")
|
||||
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
|
||||
else:
|
||||
# too old to retake: the stale reference stands, marked
|
||||
self.assertTrue(any("predates the controller's config" in l for l in self.lines), self.lines)
|
||||
self.assertEqual(ref["ts"], "old")
|
||||
finally:
|
||||
baseline.uptime_s = real
|
||||
os.environ.pop("FORGETEST_BOOT_ID", None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
class ExtensionFreeTests(BaselineFixture, unittest.TestCase):
|
||||
"""A campaign's machine is extension-free: what a test made is removed,
|
||||
what is the operator's is reported and left alone."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.root = os.path.join(self.tmp, "ext")
|
||||
for d in ("pkg", "keys", "data"):
|
||||
os.makedirs(os.path.join(self.root, d))
|
||||
# a stand-in for the host's command line: remove <id> takes the package away
|
||||
self.forgeext = os.path.join(self.tmp, "forgeext")
|
||||
with open(self.forgeext, "w") as f:
|
||||
f.write('#!/bin/sh\n[ "$1" = remove ] && rm -rf "%s/pkg/$2" && echo \'{"ok": true}\'\n' % self.root)
|
||||
os.chmod(self.forgeext, 0o755)
|
||||
self.pool = {}
|
||||
self._saved = (baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT, baseline.hw.pool_pids, baseline.EXT_STOP_S)
|
||||
baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT = self.root, self.forgeext
|
||||
baseline.hw.pool_pids = lambda: dict(self.pool)
|
||||
baseline.EXT_STOP_S = 0.2
|
||||
|
||||
class TransientNotLeftoverTests(BaselineTests):
|
||||
"""A leftover is what a run left behind, not the machine part-way
|
||||
through its own work. Found on the bench reference, where
|
||||
laser.arm-wait-lid passed every check it makes and failed its
|
||||
hand-back on the post-job smoke clear."""
|
||||
def tearDown(self):
|
||||
baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT, baseline.hw.pool_pids, baseline.EXT_STOP_S = self._saved
|
||||
super().tearDown()
|
||||
|
||||
def test_a_fading_button_led_is_not_a_leftover(self):
|
||||
# the trigger fades brightness toward target: target 0 with
|
||||
# brightness still high is the fade from a level the machine ended
|
||||
for name in baseline.BUTTON_LEDS:
|
||||
self._led2(name, target="0", brightness="900")
|
||||
def _package(self, id_):
|
||||
os.makedirs(os.path.join(self.root, "pkg", id_, "1.0.0"))
|
||||
|
||||
def _key(self, name):
|
||||
with open(os.path.join(self.root, "keys", name), "w") as f:
|
||||
f.write("x")
|
||||
|
||||
def test_an_installed_package_that_does_not_run_is_nobodys_leftover(self):
|
||||
self._package("org.example.notify")
|
||||
self._key("owner.pub")
|
||||
self.assertEqual(self.bl().enforce("pre", captured=None), [])
|
||||
|
||||
def test_what_a_test_left_behind_is_removed_and_the_operators_is_not(self):
|
||||
self._package("org.forgetest.reference")
|
||||
self._package("org.example.notify")
|
||||
self._key("forgetest-reference.pub")
|
||||
self._key("owner.pub")
|
||||
left = {x.item: x for x in self.bl().enforce("post", captured=None)}
|
||||
self.assertEqual(sorted(left), ["ext.owner_key", "ext.package"])
|
||||
self.assertEqual((left["ext.package"].found, left["ext.package"].action), ("org.forgetest.reference", "restored"))
|
||||
self.assertEqual((left["ext.owner_key"].found, left["ext.owner_key"].action), ("forgetest-reference.pub", "restored"))
|
||||
self.assertEqual(os.listdir(os.path.join(self.root, "pkg")), ["org.example.notify"])
|
||||
self.assertEqual(os.listdir(os.path.join(self.root, "keys")), ["owner.pub"])
|
||||
|
||||
def test_a_package_that_cannot_be_removed_says_so(self):
|
||||
self._package("org.forgetest.reference")
|
||||
with open(self.forgeext, "w") as f:
|
||||
f.write('#!/bin/sh\necho \'{"ok": false, "error": "the lock is held"}\'\nexit 1\n')
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
self.assertEqual([x for x in left if x.item.startswith("leds/")], [])
|
||||
self.assertEqual([x.item for x in left], ["ext.package"])
|
||||
self.assertTrue(left[0].action.startswith("failed:") and "the lock is held" in left[0].action, left[0].action)
|
||||
|
||||
def test_a_lit_button_led_is_a_leftover(self):
|
||||
# a target the run left standing is dirt, whatever the fade reads
|
||||
self._led2(baseline.BUTTON_LEDS[0], target="1014", brightness="0")
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
items = [x for x in left if x.item.startswith("leds/")]
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0].found, "1014")
|
||||
def test_running_extensions_are_reported_and_left_alone(self):
|
||||
self._package("org.example.notify")
|
||||
self.pool = {4242: 800}
|
||||
left = self.bl().enforce("pre", captured=None)
|
||||
self.assertEqual([(x.item, x.action) for x in left], [("ext.running", "unrestorable")])
|
||||
self.assertIn("org.example.notify", left[0].found)
|
||||
self.assertTrue(os.path.isdir(os.path.join(self.root, "pkg", "org.example.notify")))
|
||||
|
||||
|
||||
class CloudOwnedSysfsTests(unittest.TestCase):
|
||||
@@ -439,52 +547,35 @@ class PositionDeadbandTests(unittest.TestCase):
|
||||
where motion.lid-cancel-home returned the head twice, each within
|
||||
0.04 mm, and failed the hand-back on the four steps left over."""
|
||||
|
||||
def test_a_few_steps_of_xy_are_the_quantization(self):
|
||||
self.assertTrue(baseline.position_quantized([0, 0, 0], [4, 0, 0]))
|
||||
self.assertTrue(baseline.position_quantized([0, 0, 0], [-4, 4, 0]))
|
||||
self.assertTrue(baseline.position_quantized([10, 20, 30], [10, 20, 30]))
|
||||
|
||||
def test_past_the_dead_band_is_a_leftover(self):
|
||||
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
|
||||
|
||||
def test_the_dead_band_scales_with_the_counters(self):
|
||||
# the same step count is a quarter of the distance at x32
|
||||
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
|
||||
x32 = baseline.XY_STEPS_PER_MM_OF[32]
|
||||
self.assertTrue(baseline.position_quantized([0, 0, 0], [over, 0, 0], x32))
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [4 * over, 0, 0], x32))
|
||||
|
||||
def test_z_is_exact_because_the_return_never_moves_it(self):
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
|
||||
|
||||
def test_an_unreadable_reading_is_never_quantization(self):
|
||||
self.assertFalse(baseline.position_quantized(None, [0, 0, 0]))
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], None))
|
||||
|
||||
|
||||
class BaselineModeTests(BaselineTests):
|
||||
class BaselineModeTests(BaselineFixture, unittest.TestCase):
|
||||
"""The baseline against a fake forgectrl: what the mode in force
|
||||
owns. Reuses the fake sysfs tree of BaselineTests; only the new
|
||||
tests run here (the inherited ones are skipped)."""
|
||||
owns, over the same fake sysfs tree."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
import helpers
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
baseline.Baseline._unreachable_until = 0.0
|
||||
# the lamp's settle, short: five polls, so a lamp that never holds
|
||||
# costs the suite no real seconds
|
||||
self._lamp_timing = (baseline.LID_LAMP_SETTLE_S, baseline.LID_LAMP_POLL_S)
|
||||
baseline.LID_LAMP_SETTLE_S, baseline.LID_LAMP_POLL_S = 0.05, 0.01
|
||||
|
||||
def tearDown(self):
|
||||
baseline.LID_LAMP_SETTLE_S, baseline.LID_LAMP_POLL_S = self._lamp_timing
|
||||
self.fc.stop()
|
||||
super().tearDown()
|
||||
|
||||
def run(self, result=None):
|
||||
# only this class's own tests, not the base class's
|
||||
if self._testMethodName not in BaselineModeTests.__dict__:
|
||||
return
|
||||
return super().run(result)
|
||||
|
||||
def cloud(self):
|
||||
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
|
||||
self.fc.state["settings"]["controller_mode"] = "cloud"
|
||||
@@ -571,7 +662,7 @@ class BaselineModeTests(BaselineTests):
|
||||
self._attr("pic/lid_led", "77")
|
||||
left = self.bl().enforce("pre", captured=None)
|
||||
self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"])
|
||||
self.assertEqual(self._read("cnc/step_freq"), "28160")
|
||||
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
|
||||
self.assertEqual(self._read("pic/lid_led"), "236")
|
||||
|
||||
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
|
||||
@@ -631,3 +722,7 @@ class BaselineModeTests(BaselineTests):
|
||||
cap["mode"] = "cloud"
|
||||
b.enforce("post", captured=cap)
|
||||
self.assertEqual([p for p, _ in self.fc.posts if p == "/settings"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The bench registry against scripts/bench: every python tool is
|
||||
registered (or named as not-a-tool), every registered script exists,
|
||||
every argument spec builds a command line, every script compiles, and
|
||||
the shared helper resolves host and local mode as documented."""
|
||||
a ported tool builds a command line with its defaults, and every
|
||||
script compiles."""
|
||||
import glob
|
||||
import os
|
||||
import py_compile
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import helpers # noqa: F401 (sys.path)
|
||||
@@ -51,78 +54,10 @@ class RegistryTests(unittest.TestCase):
|
||||
self.assertTrue(ok, "%s: %s" % (t["id"], err))
|
||||
self.assertEqual(argv[1], os.path.join(BENCH, t["script"]))
|
||||
|
||||
def test_flagged_args_build_option_pairs(self):
|
||||
tool = next(t for t in bench_mod.TOOLS if t["id"] == "resume-dark-lead")
|
||||
ok, argv, err = self.bench.command(tool, {"run": "live", "feed": 900})
|
||||
self.assertTrue(ok, err)
|
||||
self.assertIn("--run", argv)
|
||||
self.assertEqual(argv[argv.index("--run") + 1], "live")
|
||||
self.assertEqual(argv[argv.index("--feed") + 1], "900.0")
|
||||
# an empty optional with an empty default is left off entirely
|
||||
self.assertNotIn("--auto", argv)
|
||||
# a choice outside the list is refused before anything runs
|
||||
ok, _argv, err = self.bench.command(tool, {"run": "sideways"})
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("run must be one of", err)
|
||||
|
||||
def test_unported_tools_are_host_only(self):
|
||||
# what stays unported is what cannot run on the machine at all
|
||||
for t in bench_mod.TOOLS:
|
||||
if not t["ported"]:
|
||||
self.assertEqual(t["where"], "host", t["id"])
|
||||
self.assertIn("not a bench-page tool", t["desc"], t["id"])
|
||||
|
||||
def test_scripts_compile(self):
|
||||
for s in self.scripts:
|
||||
py_compile.compile(os.path.join(BENCH, s), doraise=True)
|
||||
|
||||
|
||||
class GfbenchTests(unittest.TestCase):
|
||||
"""gfbench.py in a subprocess (module-level host resolution)."""
|
||||
|
||||
def run_snippet(self, code, env_extra):
|
||||
env = dict(os.environ)
|
||||
env.pop("GF_HOST", None)
|
||||
env.pop("FORGETEST_BENCH_DATA", None)
|
||||
env.update(env_extra)
|
||||
r = subprocess.run([sys.executable, "-c", code], cwd=BENCH, env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
|
||||
return r.returncode, r.stdout.strip()
|
||||
|
||||
def test_host_mode(self):
|
||||
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL, gfbench.SSH[0])",
|
||||
{"GF_HOST": "192.0.2.7", "GF_SSH": "fakessh -x"})
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(out, "192.0.2.7 False fakessh")
|
||||
|
||||
def test_local_when_named_local(self):
|
||||
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL); "
|
||||
"print(gfbench.board('echo hi').strip())",
|
||||
{"GF_HOST": "127.0.0.1"})
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(out.splitlines(), ["127.0.0.1 True", "hi"])
|
||||
|
||||
def test_refuses_without_a_machine(self):
|
||||
if os.path.isdir("/sys/glowforge"):
|
||||
self.skipTest("running on the machine")
|
||||
rc, out = self.run_snippet("import gfbench", {})
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("GF_HOST", out)
|
||||
|
||||
def test_degc_and_data_dir(self):
|
||||
import tempfile
|
||||
d = tempfile.mkdtemp(prefix="forgetest-bench-")
|
||||
rc, out = self.run_snippet(
|
||||
"import gfbench, os; print(round(gfbench.degc(700), 2), gfbench.degc('x') != gfbench.degc('x'), "
|
||||
"os.path.dirname(gfbench.data_path('f.json')) == os.environ['FORGETEST_BENCH_DATA'])",
|
||||
{"GF_HOST": "127.0.0.1", "FORGETEST_BENCH_DATA": d})
|
||||
self.assertEqual(rc, 0, out)
|
||||
val, nan, indir = out.split()
|
||||
# a plausible coolant reading (700 counts is room temperature territory)
|
||||
self.assertTrue(15.0 < float(val) < 35.0, val)
|
||||
self.assertEqual(nan, "True")
|
||||
self.assertEqual(indir, "True")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user