mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-19 02:51:42 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9190ffddc | ||
|
|
5036517e22 | ||
|
|
0de9051820 | ||
|
|
9e7b065343 | ||
|
|
8fc411f50f | ||
|
|
4fa03f7e2b | ||
|
|
9c97f05db9 | ||
|
|
e1d1c1b84e | ||
|
|
b4493f5052 | ||
|
|
a3211ba1ea | ||
|
|
84a231163a | ||
|
|
c978e76b4c | ||
|
|
18cba1daa2 | ||
|
|
9c768619f8 | ||
|
|
79796b305b | ||
|
|
7a71dd80b5 | ||
|
|
614efb23e4 | ||
|
|
3aecf800a5 | ||
|
|
609ae561bf | ||
|
|
c849603942 | ||
|
|
6473e7d9ae | ||
|
|
fc6b7797af | ||
|
|
a10fd7782d | ||
|
|
2af9b0277f | ||
|
|
c717ecbcce | ||
|
|
49f087f376 | ||
|
|
e046c534c2 | ||
|
|
36c1b75bb8 | ||
|
|
ce73dbb052 | ||
|
|
7b0f065a3f | ||
|
|
d57421c71d | ||
|
|
50c56a863d | ||
|
|
2172ae4101 | ||
|
|
9451587728 | ||
|
|
bed46e961e | ||
|
|
41ca7fab82 | ||
|
|
eb684d366e | ||
|
|
9ea9c5d10f | ||
|
|
5d26818946 | ||
|
|
8d16b93e86 | ||
|
|
5bcc536853 | ||
|
|
c0c74d1658 | ||
|
|
b2a011ac08 | ||
|
|
68e9d3fb8e | ||
|
|
f47373a20d | ||
|
|
199282c029 | ||
|
|
6a4ece4b0d | ||
|
|
802cd3af8d | ||
|
|
099399b58f | ||
|
|
03146a1529 | ||
|
|
852943e20c | ||
|
|
a596767a9a | ||
|
|
3d2f2d9ab4 | ||
|
|
850498fb00 | ||
|
|
4d8f0d2e67 | ||
|
|
f165713d05 | ||
|
|
80ddd53bcd | ||
|
|
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 |
@@ -11,10 +11,18 @@
|
|||||||
# release with source missing
|
# release with source missing
|
||||||
# - shared UI files: theme.css and the vendored Bootstrap the page
|
# - shared UI files: theme.css and the vendored Bootstrap the page
|
||||||
# carries are byte-identical to forgectrl's at its pinned revision
|
# carries are byte-identical to forgectrl's at its pinned revision
|
||||||
|
# - the extension key: the public key the image installs for the official
|
||||||
|
# tier is byte-identical to the one the extension catalog publishes
|
||||||
|
# (keys/forgefirm-ext.pub on its main branch)
|
||||||
|
# - the catalog's address: EXTPKG_INDEX_URL in the pinned forgectrl's
|
||||||
|
# src/extpkg.h is the catalog's latest-release download of the file its
|
||||||
|
# tools/publish.py passes to gh release create; read, never requested
|
||||||
|
# (every request of it is counted as a download)
|
||||||
# - coverage lint: every source path of every component pinned by the
|
# - coverage lint: every source path of every component pinned by the
|
||||||
# recipes must be selected by some catalog test's coverage globs (the
|
# recipes must be selected by some catalog test's coverage globs or by a
|
||||||
# tree manifest is generated from the pins with git - no Yocto build);
|
# host-tested row naming the host test that proves it (the tree manifest
|
||||||
# enforced: an uncovered path fails the job (https://docs.forgefirm.org/developers/acceptance/).
|
# is generated from the pins with git - no Yocto build); enforced: an
|
||||||
|
# uncovered path fails the job (https://docs.forgefirm.org/developers/testing/).
|
||||||
|
|
||||||
name: forgetest-ci
|
name: forgetest-ci
|
||||||
|
|
||||||
@@ -49,6 +57,13 @@ jobs:
|
|||||||
ref: scarthgap
|
ref: scarthgap
|
||||||
path: meta-openglow
|
path: meta-openglow
|
||||||
|
|
||||||
|
- name: Checkout forgefirm-extensions-catalog (sibling)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: openglow-org/forgefirm-extensions-catalog
|
||||||
|
ref: main
|
||||||
|
path: forgefirm-extensions-catalog
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -58,6 +73,68 @@ jobs:
|
|||||||
working-directory: forgefirm
|
working-directory: forgefirm
|
||||||
run: python scripts/manifest-from-tree.py --out tree-manifest.json
|
run: python scripts/manifest-from-tree.py --out tree-manifest.json
|
||||||
|
|
||||||
|
- name: The image's extension key is the catalog's published key
|
||||||
|
run: |
|
||||||
|
image=forgefirm/meta-forgefirm/recipes-forgefirm/forgefirm-keys/files/ext/forgefirm-ext.pub
|
||||||
|
published=forgefirm-extensions-catalog/keys/forgefirm-ext.pub
|
||||||
|
for f in "$image" "$published"; do
|
||||||
|
test -f "$f" || { echo "missing: $f"; exit 1; }
|
||||||
|
done
|
||||||
|
cmp -s "$image" "$published" || { echo "$image is not $published"; exit 1; }
|
||||||
|
|
||||||
|
- name: The catalog's address in the pinned forgectrl is the file the catalog publishes (read, never requested)
|
||||||
|
run: |
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import ast, glob, os, re, subprocess, sys
|
||||||
|
|
||||||
|
PREFIX = "https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/"
|
||||||
|
|
||||||
|
def fail(why):
|
||||||
|
print(why)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# forgectrl's pin, and its src/extpkg.h from the tree manifest's cache
|
||||||
|
pin = open("forgefirm/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc").read()
|
||||||
|
m = re.search(r'^SRCREV = "([0-9a-f]{40})"$', pin, re.M)
|
||||||
|
if not m:
|
||||||
|
fail("forgectrl-pin.inc names no SRCREV")
|
||||||
|
rev = m.group(1)
|
||||||
|
header = None
|
||||||
|
for repo in sorted(glob.glob("forgefirm/.manifest-cache/*")):
|
||||||
|
p = subprocess.run(["git", "-C", repo, "show", rev + ":src/extpkg.h"], capture_output=True, text=True)
|
||||||
|
if p.returncode == 0:
|
||||||
|
header = p.stdout
|
||||||
|
break
|
||||||
|
if header is None:
|
||||||
|
fail("forgectrl %s is not in forgefirm/.manifest-cache" % rev)
|
||||||
|
m = re.search(r'#define\s+EXTPKG_INDEX_URL\s*(?:\\\n\s*)?"([^"]*)"', header)
|
||||||
|
if not m:
|
||||||
|
fail("forgectrl %s's src/extpkg.h defines no EXTPKG_INDEX_URL" % rev[:7])
|
||||||
|
url = m.group(1)
|
||||||
|
|
||||||
|
# the file the catalog's publish tool passes to gh release create
|
||||||
|
tree = ast.parse(open("forgefirm-extensions-catalog/tools/publish.py").read())
|
||||||
|
assigned = {n.targets[0].id: n.value for n in ast.walk(tree)
|
||||||
|
if isinstance(n, ast.Assign) and len(n.targets) == 1 and isinstance(n.targets[0], ast.Name)}
|
||||||
|
|
||||||
|
def file_name(v):
|
||||||
|
if isinstance(v, ast.Name):
|
||||||
|
v = assigned.get(v.id)
|
||||||
|
if isinstance(v, ast.Call) and v.args:
|
||||||
|
v = v.args[-1]
|
||||||
|
return os.path.basename(v.value) if isinstance(v, ast.Constant) and isinstance(v.value, str) else None
|
||||||
|
|
||||||
|
names = [file_name(n.args[3]) if len(n.args) > 3 else None for n in ast.walk(tree)
|
||||||
|
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "gh"
|
||||||
|
and [a.value for a in n.args[:2] if isinstance(a, ast.Constant)] == ["release", "create"]]
|
||||||
|
if len(names) != 1 or not names[0]:
|
||||||
|
fail("tools/publish.py passes no one file to gh release create: %s" % names)
|
||||||
|
|
||||||
|
print("forgectrl %s: %s; the catalog publishes %s" % (rev[:7], url, names[0]))
|
||||||
|
if url != PREFIX + names[0]:
|
||||||
|
fail("the address is not %s" % (PREFIX + names[0]))
|
||||||
|
EOF
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
working-directory: forgefirm/forgetest
|
working-directory: forgefirm/forgetest
|
||||||
# -B: no bytecode caches under the package directory (the recipe's fetch
|
# -B: no bytecode caches under the package directory (the recipe's fetch
|
||||||
@@ -66,9 +143,18 @@ jobs:
|
|||||||
|
|
||||||
- name: No undefined names (the live drills never execute on the host)
|
- name: No undefined names (the live drills never execute on the host)
|
||||||
working-directory: forgefirm/forgetest
|
working-directory: forgefirm/forgetest
|
||||||
|
# pyflakes exits 1 for any message, unused imports included, so the
|
||||||
|
# grep is the gate. A run stopped by SIGINT exits 1 too, and its
|
||||||
|
# "... stopped" line fails the grep. Any other status (a usage
|
||||||
|
# error, a signal pyflakes does not handle) fails the step and
|
||||||
|
# prints the capture, and a capture that cannot be written fails it
|
||||||
|
# too. pyflakes ends a SIGPIPE with status 1 and no line, and a
|
||||||
|
# write to the capture file raises none.
|
||||||
run: |
|
run: |
|
||||||
pip install pyflakes
|
python -m pip install pyflakes
|
||||||
if python -B -m pyflakes forgetest tests | grep -E "undefined name"; then exit 1; fi
|
python -B -m pyflakes --version
|
||||||
|
{ python -B -m pyflakes forgetest tests || test $? -eq 1; } > pyflakes.txt 2>&1 || { cat pyflakes.txt; exit 1; }
|
||||||
|
if grep -E "undefined name|Traceback|^\.\.\. stopped$" pyflakes.txt; then exit 1; fi
|
||||||
|
|
||||||
- name: Shared UI files match forgectrl at its pinned revision
|
- name: Shared UI files match forgectrl at its pinned revision
|
||||||
working-directory: forgefirm
|
working-directory: forgefirm
|
||||||
@@ -76,7 +162,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Coverage lint (enforced)
|
- name: Coverage lint (enforced)
|
||||||
working-directory: forgefirm/forgetest
|
working-directory: forgefirm/forgetest
|
||||||
run: python -B -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY"
|
# The step runs under bash -e with no pipefail of its own: set here,
|
||||||
|
# the status of the step is the lint's, not tee's.
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
python -B -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
- name: Gate self-check on the tree manifest
|
- name: Gate self-check on the tree manifest
|
||||||
working-directory: forgefirm
|
working-directory: forgefirm
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ ForgeFIRM is open firmware for stock Glowforge lasers (Basic, Plus, Pro). It
|
|||||||
runs on the factory NXP i.MX6 control board with no hardware modification. It
|
runs on the factory NXP i.MX6 control board with no hardware modification. It
|
||||||
replaces the cloud-dependent factory userspace with an open Linux image that
|
replaces the cloud-dependent factory userspace with an open Linux image that
|
||||||
controls the machine locally. In GRBL mode the machine is a standard grblHAL
|
controls the machine locally. In GRBL mode the machine is a standard grblHAL
|
||||||
controller for LightBurn and other senders on TCP port 23. Cloud mode runs the
|
controller for a Grbl sender on TCP port 23. Cloud mode runs the
|
||||||
factory experience through the Glowforge web service, and it is a deliberate,
|
factory experience through the Glowforge web service, and it is a deliberate,
|
||||||
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
|
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
|
||||||
and cloud mode runs end to end.
|
and cloud mode runs end to end.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> ### BETA
|
> ### BETA
|
||||||
>
|
>
|
||||||
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
|
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
|
||||||
> Expect problems, and expect frequent updates. Upgrade whenever a newer
|
> It's stable, but you may still hit the occasional bug, and expect frequent updates. Upgrade whenever a newer
|
||||||
> release is available, and report what you find on the
|
> release is available, and report what you find on the
|
||||||
> [community forum](https://community.openglow.org).
|
> [community forum](https://community.openglow.org).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# The scratch trees forgectrl's cool_flow_test and super_test write in the
|
||||||
|
# directory they run from
|
||||||
|
/cool-flow-test/
|
||||||
|
/super-test/
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"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}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
255
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
idle
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
1023
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
300
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
629
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
700
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
700
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
65535
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
26214
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
43278
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
300
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
300
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
300
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"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}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
1
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
idle
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
1
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
204
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
300
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
629
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
100
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
100
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
100
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
100
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
700
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
700
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
300
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
300
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
300
|
|
||||||
@@ -26,13 +26,6 @@ case "$1" in
|
|||||||
start)
|
start)
|
||||||
echo "Starting forgetest"
|
echo "Starting forgetest"
|
||||||
mkdir -p "$DATA"
|
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 \
|
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
|
||||||
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
|
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ restores it after (on every exit path), so a test cannot hand the next one
|
|||||||
restored where the interface allows.
|
restored where the interface allows.
|
||||||
|
|
||||||
The lid lamp is fixed too, at forgectrl's `lid_lamp_idle` setting (unset =
|
The lid lamp is fixed too, at forgectrl's `lid_lamp_idle` setting (unset =
|
||||||
236): the daemon asserts it at start and at every controller spawn.
|
236): the daemon asserts it at start and at every controller spawn. The
|
||||||
|
lamp fades toward a level written to it, so a level written a moment
|
||||||
|
before the check is read again until it holds before it is judged.
|
||||||
|
|
||||||
The controller mode in force decides what the baseline owns. In GRBL mode
|
The controller mode in force decides what the baseline owns. In GRBL mode
|
||||||
everything above applies. In cloud mode the cloud client owns what it
|
everything above applies. In cloud mode the cloud client owns what it
|
||||||
@@ -140,6 +142,15 @@ PRESERVED_SYSFS = [] # sysfs attrs captured before, restored afte
|
|||||||
|
|
||||||
LID_LAMP_ATTR = "pic/lid_led"
|
LID_LAMP_ATTR = "pic/lid_led"
|
||||||
LID_LAMP_DEFAULT = "236" # forgectrl's lid_lamp_idle default
|
LID_LAMP_DEFAULT = "236" # forgectrl's lid_lamp_idle default
|
||||||
|
# The lid LED runs on the kernel's "smooth" trigger: a level written to it
|
||||||
|
# is reached by a fade over a fraction of a second, so a read right after
|
||||||
|
# a write (the supervisor re-applies the idle level at every controller
|
||||||
|
# spawn) finds the lamp part-way there: on the bench reference, 232 read
|
||||||
|
# 0.05 s after the supervisor re-applies 236. The lamp is read again every
|
||||||
|
# LID_LAMP_POLL_S until it holds the level, for at most LID_LAMP_SETTLE_S,
|
||||||
|
# before it is called a leftover.
|
||||||
|
LID_LAMP_SETTLE_S = 3.0
|
||||||
|
LID_LAMP_POLL_S = 0.1
|
||||||
|
|
||||||
BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon after boot
|
BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon after boot
|
||||||
SETTLE_S = 150 # the supervisor's probe + rail-off ladder
|
SETTLE_S = 150 # the supervisor's probe + rail-off ladder
|
||||||
@@ -743,13 +754,34 @@ class Baseline:
|
|||||||
got = hw.sysfs_read(LID_LAMP_ATTR)
|
got = hw.sysfs_read(LID_LAMP_ATTR)
|
||||||
if got is None or got == want:
|
if got is None or got == want:
|
||||||
return
|
return
|
||||||
|
held, now = self._lamp_holds(want)
|
||||||
|
if held:
|
||||||
|
self.log("lid lamp: read %s, then held %s; the fade toward a level just written, "
|
||||||
|
"not a leftover" % (got, want))
|
||||||
|
return
|
||||||
|
found = now if now is not None else got
|
||||||
try:
|
try:
|
||||||
hw.sysfs_write(LID_LAMP_ATTR, want)
|
hw.sysfs_write(LID_LAMP_ATTR, want)
|
||||||
back = hw.sysfs_read(LID_LAMP_ATTR)
|
held, back = self._lamp_holds(want)
|
||||||
act = "restored" if back == want else "failed: reads %s" % back
|
act = "restored" if held else "failed: reads %s" % back
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
act = "failed: %s" % e
|
act = "failed: %s" % e
|
||||||
left.append(Leftover(LID_LAMP_ATTR, got, want, act))
|
left.append(Leftover(LID_LAMP_ATTR, found, want, act))
|
||||||
|
|
||||||
|
def _lamp_holds(self, want):
|
||||||
|
"""Read the lid lamp every LID_LAMP_POLL_S, for at most
|
||||||
|
LID_LAMP_SETTLE_S, until it holds `want`: two reads in a row at
|
||||||
|
it, so a fade that only passes through the level does not count.
|
||||||
|
Returns (held, the last read)."""
|
||||||
|
last = prev = None
|
||||||
|
for _ in range(max(2, int(round(LID_LAMP_SETTLE_S / LID_LAMP_POLL_S)))):
|
||||||
|
if self.abort():
|
||||||
|
break
|
||||||
|
time.sleep(LID_LAMP_POLL_S)
|
||||||
|
prev, last = last, hw.sysfs_read(LID_LAMP_ATTR)
|
||||||
|
if last == want and prev == want:
|
||||||
|
return True, last
|
||||||
|
return False, last
|
||||||
|
|
||||||
def _kernel_side(self, left):
|
def _kernel_side(self, left):
|
||||||
if hw.sysfs_read("cnc/state") is None:
|
if hw.sysfs_read("cnc/state") is None:
|
||||||
|
|||||||
@@ -69,11 +69,14 @@ class Test:
|
|||||||
def source_sha(self):
|
def source_sha(self):
|
||||||
"""The hash of the test's own implementation: its function (the
|
"""The hash of the test's own implementation: its function (the
|
||||||
decorator included) together with the code its module shares
|
decorator included) together with the code its module shares
|
||||||
among its tests - everything outside the @test functions. Part
|
among its tests (everything outside the @test functions), the
|
||||||
of the fingerprint: a change inside one test's body invalidates
|
shared code of every sibling module that either of the two
|
||||||
that test's earlier passes and no other; a change to a helper
|
imports, and the shared judges (SHARED_JUDGES). Part of the
|
||||||
invalidates the tests of that module. A test the module does not
|
fingerprint: a change inside one test's body invalidates that
|
||||||
define in the @test form hashes its whole file."""
|
test's earlier passes and no other; a change to a helper
|
||||||
|
invalidates the tests of that module and the tests that import
|
||||||
|
it; a change to a shared judge invalidates every test. A test the
|
||||||
|
module does not define in the @test form hashes its whole file."""
|
||||||
if self._source_sha is None:
|
if self._source_sha is None:
|
||||||
# One filler at a time: a second poll that arrives while the
|
# One filler at a time: a second poll that arrives while the
|
||||||
# first computes waits for it rather than repeating the work.
|
# first computes waits for it rather than repeating the work.
|
||||||
@@ -144,6 +147,28 @@ def source_file_sha(path):
|
|||||||
return hashlib.sha256(data).hexdigest()
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# The package modules that judge the machine. Every test runs through
|
||||||
|
# the first three without importing them: the Context a test is handed
|
||||||
|
# and the takeover (runner.py), the sysfs, LED, switch and HTTP readers
|
||||||
|
# behind it (hw.py), and the fresh-boot idle state judged around every
|
||||||
|
# run (baseline.py). puls.py is the dark job the cloud and extension
|
||||||
|
# tests print: suite/cloud.py imports it from the package (`from .. import
|
||||||
|
# puls`), an import the sibling walk does not follow. Their text joins
|
||||||
|
# every implementation hash: a change to one of them moves every test.
|
||||||
|
# The rest of the package (the server, the page, the campaign rules, the
|
||||||
|
# artifact, the manifest, this registry, the bench actuator) judges
|
||||||
|
# nothing about the machine and stays out.
|
||||||
|
SHARED_JUDGES = ("baseline.py", "hw.py", "puls.py", "runner.py")
|
||||||
|
|
||||||
|
|
||||||
|
def shared_judge_paths():
|
||||||
|
"""The SHARED_JUDGES files, next to this module. Every one has to be
|
||||||
|
there: a judge missing from the package is an error, never a hash
|
||||||
|
computed without it."""
|
||||||
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
return [os.path.join(here, name) for name in SHARED_JUDGES]
|
||||||
|
|
||||||
|
|
||||||
# The files never change under a run, so each is read and parsed once:
|
# 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 -> (text, tree or None), path -> (shared sha, {test id: own sha}),
|
||||||
# path -> the sibling modules it imports itself.
|
# path -> the sibling modules it imports itself.
|
||||||
@@ -178,6 +203,20 @@ def _parsed(path):
|
|||||||
return hit
|
return hit
|
||||||
|
|
||||||
|
|
||||||
|
def _test_defs(tree):
|
||||||
|
"""{test id: its function} for the @test("...") functions at the top
|
||||||
|
level of a module's tree."""
|
||||||
|
defs = {}
|
||||||
|
for node in (tree.body if tree is not None else []):
|
||||||
|
if not isinstance(node, ast.FunctionDef):
|
||||||
|
continue
|
||||||
|
for d in node.decorator_list:
|
||||||
|
if (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == "test"
|
||||||
|
and d.args and isinstance(d.args[0], ast.Constant) and isinstance(d.args[0].value, str)):
|
||||||
|
defs[d.args[0].value] = node
|
||||||
|
return defs
|
||||||
|
|
||||||
|
|
||||||
def module_parts(path):
|
def module_parts(path):
|
||||||
"""(shared sha, {test id: own sha}) for a suite module: the test ids
|
"""(shared sha, {test id: own sha}) for a suite module: the test ids
|
||||||
are read from the @test("...") decorators on the module's top-level
|
are read from the @test("...") decorators on the module's top-level
|
||||||
@@ -191,14 +230,9 @@ def module_parts(path):
|
|||||||
text, tree = _parsed(path)
|
text, tree = _parsed(path)
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
spans = {}
|
spans = {}
|
||||||
for node in (tree.body if tree is not None else []):
|
for tid, node in _test_defs(tree).items():
|
||||||
if not isinstance(node, ast.FunctionDef):
|
start = min([dd.lineno for dd in node.decorator_list] + [node.lineno])
|
||||||
continue
|
spans[tid] = (start, node.end_lineno)
|
||||||
for d in node.decorator_list:
|
|
||||||
if (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == "test"
|
|
||||||
and d.args and isinstance(d.args[0], ast.Constant) and isinstance(d.args[0].value, str)):
|
|
||||||
start = min([dd.lineno for dd in node.decorator_list] + [node.lineno])
|
|
||||||
spans[d.args[0].value] = (start, node.end_lineno)
|
|
||||||
owned = set()
|
owned = set()
|
||||||
for a, b in spans.values():
|
for a, b in spans.values():
|
||||||
owned.update(range(a, b + 1))
|
owned.update(range(a, b + 1))
|
||||||
@@ -211,10 +245,10 @@ def module_parts(path):
|
|||||||
|
|
||||||
|
|
||||||
def sibling_imports(path, seen=None):
|
def sibling_imports(path, seen=None):
|
||||||
"""The suite modules a module imports with `from .x import ...`, as
|
"""The suite modules a module's shared text imports with `from .x
|
||||||
paths, transitively, in a stable order. A helper a test calls from
|
import ...` or `from . import x`, as paths, transitively, in a stable
|
||||||
a sibling module is part of the judge, so its text belongs in the
|
order. A helper a test calls from a sibling module is part of the
|
||||||
test's fingerprint."""
|
judge, so its text belongs in the test's fingerprint."""
|
||||||
if seen is None:
|
if seen is None:
|
||||||
seen = []
|
seen = []
|
||||||
for sib in _direct_siblings(path):
|
for sib in _direct_siblings(path):
|
||||||
@@ -224,32 +258,65 @@ def sibling_imports(path, seen=None):
|
|||||||
return seen
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def _imported(path, nodes):
|
||||||
|
"""The suite modules the statements `nodes` import with `from .x
|
||||||
|
import` or `from . import x` (each name a module of the suite), at any
|
||||||
|
depth (inside a function or a block too), in the order they are
|
||||||
|
written."""
|
||||||
|
here = os.path.dirname(path)
|
||||||
|
found = sorted((n for top in nodes for n in ast.walk(top)
|
||||||
|
if isinstance(n, ast.ImportFrom) and n.level == 1),
|
||||||
|
key=lambda n: (n.lineno, n.col_offset))
|
||||||
|
out = []
|
||||||
|
for node in found:
|
||||||
|
for name in ([node.module] if node.module else [a.name for a in node.names]):
|
||||||
|
sib = os.path.join(here, name.replace(".", os.sep) + ".py")
|
||||||
|
if os.path.isfile(sib) and sib not in out:
|
||||||
|
out.append(sib)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _direct_siblings(path):
|
def _direct_siblings(path):
|
||||||
"""The suite modules one module imports itself, in import order."""
|
"""The suite modules one module's shared text imports itself: at its
|
||||||
|
top level, and inside its helpers and blocks. What a @test function
|
||||||
|
imports is that test's own (_own_siblings)."""
|
||||||
with _PARTS_LOCK:
|
with _PARTS_LOCK:
|
||||||
hit = _SIBLINGS.get(path)
|
hit = _SIBLINGS.get(path)
|
||||||
if hit is None:
|
if hit is None:
|
||||||
_text, tree = _parsed(path)
|
_text, tree = _parsed(path)
|
||||||
here = os.path.dirname(path)
|
tests = list(_test_defs(tree).values())
|
||||||
hit = []
|
shared = [n for n in (tree.body if tree is not None else []) if not any(n is t for t in tests)]
|
||||||
for node in (tree.body if tree is not None else []):
|
hit = _SIBLINGS[path] = _imported(path, shared)
|
||||||
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
|
return hit
|
||||||
|
|
||||||
|
|
||||||
def implementation_sha(path, test_id):
|
def _own_siblings(path, test_id):
|
||||||
|
"""The suite modules one test's own function imports: part of that
|
||||||
|
test's judge, and of no other test's."""
|
||||||
|
_text, tree = _parsed(path)
|
||||||
|
node = _test_defs(tree).get(test_id)
|
||||||
|
return _imported(path, [node]) if node is not None else []
|
||||||
|
|
||||||
|
|
||||||
|
def implementation_sha(path, test_id, judges=None):
|
||||||
"""The implementation hash of one test (see Test.source_sha): the
|
"""The implementation hash of one test (see Test.source_sha): the
|
||||||
test's own text, the shared text of its module, and the shared text
|
test's own text, the shared text of its module, the shared text of
|
||||||
of every sibling suite module the module imports."""
|
every sibling suite module the module's shared text or the test's
|
||||||
|
own function imports (transitively), and the text of the shared
|
||||||
|
judges. `judges` names files to take as the judges in place of
|
||||||
|
SHARED_JUDGES (the unit tests edit their own)."""
|
||||||
shared, own = module_parts(path)
|
shared, own = module_parts(path)
|
||||||
if test_id not in own:
|
if test_id not in own:
|
||||||
return source_file_sha(path)
|
return source_file_sha(path)
|
||||||
helpers = [module_parts(p)[0] for p in sorted(sibling_imports(path))]
|
sibs = sibling_imports(path)
|
||||||
return _manifest.sha256_text("%s:%s" % (":".join([shared] + helpers), own[test_id]))
|
for sib in _own_siblings(path, test_id):
|
||||||
|
if sib not in sibs and sib != path:
|
||||||
|
sibs.append(sib)
|
||||||
|
sibling_imports(sib, sibs)
|
||||||
|
helpers = [module_parts(p)[0] for p in sorted(sibs) if p != path]
|
||||||
|
paths = shared_judge_paths() if judges is None else list(judges)
|
||||||
|
judged = [module_parts(p)[0] for p in paths]
|
||||||
|
return _manifest.sha256_text("%s:%s" % (":".join([shared] + helpers + judged), own[test_id]))
|
||||||
|
|
||||||
|
|
||||||
def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers=(),
|
def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers=(),
|
||||||
|
|||||||
@@ -4,24 +4,33 @@
|
|||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Coverage lint: every source path in the manifest must be selected by
|
"""Coverage lint: every source path in the manifest must be selected by
|
||||||
some test's coverage globs, except the allowlisted non-behavioral paths,
|
some test's coverage globs or by a host-tested row (manifest.HOST_TESTED),
|
||||||
and every coverage glob must select at least one path of its component.
|
except the allowlisted non-behavioral paths; every coverage glob and every
|
||||||
|
host-tested glob must select at least one path of its component; and every
|
||||||
|
host-tested row must name a host test that exists and a CI workflow that
|
||||||
|
runs it.
|
||||||
|
|
||||||
Why a hard rule: under the domain model an uncovered file is worse than an
|
Why a hard rule: under the domain model an uncovered file is worse than an
|
||||||
untested one - a change there leaves every inherited PASS valid when it
|
untested one - a change there leaves every inherited PASS valid when it
|
||||||
should have invalidated them. The lint is the floor (the file is
|
should have invalidated them. The lint is the floor (the file is
|
||||||
fingerprinted by at least one test); whether that test exercises the
|
fingerprinted by at least one test, or a named host test that CI runs
|
||||||
change stays with the change author. An entry that selects nothing is
|
answers for it); whether that test exercises the change stays with the
|
||||||
the same defect from the other side: the test's fingerprint ignores the
|
change author. An entry that selects nothing is the same defect from the
|
||||||
file it named.
|
other side: the test's fingerprint ignores the file it named.
|
||||||
|
|
||||||
python3 -m forgetest.coverage [--manifest PATH] [--enforce] [--json]
|
Whether a workflow runs its host test is read from the workflow's text at
|
||||||
|
the pinned revision, in the sources scripts/manifest-from-tree.py fetched
|
||||||
|
(--sources, default the checkout's .manifest-cache). Without them (on an
|
||||||
|
image) that part is not checked, and the report says so.
|
||||||
|
|
||||||
|
python3 -m forgetest.coverage [--manifest PATH] [--sources DIR] [--enforce] [--json]
|
||||||
|
|
||||||
Exit 0 when nothing is uncovered (or when only reporting), 1 under
|
Exit 0 when nothing is uncovered (or when only reporting), 1 under
|
||||||
--enforce with uncovered paths, 2 on a usage or load error.
|
--enforce with uncovered paths, 2 on a usage or load error.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from . import catalog as _catalog
|
from . import catalog as _catalog
|
||||||
@@ -33,16 +42,22 @@ ALLOW = _manifest.NON_BEHAVIORAL
|
|||||||
|
|
||||||
|
|
||||||
def run(manifest, tests, allow=ALLOW):
|
def run(manifest, tests, allow=ALLOW):
|
||||||
return _manifest.coverage_report(manifest, tests, allow)
|
return _manifest.coverage_report(manifest, tests, allow, host=_manifest.HOST_TESTED)
|
||||||
|
|
||||||
|
|
||||||
def empty(manifest, tests):
|
def empty(manifest, tests):
|
||||||
return _manifest.empty_covers(manifest, tests)
|
return _manifest.empty_covers(manifest, tests)
|
||||||
|
|
||||||
|
|
||||||
|
def host_problems(manifest, sources=_manifest.SOURCES):
|
||||||
|
return _manifest.host_row_problems(manifest, host=_manifest.HOST_TESTED, sources=sources)
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
ap = argparse.ArgumentParser(description="forgetest coverage lint")
|
ap = argparse.ArgumentParser(description="forgetest coverage lint")
|
||||||
ap.add_argument("--manifest", default=None, help="manifest JSON (default: the running image's)")
|
ap.add_argument("--manifest", default=None, help="manifest JSON (default: the running image's)")
|
||||||
|
ap.add_argument("--sources", default=_manifest.SOURCES,
|
||||||
|
help="the pinned sources manifest-from-tree.py fetched (default: the checkout's .manifest-cache)")
|
||||||
ap.add_argument("--enforce", action="store_true", help="exit 1 when any path is uncovered")
|
ap.add_argument("--enforce", action="store_true", help="exit 1 when any path is uncovered")
|
||||||
ap.add_argument("--json", action="store_true", help="machine-readable report")
|
ap.add_argument("--json", action="store_true", help="machine-readable report")
|
||||||
args = ap.parse_args(argv)
|
args = ap.parse_args(argv)
|
||||||
@@ -56,19 +71,30 @@ def main(argv=None):
|
|||||||
report = run(manifest, tests)
|
report = run(manifest, tests)
|
||||||
total = sum(len(v) for v in report.values())
|
total = sum(len(v) for v in report.values())
|
||||||
hollow = empty(manifest, tests)
|
hollow = empty(manifest, tests)
|
||||||
|
sources = args.sources if args.sources and os.path.isdir(args.sources) else None
|
||||||
|
rows = host_problems(manifest, sources)
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps({"uncovered": report, "total": total, "tests": len(tests),
|
print(json.dumps({"uncovered": report, "total": total, "tests": len(tests),
|
||||||
"empty": [list(e) for e in hollow]}, indent=1, sort_keys=True))
|
"empty": [list(e) for e in hollow],
|
||||||
|
"host_tested": [list(r) for r in rows],
|
||||||
|
"workflows_read": sources is not None and not any("cannot be read" in r[3] for r in rows)},
|
||||||
|
indent=1, sort_keys=True))
|
||||||
else:
|
else:
|
||||||
|
if _manifest.HOST_TESTED and sources is None:
|
||||||
|
print("host-tested: no sources, so whether a workflow runs each host test is not checked")
|
||||||
for comp in sorted(report):
|
for comp in sorted(report):
|
||||||
print("%s: %d uncovered path(s)" % (comp, len(report[comp])))
|
print("%s: %d uncovered path(s)" % (comp, len(report[comp])))
|
||||||
for p in report[comp]:
|
for p in report[comp]:
|
||||||
print(" %s" % p)
|
print(" %s" % p)
|
||||||
for tid, comp, pat in hollow:
|
for tid, comp, pat in hollow:
|
||||||
print("%s: covers (%s, %s) selects nothing" % (tid, comp, pat))
|
print("%s: covers (%s, %s) selects nothing" % (tid, comp, pat))
|
||||||
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, %d tests"
|
for comp, pat, ref, why in rows:
|
||||||
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(tests)))
|
print("host-tested (%s, %s) by %s: %s" % (comp, pat, ref, why))
|
||||||
if args.enforce and (total or hollow):
|
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, "
|
||||||
|
"%d host-tested row problem(s), %d tests"
|
||||||
|
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(rows),
|
||||||
|
len(tests)))
|
||||||
|
if args.enforce and (total or hollow or rows):
|
||||||
return 1
|
return 1
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,14 @@ PULSE_WAIT_S = 1.5 # a pulse in progress ends within the firmware's
|
|||||||
|
|
||||||
|
|
||||||
class FixtureError(Exception):
|
class FixtureError(Exception):
|
||||||
pass
|
"""A request the fixture did not carry out as asked. `sent` is True
|
||||||
|
when the failure came after the request went out (no reply, or a
|
||||||
|
reply cut off), so the box may have acted on it; a refusal, a failed
|
||||||
|
lookup and a failed connect leave it False."""
|
||||||
|
|
||||||
|
def __init__(self, *args, sent=False):
|
||||||
|
super().__init__(*args)
|
||||||
|
self.sent = sent
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- mDNS
|
# ---------------------------------------------------------------- mDNS
|
||||||
@@ -271,11 +278,20 @@ class Fixture:
|
|||||||
body = {}
|
body = {}
|
||||||
return e.code, body
|
return e.code, body
|
||||||
except (urllib.error.URLError, socket.timeout, OSError) as e:
|
except (urllib.error.URLError, socket.timeout, OSError) as e:
|
||||||
|
# urllib raises a failed connect or send as a URLError; a
|
||||||
|
# timeout or a reset after the request went out comes bare, and
|
||||||
|
# the box may have acted on the request
|
||||||
|
sent = not isinstance(e, urllib.error.URLError)
|
||||||
if retry and not self.ip_override:
|
if retry and not self.ip_override:
|
||||||
# the address may have moved: one fresh lookup, one retry
|
# the address may have moved: one fresh lookup, one retry,
|
||||||
self.address(refresh=True)
|
# and a failure of either still says the first one went out
|
||||||
return self._request(method, path, body, retry=False)
|
try:
|
||||||
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e))
|
self.address(refresh=True)
|
||||||
|
return self._request(method, path, body, retry=False)
|
||||||
|
except FixtureError as again:
|
||||||
|
again.sent = again.sent or sent
|
||||||
|
raise
|
||||||
|
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e), sent=sent)
|
||||||
|
|
||||||
def status(self):
|
def status(self):
|
||||||
st, body = self._request("GET", "/")
|
st, body = self._request("GET", "/")
|
||||||
@@ -314,11 +330,12 @@ class Fixture:
|
|||||||
|
|
||||||
def _press(self, ms=None):
|
def _press(self, ms=None):
|
||||||
"""One press, spaced from the last one. `ms` is how long the box
|
"""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
|
holds it: the box's own 200 ms when it is not said, and the box
|
||||||
it is not said): a check that must reach the machine *while* the
|
clamps a request into 20 to 500, so no press is ever held. Every
|
||||||
button is down asks for the longest press the box can make. The controller detects
|
reader of the button catches the default press (an edge, or a poll
|
||||||
the button's rising edge, so a press must follow a release it has
|
of 100 ms or less), so no check needs a longer one. The controller
|
||||||
seen: the next press waits for the last pulse to end (the
|
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
|
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
|
||||||
progress (a press this client did not time) is waited out once
|
progress (a press this client did not time) is waited out once
|
||||||
against the fixture's state, then retried."""
|
against the fixture's state, then retried."""
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ exercised against a mock on a host:
|
|||||||
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
|
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
|
||||||
FORGETEST_INITD default /etc/init.d
|
FORGETEST_INITD default /etc/init.d
|
||||||
"""
|
"""
|
||||||
|
import glob
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import select
|
||||||
import socket
|
import socket
|
||||||
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -310,6 +313,89 @@ def button_lit():
|
|||||||
return any(v > 0 for v in vals)
|
return any(v > 0 for v in vals)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- the button
|
||||||
|
|
||||||
|
EV_SW = 0x05
|
||||||
|
SW_BUTTON = 2 # the button's EV_SW code (bit 2 of the switch word)
|
||||||
|
|
||||||
|
|
||||||
|
def switches_device():
|
||||||
|
"""The machine's switches as forgectrl and the controller find them:
|
||||||
|
GF_SWITCH_DEV, else the input device the device tree names
|
||||||
|
"switches". None when there is none."""
|
||||||
|
dev = os.environ.get("GF_SWITCH_DEV")
|
||||||
|
if dev:
|
||||||
|
return dev
|
||||||
|
for d in sorted(glob.glob("/sys/class/input/event*")):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(d, "device", "name")) as f:
|
||||||
|
if f.read().strip() == "switches":
|
||||||
|
return "/dev/input/" + os.path.basename(d)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ButtonEdges:
|
||||||
|
"""The button as edges, from the switches' input events. A press is an
|
||||||
|
event however short it is, so nothing has to be held long enough for a
|
||||||
|
poll to land on it: an operator's press is never longer than a quarter
|
||||||
|
second. Open it before the press is asked for. It reads without a
|
||||||
|
grab, so forgectrl and the controller see the switches as always."""
|
||||||
|
|
||||||
|
EVENT = struct.Struct("llHHi") # struct input_event: time, type, code, value
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.fd = None
|
||||||
|
self.pending = b"" # events read and not yet looked at
|
||||||
|
dev = switches_device()
|
||||||
|
if dev:
|
||||||
|
try:
|
||||||
|
self.fd = os.open(dev, os.O_RDONLY | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0))
|
||||||
|
except OSError:
|
||||||
|
self.fd = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self):
|
||||||
|
return self.fd is not None
|
||||||
|
|
||||||
|
def wait(self, value, timeout, checkpoint=None):
|
||||||
|
"""The seconds until the button's next edge to `value` (1 pressed,
|
||||||
|
0 released), or None at the timeout. checkpoint() is called about
|
||||||
|
every 0.1 s (an abort raises out of it)."""
|
||||||
|
size = self.EVENT.size
|
||||||
|
t0 = time.time()
|
||||||
|
while True:
|
||||||
|
# The events already read come first: a short press arrives
|
||||||
|
# with its release in one read, and the release is the next
|
||||||
|
# wait's.
|
||||||
|
while len(self.pending) >= size:
|
||||||
|
_sec, _usec, typ, code, val = self.EVENT.unpack_from(self.pending, 0)
|
||||||
|
self.pending = self.pending[size:]
|
||||||
|
if typ == EV_SW and code == SW_BUTTON and val == value:
|
||||||
|
return time.time() - t0
|
||||||
|
if time.time() - t0 >= timeout:
|
||||||
|
return None
|
||||||
|
if checkpoint:
|
||||||
|
checkpoint()
|
||||||
|
ready, _, _ = select.select([self.fd], [], [], 0.1)
|
||||||
|
if not ready:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = os.read(self.fd, size * 64)
|
||||||
|
except BlockingIOError:
|
||||||
|
continue
|
||||||
|
if not data:
|
||||||
|
time.sleep(0.02) # a FIFO with no writer (the host tests) reads empty
|
||||||
|
continue
|
||||||
|
self.pending += data
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.fd is not None:
|
||||||
|
os.close(self.fd)
|
||||||
|
self.fd = None
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- init.d
|
# --------------------------------------------------------------- init.d
|
||||||
|
|
||||||
def initd(service, action, timeout=60):
|
def initd(service, action, timeout=60):
|
||||||
|
|||||||
+1005
-3
File diff suppressed because it is too large
Load Diff
+100
-10
@@ -125,6 +125,8 @@ ACT_TIMEOUT_S = 180
|
|||||||
# machine by pressing its button. Generous: they may be setting up the
|
# machine by pressing its button. Generous: they may be setting up the
|
||||||
# scrap, and nothing fires until it lands.
|
# scrap, and nothing fires until it lands.
|
||||||
PRESENCE_TIMEOUT_S = 600
|
PRESENCE_TIMEOUT_S = 600
|
||||||
|
ARM_LIT_WAIT_S = 1800 # an arm can wait minutes (a coolant settle) before it lights
|
||||||
|
ARM_PRESS_WAIT_S = 3600 # the controller's own button timeout, at most
|
||||||
|
|
||||||
|
|
||||||
class Run:
|
class Run:
|
||||||
@@ -316,17 +318,31 @@ class Context:
|
|||||||
|
|
||||||
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
|
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
|
||||||
if fixture is not None and fixture.covers("button"):
|
if fixture is not None and fixture.covers("button"):
|
||||||
|
# The press is read as an edge from the switches' events, opened
|
||||||
|
# before the notice goes up: a press of any length counts, and
|
||||||
|
# nobody holds the button for a poll to land on it.
|
||||||
|
edges = hw.ButtonEdges()
|
||||||
self.notice(text + " Then press the button on the machine to start.")
|
self.notice(text + " Then press the button on the machine to start.")
|
||||||
self.log("READY: waiting for the operator's press on the machine "
|
self.log("READY: waiting for the operator's press on the machine "
|
||||||
"(the bench then does every press in this test)")
|
"(the bench then does every press in this test)")
|
||||||
pressed = self.wait_for(lambda: self.switch("button") is True, PRESENCE_TIMEOUT_S)
|
try:
|
||||||
self.clear_notice()
|
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:
|
if pressed is None:
|
||||||
raise Failed("no press on the machine within %d s: %s"
|
raise Failed("no press on the machine within %d s: %s"
|
||||||
% (PRESENCE_TIMEOUT_S, text))
|
% (PRESENCE_TIMEOUT_S, text))
|
||||||
# Let the button come back up, so this press is never read as
|
|
||||||
# the arm press or a pause toggle.
|
|
||||||
self.wait_for(lambda: self.switch("button") is False, 10)
|
|
||||||
self.run.fixture_takeover = True
|
self.run.fixture_takeover = True
|
||||||
self.evidence.setdefault("actions", []).append(
|
self.evidence.setdefault("actions", []).append(
|
||||||
{"channel": "button", "state": "presence", "by": "operator",
|
{"channel": "button", "state": "presence", "by": "operator",
|
||||||
@@ -448,8 +464,9 @@ class Context:
|
|||||||
|
|
||||||
The fixture presses where the bench took this test's presses (or
|
The fixture presses where the bench took this test's presses (or
|
||||||
opted in standing); otherwise the notice goes up for a person,
|
opted in standing); otherwise the notice goes up for a person,
|
||||||
who is looking at the same prompt. Returns True when the fixture
|
who is looking at the same prompt. A press the fixture reports
|
||||||
pressed."""
|
failed while the button went out was taken all the same, and no
|
||||||
|
notice goes up for it. Returns True when the fixture pressed."""
|
||||||
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
|
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
|
||||||
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
|
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
|
||||||
self.evidence.setdefault("actions", []).append(rec)
|
self.evidence.setdefault("actions", []).append(rec)
|
||||||
@@ -461,18 +478,84 @@ class Context:
|
|||||||
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
|
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
|
||||||
"gone - asking the operator for the arm press")
|
"gone - asking the operator for the arm press")
|
||||||
self.notice(text)
|
self.notice(text)
|
||||||
|
self._clear_when_taken()
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
fixture.act("button", "press")
|
fixture.act("button", "press")
|
||||||
except _fixture.FixtureError as e:
|
except _fixture.FixtureError as e:
|
||||||
rec["fixture_error"] = str(e)
|
rec["fixture_error"] = str(e)
|
||||||
|
if self._press_taken(rec, e):
|
||||||
|
return rec["by"] == "fixture"
|
||||||
self.log("ARM press: fixture failed (%s) - asking the operator", e)
|
self.log("ARM press: fixture failed (%s) - asking the operator", e)
|
||||||
self.notice(text)
|
self.notice(text)
|
||||||
|
self._clear_when_taken()
|
||||||
return False
|
return False
|
||||||
rec["by"] = "fixture"
|
rec["by"] = "fixture"
|
||||||
self.log("ARM press by the fixture, at the machine's own press prompt")
|
self.log("ARM press by the fixture, at the machine's own press prompt")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _press_taken(self, rec, err):
|
||||||
|
"""After the box reported a failed press of a lit button: True when
|
||||||
|
the button is out, so the controller took a press. No notice goes
|
||||||
|
up then, because it would ask for a second press, and a press during
|
||||||
|
the job pauses it. The press is recorded as the box's only when its
|
||||||
|
request went out and the failure came after it (err.sent: the box
|
||||||
|
pressed and its reply was lost). After a refusal, a failed lookup
|
||||||
|
or a failed connect the box made no press, and the record stays as
|
||||||
|
it was: the press was a person's. A button still lit, or
|
||||||
|
unreadable, is False: the operator is asked."""
|
||||||
|
if hw.button_lit() is not False:
|
||||||
|
return False
|
||||||
|
if getattr(err, "sent", False):
|
||||||
|
rec["by"] = "fixture"
|
||||||
|
self.log("ARM press: the fixture reported a failure (%s), and the button is out: the press "
|
||||||
|
"was taken; no notice", err)
|
||||||
|
else:
|
||||||
|
self.log("ARM press: the fixture made no press (%s), and the button is out: a press was "
|
||||||
|
"taken all the same; no notice", err)
|
||||||
|
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. The watch lives
|
||||||
|
only as long as the notice it guards and the run: a notice cleared
|
||||||
|
or replaced, a run that finished (passed, failed, or errored before
|
||||||
|
the press) or an abort ends it, so no thread is left polling the
|
||||||
|
LEDs behind a test."""
|
||||||
|
run = self.run
|
||||||
|
with run._lock:
|
||||||
|
nid = (run.notice or {}).get("id")
|
||||||
|
if nid is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def standing():
|
||||||
|
with run._lock:
|
||||||
|
return run.finished is None and (run.notice or {}).get("id") == nid
|
||||||
|
|
||||||
|
def watch():
|
||||||
|
t0 = time.time()
|
||||||
|
while hw.button_lit() is not True:
|
||||||
|
if not standing() or time.time() - t0 > lit_timeout:
|
||||||
|
return
|
||||||
|
if run.aborted.wait(0.05):
|
||||||
|
return
|
||||||
|
while hw.button_lit() is not False:
|
||||||
|
if not standing() or time.time() - t0 > lit_timeout + ARM_PRESS_WAIT_S:
|
||||||
|
return
|
||||||
|
if run.aborted.wait(0.05):
|
||||||
|
return
|
||||||
|
with run._lock:
|
||||||
|
same = (run.notice or {}).get("id") == nid
|
||||||
|
if same:
|
||||||
|
run.notice = None
|
||||||
|
if same:
|
||||||
|
self.log("ARM: the button went out at the press; the notice is down")
|
||||||
|
|
||||||
|
threading.Thread(target=watch, daemon=True, name="forgetest-arm-notice").start()
|
||||||
|
|
||||||
def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.",
|
def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.",
|
||||||
lit_timeout=60):
|
lit_timeout=60):
|
||||||
"""The arm cue of a live test. A person's press by default: a
|
"""The arm cue of a live test. A person's press by default: a
|
||||||
@@ -482,8 +565,9 @@ class Context:
|
|||||||
seconds for the button to light (the job may still be on its way
|
seconds for the button to light (the job may still be on its way
|
||||||
to the arm wait; a card that settles the coolant first takes
|
to the arm wait; a card that settles the coolant first takes
|
||||||
minutes) and presses once, recorded as the fixture's; if the
|
minutes) and presses once, recorded as the fixture's; if the
|
||||||
button never lights or the press fails, the notice goes up for a
|
button never lights, or the press fails with the button still lit,
|
||||||
person. Returns True when the fixture has been asked."""
|
the notice goes up for a person. Returns True when the fixture has
|
||||||
|
been asked."""
|
||||||
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
|
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
|
||||||
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
|
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
|
||||||
self.evidence.setdefault("actions", []).append(rec)
|
self.evidence.setdefault("actions", []).append(rec)
|
||||||
@@ -499,6 +583,7 @@ class Context:
|
|||||||
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
|
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
|
||||||
"gone - asking the operator for the arm press")
|
"gone - asking the operator for the arm press")
|
||||||
self.notice(text)
|
self.notice(text)
|
||||||
|
self._clear_when_taken()
|
||||||
return False
|
return False
|
||||||
self.log("ARM: the fixture presses when the button lights (the bench's arm_press opt-in)")
|
self.log("ARM: the fixture presses when the button lights (the bench's arm_press opt-in)")
|
||||||
|
|
||||||
@@ -507,13 +592,18 @@ class Context:
|
|||||||
if lit is None:
|
if lit is None:
|
||||||
self.log("ARM: the button never lit within %d s - asking the operator", lit_timeout)
|
self.log("ARM: the button never lit within %d s - asking the operator", lit_timeout)
|
||||||
self.notice(text)
|
self.notice(text)
|
||||||
|
self._clear_when_taken()
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
fixture.act("button", "press")
|
fixture.act("button", "press")
|
||||||
except _fixture.FixtureError as e:
|
except _fixture.FixtureError as e:
|
||||||
self.log("ARM press: fixture failed (%s) - asking the operator", e)
|
|
||||||
rec["fixture_error"] = str(e)
|
rec["fixture_error"] = str(e)
|
||||||
|
if self._press_taken(rec, e):
|
||||||
|
rec["took_s"] = round(lit, 2)
|
||||||
|
return
|
||||||
|
self.log("ARM press: fixture failed (%s) - asking the operator", e)
|
||||||
self.notice(text)
|
self.notice(text)
|
||||||
|
self._clear_when_taken()
|
||||||
return
|
return
|
||||||
rec["by"] = "fixture"
|
rec["by"] = "fixture"
|
||||||
rec["took_s"] = round(lit, 2)
|
rec["took_s"] = round(lit, 2)
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ from . import kernel # noqa: F401,E402
|
|||||||
from . import forgectrl # noqa: F401,E402
|
from . import forgectrl # noqa: F401,E402
|
||||||
from . import setup # noqa: F401,E402
|
from . import setup # noqa: F401,E402
|
||||||
from . import setup_dark # noqa: F401,E402
|
from . import setup_dark # noqa: F401,E402
|
||||||
from . import setup_sheet # noqa: F401,E402
|
from . import setup_burns # noqa: F401,E402
|
||||||
from . import logs # noqa: F401,E402
|
|
||||||
from . import motion # noqa: F401,E402
|
from . import motion # noqa: F401,E402
|
||||||
from . import cooling # noqa: F401,E402
|
from . import cooling # noqa: F401,E402
|
||||||
from . import laser # noqa: F401,E402
|
from . import laser # noqa: F401,E402
|
||||||
@@ -21,17 +20,5 @@ from . import update # noqa: F401,E402
|
|||||||
from . import cloud # noqa: F401,E402
|
from . import cloud # noqa: F401,E402
|
||||||
# Last: it stands on the setup record's consent and on the cloud suite's dark print.
|
# 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 exthost # noqa: F401,E402
|
||||||
from . import extcore # noqa: F401,E402
|
|
||||||
from . import extcall # noqa: F401,E402
|
|
||||||
from . import extdest # noqa: F401,E402
|
|
||||||
from . import extlife # noqa: F401,E402
|
|
||||||
from . import evmore # noqa: F401,E402
|
|
||||||
from . import extcat # noqa: F401,E402
|
|
||||||
from . import extmcode # noqa: F401,E402
|
|
||||||
from . import extwizard # noqa: F401,E402
|
|
||||||
from . import updlock # noqa: F401,E402
|
|
||||||
from . import homeoff # noqa: F401,E402
|
from . import homeoff # noqa: F401,E402
|
||||||
from . import bedsize # noqa: F401,E402
|
|
||||||
from . import extsender # noqa: F401,E402
|
|
||||||
from . import tray # noqa: F401,E402
|
|
||||||
from . import tlsrec # noqa: F401,E402
|
from . import tlsrec # noqa: F401,E402
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""The Setup page's bed check, on the machine.
|
|
||||||
|
|
||||||
Its own module, so that no other test's fingerprint moves. The check is
|
|
||||||
driven from here inside the travel the machine already knows: a few jogs
|
|
||||||
out from the camera home, never toward an end, so it needs no operator.
|
|
||||||
Measuring the real ends is the operator's own run of the check. The
|
|
||||||
homing is the web-service session homing.cloud-offsets runs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .cloud import _HOMING_PATH, gfhome_homing
|
|
||||||
from .homeoff import camera_home_return, judge_whole_motions, session_mark
|
|
||||||
from .setup import read_file, record_path, write_file
|
|
||||||
from .setup_dark import DARK_COVERS, answer, dark, start
|
|
||||||
|
|
||||||
WID = "motion.envelope"
|
|
||||||
END = "This is the end"
|
|
||||||
# The operator's answers, as the page sends them: out 60.1 mm in X and
|
|
||||||
# 61.1 mm in Y from the home, the fine steps included. A far edge is 50 mm
|
|
||||||
# at the least, so the ends are well past that and well inside the bed.
|
|
||||||
X_STEPS = ["X+10"] * 6 + ["X+1", "X+0.1", "X-1"]
|
|
||||||
Y_STEPS = ["Y+10"] * 6 + ["Y+1", "Y+0.1"]
|
|
||||||
X_OUT, Y_OUT = 60.1, 61.1
|
|
||||||
MARGIN_MM = 1.0
|
|
||||||
# The head is back on the home's step: half a step (213.3 steps/mm) and
|
|
||||||
# the port's three decimals.
|
|
||||||
HOME_TOL_MM = 0.003
|
|
||||||
KEYS =("envelope_x_mm", "envelope_y_mm", "homing_mode")
|
|
||||||
CLOSED = "closed the check's envelope"
|
|
||||||
CLOSED_MSG = "Bed check envelope closed: a sender line"
|
|
||||||
|
|
||||||
|
|
||||||
def _port(fc):
|
|
||||||
st, body = fc.get("/motion/state")
|
|
||||||
return body if st == 200 and isinstance(body, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def _drive(ctx, fc, x_steps, y_steps, hook=None, timeout=300):
|
|
||||||
"""Start the check and answer each prompt from the steps in turn, then
|
|
||||||
This is the end; hook(prompt_id, n) runs before the answer to the n-th
|
|
||||||
prompt. The final status document, and the answers given."""
|
|
||||||
# The daemon calls the machine busy for a moment after the last jog.
|
|
||||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "the machine is not idle: %s", fc.status().get("state"))
|
|
||||||
start(ctx, fc, WID)
|
|
||||||
queue = {"x-end": list(x_steps) + [END], "y-end": list(y_steps) + [END]}
|
|
||||||
seen, given = set(), []
|
|
||||||
t0 = time.time()
|
|
||||||
try:
|
|
||||||
while time.time() - t0 < timeout:
|
|
||||||
ctx.checkpoint()
|
|
||||||
d = dark(fc)
|
|
||||||
p = d.get("prompt")
|
|
||||||
if d.get("running") and p and p.get("seq") not in seen:
|
|
||||||
seen.add(p["seq"])
|
|
||||||
pid = p.get("id")
|
|
||||||
ctx.check(pid in queue and queue[pid], "the check asked %s: %s (the lid must be closed)",
|
|
||||||
pid, p.get("text"))
|
|
||||||
ctx.check(p.get("kind") == "jog" and END in (p.get("options") or []),
|
|
||||||
"the %s prompt is not a jog with %r: %s", pid, END, p)
|
|
||||||
if hook:
|
|
||||||
hook(pid, len(given))
|
|
||||||
value = queue[pid].pop(0)
|
|
||||||
st, body = answer(fc, WID, p, value)
|
|
||||||
ctx.check(st == 200, "the answer %s to %s -> %s %s", value, pid, st, body)
|
|
||||||
given.append(value)
|
|
||||||
if not d.get("running"):
|
|
||||||
return d, given
|
|
||||||
time.sleep(0.3)
|
|
||||||
except BaseException:
|
|
||||||
fc.post("/wiz/%s/abort" % WID)
|
|
||||||
raise
|
|
||||||
fc.post("/wiz/%s/abort" % WID)
|
|
||||||
ctx.fail("the bed check did not end within %d s", timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def _jog(fc, dx=0.0, dy=0.0):
|
|
||||||
params = {"feed": "1200"}
|
|
||||||
if dx:
|
|
||||||
params["x"] = "%.3f" % dx
|
|
||||||
if dy:
|
|
||||||
params["y"] = "%.3f" % dy
|
|
||||||
return fc.post("/motion/jog", params=params)
|
|
||||||
|
|
||||||
|
|
||||||
def _settled(ctx, fc):
|
|
||||||
"""After the suite's Grbl client closes, the daemon calls the machine
|
|
||||||
busy for a moment: the check and a settings write each wait for it."""
|
|
||||||
ctx.wait_for(lambda: _port(fc).get("sender") is False, 20, poll=0.5)
|
|
||||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "the machine is not idle: %s", fc.status().get("state"))
|
|
||||||
|
|
||||||
|
|
||||||
def _put_back(ctx, fc, found):
|
|
||||||
for k in KEYS:
|
|
||||||
v = found.get(k) or ""
|
|
||||||
|
|
||||||
def put():
|
|
||||||
st_, body_ = fc.post("/settings", params={k: ""}) if v == "" else fc.post("/settings", data={k: v})
|
|
||||||
if st_ != 200:
|
|
||||||
ctx.log("restore %s=%r -> %s %s", k, v, st_, body_)
|
|
||||||
return st_ == 200 or None
|
|
||||||
took = ctx.wait_for(put, 20, poll=0.5)
|
|
||||||
ctx.log("restore %s=%r -> %s", k, v, "taken" if took is not None else "not taken")
|
|
||||||
|
|
||||||
|
|
||||||
def _back_home(ctx, fc, home):
|
|
||||||
"""The head back where the home put it: one jog to the home in machine
|
|
||||||
coordinates, from a Grbl client. A relative jog aimed at the home from
|
|
||||||
the port's position can be refused whole: the port reports the step
|
|
||||||
counters, the core adds a relative jog to its own position, which the
|
|
||||||
check's jogs leave up to half a step off the counters, and the envelope
|
|
||||||
begins at the home, so the target can land microns behind it (error:15).
|
|
||||||
Where the head stands after, from the port; None when unread."""
|
|
||||||
# 0.5 um inside an envelope that begins at the home (a camera home
|
|
||||||
# behind the origin), far under half a step: the head lands on the
|
|
||||||
# home's own step.
|
|
||||||
line = "$J=G90 G53 X%.4f Y%.4f F1200" % (home[0] + 0.0005, home[1] + 0.0005)
|
|
||||||
try:
|
|
||||||
with ctx.grbl() as g:
|
|
||||||
reply = g.command(line, timeout=10)
|
|
||||||
ctx.log("the jog back to the home: %s -> %s", line, " | ".join(reply))
|
|
||||||
if reply[-1:] == ["ok"]:
|
|
||||||
t0 = time.time()
|
|
||||||
ctx.wait_for(lambda: time.time() - t0 > 0.5 and g.status_report()["state"].startswith("Idle"),
|
|
||||||
60, poll=0.2)
|
|
||||||
except (OSError, ValueError) as e:
|
|
||||||
ctx.log("the jog back to the home: %s", e)
|
|
||||||
ctx.wait_for(lambda: _port(fc).get("sender") is False and _port(fc).get("state") == "Idle", 20, poll=0.5)
|
|
||||||
at = _port(fc).get("mpos")
|
|
||||||
ctx.log("the head after the jog back: %s (the home %s)", at, home)
|
|
||||||
return at
|
|
||||||
|
|
||||||
|
|
||||||
def _port_idle(ctx, fc):
|
|
||||||
ok = ctx.wait_for(lambda: (lambda s: s.get("state") == "Idle" and not s.get("port_jog"))(_port(fc)), 30,
|
|
||||||
poll=0.1)
|
|
||||||
ctx.check(ok is not None, "the port jog did not end: %s", _port(fc))
|
|
||||||
|
|
||||||
|
|
||||||
def _edge(ctx, fc, ev, axis, edge):
|
|
||||||
"""From where the head stands, a jog 0.5 mm short of the far edge runs
|
|
||||||
and comes back, and one 0.5 mm past it is refused with nothing moved."""
|
|
||||||
s = _port(fc)
|
|
||||||
at = s["mpos"]["XY".index(axis)]
|
|
||||||
short, past = edge - 0.5 - at, edge + 0.5 - at
|
|
||||||
kw = (lambda d: {"dx": d}) if axis == "X" else (lambda d: {"dy": d})
|
|
||||||
st1, b1 = _jog(fc, **kw(short))
|
|
||||||
_port_idle(ctx, fc)
|
|
||||||
st2, b2 = _jog(fc, **kw(-short))
|
|
||||||
_port_idle(ctx, fc)
|
|
||||||
st3, b3 = _jog(fc, **kw(past))
|
|
||||||
_port_idle(ctx, fc)
|
|
||||||
back = _port(fc)["mpos"]["XY".index(axis)]
|
|
||||||
ev["edge_" + axis] = {"edge": edge, "short": [st1, st2], "past": [st3, b3], "at": [at, back]}
|
|
||||||
ctx.check(st1 == 200 and st2 == 200, "a jog 0.5 mm short of the %s edge %.2f and back -> %s %s, %s %s",
|
|
||||||
axis, edge, st1, b1, st2, b2)
|
|
||||||
ctx.check(st3 == 409 and "envelope" in json.dumps(b3) and abs(back - at) < 0.02,
|
|
||||||
"a jog 0.5 mm past the %s edge %.2f -> %s %s, the head at %.3f from %.3f", axis, edge, st3, b3, back, at)
|
|
||||||
|
|
||||||
|
|
||||||
@test("setup.check-envelope", title="The bed check measures the far edges from the operator's jogs, and a Grbl "
|
|
||||||
"client's line ends it with nothing written",
|
|
||||||
subsystem="setup", kind="auto", mode="grbl", est_min=10,
|
|
||||||
covers=DARK_COVERS + _HOMING_PATH + [("forgectrl", "src/grblport.*"),
|
|
||||||
("grblhal-glowforge", "src/glowforge_homing.*"),
|
|
||||||
("grblhal-glowforge", "src/ctlport.*"),
|
|
||||||
("grblhal-glowforge", "src/driver.c")],
|
|
||||||
requires=["forgectrl.auth", "motion.pacing"],
|
|
||||||
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. The head moves "
|
|
||||||
"at most about 62 mm out from the camera home in X and in Y, and ends where it was found."],
|
|
||||||
description="With cloud mode on, the machine is homed with the camera (the web-service session), every "
|
|
||||||
"service motion of it run whole. "
|
|
||||||
"POST /wiz/motion.envelope/start runs the Bed size check; its prompts are jogs with This is the "
|
|
||||||
"end among the options, and are answered as the page would, out 60.1 mm in X and 61.1 mm in Y "
|
|
||||||
"(steps of 10, 1, and 0.1 mm). The check ends complete: the ends it reports are the home plus "
|
|
||||||
"those, the far edges 1 mm short of them are written as envelope_x_mm and envelope_y_mm, and the "
|
|
||||||
"controller holds them at once, without a home: a panel jog 0.5 mm short of each edge runs, "
|
|
||||||
"and one 0.5 mm past it is refused with nothing moved. A second run is opened, and while its "
|
|
||||||
"first prompt waits a Grbl client sends one line, which draws the controller's word that it "
|
|
||||||
"closed the envelope and then ok; the port's state then says the envelope is closed, and the "
|
|
||||||
"next answer ends the check in words, with the keys as the "
|
|
||||||
"first run left them and the edges still in force. The keys, the homing mode, and the setup "
|
|
||||||
"record are put back as found, the record under a restart, and the head goes back to the "
|
|
||||||
"home by a Grbl client's jog in machine coordinates; the run fails unless the port reads "
|
|
||||||
"the head on the home's step before the restart. Then the camera home is dropped and the "
|
|
||||||
"head goes back to where the test found it, by the travel the session's motions logged.")
|
|
||||||
def check_envelope(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
s0 = fc.settings() or {}
|
|
||||||
ctx.check(s0.get("cloud_enabled") == "1", "cloud mode is off: a camera home needs it on")
|
|
||||||
raw = read_file(record_path())
|
|
||||||
found = {k: s0.get(k) or "" for k in KEYS}
|
|
||||||
ev["found"] = found
|
|
||||||
home = back = session_at = None
|
|
||||||
try:
|
|
||||||
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
|
|
||||||
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
|
|
||||||
with ctx.grbl() as g:
|
|
||||||
session_at = session_mark()
|
|
||||||
gfhome_homing(ctx, ev, g)
|
|
||||||
home = g.status_report().get("MPos")
|
|
||||||
judge_whole_motions(ctx, ev, session_at)
|
|
||||||
ev["home"] = home
|
|
||||||
ctx.log("the camera home declared %s", home)
|
|
||||||
ctx.check(home and home[0] is not None, "no position after the home: %s", home)
|
|
||||||
_settled(ctx, fc)
|
|
||||||
|
|
||||||
last, given = _drive(ctx, fc, X_STEPS, Y_STEPS)
|
|
||||||
r = last.get("result") or {}
|
|
||||||
ev["run1"] = {"answers": given, "error": last.get("error"), "result": r, "log": last.get("log", [])[-8:]}
|
|
||||||
ctx.log("the bed check ended: %s", json.dumps(r))
|
|
||||||
ctx.check(not last.get("error"), "the bed check failed: %s", last.get("error"))
|
|
||||||
want_x, want_y = home[0] + X_OUT, home[1] + Y_OUT
|
|
||||||
ctx.check(abs((r.get("x_end_mm") or -1) - want_x) < 0.02 and abs((r.get("y_end_mm") or -1) - want_y) < 0.02,
|
|
||||||
"the ends are not the home plus the jogs (%.2f, %.2f): %s", want_x, want_y, r)
|
|
||||||
edge_x, edge_y = round(want_x - MARGIN_MM, 2), round(want_y - MARGIN_MM, 2)
|
|
||||||
s1 = fc.settings() or {}
|
|
||||||
ev["keys_after_run1"] = {k: s1.get(k) for k in ("envelope_x_mm", "envelope_y_mm")}
|
|
||||||
ctx.check(abs(float(s1.get("envelope_x_mm") or 0) - edge_x) < 0.011 and
|
|
||||||
abs(float(s1.get("envelope_y_mm") or 0) - edge_y) < 0.011,
|
|
||||||
"the keys are not the ends less %.1f mm (%.2f, %.2f): %s", MARGIN_MM, edge_x, edge_y,
|
|
||||||
ev["keys_after_run1"])
|
|
||||||
ctx.check(_port(fc).get("envelope_open") is False, "the envelope is still open after the check: %s",
|
|
||||||
_port(fc))
|
|
||||||
_edge(ctx, fc, ev, "X", edge_x)
|
|
||||||
_edge(ctx, fc, ev, "Y", edge_y)
|
|
||||||
|
|
||||||
# A Grbl client's line while the envelope is open ends the check.
|
|
||||||
said = {}
|
|
||||||
|
|
||||||
def client_line(pid, n):
|
|
||||||
if n:
|
|
||||||
return
|
|
||||||
ctx.check(_port(fc).get("envelope_open") is True, "the second run did not open the envelope: %s",
|
|
||||||
_port(fc))
|
|
||||||
with ctx.grbl() as g2:
|
|
||||||
said["reply"] = g2.command("G90", timeout=10)
|
|
||||||
said["text"] = g2.drain()
|
|
||||||
said["port"] = _port(fc)
|
|
||||||
|
|
||||||
last2, given2 = _drive(ctx, fc, ["X+1"], [], hook=client_line)
|
|
||||||
ev["run2"] = {"answers": given2, "said": said, "error": last2.get("error"), "log": last2.get("log", [])[-6:]}
|
|
||||||
ctx.log("the second run: %s", json.dumps(ev["run2"]))
|
|
||||||
ctx.check((said.get("reply") or [])[-1:] == ["ok"], "the client's line did not draw ok: %s", said)
|
|
||||||
ctx.check(any(CLOSED_MSG in l for l in (said.get("reply") or [])) or CLOSED_MSG in (said.get("text") or ""),
|
|
||||||
"the client was not told its line closed the envelope: %s", said)
|
|
||||||
ctx.check((said.get("port") or {}).get("envelope_open") is False,
|
|
||||||
"the client's line did not close the envelope: %s", said.get("port"))
|
|
||||||
ctx.check(CLOSED in (last2.get("error") or ""), "the second run did not end on the closed envelope: %s",
|
|
||||||
last2.get("error"))
|
|
||||||
s2 = fc.settings() or {}
|
|
||||||
ctx.check({k: s2.get(k) for k in ("envelope_x_mm", "envelope_y_mm")} == ev["keys_after_run1"],
|
|
||||||
"the second run wrote the keys: %s", s2)
|
|
||||||
_settled(ctx, fc)
|
|
||||||
_edge(ctx, fc, ev, "X", edge_x)
|
|
||||||
finally:
|
|
||||||
fc.post("/wiz/%s/abort" % WID)
|
|
||||||
if home and home[0] is not None:
|
|
||||||
ctx.wait_for(lambda: not dark(fc).get("running"), 30, poll=0.5)
|
|
||||||
back = _back_home(ctx, fc, home)
|
|
||||||
_put_back(ctx, fc, found)
|
|
||||||
try:
|
|
||||||
# Before the record's restart: the counters since the home say
|
|
||||||
# where the head stands until a controller start zeroes them.
|
|
||||||
if session_at is not None:
|
|
||||||
camera_home_return(ctx, ev, session_at)
|
|
||||||
finally:
|
|
||||||
# The check records itself when it completes; the record goes back
|
|
||||||
# as found under a restart, which also ends the envelope it set.
|
|
||||||
if read_file(record_path()) != raw:
|
|
||||||
with ctx.takeover():
|
|
||||||
write_file(record_path(), raw)
|
|
||||||
ctx.log("the previous setup record is back under a restart")
|
|
||||||
# Judged from the reading before the restart: the restart zeroes the
|
|
||||||
# counters where the head stands, so the baseline's position check
|
|
||||||
# after it cannot see a head left out.
|
|
||||||
ev["back"] = back
|
|
||||||
ctx.check(back and all(abs(back[i] - home[i]) < HOME_TOL_MM for i in (0, 1)),
|
|
||||||
"the head is not back at the home %s: %s", home, back)
|
|
||||||
@@ -4,8 +4,6 @@
|
|||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""camera.* - the lid camera pipeline through forgectrl."""
|
"""camera.* - the lid camera pipeline through forgectrl."""
|
||||||
import json
|
|
||||||
|
|
||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
from .. import hw
|
from .. import hw
|
||||||
from ..baseline import LID_LAMP_ATTR
|
from ..baseline import LID_LAMP_ATTR
|
||||||
@@ -57,7 +55,7 @@ def _jpeg_size(data):
|
|||||||
|
|
||||||
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
|
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
|
||||||
kind="auto", est_min=2,
|
kind="auto", est_min=2,
|
||||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
covers=_CAM_COVERS,
|
||||||
steps=["Setup: lid closed."],
|
steps=["Setup: lid closed."],
|
||||||
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
|
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
|
||||||
"far smaller), a second snapshot with the lid lamp switched off differs from it "
|
"far smaller), a second snapshot with the lid lamp switched off differs from it "
|
||||||
@@ -171,7 +169,7 @@ def snapshot(ctx):
|
|||||||
|
|
||||||
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera",
|
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera",
|
||||||
kind="auto", est_min=1,
|
kind="auto", est_min=1,
|
||||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
covers=_CAM_COVERS,
|
||||||
description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an "
|
description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an "
|
||||||
"8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it "
|
"8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it "
|
||||||
"actually returns are that size. A machine that reports 'unknown', or whose frames "
|
"actually returns are that size. A machine that reports 'unknown', or whose frames "
|
||||||
@@ -214,7 +212,7 @@ def sensor_profile(ctx):
|
|||||||
|
|
||||||
@test("camera.h264-stream", title="H.264 live stream", subsystem="camera",
|
@test("camera.h264-stream", title="H.264 live stream", subsystem="camera",
|
||||||
kind="auto", est_min=2,
|
kind="auto", est_min=2,
|
||||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
covers=_CAM_COVERS,
|
||||||
steps=["Setup: lid closed."],
|
steps=["Setup: lid closed."],
|
||||||
description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an "
|
description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an "
|
||||||
"init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few "
|
"init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few "
|
||||||
@@ -276,7 +274,7 @@ def h264_stream(ctx):
|
|||||||
|
|
||||||
@test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera",
|
@test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera",
|
||||||
kind="auto", est_min=1,
|
kind="auto", est_min=1,
|
||||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
covers=_CAM_COVERS,
|
||||||
description="The capture queue flags a frame errored when it is short, torn, or arrived "
|
description="The capture queue flags a frame errored when it is short, torn, or arrived "
|
||||||
"after the CSI-2 receiver lost sync; forgectrl drops those rather than "
|
"after the CSI-2 receiver lost sync; forgectrl drops those rather than "
|
||||||
"demosaicing them, restarts the stream if they persist, and withholds the "
|
"demosaicing them, restarts the stream if they persist, and withholds the "
|
||||||
@@ -364,7 +362,7 @@ def frame_health(ctx):
|
|||||||
|
|
||||||
@test("camera.lid-privacy", title="Cameras see the room only with the lid closed", subsystem="camera",
|
@test("camera.lid-privacy", title="Cameras see the room only with the lid closed", subsystem="camera",
|
||||||
kind="operator", est_min=3,
|
kind="operator", est_min=3,
|
||||||
covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")], requires=["forgectrl.panel-serves"],
|
covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")],
|
||||||
actions=["lid"],
|
actions=["lid"],
|
||||||
steps=["Start with the lid closed; you will be told to open it, then close it again.",
|
steps=["Start with the lid closed; you will be told to open it, then close it again.",
|
||||||
"Nothing moves and the laser is not involved."],
|
"Nothing moves and the laser is not involved."],
|
||||||
@@ -496,66 +494,3 @@ def lid_privacy(ctx):
|
|||||||
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
|
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
|
||||||
"snapshot after closing the lid -> %s", st)
|
"snapshot after closing the lid -> %s", st)
|
||||||
ctx.log("lid closed again: snapshot %d bytes", len(data))
|
ctx.log("lid closed again: snapshot %d bytes", len(data))
|
||||||
|
|
||||||
|
|
||||||
@test("camera.key-read", title="The camera key reads without a login", subsystem="camera",
|
|
||||||
kind="auto", est_min=1,
|
|
||||||
covers=[("forgectrl", "src/camkey.*"), ("forgectrl", "src/auth.c"),
|
|
||||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/panel.js"),
|
|
||||||
("forgectrl", "src/ui/index.html"), ("forgectrl", "src/ui/help.js")],
|
|
||||||
requires=["forgectrl.auth"],
|
|
||||||
description="With the read routes closed to the network (panel_open_reads=0), a camera "
|
|
||||||
"status read from the board's LAN address is refused without the key and "
|
|
||||||
"served with it, on HTTP and on HTTPS; a wrong key is refused; the key never "
|
|
||||||
"authorizes a write; a rotation from the panel route stops the old key and "
|
|
||||||
"the new one reads. The setting is put back after.")
|
|
||||||
def key_read(ctx):
|
|
||||||
from .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})
|
|
||||||
|
|||||||
@@ -1262,123 +1262,7 @@ def lid_interlock_abort_body(ctx, ev, off, job, offset):
|
|||||||
ev.get("edge_to_stop_ms"))
|
ev.get("edge_to_stop_ms"))
|
||||||
|
|
||||||
|
|
||||||
@test("cloud.lid-during-button-wait", title="Lid open at the cloud button prompt cancels the print",
|
# ---------------------------------------------------------------- the dark print (exthost.armed-freeze)
|
||||||
subsystem="cloud", kind="operator", est_min=8,
|
|
||||||
covers=_MACHINE_RUN, requires=[], actions=["lid"],
|
|
||||||
steps=[OFFLINE_STEP, LID_STEP,
|
|
||||||
"The job is longer than the ring holds, so the ring is filled before the button "
|
|
||||||
"lights; that takes a minute. When the button lights white, do NOT press it - open "
|
|
||||||
"the lid, and close it when told. Nothing moves and nothing fires."],
|
|
||||||
description="A cloud print waiting for the button is canceled by the lid: the wait ends "
|
|
||||||
"with the lid named as the reason, the laser latch relocks, the armed window "
|
|
||||||
"closes, no run starts, and the job ends ':cancelled'. The job is longer than "
|
|
||||||
"the ring, so its feeder is alive through the wait with the rest of the print "
|
|
||||||
"in hand: the cancel has to stop that feeder before the park clears the ring, "
|
|
||||||
"and the ring has to stay empty afterward. A feeder left alive would refill "
|
|
||||||
"it and the park, which ignores the lid, would play the print's path.")
|
|
||||||
def lid_during_button_wait(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
offset = enter_offline(ctx)
|
|
||||||
job = offline_job(ctx, "wait.puls", seconds=3500)
|
|
||||||
off = Offline().__enter__()
|
|
||||||
try:
|
|
||||||
lid_during_button_wait_body(ctx, ev, off, job, offset)
|
|
||||||
finally:
|
|
||||||
off.__exit__(None, None, None)
|
|
||||||
offline_cleanup(ctx)
|
|
||||||
|
|
||||||
|
|
||||||
def lid_during_button_wait_body(ctx, ev, off, job, offset):
|
|
||||||
off.print_ready(9003, job)
|
|
||||||
got = wait_button_wait(ctx, offset, 180, also=["job is longer than the ring"])
|
|
||||||
ctx.check(got["job is longer than the ring"],
|
|
||||||
"the job fit the ring: this test needs a job the ring cannot hold")
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def dark_print_body(ctx, ev, off, job, offset, fc):
|
def dark_print_body(ctx, ev, off, job, offset, fc):
|
||||||
off.print_ready(9005, job)
|
off.print_ready(9005, job)
|
||||||
@@ -1404,91 +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")
|
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)
|
|
||||||
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. 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)",
|
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
|
||||||
subsystem="cloud", kind="live", est_min=8,
|
subsystem="cloud", kind="live", est_min=8,
|
||||||
covers=_CLOUD_ALL,
|
covers=_CLOUD_ALL,
|
||||||
@@ -1722,68 +1521,3 @@ def oversize_stream_body(ctx, ev, off, job, offset, before):
|
|||||||
"stopped it, parked, relocked and reported ':cancelled'")
|
"stopped it, parked, relocked and reported ':cancelled'")
|
||||||
|
|
||||||
|
|
||||||
@test("cloud.paused-lid-cancel", title="A paused cloud print is canceled by the lid",
|
|
||||||
subsystem="cloud", kind="live", est_min=6,
|
|
||||||
covers=_MACHINE_RUN, requires=["cloud.pause-resume", "cloud.lid-interlock-abort"],
|
|
||||||
actions=["button", "lid"],
|
|
||||||
steps=[OFFLINE_STEP, ARM_STEP, LID_STEP,
|
|
||||||
"The head needs 40 mm of free +X and +Y travel.",
|
|
||||||
"Press the button to start; when asked, press it again (pause), then open the lid and "
|
|
||||||
"leave it open until the head is back; close it when told."],
|
|
||||||
description="A job paused on the button is canceled by the lid, from the state the factory "
|
|
||||||
"ends it in - there is no resume past a lid open. The same tail as every abort: "
|
|
||||||
"the motion stops, the head parks back at the job start, the latch relocks and "
|
|
||||||
"the armed window closes, the button goes dark, and the print ends ':cancelled'. "
|
|
||||||
"(The app's own cancel of a running print is exercised where a print has to be "
|
|
||||||
"ended that way: cloud.oversize-stream.)")
|
|
||||||
def paused_lid_cancel(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
offset = enter_offline(ctx)
|
|
||||||
job = offline_job(ctx, "paused.puls", seconds=60)
|
|
||||||
off = Offline().__enter__()
|
|
||||||
try:
|
|
||||||
paused_lid_cancel_body(ctx, ev, off, job, offset)
|
|
||||||
finally:
|
|
||||||
off.__exit__(None, None, None)
|
|
||||||
offline_cleanup(ctx)
|
|
||||||
|
|
||||||
|
|
||||||
def paused_lid_cancel_body(ctx, ev, off, job, offset):
|
|
||||||
# -- paused on the button, then the lid ---------------------------------
|
|
||||||
offline_start_print(ctx, off, 9005, job, offset)
|
|
||||||
ctx.act("button", "press", text="The head is moving: the press pauses the print.",
|
|
||||||
until=lambda: log_has(offset, PAUSE_LINES[0]), timeout=PRESS_TIMEOUT_S, fail=False)
|
|
||||||
got = wait_log(ctx, offset, list(PAUSE_LINES), 10)
|
|
||||||
ev["paused"] = {k: bool(v) for k, v in got.items()}
|
|
||||||
ctx.check(got["button pressed mid-run; pausing"], "the press did not pause the print")
|
|
||||||
ctx.check(got["paused at"], "the pause did not settle (no 'paused at')")
|
|
||||||
ctx.act("lid", "open", text="The print is paused: leave the lid open until the head has returned "
|
|
||||||
"to the corner.", timeout=60)
|
|
||||||
lid_stop = "lid opened mid-run; stopping motion"
|
|
||||||
got = wait_log(ctx, offset, [lid_stop, "start return home", "return home complete"], 120)
|
|
||||||
fin1 = wait_action_finished(ctx, offset, "print", 60)
|
|
||||||
ev["lid_from_pause"] = {k: message(v) for k, v in got.items()}
|
|
||||||
ev["lid_from_pause"]["print finished"] = message(fin1)
|
|
||||||
for k, v in ev["lid_from_pause"].items():
|
|
||||||
ctx.log(" [print] %s: %s", k, "seen" if v else "MISSING")
|
|
||||||
ctx.check(got[lid_stop], "the lid did not end the paused print")
|
|
||||||
ctx.check(got["return home complete"], "the paused print did not park to completion")
|
|
||||||
ctx.check(fin1 and CANCELLED in fin1, "the print did not end ':cancelled': %s",
|
|
||||||
message(fin1) or "no finish line")
|
|
||||||
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the park")
|
|
||||||
kpos = read_position()
|
|
||||||
ev["counters_after"] = kpos
|
|
||||||
ctx.check(kpos is not None and abs(kpos[0]) <= 3 and abs(kpos[1]) <= 3,
|
|
||||||
"the head did not come back to the job start (kernel counters %s)", kpos)
|
|
||||||
st, cs = ctx.forgectrl.get("/cool/status")
|
|
||||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
|
||||||
ev["latch_locked_after"] = latch_locked()
|
|
||||||
ctx.check(not ev["armed_after"], "armed window still open after the paused print was canceled")
|
|
||||||
ctx.check(ev["latch_locked_after"],
|
|
||||||
"kernel latch not locked after the paused print was canceled")
|
|
||||||
ev["button_dark"] = hw.button_lit()
|
|
||||||
ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
|
|
||||||
ctx.act("lid", "close")
|
|
||||||
settle_cloud(ctx, offset)
|
|
||||||
ev["events"] = offline_events(off)
|
|
||||||
ctx.log("PASS: a paused print canceled by the lid stopped, parked, relocked and reported ':cancelled'")
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""The event stream's button and its telemetry, on the machine.
|
|
||||||
|
|
||||||
Its own module, so that no other test's fingerprint moves. The stream is
|
|
||||||
opened straight at forgectrl's read-only listener, as a client off the
|
|
||||||
machine would, from a loopback source address of its own so that it takes
|
|
||||||
a place of its own among the three.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .exthost import _stream
|
|
||||||
|
|
||||||
SOURCE = "127.0.0.9"
|
|
||||||
|
|
||||||
|
|
||||||
def _reader(sock, seen, stop):
|
|
||||||
"""Every event on the stream into seen, as (name, data), until stop."""
|
|
||||||
buf = b""
|
|
||||||
sock.settimeout(1.0)
|
|
||||||
while not stop.is_set():
|
|
||||||
try:
|
|
||||||
k = sock.recv(4096)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
if not k:
|
|
||||||
break
|
|
||||||
buf += k
|
|
||||||
while b"\n\n" in buf:
|
|
||||||
block, buf = buf.split(b"\n\n", 1)
|
|
||||||
name, data = None, None
|
|
||||||
for line in block.decode("utf-8", "replace").split("\n"):
|
|
||||||
if line.startswith("event: "):
|
|
||||||
name = line[7:]
|
|
||||||
elif line.startswith("data: "):
|
|
||||||
data = line[6:]
|
|
||||||
if name:
|
|
||||||
try:
|
|
||||||
seen.append((name, json.loads(data or "null"), time.time()))
|
|
||||||
except ValueError:
|
|
||||||
seen.append((name, data, time.time()))
|
|
||||||
|
|
||||||
|
|
||||||
@test("events.button-telemetry", title="The event stream tells a press of the button, and the telemetry",
|
|
||||||
subsystem="forgectrl", kind="auto", mode="grbl", est_min=2,
|
|
||||||
covers=[("forgectrl", "src/events.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*")],
|
|
||||||
description="A stream opened at forgectrl's read-only listener gets a telemetry.tick within 12 s, "
|
|
||||||
"and it is what the daemon already holds: the cooling phase, the verdict, fire_ok, the "
|
|
||||||
"two coolant temperatures, the controller's state, and the lid, with no sensor read of "
|
|
||||||
"its own. The machine idle and nothing waiting for the button, one press (the bench "
|
|
||||||
"fixture's, or the operator's) arrives as two button events, pressed true and then false, "
|
|
||||||
"in that order: the button is looked at 25 times a second, so a press shorter than the "
|
|
||||||
"rest of the stream's 5 Hz look is not lost. That a press the machine is waiting for - a "
|
|
||||||
"job arming or armed, a wizard under the lease, the daemon's own wait - is no event is "
|
|
||||||
"forgectrl's events_test's, as are update.available and setup.flag, which a machine in "
|
|
||||||
"use cannot be made to show on demand.")
|
|
||||||
def button_telemetry(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
|
|
||||||
code, why, sock = _stream("127.0.0.1", keep=True, source=SOURCE)
|
|
||||||
ctx.check(code == 200 and sock, "the event stream: %s %s", code, why)
|
|
||||||
seen, stop = [], threading.Event()
|
|
||||||
th = threading.Thread(target=_reader, args=(sock, seen, stop), daemon=True)
|
|
||||||
th.start()
|
|
||||||
try:
|
|
||||||
ctx.wait_for(lambda: any(n == "telemetry.tick" for n, _d, _t in seen), 14)
|
|
||||||
tick = next((d for n, d, _t in seen if n == "telemetry.tick"), None)
|
|
||||||
ev["telemetry"] = tick
|
|
||||||
ctx.log("telemetry.tick: %s", tick)
|
|
||||||
ctx.check(isinstance(tick, dict) and set(tick) == {"phase", "verdict", "fire_ok", "down_c", "up_c", "state", "lid"},
|
|
||||||
"the telemetry's fields: %s", tick)
|
|
||||||
ctx.check(tick.get("lid") in ("closed", "open") and isinstance(tick.get("down_c"), (int, float)),
|
|
||||||
"its values: %s", tick)
|
|
||||||
mark = len(seen)
|
|
||||||
|
|
||||||
def both():
|
|
||||||
got = [d for n, d, _t in seen[mark:] if n == "button"]
|
|
||||||
return got if len(got) >= 2 else None
|
|
||||||
ctx.act("button", "press", until=both, text="Press the button once: nothing is waiting for it, and "
|
|
||||||
"the press is only reported.")
|
|
||||||
presses = [d for n, d, _t in seen[mark:] if n == "button"]
|
|
||||||
ev["button"] = presses
|
|
||||||
ctx.log("button events: %s", presses)
|
|
||||||
ctx.check(presses[:2] == [{"pressed": True}, {"pressed": False}], "a press is pressed, then released: %s",
|
|
||||||
presses)
|
|
||||||
finally:
|
|
||||||
stop.set()
|
|
||||||
try:
|
|
||||||
sock.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
th.join(3)
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""A package's page asking its own service, through the panel's relay.
|
|
||||||
|
|
||||||
Its own module rather than a test in exthost.py, for the reason extcore.py
|
|
||||||
gives: a test added there moves the fingerprint of every test in that
|
|
||||||
module. The helpers come from exthost, and importing them puts that
|
|
||||||
module's shared text in this test's fingerprint and not the other way
|
|
||||||
round. The package here is the reference package's id and key with a
|
|
||||||
service of its own, so exthost's put-back takes it away like the others.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until,
|
|
||||||
_write)
|
|
||||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
|
||||||
|
|
||||||
CALL_DIR = "/run/forgefirm/ext/call"
|
|
||||||
|
|
||||||
# The service: it says what it was handed, then answers its page's calls
|
|
||||||
# on the listening end the host gave it, one connection at a time.
|
|
||||||
CALL_SERVICE = r'''
|
|
||||||
import json, os, socket, stat, time
|
|
||||||
data = os.environ["FFX_DATA"]
|
|
||||||
|
|
||||||
|
|
||||||
def api(method, path):
|
|
||||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
s.settimeout(5)
|
|
||||||
try:
|
|
||||||
s.connect(os.environ["FFX_API"])
|
|
||||||
s.sendall(("%s %s HTTP/1.1\r\nHost: forgeext\r\n\r\n" % (method, path)).encode())
|
|
||||||
buf = b""
|
|
||||||
while True:
|
|
||||||
c = s.recv(65536)
|
|
||||||
if not c:
|
|
||||||
break
|
|
||||||
buf += c
|
|
||||||
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
|
|
||||||
def kind(n):
|
|
||||||
try:
|
|
||||||
return "socket" if stat.S_ISSOCK(os.fstat(n).st_mode) else "other"
|
|
||||||
except OSError:
|
|
||||||
return "closed"
|
|
||||||
|
|
||||||
|
|
||||||
fd = os.environ.get("FFX_CALL_FD")
|
|
||||||
listener = socket.socket(fileno=int(fd)) if fd else None
|
|
||||||
report = {"call_env": fd, "fds": {str(n): kind(n) for n in (3, 4, 5)},
|
|
||||||
"listening": listener.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN) if listener else None}
|
|
||||||
with open(os.path.join(data, "report.json.new"), "w") as f:
|
|
||||||
json.dump(report, f)
|
|
||||||
os.rename(os.path.join(data, "report.json.new"), os.path.join(data, "report.json"))
|
|
||||||
print("page-call service up", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def answer(conn, status, doc):
|
|
||||||
body = json.dumps(doc).encode()
|
|
||||||
conn.sendall(("HTTP/1.1 %d Answer\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n"
|
|
||||||
% (status, len(body))).encode() + body)
|
|
||||||
|
|
||||||
|
|
||||||
while listener:
|
|
||||||
conn, _ = listener.accept()
|
|
||||||
try:
|
|
||||||
conn.settimeout(5)
|
|
||||||
buf = b""
|
|
||||||
while b"\r\n\r\n" not in buf:
|
|
||||||
c = conn.recv(4096)
|
|
||||||
if not c:
|
|
||||||
break
|
|
||||||
buf += c
|
|
||||||
head, _, body = buf.partition(b"\r\n\r\n")
|
|
||||||
lines = head.decode("latin-1").split("\r\n")
|
|
||||||
method, path = lines[0].split()[:2]
|
|
||||||
want = 0
|
|
||||||
for line in lines[1:]:
|
|
||||||
k, _, v = line.partition(":")
|
|
||||||
if k.strip().lower() == "content-length":
|
|
||||||
want = int(v)
|
|
||||||
while len(body) < want:
|
|
||||||
c = conn.recv(4096)
|
|
||||||
if not c:
|
|
||||||
break
|
|
||||||
body += c
|
|
||||||
if path == "/echo":
|
|
||||||
answer(conn, 200, {"method": method, "path": path, "body": json.loads(body or b"{}")})
|
|
||||||
elif path == "/refuse":
|
|
||||||
answer(conn, 409, {"error": "not now"})
|
|
||||||
elif path == "/me":
|
|
||||||
answer(conn, 200, (api("GET", "/v0/self") or {}).get("id"))
|
|
||||||
else:
|
|
||||||
answer(conn, 404, {"error": "there is no " + path})
|
|
||||||
except (OSError, ValueError):
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
while True:
|
|
||||||
time.sleep(60)
|
|
||||||
'''
|
|
||||||
|
|
||||||
CALL_UI = "<!doctype html><title>forgetest page call</title>\n<p>the page that calls its own service</p>\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _pack_caller(work):
|
|
||||||
"""The reference package's id and key, with a page and the service above: (archive, public key)."""
|
|
||||||
import io
|
|
||||||
import subprocess
|
|
||||||
import tarfile
|
|
||||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest page call", "version": "1.0.0",
|
|
||||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
|
||||||
"service": {"exec": "bin/caller.py"}, "capabilities": ["ui", "machine.read"]}
|
|
||||||
payload = os.path.join(work, "payload.tar.gz")
|
|
||||||
with tarfile.open(payload, "w:gz") as t:
|
|
||||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644),
|
|
||||||
("bin/caller.py", CALL_SERVICE, 0o755), ("ui/index.html", CALL_UI, 0o644)):
|
|
||||||
info = tarfile.TarInfo(name)
|
|
||||||
data = text.encode()
|
|
||||||
info.size, info.mode = len(data), mode
|
|
||||||
t.addfile(info, io.BytesIO(data))
|
|
||||||
conf = os.path.join(work, "fwup.conf")
|
|
||||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
|
||||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
|
||||||
% (REF_ID, payload))
|
|
||||||
key = os.path.join(work, REF_KEY)
|
|
||||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "caller.ffx")
|
|
||||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
|
||||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
|
||||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
|
||||||
return signed, key + ".pub"
|
|
||||||
|
|
||||||
|
|
||||||
@test("exthost.page-call", title="A package's page asks its own service, through the panel",
|
|
||||||
subsystem="exthost", kind="auto", hardware="api", est_min=4,
|
|
||||||
covers=[("forgeext", "src/call.*"), ("forgeext", "src/run.*"), ("forgeext", "src/sandbox.*"),
|
|
||||||
("forgeext", "src/main.c"), ("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c")],
|
|
||||||
requires=["exthost.service"],
|
|
||||||
description="A package with a page and a service may have the one ask the other. The host binds the "
|
|
||||||
"service's call socket in /run/forgefirm/ext/call, a directory that is root's alone "
|
|
||||||
"(0700), as root's 0600 socket, and hands the service only the listening end, at "
|
|
||||||
"descriptor 4 (FFX_CALL_FD=4), with nothing at 3 or 5. POST /ext/call relays a call "
|
|
||||||
"through the host's command line: a POST's JSON object reaches the service and its "
|
|
||||||
"answer comes back as its status and its JSON; a GET carries no body; the service's own "
|
|
||||||
"refusal is its status and its words, not the relay's; a service may ask the machine "
|
|
||||||
"while it answers. The form is held before anything runs: a method that is not GET or "
|
|
||||||
"POST, a GET with a body, and a body that is not a JSON object are 400, and a path with "
|
|
||||||
".. in it and a package that is not installed are the host's refusals, 409 in its words. "
|
|
||||||
"The command line answers alike. Disabled, the package's service stops, a call is refused "
|
|
||||||
"in words, and the socket's name is gone; enabled again, the service answers again. A "
|
|
||||||
"call to a frozen service and the relay's bridge in a browser are not this test's: "
|
|
||||||
"forgeext's sdk_test and the frame-isolation harness hold those. The package, the key, "
|
|
||||||
"the setting, and the setup record are put back as found.")
|
|
||||||
def page_call(ctx):
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
|
||||||
prior = fc.settings().get("ext_enabled") or ""
|
|
||||||
raw = read_file(record_path())
|
|
||||||
found_tree = _tree(EXT_ROOT)
|
|
||||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
|
||||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
|
||||||
etag = hdrs.get("etag")
|
|
||||||
work = tempfile.mkdtemp(prefix="forgetest-call.")
|
|
||||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
|
||||||
sock = os.path.join(CALL_DIR, REF_ID + ".sock")
|
|
||||||
report = os.path.join(EXT_ROOT, "data", REF_ID, "report.json")
|
|
||||||
|
|
||||||
def call(method, path, body=None, id_=REF_ID):
|
|
||||||
form = {"id": id_, "method": method, "path": path}
|
|
||||||
if body is not None:
|
|
||||||
form["body"] = body
|
|
||||||
return fc.post("/ext/call", data=form)
|
|
||||||
|
|
||||||
try:
|
|
||||||
archive, pub = _pack_caller(work)
|
|
||||||
shutil.copy(pub, owner_key)
|
|
||||||
os.chmod(owner_key, 0o644)
|
|
||||||
r = _forgeext("install", archive, "--consent-community")
|
|
||||||
ctx.check(r.get("ok") is True, "the install of a package with a page and a service -> %s", r.get("error"))
|
|
||||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
|
||||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
|
||||||
up = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and os.path.exists(report), 60)
|
|
||||||
ctx.check(up, "the service did not come up: %s", _svc(REF_ID))
|
|
||||||
|
|
||||||
# What the service was handed, from inside, and the socket from outside.
|
|
||||||
with open(report) as f:
|
|
||||||
inside = json.load(f)
|
|
||||||
ev["inside"] = inside
|
|
||||||
ctx.log("inside: %s", inside)
|
|
||||||
ctx.check(inside.get("call_env") == "4", "FFX_CALL_FD is %r, not 4", inside.get("call_env"))
|
|
||||||
ctx.check(inside.get("fds") == {"3": "closed", "4": "socket", "5": "closed"},
|
|
||||||
"its descriptors 3, 4, 5: %s", inside.get("fds"))
|
|
||||||
ctx.check(inside.get("listening") == 1, "descriptor 4 is not a listening socket: %s", inside.get("listening"))
|
|
||||||
d, s = os.lstat(CALL_DIR), os.lstat(sock)
|
|
||||||
ev["socket"] = {"dir": [oct(d.st_mode & 0o7777), d.st_uid], "sock": [oct(s.st_mode & 0o7777), s.st_uid]}
|
|
||||||
ctx.check(stat.S_ISDIR(d.st_mode) and d.st_uid == 0 and d.st_mode & 0o7777 == 0o700,
|
|
||||||
"%s is not root's 0700 directory: %s", CALL_DIR, ev["socket"]["dir"])
|
|
||||||
ctx.check(stat.S_ISSOCK(s.st_mode) and s.st_uid == 0 and s.st_mode & 0o777 == 0o600,
|
|
||||||
"%s is not root's 0600 socket: %s", sock, ev["socket"]["sock"])
|
|
||||||
|
|
||||||
# Calls through the relay, as the panel's bridge makes them.
|
|
||||||
st_, doc = call("POST", "/echo", '{"a": 1, "b": "two"}')
|
|
||||||
ev["post"] = [st_, doc]
|
|
||||||
ctx.log("POST /ext/call /echo -> %s %s", st_, doc)
|
|
||||||
ctx.check(st_ == 200 and (doc or {}).get("status") == 200
|
|
||||||
and doc.get("body") == {"method": "POST", "path": "/echo", "body": {"a": 1, "b": "two"}},
|
|
||||||
"a POST -> %s %s", st_, doc)
|
|
||||||
st_, doc = call("GET", "/echo")
|
|
||||||
ev["get"] = [st_, doc]
|
|
||||||
ctx.check(st_ == 200 and (doc or {}).get("body") == {"method": "GET", "path": "/echo", "body": {}},
|
|
||||||
"a GET -> %s %s", st_, doc)
|
|
||||||
st_, doc = call("GET", "/refuse")
|
|
||||||
ev["refuse"] = [st_, doc]
|
|
||||||
ctx.check(st_ == 200 and (doc or {}).get("status") == 409 and doc.get("body") == {"error": "not now"},
|
|
||||||
"the service's own refusal -> %s %s", st_, doc)
|
|
||||||
st_, doc = call("GET", "/me")
|
|
||||||
ev["me"] = [st_, doc]
|
|
||||||
ctx.check(st_ == 200 and (doc or {}).get("body") == REF_ID, "a service asking the machine while it answers "
|
|
||||||
"-> %s %s", st_, doc)
|
|
||||||
for args, want, words in ((("PUT", "/echo"), 400, "a call is GET or POST"),
|
|
||||||
(("GET", "/echo", "{}"), 400, "a GET call has no body"),
|
|
||||||
(("POST", "/echo", "[1]"), 400, "a call's body is a JSON object"),
|
|
||||||
(("GET", "/../etc"), 409, "a call's path starts with"),
|
|
||||||
(("GET", "/echo", None, "org.forgetest.nothere"), 409, "that package is not installed")):
|
|
||||||
st_, why = call(*args)
|
|
||||||
ev.setdefault("refusals", []).append([list(args), st_, why])
|
|
||||||
ctx.check(st_ == want and words in json.dumps(why), "%s -> %s %s, wanted %s %s", args, st_, why, want, words)
|
|
||||||
|
|
||||||
# The command line answers alike.
|
|
||||||
r = _forgeext("call", REF_ID, "POST", "/echo", '{"from": "the console"}')
|
|
||||||
ev["cli"] = r
|
|
||||||
ctx.check(r.get("ok") is True and r.get("status") == 200
|
|
||||||
and (r.get("body") or {}).get("body") == {"from": "the console"}, "forgeext call -> %s", r)
|
|
||||||
|
|
||||||
# Disabled: no service, no socket, a refusal in words; and back.
|
|
||||||
r = _forgeext("disable", REF_ID)
|
|
||||||
ctx.check(r.get("ok") is True, "disable -> %s", r.get("error"))
|
|
||||||
st_, why = call("GET", "/echo")
|
|
||||||
ev["disabled"] = [st_, why]
|
|
||||||
ctx.check(st_ == 409 and "disabled" in json.dumps(why), "a call to a disabled package -> %s %s", st_, why)
|
|
||||||
gone = _until(ctx, lambda: not os.path.exists(sock) and _svc(REF_ID).get("state") != "running", 30)
|
|
||||||
ctx.check(gone, "disabled, the socket's name is still there or the service still runs: %s", _svc(REF_ID))
|
|
||||||
r = _forgeext("enable", REF_ID)
|
|
||||||
ctx.check(r.get("ok") is True, "enable -> %s", r.get("error"))
|
|
||||||
back = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and os.path.exists(sock), 60)
|
|
||||||
ctx.check(back, "enabled again, the service did not come back: %s", _svc(REF_ID))
|
|
||||||
st_, doc = _until(ctx, lambda: (lambda a: a if a[0] == 200 else None)(call("GET", "/echo")), 20) or (0, None)
|
|
||||||
ctx.check(st_ == 200 and (doc or {}).get("status") == 200, "enabled again, it answers -> %s %s", st_, doc)
|
|
||||||
finally:
|
|
||||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
|
||||||
ctx.check(not os.path.exists(sock), "the call socket's name is left after the package went")
|
|
||||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""The catalog: the signed index, on the machine.
|
|
||||||
|
|
||||||
Its own module, for the reason extcore.py gives. The endorsement, the
|
|
||||||
judgment of each listed version, the index that never goes back, and a
|
|
||||||
withdrawn version are shown on a scratch root under /tmp with a throwaway
|
|
||||||
key standing in for the OpenGlow extension key, because only OpenGlow holds
|
|
||||||
the real one; the machine's own root is where every index that key did not
|
|
||||||
sign is refused.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tarfile
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .exthost import FWUP, _forgeext, _write
|
|
||||||
|
|
||||||
INDEX_URL = "https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/index-1.ffi"
|
|
||||||
STAGE_DIR = "/data/forgefirm/tmp"
|
|
||||||
PROBE_ID, OTHER_ID = "org.example.catprobe", "org.example.catother"
|
|
||||||
|
|
||||||
|
|
||||||
def _key(work, name):
|
|
||||||
subprocess.run([FWUP, "-g", "-o", os.path.join(work, name)], check=True, capture_output=True, timeout=60, cwd=work)
|
|
||||||
return os.path.join(work, name)
|
|
||||||
|
|
||||||
|
|
||||||
def _archive(work, name, product, members, version, key):
|
|
||||||
"""An fwup archive of the extension container's form: payload.tar.gz of members {name: text}, signed with key."""
|
|
||||||
payload = os.path.join(work, name + ".tar.gz")
|
|
||||||
with tarfile.open(payload, "w:gz") as t:
|
|
||||||
for member, text in members.items():
|
|
||||||
info = tarfile.TarInfo(member)
|
|
||||||
data = text.encode()
|
|
||||||
info.size, info.mode = len(data), 0o644
|
|
||||||
t.addfile(info, io.BytesIO(data))
|
|
||||||
conf = os.path.join(work, name + ".conf")
|
|
||||||
_write(conf, 'meta-product = "%s"\nmeta-description = "%s"\nmeta-version = "%s"\nmeta-platform = "forgefirm-ext"\n'
|
|
||||||
'file-resource payload.tar.gz {\n host-path = "%s"\n}\n' % (product, name, version, payload))
|
|
||||||
raw, signed = os.path.join(work, name + ".raw"), os.path.join(work, name + ".signed")
|
|
||||||
subprocess.run([FWUP, "-c", "-f", conf, "-o", raw], check=True, capture_output=True, timeout=60, cwd=work)
|
|
||||||
subprocess.run([FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed], check=True, capture_output=True,
|
|
||||||
timeout=60, cwd=work)
|
|
||||||
return signed
|
|
||||||
|
|
||||||
|
|
||||||
def _package(work, id_, key):
|
|
||||||
m = {"manifest": 1, "id": id_, "name": "catalog probe", "version": "1.0.0", "author": "forgetest", "license": "MIT",
|
|
||||||
"api": "0.1", "runtime": "data", "capabilities": []}
|
|
||||||
return _archive(work, id_, "ForgeFIRM extension", {"manifest.json": json.dumps(m)}, "1.0.0", key)
|
|
||||||
|
|
||||||
|
|
||||||
def _index(work, doc, key, version):
|
|
||||||
return _archive(work, "index-" + version, "ForgeFIRM extension index", {"index.json": json.dumps(doc)}, version, key)
|
|
||||||
|
|
||||||
|
|
||||||
def _staged():
|
|
||||||
try:
|
|
||||||
return sorted(n for n in os.listdir(STAGE_DIR) if n.startswith("ext-"))
|
|
||||||
except OSError:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
@test("exthost.catalog", title="The catalog: the signed index verified, kept, judged, and endorsing one key for one id",
|
|
||||||
subsystem="exthost", kind="auto", hardware="api", est_min=2,
|
|
||||||
covers=[("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c"), ("forgeext", "src/index.*"),
|
|
||||||
("forgeext", "src/pkg.*"), ("forgeext", "src/install.*"), ("forgeext", "src/main.c")],
|
|
||||||
description="GET /ext/catalog answers the index the host keeps (or null), judged against this firmware, and "
|
|
||||||
"the one address it is fetched from. On a scratch root, with a throwaway key standing in for the "
|
|
||||||
"OpenGlow extension key, the machine's own forgeext keeps an index signed with it, and the author "
|
|
||||||
"key it names for one id makes a package of that id signed with it read as community and "
|
|
||||||
"endorsed, where before it was unverified; the same key on another id counts for nothing. Read "
|
|
||||||
"back, each listed version is judged on the machine: one that asks for a capability this "
|
|
||||||
"firmware does not have is kept and not offered, and the offer is the newest it runs. An index "
|
|
||||||
"older than the one kept is refused however well signed, and one that withdraws the probe's "
|
|
||||||
"version makes the machine refuse to install it, in OpenGlow's words. On the machine's own root, "
|
|
||||||
"the stand-in's index (signed by a key that is not the OpenGlow extension key) is refused in "
|
|
||||||
"words, a package handed over as an index is refused by the product gate, and the index kept "
|
|
||||||
"is left as it was. POST /ext/catalog/get refuses an id with no such form (400), and one the "
|
|
||||||
"kept index does not list (404, or 409 with no index kept), before anything is fetched. Nothing "
|
|
||||||
"is left in the staging directory. POST /ext/catalog/refresh is not asked here: GitHub counts "
|
|
||||||
"every request of the index's address as a download, and that count is the operators'. The "
|
|
||||||
"refresh (curl, https alone and bounded, 502 in curl's words, 409 in the host's, the file "
|
|
||||||
"removed), the fetch of the offered version held to its size and SHA-256, and the tiers an "
|
|
||||||
"install takes from it, are forgectrl's extpkg_test and forgeext's install_test.")
|
|
||||||
def catalog(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
|
|
||||||
staged_before = _staged()
|
|
||||||
kept = _forgeext("index")
|
|
||||||
ctx.check(kept.get("ok") is True, "forgeext index -> %s", kept.get("error"))
|
|
||||||
ev["kept_before"] = (kept.get("index") or {}).get("version")
|
|
||||||
work = tempfile.mkdtemp(prefix="ft-catalog-", dir="/tmp")
|
|
||||||
try:
|
|
||||||
st, doc = fc.get("/ext/catalog")
|
|
||||||
ctx.check(st == 200 and isinstance(doc, dict) and doc.get("url") == INDEX_URL and "index" in doc
|
|
||||||
and doc.get("index") == kept.get("index") and "core_checked" in doc,
|
|
||||||
"GET /ext/catalog -> %s %s", st, doc)
|
|
||||||
|
|
||||||
# The endorsement, on a scratch root with a stand-in for the OpenGlow extension key.
|
|
||||||
root = os.path.join(work, "root")
|
|
||||||
os.makedirs(os.path.join(root, "keys"))
|
|
||||||
og, author = _key(work, "standin"), _key(work, "author")
|
|
||||||
probe, other = _package(work, PROBE_ID, author), _package(work, OTHER_ID, author)
|
|
||||||
with open(probe, "rb") as f:
|
|
||||||
data = f.read()
|
|
||||||
with open(author + ".pub") as f:
|
|
||||||
author_pub = f.read().strip()
|
|
||||||
listed = {"version": "1.0.0", "url": "https://example.org/catprobe.ffx", "sha256": hashlib.sha256(data).hexdigest(),
|
|
||||||
"size": len(data), "capabilities": [], "api": "0.1"}
|
|
||||||
later = dict(listed, version="1.1.0", url="https://example.org/catprobe-1.1.0.ffx",
|
|
||||||
capabilities=["forgetest.not-a-capability"])
|
|
||||||
entry = {"id": PROBE_ID, "name": "catalog probe", "author": "forgetest", "key": author_pub,
|
|
||||||
"versions": [later, listed]}
|
|
||||||
idx = _index(work, {"index": 1, "packages": [entry]}, og, "2026.923.2")
|
|
||||||
|
|
||||||
def scratch(*args):
|
|
||||||
return _forgeext("--root", root, "--official-key", og + ".pub", "--no-reserve", *args)
|
|
||||||
r = scratch("inspect", probe)
|
|
||||||
ctx.check(r.get("ok") is True and r.get("tier") == "unverified", "before an index: %s %s", r.get("tier"), r.get("error"))
|
|
||||||
r = scratch("index-verify", idx)
|
|
||||||
ev["scratch_verify"] = r
|
|
||||||
ctx.check(r.get("ok") is True and r.get("packages") == 1 and r.get("version") == "2026.923.2",
|
|
||||||
"the stand-in's index is kept on the scratch root: %s", r)
|
|
||||||
read = scratch("index")
|
|
||||||
mine = ((read.get("index") or {}).get("packages") or [{}])[0]
|
|
||||||
vs = {v.get("version"): v for v in mine.get("versions", [])}
|
|
||||||
ev["judged"] = {"offer": mine.get("offer"), "1.1.0": vs.get("1.1.0", {}).get("why"),
|
|
||||||
"core_checked": read.get("core_checked")}
|
|
||||||
ctx.check(mine.get("id") == PROBE_ID and len(mine.get("key_id", "")) == 64, "and read back: %s", mine)
|
|
||||||
ctx.check(vs.get("1.1.0", {}).get("usable") is False and "not a capability" in vs.get("1.1.0", {}).get("why", "")
|
|
||||||
and vs.get("1.0.0", {}).get("usable") is True and mine.get("offer") == "1.0.0",
|
|
||||||
"judged on the machine: 1.1.0 kept and not offered, 1.0.0 offered: %s", ev["judged"])
|
|
||||||
r = scratch("inspect", probe)
|
|
||||||
ev["endorsed"] = {"tier": r.get("tier"), "endorsed": r.get("endorsed")}
|
|
||||||
ctx.check(r.get("tier") == "community" and r.get("endorsed") is True,
|
|
||||||
"signed with the key it endorses for its id: community, endorsed (%s %s)", r.get("tier"), r.get("endorsed"))
|
|
||||||
r = scratch("inspect", other)
|
|
||||||
ctx.check(r.get("tier") == "unverified" and r.get("endorsed") is False,
|
|
||||||
"the same key on another id: %s %s", r.get("tier"), r.get("endorsed"))
|
|
||||||
|
|
||||||
# Never back: an older index is refused, and the one kept stays.
|
|
||||||
r = scratch("index-verify", _index(work, {"index": 1, "packages": []}, og, "2026.923.1"))
|
|
||||||
ev["older"] = r.get("error")
|
|
||||||
ctx.check(r.get("ok") is False and "never goes back" in (r.get("error") or ""), "an older index -> %s", r.get("error"))
|
|
||||||
ctx.check((scratch("index").get("index") or {}).get("version") == "2026.923.2", "and the one kept stays")
|
|
||||||
|
|
||||||
# A withdrawn version does not install, from the catalog or from anywhere else.
|
|
||||||
gone = dict(entry, versions=[later], withdrawn=[{"version": "1.0.0", "reason": "forgetest withdrew it"}])
|
|
||||||
r = scratch("index-verify", _index(work, {"index": 1, "packages": [gone]}, og, "2026.923.3"))
|
|
||||||
ctx.check(r.get("ok") is True, "an index withdrawing the probe's version is kept: %s", r.get("error"))
|
|
||||||
r = scratch("inspect", probe)
|
|
||||||
ev["withdrawn"] = r.get("error")
|
|
||||||
ctx.check(r.get("ok") is False and "OpenGlow withdrew %s 1.0.0 from its catalog: forgetest withdrew it" % PROBE_ID
|
|
||||||
in (r.get("error") or ""), "the withdrawn version -> %s", r.get("error"))
|
|
||||||
|
|
||||||
# The machine's own root keeps no index but OpenGlow's.
|
|
||||||
refused = {}
|
|
||||||
for name, path, words in (("the stand-in's index", idx, "not signed with the OpenGlow extension key"),
|
|
||||||
("a package as an index", probe, "product")):
|
|
||||||
r = _forgeext("index-verify", path)
|
|
||||||
refused[name] = r.get("error")
|
|
||||||
ctx.check(r.get("ok") is False and words in (r.get("error") or ""), "%s -> %s", name, r.get("error"))
|
|
||||||
ev["refused"] = refused
|
|
||||||
ctx.check(_forgeext("index").get("index") == kept.get("index"), "a refused index changed the one kept")
|
|
||||||
|
|
||||||
# The relay's refusals, before anything is fetched.
|
|
||||||
st, why = fc.post("/ext/catalog/get", data={"id": "org.example;reboot"})
|
|
||||||
ctx.check(st == 400, "an id with no such form -> %s %s", st, why)
|
|
||||||
st, why = fc.post("/ext/catalog/get", data={"id": PROBE_ID})
|
|
||||||
want = (404, "not in the catalog") if kept.get("index") else (409, "fetch it first")
|
|
||||||
ev["get_unlisted"] = [st, why]
|
|
||||||
ctx.check(st == want[0] and want[1] in json.dumps(why), "an id the kept index does not list -> %s %s", st, why)
|
|
||||||
ctx.check(_forgeext("index").get("index") == kept.get("index"), "the refusals changed the index kept")
|
|
||||||
ctx.check(_staged() == staged_before, "the staging directory holds %s, and held %s before", _staged(), staged_before)
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(work, ignore_errors=True)
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""What the extension host makes of the firmware's own version file.
|
|
||||||
|
|
||||||
A campaign only ever runs on a dev image - the release image does not
|
|
||||||
ship this suite - and a dev image stamps ${DATETIME} (dev), which is no
|
|
||||||
version at all. So the branch where a core range is actually judged
|
|
||||||
cannot be reached from here by any machine this suite runs on, and it is
|
|
||||||
not this module's to prove: that is forgeext's version_test, over the
|
|
||||||
file as each image build writes it. What is left here is the half a
|
|
||||||
machine can show, and it is the half that ties the daemon to the image:
|
|
||||||
that the host reads the image's own file, finds a build stamp, and
|
|
||||||
judges nothing rather than guessing.
|
|
||||||
|
|
||||||
Its own module rather than a test in exthost.py: a test's fingerprint
|
|
||||||
holds the shared text of the module it lives in, and the blank lines
|
|
||||||
between tests are shared text, so a test added to exthost.py moves the
|
|
||||||
fingerprint of every other test there and costs a campaign a re-run of
|
|
||||||
each - exthost.panel-install among them, which is an operator drill with
|
|
||||||
the button held. The helpers come from exthost, and importing them puts
|
|
||||||
that module's shared text in this test's fingerprint and not the other
|
|
||||||
way round.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .exthost import FWUP, _forgeext, _read, _write
|
|
||||||
|
|
||||||
|
|
||||||
@test("exthost.core-range", title="The host reads the image's own version file and judges by it",
|
|
||||||
subsystem="exthost", kind="auto", est_min=2,
|
|
||||||
covers=[("forgeext", "src/install.*"), ("forgeext", "src/manifest.*"), ("forgeext", "src/main.c")],
|
|
||||||
description="The firmware's version reaches the host from /etc/forgefirm-version, which the image "
|
|
||||||
"build writes. This test asks the host what it made of that file rather than reading the "
|
|
||||||
"file and believing it. On the dev image a campaign runs on, the first word is a build "
|
|
||||||
"stamp and no version: inspect answers core_checked false, and a package asking for a "
|
|
||||||
"minimum far above or a maximum far below is accepted all the same, because a range "
|
|
||||||
"cannot be judged against a stamp and the host does not guess one. A dev image that "
|
|
||||||
"began stamping something version-shaped would fail here, which is the regression this "
|
|
||||||
"test is for; the judging branch belongs to forgeext's version_test, since no image "
|
|
||||||
"carrying this suite can reach it. Nothing is installed, nothing runs, and nothing "
|
|
||||||
"moves: inspect changes nothing and the work directory goes at the end.")
|
|
||||||
def core_range(ctx):
|
|
||||||
import io
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import tarfile
|
|
||||||
import tempfile
|
|
||||||
ev = ctx.evidence
|
|
||||||
|
|
||||||
stamp = (_read("/etc/forgefirm-version", "").split() or [""])[0]
|
|
||||||
ctx.check(stamp, "/etc/forgefirm-version says nothing: the image did not stamp itself")
|
|
||||||
version = stamp[1:] if stamp[:1] == "v" and stamp[1:2].isdigit() else stamp
|
|
||||||
looks_like_a_version = bool(re.match(r"^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$", version))
|
|
||||||
ctx.log("/etc/forgefirm-version: %r -> %r", stamp, version)
|
|
||||||
ev["firmware_version"] = {"file": stamp, "version": version, "looks_like_a_version": looks_like_a_version}
|
|
||||||
|
|
||||||
# The suite ships on the dev image alone, so this is the only shape a
|
|
||||||
# campaign can meet. If it ever is not, the image changed and the
|
|
||||||
# judging path needs a proof that is not here.
|
|
||||||
ctx.check(not looks_like_a_version,
|
|
||||||
"this image stamped %r, which is a version: a campaign cannot run on a release image, so "
|
|
||||||
"either the dev image's stamp changed or this is not the image this suite belongs on", stamp)
|
|
||||||
|
|
||||||
work = tempfile.mkdtemp(prefix="forgetest-core-")
|
|
||||||
try:
|
|
||||||
def packed(core):
|
|
||||||
"""A data package that asks for nothing, with the core range given."""
|
|
||||||
m = {"manifest": 1, "id": "org.forgetest.corerange", "name": "forgetest core range",
|
|
||||||
"version": "1.0.0", "author": "forgetest", "license": "MIT", "api": "0.1",
|
|
||||||
"runtime": "data", "capabilities": []}
|
|
||||||
if core:
|
|
||||||
m["core"] = core
|
|
||||||
payload = os.path.join(work, "payload.tar.gz")
|
|
||||||
with tarfile.open(payload, "w:gz") as t:
|
|
||||||
data = json.dumps(m).encode()
|
|
||||||
info = tarfile.TarInfo("manifest.json")
|
|
||||||
info.size, info.mode = len(data), 0o644
|
|
||||||
t.addfile(info, io.BytesIO(data))
|
|
||||||
conf = os.path.join(work, "fwup.conf")
|
|
||||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "core range"\n'
|
|
||||||
'meta-version = "1.0.0"\nmeta-platform = "forgefirm-ext"\n'
|
|
||||||
'file-resource payload.tar.gz {\n host-path = "%s"\n}\n' % payload)
|
|
||||||
out = os.path.join(work, "corerange.ffx")
|
|
||||||
if os.path.exists(out):
|
|
||||||
os.remove(out)
|
|
||||||
subprocess.run([FWUP, "-c", "-f", conf, "-o", out], check=True, capture_output=True,
|
|
||||||
timeout=60, cwd=work)
|
|
||||||
return out
|
|
||||||
|
|
||||||
r = _forgeext("inspect", packed(None))
|
|
||||||
ctx.check(r.get("ok") is True, "a package with no core range was refused: %s", r.get("error"))
|
|
||||||
ctx.check(r.get("core_checked") is False,
|
|
||||||
"the host judged a range against the stamp %r: core_checked %s", stamp, r.get("core_checked"))
|
|
||||||
ev["no_range"] = r
|
|
||||||
|
|
||||||
# A stamp is no version, so neither end of a range is judged and
|
|
||||||
# neither refuses the package. The host says so rather than
|
|
||||||
# refusing on a comparison it cannot make.
|
|
||||||
ev["ranges"] = []
|
|
||||||
for core in ({"min": "99.0.0"}, {"max": "0.0.1"}, {"min": "0.0.1", "max": "0.0.2"}):
|
|
||||||
r = _forgeext("inspect", packed(core))
|
|
||||||
ctx.check(r.get("ok") is True and r.get("core_checked") is False,
|
|
||||||
"the host judged %s against the stamp anyway: %s", core, r)
|
|
||||||
ev["ranges"].append({"core": core, "inspect": r})
|
|
||||||
|
|
||||||
ctx.check("org.forgetest.corerange" not in
|
|
||||||
[x.get("id") for x in (_forgeext("list").get("packages") or [])],
|
|
||||||
"inspect installed the package: it must change nothing")
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(work, ignore_errors=True)
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""The destinations the operator names for a package that asks for them.
|
|
||||||
|
|
||||||
Its own module rather than a test in exthost.py, for the reason extcore.py
|
|
||||||
gives. The package here is the reference package's id and key with a
|
|
||||||
service of its own, so exthost's put-back takes it away like the others.
|
|
||||||
|
|
||||||
What it dials is the machine's own DNS resolver on TCP port 53: an address
|
|
||||||
every machine on a network has, that is not the machine, and that answers.
|
|
||||||
Named by address, it gives the service no resolver of its own, so the
|
|
||||||
only way through to it is the one the operator named.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .exthost import (EXT_ROOT, FWUP, NFT, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _read, _svc, _tree,
|
|
||||||
_until, _write)
|
|
||||||
from .forgectrl import lan_ip
|
|
||||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
|
||||||
|
|
||||||
# The service: at every start, where it may connect and what one dial does.
|
|
||||||
DEST_SERVICE = r'''
|
|
||||||
import errno, json, os, socket, sys, time
|
|
||||||
data, target, port = os.environ["FFX_DATA"], sys.argv[1], int(sys.argv[2])
|
|
||||||
|
|
||||||
|
|
||||||
def api(method, path):
|
|
||||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
s.settimeout(5)
|
|
||||||
try:
|
|
||||||
s.connect(os.environ["FFX_API"])
|
|
||||||
s.sendall(("%s %s HTTP/1.1\r\nHost: forgeext\r\n\r\n" % (method, path)).encode())
|
|
||||||
buf = b""
|
|
||||||
while True:
|
|
||||||
c = s.recv(65536)
|
|
||||||
if not c:
|
|
||||||
break
|
|
||||||
buf += c
|
|
||||||
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
|
|
||||||
def dial(p):
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.settimeout(4)
|
|
||||||
try:
|
|
||||||
s.connect((target, p))
|
|
||||||
return "connected"
|
|
||||||
except socket.timeout:
|
|
||||||
return "timeout"
|
|
||||||
except OSError as e:
|
|
||||||
return errno.errorcode.get(e.errno, str(e.errno))
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
|
|
||||||
report = {"pid": os.getpid(), "destinations": (api("GET", "/v0/self") or {}).get("destinations"),
|
|
||||||
"dial": dial(port), "dial_other_port": dial(port + 1)}
|
|
||||||
with open(os.path.join(data, "report.json.new"), "w") as f:
|
|
||||||
json.dump(report, f)
|
|
||||||
os.rename(os.path.join(data, "report.json.new"), os.path.join(data, "report.json"))
|
|
||||||
print("destinations service up: %s" % report, flush=True)
|
|
||||||
while True:
|
|
||||||
time.sleep(60)
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def _resolver():
|
|
||||||
"""The first IPv4 nameserver of /etc/resolv.conf, or None."""
|
|
||||||
for line in _read("/etc/resolv.conf").splitlines():
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) >= 2 and parts[0] == "nameserver" and parts[1].count(".") == 3:
|
|
||||||
return parts[1]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _pack_dest(work, target, port):
|
|
||||||
"""The reference package's id and key, asking for the operator's destinations: (archive, public key)."""
|
|
||||||
import io
|
|
||||||
import tarfile
|
|
||||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest destinations", "version": "1.0.0",
|
|
||||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
|
||||||
"service": {"exec": "bin/dest.py", "args": [target, str(port)]},
|
|
||||||
"capabilities": ["net.outbound.operator"]}
|
|
||||||
payload = os.path.join(work, "payload.tar.gz")
|
|
||||||
with tarfile.open(payload, "w:gz") as t:
|
|
||||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/dest.py", DEST_SERVICE, 0o755)):
|
|
||||||
info = tarfile.TarInfo(name)
|
|
||||||
data = text.encode()
|
|
||||||
info.size, info.mode = len(data), mode
|
|
||||||
t.addfile(info, io.BytesIO(data))
|
|
||||||
conf = os.path.join(work, "fwup.conf")
|
|
||||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
|
||||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
|
||||||
% (REF_ID, payload))
|
|
||||||
key = os.path.join(work, REF_KEY)
|
|
||||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "dest.ffx")
|
|
||||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
|
||||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
|
||||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
|
||||||
return signed, key + ".pub"
|
|
||||||
|
|
||||||
|
|
||||||
@test("exthost.operator-destinations", title="The operator names where a package may connect, and takes it away",
|
|
||||||
subsystem="exthost", kind="auto", hardware="api", est_min=4,
|
|
||||||
covers=[("forgeext", "src/install.*"), ("forgeext", "src/state.*"), ("forgeext", "src/run.*"),
|
|
||||||
("forgeext", "src/super.*"), ("forgeext", "src/api.*"), ("forgeext", "src/caps.*"),
|
|
||||||
("forgeext", "src/manifest.*"), ("forgeext", "src/netrules.*"), ("forgeext", "src/main.c"),
|
|
||||||
("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c")],
|
|
||||||
requires=["exthost.service"],
|
|
||||||
description="A package that asks for net.outbound.operator may reach the places the operator names for it "
|
|
||||||
"and nothing else. Installed, it has none: its own GET /v0/self names no destination and its "
|
|
||||||
"dial to the machine's DNS resolver on TCP port 53 is refused inside the sandbox (EACCES). "
|
|
||||||
"The operator names that resolver through POST /ext/dest; the host starts the service again "
|
|
||||||
"(said in its log, and no crash), its answer names the destination, its dial connects, the "
|
|
||||||
"next port up is still refused, and the account's chain in the rule table carries the "
|
|
||||||
"address. The machine's own LAN address and loopback are refused in words, a destination "
|
|
||||||
"out of form is 400, and taking away one that was never named is refused. Taken away, the "
|
|
||||||
"service starts again with no destination, the dial is refused again, and the chain no longer "
|
|
||||||
"carries the address. The package, the key, the setting, and the setup record are put back as "
|
|
||||||
"found.")
|
|
||||||
def operator_destinations(ctx):
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
target = _resolver()
|
|
||||||
ctx.check(target, "/etc/resolv.conf names no IPv4 resolver: there is nothing to dial")
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.settimeout(4)
|
|
||||||
try:
|
|
||||||
s.connect((target, 53))
|
|
||||||
reachable = True
|
|
||||||
except OSError as e:
|
|
||||||
reachable = str(e)
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
ev["resolver"] = {"address": target, "tcp_53_from_root": reachable}
|
|
||||||
ctx.check(reachable is True, "the resolver %s does not take a TCP connection on port 53 even from outside the "
|
|
||||||
"sandbox (%s): this network cannot show a way out opening", target, reachable)
|
|
||||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
|
||||||
prior = fc.settings().get("ext_enabled") or ""
|
|
||||||
raw = read_file(record_path())
|
|
||||||
found_tree = _tree(EXT_ROOT)
|
|
||||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
|
||||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
|
||||||
etag = hdrs.get("etag")
|
|
||||||
work = tempfile.mkdtemp(prefix="forgetest-dest.")
|
|
||||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
|
||||||
report = os.path.join(EXT_ROOT, "data", REF_ID, "report.json")
|
|
||||||
where = "%s:53" % target
|
|
||||||
|
|
||||||
def seen():
|
|
||||||
try:
|
|
||||||
with open(report) as f:
|
|
||||||
return json.load(f)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def fresh(than):
|
|
||||||
r = seen()
|
|
||||||
return r if r and r.get("pid") != than and _svc(REF_ID).get("state") == "running" else None
|
|
||||||
|
|
||||||
def chain_has(addr):
|
|
||||||
"""Does the package's own chain let it through to addr on TCP 53? No chain is no."""
|
|
||||||
acct = _svc(REF_ID).get("account") or ""
|
|
||||||
if not acct.startswith("ffx"):
|
|
||||||
return False
|
|
||||||
out = subprocess.run([NFT, "list", "chain", "inet", "ffx", "u%d" % (800 + int(acct[3:]))], capture_output=True,
|
|
||||||
text=True, timeout=30).stdout
|
|
||||||
return ("daddr %s tcp dport 53 accept" % addr) in out
|
|
||||||
|
|
||||||
try:
|
|
||||||
archive, pub = _pack_dest(work, target, 53)
|
|
||||||
shutil.copy(pub, owner_key)
|
|
||||||
os.chmod(owner_key, 0o644)
|
|
||||||
r = _forgeext("install", archive, "--consent-community")
|
|
||||||
ctx.check(r.get("ok") is True, "the install of a package asking for the operator's destinations -> %s",
|
|
||||||
r.get("error"))
|
|
||||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
|
||||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
|
||||||
first = _until(ctx, lambda: fresh(None), 60)
|
|
||||||
ctx.check(first, "the service did not come up: %s", _svc(REF_ID))
|
|
||||||
ev["none_named"] = first
|
|
||||||
ctx.log("none named: %s", first)
|
|
||||||
ctx.check(first.get("destinations") == [] and first.get("dial") == "EACCES",
|
|
||||||
"with nothing named it may reach nothing, and the sandbox refuses the dial: %s", first)
|
|
||||||
ctx.check(not chain_has(target), "the rule table carries %s before the operator named it", target)
|
|
||||||
|
|
||||||
# Refusals, in the host's words or the relay's.
|
|
||||||
refusals = []
|
|
||||||
for dest, want, words in (("%s:80" % lan_ip(), 409, "is this machine"), ("127.0.0.1:80", 409, "is this machine"),
|
|
||||||
("--root", 400, "host:port"), ("%s" % target, 409, "host:port")):
|
|
||||||
st_, why = fc.post("/ext/dest", data={"id": REF_ID, "action": "add", "dest": dest})
|
|
||||||
refusals.append([dest, st_, why])
|
|
||||||
ctx.check(st_ == want and words in json.dumps(why), "add %s -> %s %s, wanted %s %s", dest, st_, why, want, words)
|
|
||||||
st_, why = fc.post("/ext/dest", data={"id": REF_ID, "action": "remove", "dest": where})
|
|
||||||
refusals.append(["remove " + where, st_, why])
|
|
||||||
ctx.check(st_ == 409 and "is not one of" in json.dumps(why), "taking away one never named -> %s %s", st_, why)
|
|
||||||
ev["refusals"] = refusals
|
|
||||||
|
|
||||||
# Named: started again with its way out.
|
|
||||||
st_, doc = fc.post("/ext/dest", data={"id": REF_ID, "action": "add", "dest": where})
|
|
||||||
mine = [p for p in (doc or {}).get("packages", []) if p.get("id") == REF_ID] if isinstance(doc, dict) else []
|
|
||||||
ctx.check(st_ == 200 and mine and mine[0].get("destinations") == [where], "POST /ext/dest add %s -> %s %s",
|
|
||||||
where, st_, doc if st_ != 200 else mine)
|
|
||||||
named = _until(ctx, lambda: fresh(first.get("pid")), 60)
|
|
||||||
ev["named"] = named
|
|
||||||
ctx.log("named: %s", named)
|
|
||||||
ctx.check(named and named.get("destinations") == [where] and named.get("dial") == "connected"
|
|
||||||
and named.get("dial_other_port") == "EACCES",
|
|
||||||
"named, it is started again, names it, reaches it, and still not the next port: %s", named)
|
|
||||||
ctx.check(chain_has(target), "the rule table does not carry %s once named", target)
|
|
||||||
svc = _svc(REF_ID)
|
|
||||||
ctx.check(svc.get("state") == "running" and "quarantined" not in (svc.get("reason") or ""),
|
|
||||||
"the restart was no crash: %s", svc)
|
|
||||||
|
|
||||||
# Taken away: started again with none.
|
|
||||||
st_, doc = fc.post("/ext/dest", data={"id": REF_ID, "action": "remove", "dest": where})
|
|
||||||
ctx.check(st_ == 200, "POST /ext/dest remove %s -> %s %s", where, st_, doc)
|
|
||||||
gone = _until(ctx, lambda: fresh(named.get("pid") if named else None), 60)
|
|
||||||
ev["taken_away"] = gone
|
|
||||||
ctx.check(gone and gone.get("destinations") == [] and gone.get("dial") == "EACCES",
|
|
||||||
"taken away, it starts again and reaches nothing: %s", gone)
|
|
||||||
ctx.check(not chain_has(target), "the rule table still carries %s after it was taken away", target)
|
|
||||||
finally:
|
|
||||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
|
||||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,154 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""The host's own events: a service is told before it is stopped.
|
|
||||||
|
|
||||||
Its own module, for the reason extcore.py gives. The package here is the
|
|
||||||
reference package's id and key with a service of its own, so exthost's
|
|
||||||
put-back takes it away like the others.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until, _write)
|
|
||||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
|
||||||
|
|
||||||
# The service: it follows the host's event feed and keeps every host event it reads, with the
|
|
||||||
# monotonic time it read it, in its data directory.
|
|
||||||
LIFE_SERVICE = r'''
|
|
||||||
import json, os, socket, time
|
|
||||||
data = os.environ["FFX_DATA"]
|
|
||||||
|
|
||||||
|
|
||||||
def api(body):
|
|
||||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
s.settimeout(40)
|
|
||||||
try:
|
|
||||||
s.connect(os.environ["FFX_API"])
|
|
||||||
payload = json.dumps(body).encode()
|
|
||||||
s.sendall(b"POST /v0/events HTTP/1.1\r\nHost: forgeext\r\nContent-Type: application/json\r\n"
|
|
||||||
b"Content-Length: %d\r\n\r\n" % len(payload) + payload)
|
|
||||||
buf = b""
|
|
||||||
while True:
|
|
||||||
c = s.recv(65536)
|
|
||||||
if not c:
|
|
||||||
break
|
|
||||||
buf += c
|
|
||||||
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
|
|
||||||
def keep(name, doc):
|
|
||||||
with open(os.path.join(data, name + ".new"), "w") as f:
|
|
||||||
json.dump(doc, f)
|
|
||||||
os.rename(os.path.join(data, name + ".new"), os.path.join(data, name))
|
|
||||||
|
|
||||||
|
|
||||||
place = api({})["next"]
|
|
||||||
keep("ready.json", {"place": place, "t": time.monotonic()})
|
|
||||||
got = []
|
|
||||||
while True:
|
|
||||||
ans = api({"since": place, "wait": 20})
|
|
||||||
for e in ans.get("events") or []:
|
|
||||||
if e.get("event", "").startswith("ext."):
|
|
||||||
got.append({"event": e["event"], "data": e.get("data"), "t": time.monotonic()})
|
|
||||||
keep("host-events.json", got)
|
|
||||||
place = ans.get("next", place)
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def _pack_life(work):
|
|
||||||
import io
|
|
||||||
import tarfile
|
|
||||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest lifecycle", "version": "1.0.0",
|
|
||||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
|
||||||
"service": {"exec": "bin/life.py"}, "capabilities": ["events"]}
|
|
||||||
payload = os.path.join(work, "payload.tar.gz")
|
|
||||||
with tarfile.open(payload, "w:gz") as t:
|
|
||||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/life.py", LIFE_SERVICE, 0o755)):
|
|
||||||
info = tarfile.TarInfo(name)
|
|
||||||
data = text.encode()
|
|
||||||
info.size, info.mode = len(data), mode
|
|
||||||
t.addfile(info, io.BytesIO(data))
|
|
||||||
conf = os.path.join(work, "fwup.conf")
|
|
||||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
|
||||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
|
||||||
% (REF_ID, payload))
|
|
||||||
key = os.path.join(work, REF_KEY)
|
|
||||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "life.ffx")
|
|
||||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
|
||||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
|
||||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
|
||||||
return signed, key + ".pub"
|
|
||||||
|
|
||||||
|
|
||||||
@test("exthost.lifecycle", title="A package is told, in the feed it reads, before the host stops it",
|
|
||||||
subsystem="exthost", kind="auto", hardware="api", est_min=3,
|
|
||||||
covers=[("forgeext", "src/run.*"), ("forgeext", "src/machine.*"), ("forgeext", "src/evfeed.*"),
|
|
||||||
("forgeext", "src/api.*")],
|
|
||||||
requires=["exthost.service"],
|
|
||||||
description="A package that holds events reads the host's own events beside the machine's. With the "
|
|
||||||
"package running and following its feed, extensions are turned off: the host puts "
|
|
||||||
"ext.shutdown into the feed with the reason (\"extensions are off (ext_enabled)\"), and "
|
|
||||||
"stops the service a second later, so that the package has read it - the event is in its "
|
|
||||||
"data directory, read before its process ended. Holds do not wait for that second: they "
|
|
||||||
"follow extensions off at once, as exthost.hold-pause-tier holds. ext.will_freeze and "
|
|
||||||
"ext.thawed, which an armed window brings, are the automation package's host test's "
|
|
||||||
"(automation_test.py, where a service with job_time.run reads both). The package, the "
|
|
||||||
"key, the setting, and the setup record are put back as found.")
|
|
||||||
def lifecycle(ctx):
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
|
||||||
prior = fc.settings().get("ext_enabled") or ""
|
|
||||||
raw = read_file(record_path())
|
|
||||||
found_tree = _tree(EXT_ROOT)
|
|
||||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
|
||||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
|
||||||
etag = hdrs.get("etag")
|
|
||||||
work = tempfile.mkdtemp(prefix="forgetest-life.")
|
|
||||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
|
||||||
data = os.path.join(EXT_ROOT, "data", REF_ID)
|
|
||||||
|
|
||||||
def doc(name):
|
|
||||||
try:
|
|
||||||
with open(os.path.join(data, name)) as f:
|
|
||||||
return json.load(f)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
archive, pub = _pack_life(work)
|
|
||||||
shutil.copy(pub, owner_key)
|
|
||||||
os.chmod(owner_key, 0o644)
|
|
||||||
r = _forgeext("install", archive, "--consent-community")
|
|
||||||
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
|
|
||||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
|
||||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
|
||||||
ready = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and doc("ready.json"), 60)
|
|
||||||
ctx.check(ready, "the service did not come up and follow its feed: %s", _svc(REF_ID))
|
|
||||||
time.sleep(1.0)
|
|
||||||
pid = _svc(REF_ID).get("pid")
|
|
||||||
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
|
|
||||||
ctx.check(st == 200, "ext_enabled=0 -> %s %r", st, reply)
|
|
||||||
stopped = _until(ctx, lambda: _svc(REF_ID).get("state") != "running" and time.monotonic(), 20)
|
|
||||||
ctx.check(stopped, "the service was not stopped: %s", _svc(REF_ID))
|
|
||||||
ctx.check(not os.path.exists("/proc/%s" % pid), "its process %s outlived the stop", pid)
|
|
||||||
got = doc("host-events.json") or []
|
|
||||||
ev["host_events"] = got
|
|
||||||
ctx.log("what it read: %s", got)
|
|
||||||
ctx.check(len(got) >= 1 and got[0].get("event") == "ext.shutdown"
|
|
||||||
and (got[0].get("data") or {}).get("reason") == "extensions are off (ext_enabled)",
|
|
||||||
"it read ext.shutdown, with the reason, before it was stopped: %s", got)
|
|
||||||
finally:
|
|
||||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
|
||||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""An M-code a package answers, on the machine.
|
|
||||||
|
|
||||||
Its own module, for the reason extcore.py gives. The package is the
|
|
||||||
reference package's id and key with a service of its own that answers
|
|
||||||
M160 on its call socket, so exthost's put-back takes it away like the
|
|
||||||
others. The jobs are dark: they move and never command the laser.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until, _write)
|
|
||||||
from .motion import _job, _job_post, _job_wait, _words, kernel_start, kernel_xy_mm, machine_idle
|
|
||||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
|
||||||
|
|
||||||
# The service: it answers M160 on the call socket the host hands it (fd 4), and keeps every call it was
|
|
||||||
# asked, with the monotonic time it was asked, in its data directory. P1 takes 2 s and is done; any
|
|
||||||
# other P is refused.
|
|
||||||
MC_SERVICE = r'''
|
|
||||||
import json, os, socket, time
|
|
||||||
data = os.environ["FFX_DATA"]
|
|
||||||
lst = socket.socket(fileno=int(os.environ["FFX_CALL_FD"]))
|
|
||||||
calls = []
|
|
||||||
while True:
|
|
||||||
c, _ = lst.accept()
|
|
||||||
try:
|
|
||||||
buf = b""
|
|
||||||
while b"\r\n\r\n" not in buf:
|
|
||||||
k = c.recv(4096)
|
|
||||||
if not k:
|
|
||||||
break
|
|
||||||
buf += k
|
|
||||||
head, _, body = buf.partition(b"\r\n\r\n")
|
|
||||||
n = 0
|
|
||||||
for line in head.split(b"\r\n")[1:]:
|
|
||||||
k, _, v = line.partition(b":")
|
|
||||||
if k.strip().lower() == b"content-length":
|
|
||||||
n = int(v)
|
|
||||||
while len(body) < n:
|
|
||||||
k = c.recv(4096)
|
|
||||||
if not k:
|
|
||||||
break
|
|
||||||
body += k
|
|
||||||
req = json.loads(body[:n] or b"{}")
|
|
||||||
path = head.split(b" ")[1].decode()
|
|
||||||
t0 = time.monotonic()
|
|
||||||
words = req.get("words", {})
|
|
||||||
if path == "/mcode" and words.get("P") == 1:
|
|
||||||
time.sleep(2.0)
|
|
||||||
status, ans = 200, {"message": "forgetest P1"}
|
|
||||||
elif path == "/mcode":
|
|
||||||
status, ans = 409, {"error": "the stand-in refused"}
|
|
||||||
else:
|
|
||||||
status, ans = 404, {"error": "no such call"}
|
|
||||||
calls.append({"path": path, "req": req, "t": t0, "status": status})
|
|
||||||
with open(os.path.join(data, "calls.json.new"), "w") as f:
|
|
||||||
json.dump(calls, f)
|
|
||||||
os.rename(os.path.join(data, "calls.json.new"), os.path.join(data, "calls.json"))
|
|
||||||
out = json.dumps(ans).encode()
|
|
||||||
c.sendall(b"HTTP/1.1 %d X\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"
|
|
||||||
% (status, len(out)) + out)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
c.close()
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def _pack_mcode(work):
|
|
||||||
import io
|
|
||||||
import tarfile
|
|
||||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest M-code", "version": "1.0.0",
|
|
||||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
|
||||||
"service": {"exec": "bin/mcode.py"}, "capabilities": ["mcode:160", "job_time.run"]}
|
|
||||||
payload = os.path.join(work, "payload.tar.gz")
|
|
||||||
with tarfile.open(payload, "w:gz") as t:
|
|
||||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/mcode.py", MC_SERVICE, 0o755)):
|
|
||||||
info = tarfile.TarInfo(name)
|
|
||||||
data = text.encode()
|
|
||||||
info.size, info.mode = len(data), mode
|
|
||||||
t.addfile(info, io.BytesIO(data))
|
|
||||||
conf = os.path.join(work, "fwup.conf")
|
|
||||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
|
||||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
|
||||||
% (REF_ID, payload))
|
|
||||||
key = os.path.join(work, REF_KEY)
|
|
||||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "mcode.ffx")
|
|
||||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
|
||||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
|
||||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
|
||||||
return signed, key + ".pub"
|
|
||||||
|
|
||||||
|
|
||||||
def _port_state(fc):
|
|
||||||
st, body = fc.get("/motion/state")
|
|
||||||
return body if st == 200 and isinstance(body, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
@test("exthost.mcode", title="A job waits at an M-code a package answers, dark and still",
|
|
||||||
subsystem="exthost", kind="auto", mode="grbl", est_min=4,
|
|
||||||
covers=[("grblhal-glowforge", "src/glowforge_mcode.*"), ("grblhal-glowforge", "src/ctlport.*"),
|
|
||||||
("forgectrl", "src/mcode.*"), ("forgectrl", "src/extpkg.*"), ("forgectrl", "src/grblport.*"),
|
|
||||||
("forgeext", "src/main.c"), ("forgeext", "src/run.*"), ("forgeext", "src/caps.*"),
|
|
||||||
("forgeext", "src/manifest.*"), ("forgeext", "src/call.*")],
|
|
||||||
requires=["exthost.service", "motion.job"],
|
|
||||||
steps=["Bed clear; the head needs 10 mm of free travel toward +X. Nobody touches the gantry."],
|
|
||||||
description="A package that asks for mcode:160 and job_time.run, the one granted, is installed and "
|
|
||||||
"turned on; its service answers POST /mcode on its call socket. The host's status names it "
|
|
||||||
"as the one that answers M160. A dark job (out 5 mm, M160 P1, back) plays through POST "
|
|
||||||
"/job: at the M-code the controller's port state names M160 with P 1, the head stands "
|
|
||||||
"still across the wait (the kernel's counters and the port's position unchanged over "
|
|
||||||
"1 s), and the service answers after 2 s; the job then goes on and ends done, every line "
|
|
||||||
"acknowledged, the head back where it began, the service asked once with the code and "
|
|
||||||
"its words, no discharge. A job that names M161, which nothing answers, fails at that "
|
|
||||||
"line with nothing moved. A job at M160 P2, which the service refuses, is held: the "
|
|
||||||
"port's state goes to Hold with the head still, and the job is aborted from there. "
|
|
||||||
"Everything is put back as exthost.service puts it back; the refusals the controller "
|
|
||||||
"makes of a table or an answer out of form, the timeout, the reset, and the barrier's "
|
|
||||||
"darkness under an open armed window are the driver's mcode_test harness's.")
|
|
||||||
def mcode(ctx):
|
|
||||||
import tempfile
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
machine_idle(ctx)
|
|
||||||
prior = fc.settings().get("ext_enabled") or ""
|
|
||||||
raw = read_file(record_path())
|
|
||||||
found_tree = _tree(EXT_ROOT)
|
|
||||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
|
||||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
|
||||||
etag = hdrs.get("etag")
|
|
||||||
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
|
|
||||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
|
||||||
calls_file = os.path.join(EXT_ROOT, "data", REF_ID, "calls.json")
|
|
||||||
|
|
||||||
def calls():
|
|
||||||
try:
|
|
||||||
return json.loads(read_file(calls_file) or "[]")
|
|
||||||
except ValueError:
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
archive, pub = _pack_mcode(work)
|
|
||||||
import shutil
|
|
||||||
shutil.copy(pub, owner_key)
|
|
||||||
os.chmod(owner_key, 0o644)
|
|
||||||
r = _forgeext("install", archive, "--consent-community", "--grant", "job_time.run")
|
|
||||||
ctx.check(r.get("ok") is True, "the install with job_time.run granted -> %s", r.get("error"))
|
|
||||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
|
||||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
|
||||||
x = _until(ctx, lambda: _svc(REF_ID) if _svc(REF_ID).get("state") == "running" else None, 90, poll=0.5)
|
|
||||||
ctx.check(x, "the service is not running: %s", _svc(REF_ID))
|
|
||||||
|
|
||||||
def listed():
|
|
||||||
try:
|
|
||||||
doc = json.loads(read_file("/run/forgefirm/ext/status.json") or "{}")
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return doc.get("mcodes") if doc.get("mcodes") == [{"code": 160, "id": REF_ID}] else None
|
|
||||||
ev["status_mcodes"] = _until(ctx, listed, 30, poll=0.5)
|
|
||||||
ctx.check(ev["status_mcodes"], "the host's status does not name the package as the one that answers M160")
|
|
||||||
ctx.sleep(3.0) # the relay tells the controller within its 2 s
|
|
||||||
|
|
||||||
# A dark job that waits at M160 P1 for 2 s.
|
|
||||||
x0, _y0 = kernel_start(ctx)
|
|
||||||
st, body = _job_post(fc, "(forgetest exthost.mcode)\nG21\nG91\nG1 X5 F1200\nM160 P1\nG1 X-5 F1200\nG90\n")
|
|
||||||
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "running",
|
|
||||||
"POST /job -> %s %s", st, _words(body)[:200])
|
|
||||||
waiting = _until(ctx, lambda: _port_state(fc) if isinstance(_port_state(fc).get("mcode"), dict) else None,
|
|
||||||
20, poll=0.1)
|
|
||||||
ctx.check(waiting and waiting["mcode"].get("code") == 160 and waiting["mcode"].get("words") == {"P": 1},
|
|
||||||
"the port's state does not name M160 P1 while the job waits: %s", waiting)
|
|
||||||
k1 = kernel_xy_mm(ctx)
|
|
||||||
ctx.sleep(1.0)
|
|
||||||
k2 = kernel_xy_mm(ctx)
|
|
||||||
still = _port_state(fc)
|
|
||||||
ev["wait"] = {"port": waiting, "kernel": [k1, k2], "port_after_1s": still}
|
|
||||||
ctx.log("the job waits at M160: port %s, kernel %s -> %s", waiting, k1, k2)
|
|
||||||
ctx.check(abs(k2[0] - k1[0]) < 0.001 and abs(k2[1] - k1[1]) < 0.001,
|
|
||||||
"the head moved while the job waited: %s -> %s", k1, k2)
|
|
||||||
ctx.check(still.get("mpos") == waiting.get("mpos") and still.get("state") == "Idle",
|
|
||||||
"the port's position or state moved while the job waited: %s -> %s", waiting, still)
|
|
||||||
ctx.check(abs(k1[0] - x0 - 5.0) < 0.05, "the job did not wait at the M-code 5 mm out: %.3f", k1[0] - x0)
|
|
||||||
rec, far = _job_wait(ctx, fc, 40, x0)
|
|
||||||
x1 = kernel_xy_mm(ctx)[0]
|
|
||||||
got = calls()
|
|
||||||
ev["job"] = {"record": rec, "farthest_mm": round(far, 3), "end_mm": round(x1 - x0, 3), "calls": got}
|
|
||||||
ctx.log("the job at M160 P1: %s", ev["job"])
|
|
||||||
ctx.check(rec["state"] == "done" and rec["reason"] == "", "the job did not end well: %s", rec)
|
|
||||||
ctx.check(rec["sent"] == rec["acked"] == rec["lines"] + 1, "lines %s, sent %s, acked %s",
|
|
||||||
rec["lines"], rec["sent"], rec["acked"])
|
|
||||||
ctx.check(rec["lit"] is False and rec["emission"]["laser_on_samples"] == 0, "a dark job's witnesses: %s",
|
|
||||||
rec["emission"])
|
|
||||||
ctx.check(abs(x1 - x0) < 0.1, "the job did not end where it began: %.3f mm off", x1 - x0)
|
|
||||||
ctx.check(len(got) == 1 and got[0]["path"] == "/mcode" and got[0]["req"] == {"code": 160, "words": {"P": 1}}
|
|
||||||
and got[0]["status"] == 200, "the service was not asked M160 P1 once: %s", got)
|
|
||||||
|
|
||||||
# M161: nothing answers it, and the job stops where it is parsed.
|
|
||||||
x0 = kernel_xy_mm(ctx)[0]
|
|
||||||
st, body = _job_post(fc, "G21\nG91\nM161\nG1 X5 F1200\nG1 X-5 F1200\nG90\n")
|
|
||||||
rec, far = _job_wait(ctx, fc, 20, x0)
|
|
||||||
ev["unanswered"] = {"record": rec, "farthest_mm": round(far, 3)}
|
|
||||||
ctx.log("the job at M161: %s", ev["unanswered"])
|
|
||||||
ctx.check(rec["state"] == "failed" and "answered error:20" in (rec.get("reason") or ""),
|
|
||||||
"a job at M161 did not fail with error:20: %s", rec)
|
|
||||||
ctx.check(far < 0.01, "a job at an M-code nothing answers moved the head %.3f mm", far)
|
|
||||||
|
|
||||||
# M160 P2: the service refuses it, and the job is held there.
|
|
||||||
x0 = kernel_xy_mm(ctx)[0]
|
|
||||||
st, body = _job_post(fc, "G21\nG91\nM160 P2\nG1 X5 F1200\nG1 X-5 F1200\nG90\n")
|
|
||||||
held = _until(ctx, lambda: _port_state(fc) if _port_state(fc).get("state") == "Hold" else None, 30, poll=0.2)
|
|
||||||
ev["refused"] = {"port": held, "calls": calls()}
|
|
||||||
ctx.log("the job at M160 P2: %s", ev["refused"])
|
|
||||||
ctx.check(held and held.get("mcode") is None, "a job at a refused M160 is not held: %s", _port_state(fc))
|
|
||||||
ctx.sleep(1.0)
|
|
||||||
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.01, "the held job moved the head")
|
|
||||||
st, body = fc.post("/job/abort")
|
|
||||||
ctx.check(st == 200, "POST /job/abort -> %s %s", st, _words(body)[:160])
|
|
||||||
rec, _far = _job_wait(ctx, fc, 20, x0)
|
|
||||||
ctx.check(rec["state"] == "failed", "the held job did not end aborted: %s", rec)
|
|
||||||
if _port_state(fc).get("state") == "Alarm":
|
|
||||||
st, body = _job_post(fc, "G21\n", unlock="1")
|
|
||||||
_job_wait(ctx, fc, 20, x0)
|
|
||||||
ctx.check(_port_state(fc).get("state") == "Idle", "the controller is not idle after the abort: %s",
|
|
||||||
_port_state(fc))
|
|
||||||
finally:
|
|
||||||
# A check that failed mid-job leaves the job running; it holds the settings until it ends.
|
|
||||||
if _job(fc).get("state") == "running":
|
|
||||||
fc.post("/job/abort")
|
|
||||||
_job_wait(ctx, fc, 20)
|
|
||||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
|
||||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""An extension package keeps the Grbl sender out, by the operator's grant.
|
|
||||||
|
|
||||||
Its own module rather than a test in exthost.py, for the reason extcore.py
|
|
||||||
gives: a test added there moves the fingerprint of every test in that
|
|
||||||
module. The package is the reference package's id and key with a service
|
|
||||||
that claims the sender out as it starts and says how its claim stands
|
|
||||||
every half second, so exthost's put-back takes it away like the others.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import time
|
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until,
|
|
||||||
_write)
|
|
||||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
|
||||||
|
|
||||||
CLAIM_DIR = "/run/forgefirm/sender-out"
|
|
||||||
GRBL_STATE = "/run/forgefirm/grbl.state"
|
|
||||||
KEEP_OUT_MSG = b"[MSG:The machine is in use: senders are kept out for now]"
|
|
||||||
|
|
||||||
# The service: it claims the sender out once, as it starts, writes the
|
|
||||||
# machine's answer, and then writes how its claim stands, twice a second.
|
|
||||||
KEEPER_SERVICE = r'''
|
|
||||||
import json, os, socket, time
|
|
||||||
data = os.environ["FFX_DATA"]
|
|
||||||
|
|
||||||
|
|
||||||
def api(method, path, body=None):
|
|
||||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
s.settimeout(10)
|
|
||||||
try:
|
|
||||||
s.connect(os.environ["FFX_API"])
|
|
||||||
payload = b"" if body is None else json.dumps(body).encode()
|
|
||||||
head = "%s %s HTTP/1.1\r\nHost: forgeext\r\n" % (method, path)
|
|
||||||
if body is not None:
|
|
||||||
head += "Content-Type: application/json\r\nContent-Length: %d\r\n" % len(payload)
|
|
||||||
s.sendall(head.encode() + b"\r\n" + payload)
|
|
||||||
buf = b""
|
|
||||||
while True:
|
|
||||||
c = s.recv(65536)
|
|
||||||
if not c:
|
|
||||||
break
|
|
||||||
buf += c
|
|
||||||
top, _, content = buf.partition(b"\r\n\r\n")
|
|
||||||
return int(top.split()[1]), json.loads(content or b"null")
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
|
|
||||||
def write(name, doc):
|
|
||||||
p = os.path.join(data, name)
|
|
||||||
with open(p + ".new", "w") as f:
|
|
||||||
json.dump(doc, f)
|
|
||||||
os.rename(p + ".new", p)
|
|
||||||
|
|
||||||
|
|
||||||
status, doc = api("POST", "/v0/sender", {"out": True})
|
|
||||||
write("claim.json", {"status": status, "doc": doc})
|
|
||||||
print("keeper up: %s %s" % (status, doc), flush=True)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
write("state.json", dict(zip(("status", "doc"), api("GET", "/v0/sender"))))
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
time.sleep(0.5)
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def _pack_keeper(work):
|
|
||||||
"""The reference package's id and key, with the service above: (archive, public key)."""
|
|
||||||
import io
|
|
||||||
import subprocess
|
|
||||||
import tarfile
|
|
||||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest sender keeper", "version": "1.0.0",
|
|
||||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
|
||||||
"service": {"exec": "bin/keeper.py"}, "capabilities": ["sender.keep_out"]}
|
|
||||||
payload = os.path.join(work, "payload.tar.gz")
|
|
||||||
with tarfile.open(payload, "w:gz") as t:
|
|
||||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/keeper.py", KEEPER_SERVICE, 0o755)):
|
|
||||||
info = tarfile.TarInfo(name)
|
|
||||||
data = text.encode()
|
|
||||||
info.size, info.mode = len(data), mode
|
|
||||||
t.addfile(info, io.BytesIO(data))
|
|
||||||
conf = os.path.join(work, "fwup.conf")
|
|
||||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
|
||||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
|
||||||
% (REF_ID, payload))
|
|
||||||
key = os.path.join(work, REF_KEY)
|
|
||||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "keeper.ffx")
|
|
||||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
|
||||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
|
||||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
|
||||||
return signed, key + ".pub"
|
|
||||||
|
|
||||||
|
|
||||||
def _lan_address():
|
|
||||||
"""This machine's own address on the network: a sender that connects to it comes from the network
|
|
||||||
as the controller sees it, and not from the loopback."""
|
|
||||||
u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
try:
|
|
||||||
u.connect(("192.0.2.1", 9))
|
|
||||||
return u.getsockname()[0]
|
|
||||||
except OSError:
|
|
||||||
return ""
|
|
||||||
finally:
|
|
||||||
u.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _sender(addr, secs=2.0):
|
|
||||||
"""A Grbl sender that connects and listens: (what it read, whether the controller closed it)."""
|
|
||||||
s = socket.create_connection((addr, 23), timeout=5)
|
|
||||||
s.settimeout(0.3)
|
|
||||||
got, closed, end = b"", False, time.time() + secs
|
|
||||||
try:
|
|
||||||
while time.time() < end:
|
|
||||||
try:
|
|
||||||
c = s.recv(4096)
|
|
||||||
except socket.timeout:
|
|
||||||
continue
|
|
||||||
if not c:
|
|
||||||
closed = True
|
|
||||||
break
|
|
||||||
got += c
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
return got, closed
|
|
||||||
|
|
||||||
|
|
||||||
def _read_json(path):
|
|
||||||
try:
|
|
||||||
with open(path) as f:
|
|
||||||
return json.load(f)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@test("exthost.sender-keep-out", title="An extension package keeps the Grbl sender out, by the operator's grant",
|
|
||||||
subsystem="exthost", kind="auto", hardware="api", est_min=4,
|
|
||||||
covers=[("forgectrl", "src/senderout.*"), ("forgectrl", "src/lease.*"), ("forgectrl", "src/grblport.*"),
|
|
||||||
("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"), ("forgeext", "src/api.*"),
|
|
||||||
("forgeext", "src/run.*"), ("forgeext", "src/machine.*"), ("forgeext", "src/holdkeep.*"),
|
|
||||||
("forgeext", "src/caps.*"), ("grblhal-glowforge", "src/ctlport.*"), ("grblhal-glowforge", "src/serial.*")],
|
|
||||||
requires=["exthost.service"],
|
|
||||||
description="A package the operator granted sender.keep_out keeps the Grbl sender out while it uses the "
|
|
||||||
"machine: the test installs a reference package whose service asks POST /v0/sender "
|
|
||||||
"{\"out\": true} as it starts, on an idle machine with no sender connected. The machine "
|
|
||||||
"grants it: /status names the package in sender_out and holds the machine lease as "
|
|
||||||
"ext:<id>, of the extension kind; the host keeps the claim fresh under "
|
|
||||||
"/run/forgefirm/sender-out; a Grbl sender that connects from the network (the machine's "
|
|
||||||
"own LAN address) reads the controller's message and is closed at once, and one from "
|
|
||||||
"this host still connects. The operator lets the sender back in from the panel "
|
|
||||||
"(POST /motion/sender out=0): the holder and the lease go, a sender from the network is "
|
|
||||||
"welcomed again, and the package's service reads released. Turned off and on, the "
|
|
||||||
"package's service claims again; turned off with its claim standing, forgectrl ends the "
|
|
||||||
"claim within seconds by itself, with the notice that names it, and lets senders in; the "
|
|
||||||
"notice is cleared from the panel. The package, the key, the setting, and the setup "
|
|
||||||
"record are put back as found.")
|
|
||||||
def sender_keep_out(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
|
||||||
st, m0 = fc.get("/mode")
|
|
||||||
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("mode") == "grbl" and m0.get("controller") == "running",
|
|
||||||
"start this test in grbl mode with the controller running (now %s)", m0)
|
|
||||||
grbl = _read_json(GRBL_STATE) or {}
|
|
||||||
ctx.check(not (grbl.get("sender") or {}).get("connected"),
|
|
||||||
"a Grbl sender is connected: close it first (this test would disconnect it)")
|
|
||||||
lan = _lan_address()
|
|
||||||
ctx.check(lan and not lan.startswith("127."), "the machine has no address on the network: %r", lan)
|
|
||||||
prior = fc.settings().get("ext_enabled") or ""
|
|
||||||
raw = read_file(record_path())
|
|
||||||
found_tree = _tree(EXT_ROOT)
|
|
||||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
|
||||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
|
||||||
etag = hdrs.get("etag")
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
work = tempfile.mkdtemp(prefix="forgetest-sender.")
|
|
||||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
|
||||||
data = os.path.join(EXT_ROOT, "data", REF_ID)
|
|
||||||
claim_file = os.path.join(CLAIM_DIR, REF_ID + ".json")
|
|
||||||
|
|
||||||
def sender_out():
|
|
||||||
return fc.status().get("sender_out") or {}
|
|
||||||
|
|
||||||
def holder():
|
|
||||||
return (sender_out().get("holder") or {}).get("id")
|
|
||||||
|
|
||||||
try:
|
|
||||||
got, closed = _sender(lan)
|
|
||||||
ev["before"] = [got[:80].decode(errors="replace"), closed]
|
|
||||||
ctx.check(b"Grbl" in got and not closed, "before: a sender from the network is not welcomed: %r", got[:80])
|
|
||||||
|
|
||||||
archive, pub = _pack_keeper(work)
|
|
||||||
shutil.copy(pub, owner_key)
|
|
||||||
os.chmod(owner_key, 0o644)
|
|
||||||
r = _forgeext("install", archive, "--consent-community")
|
|
||||||
ctx.check(r.get("ok") is False and "sender.keep_out" in (r.get("error") or ""),
|
|
||||||
"an install without the operator's grant is refused -> %s", r.get("error"))
|
|
||||||
r = _forgeext("install", archive, "--consent-community", "--grant", "sender.keep_out")
|
|
||||||
ctx.check(r.get("ok") is True, "the install with the grant -> %s", r.get("error"))
|
|
||||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
|
||||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
|
||||||
up = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and holder() == REF_ID, 60)
|
|
||||||
claim = _read_json(os.path.join(data, "claim.json"))
|
|
||||||
ev["claim"] = claim
|
|
||||||
ctx.log("the service's claim: %s; sender_out %s", claim, sender_out())
|
|
||||||
ctx.check(up and (claim or {}).get("status") == 200 and (claim.get("doc") or {}).get("out") is True,
|
|
||||||
"the service's claim was not granted: %s, %s", claim, sender_out())
|
|
||||||
|
|
||||||
doc = fc.status()
|
|
||||||
ev["kept"] = {"sender_out": doc.get("sender_out"), "lease": (doc.get("lease") or {}).get("holder")}
|
|
||||||
lease = (doc.get("lease") or {}).get("holder") or {}
|
|
||||||
ctx.check(lease.get("owner") == "ext:" + REF_ID and lease.get("kind") == "extension",
|
|
||||||
"the lease is not the package's: %s", lease)
|
|
||||||
def fresh():
|
|
||||||
k = _read_json(claim_file) or {}
|
|
||||||
return k if (k.get("id") == REF_ID and k.get("raised") is True and
|
|
||||||
time.clock_gettime(time.CLOCK_MONOTONIC) - float(k.get("ts_mono") or 0) < 2.0) else None
|
|
||||||
# The host writes the claim at its next turn; past forgectrl's 3 s grace the claim stands only
|
|
||||||
# because the host keeps it fresh.
|
|
||||||
kept = _until(ctx, fresh, 5)
|
|
||||||
ev["claim_file"] = kept
|
|
||||||
ctx.check(kept, "the host does not write the claim: %s", _read_json(claim_file))
|
|
||||||
time.sleep(5.0)
|
|
||||||
ctx.check(holder() == REF_ID and fresh(), "past the grace, the claim did not stand fresh: %s, %s",
|
|
||||||
sender_out(), _read_json(claim_file))
|
|
||||||
got, closed = _sender(lan)
|
|
||||||
ev["kept_out"] = [got[:120].decode(errors="replace"), closed]
|
|
||||||
ctx.check(closed and KEEP_OUT_MSG in got and b"Grbl" not in got,
|
|
||||||
"a sender from the network was not turned away: %r closed=%s", got[:120], closed)
|
|
||||||
got, closed = _sender("127.0.0.1", 1.0)
|
|
||||||
ev["loopback"] = [got[:80].decode(errors="replace"), closed]
|
|
||||||
ctx.check(b"Grbl" in got, "a sender from this host was not let in: %r", got[:80])
|
|
||||||
|
|
||||||
# The operator lets it back in, from the panel.
|
|
||||||
st, reply = fc.post("/motion/sender", data={"out": "0"})
|
|
||||||
ev["released"] = [st, reply]
|
|
||||||
ctx.check(st == 200 and holder() is None, "the operator's release -> %s %s", st, reply)
|
|
||||||
ctx.check(not ((fc.status().get("lease") or {}).get("holder")), "the lease stayed after the release")
|
|
||||||
got, closed = _sender(lan)
|
|
||||||
ctx.check(b"Grbl" in got and not closed, "released, a sender from the network is not welcomed: %r", got[:80])
|
|
||||||
saw = _until(ctx, lambda: ((_read_json(os.path.join(data, "state.json")) or {}).get("doc") or {}).get("released"), 10)
|
|
||||||
ctx.check(saw is True, "the package's service did not read released: %s",
|
|
||||||
_read_json(os.path.join(data, "state.json")))
|
|
||||||
|
|
||||||
# Off and on: the operator's release lasts until the package stops claiming, and the
|
|
||||||
# service claims again as it starts.
|
|
||||||
ctx.check(_forgeext("disable", REF_ID).get("ok") is True, "disable")
|
|
||||||
ctx.check(_until(ctx, lambda: not os.path.exists(claim_file), 15), "disabled, the host kept the claim file")
|
|
||||||
time.sleep(3.0) # past forgectrl's freshness window, so the release is forgotten
|
|
||||||
ctx.check(_forgeext("enable", REF_ID).get("ok") is True, "enable")
|
|
||||||
again = _until(ctx, lambda: holder() == REF_ID, 60)
|
|
||||||
ctx.check(again, "enabled again, the service's claim was not granted: %s", sender_out())
|
|
||||||
|
|
||||||
# Turned off with its claim standing: forgectrl ends it by itself.
|
|
||||||
t0 = time.time()
|
|
||||||
ctx.check(_forgeext("disable", REF_ID).get("ok") is True, "disable with the claim standing")
|
|
||||||
gone = _until(ctx, lambda: holder() is None, 15)
|
|
||||||
ev["stale_s"] = round(time.time() - t0, 1)
|
|
||||||
notice = sender_out().get("notice") or {}
|
|
||||||
ev["notice"] = notice
|
|
||||||
ctx.log("the claim ended %.1f s after the disable; notice %s", time.time() - t0, notice)
|
|
||||||
ctx.check(gone is True and notice.get("id") == REF_ID and notice.get("why") == "stopped",
|
|
||||||
"a claim its keeper stopped keeping did not end with the notice: %s", sender_out())
|
|
||||||
ctx.check(not os.path.exists(claim_file), "the claim file stayed after the service stopped")
|
|
||||||
got, closed = _sender(lan)
|
|
||||||
ctx.check(b"Grbl" in got and not closed, "after it ended, a sender from the network is not welcomed: %r", got[:80])
|
|
||||||
st, reply = fc.post("/motion/sender", data={"notice": "clear"})
|
|
||||||
ctx.check(st == 200 and not sender_out().get("notice"), "the notice was not cleared -> %s %s", st, reply)
|
|
||||||
finally:
|
|
||||||
if holder():
|
|
||||||
fc.post("/motion/sender", data={"out": "0"})
|
|
||||||
if sender_out().get("notice"):
|
|
||||||
fc.post("/motion/sender", data={"notice": "clear"})
|
|
||||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
|
||||||
ctx.check(holder() is None, "the sender is still kept out at the end")
|
|
||||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""A package's own check on the Setup page, on the machine.
|
|
||||||
|
|
||||||
Its own module, for the reason extcore.py gives. The package is the
|
|
||||||
reference package's id and key with a service of its own that runs the
|
|
||||||
check on its call socket, so exthost's put-back takes it away like the
|
|
||||||
others.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until, _write)
|
|
||||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
|
||||||
|
|
||||||
# The service: the check's steps on the call socket the host hands it (fd 4), its own record of whether the
|
|
||||||
# check passed, and every request it was asked, kept in its data directory.
|
|
||||||
WZ_SERVICE = r'''
|
|
||||||
import json, os, socket
|
|
||||||
data = os.environ["FFX_DATA"]
|
|
||||||
lst = socket.socket(fileno=int(os.environ["FFX_CALL_FD"]))
|
|
||||||
state = {"done": False, "calls": []}
|
|
||||||
while True:
|
|
||||||
c, _ = lst.accept()
|
|
||||||
try:
|
|
||||||
buf = b""
|
|
||||||
while b"\r\n\r\n" not in buf:
|
|
||||||
k = c.recv(4096)
|
|
||||||
if not k:
|
|
||||||
break
|
|
||||||
buf += k
|
|
||||||
head, _, body = buf.partition(b"\r\n\r\n")
|
|
||||||
n = 0
|
|
||||||
for line in head.split(b"\r\n")[1:]:
|
|
||||||
k, _, v = line.partition(b":")
|
|
||||||
if k.strip().lower() == b"content-length":
|
|
||||||
n = int(v)
|
|
||||||
while len(body) < n:
|
|
||||||
k = c.recv(4096)
|
|
||||||
if not k:
|
|
||||||
break
|
|
||||||
body += k
|
|
||||||
req = json.loads(body[:n] or b"{}")
|
|
||||||
method, path = head.split(b" ")[0].decode(), head.split(b" ")[1].decode()
|
|
||||||
if method == "GET" and path == "/wizard":
|
|
||||||
ans = {"title": "forgetest check", "done": state["done"]}
|
|
||||||
elif path == "/wizard/start":
|
|
||||||
ans = {"log": ["the check begins"], "phase": "asking", "progress": 50,
|
|
||||||
"prompt": {"kind": "confirm", "id": "ready", "text": "Is the machine ready?"}}
|
|
||||||
elif path == "/wizard/answer":
|
|
||||||
ok = req.get("id") == "ready" and req.get("answer") == "yes"
|
|
||||||
state["done"] = ok
|
|
||||||
ans = {"log": ["answered " + str(req.get("answer"))], "result": {"ok": ok, "summary": "ready" if ok else "not ready"}}
|
|
||||||
elif path == "/wizard/abort":
|
|
||||||
ans = {}
|
|
||||||
else:
|
|
||||||
ans = {"error": "no such call"}
|
|
||||||
state["calls"].append([method, path, req])
|
|
||||||
with open(os.path.join(data, "check.json.new"), "w") as f:
|
|
||||||
json.dump(state, f)
|
|
||||||
os.rename(os.path.join(data, "check.json.new"), os.path.join(data, "check.json"))
|
|
||||||
out = json.dumps(ans).encode()
|
|
||||||
c.sendall(b"HTTP/1.1 200 X\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"
|
|
||||||
% len(out) + out)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
c.close()
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def _pack_check(work):
|
|
||||||
import io
|
|
||||||
import tarfile
|
|
||||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest check", "version": "1.0.0",
|
|
||||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
|
||||||
"service": {"exec": "bin/check.py"}, "capabilities": ["wizard"]}
|
|
||||||
payload = os.path.join(work, "payload.tar.gz")
|
|
||||||
with tarfile.open(payload, "w:gz") as t:
|
|
||||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/check.py", WZ_SERVICE, 0o755)):
|
|
||||||
info = tarfile.TarInfo(name)
|
|
||||||
data = text.encode()
|
|
||||||
info.size, info.mode = len(data), mode
|
|
||||||
t.addfile(info, io.BytesIO(data))
|
|
||||||
conf = os.path.join(work, "fwup.conf")
|
|
||||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
|
||||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
|
||||||
% (REF_ID, payload))
|
|
||||||
key = os.path.join(work, REF_KEY)
|
|
||||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "check.ffx")
|
|
||||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
|
||||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
|
||||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
|
||||||
return signed, key + ".pub"
|
|
||||||
|
|
||||||
|
|
||||||
@test("exthost.wizard", title="A package's own check runs on the Setup page, and the machine's setup is untouched",
|
|
||||||
subsystem="exthost", kind="auto", est_min=3,
|
|
||||||
covers=[("forgectrl", "src/wizpkg.*"), ("forgectrl", "src/wizdark.c"), ("forgectrl", "src/wiz.c"),
|
|
||||||
("forgectrl", "src/extpkg.*"), ("forgectrl", "src/ui/wizard.js"), ("forgeext", "src/main.c"),
|
|
||||||
("forgeext", "src/run.*"), ("forgeext", "src/caps.*"), ("forgeext", "src/manifest.*")],
|
|
||||||
requires=["exthost.service"],
|
|
||||||
description="A package that asks for wizard is installed and turned on; its service runs a check on its "
|
|
||||||
"call socket and keeps whether it passed. GET /wiz lists it among the extensions as "
|
|
||||||
"pkg:<id> with the title the package gives it, not done. POST /wiz/pkg:<id>/start runs it on "
|
|
||||||
"the wizard runner: GET /wiz/dark shows the package's log line, phase, progress, and its "
|
|
||||||
"confirm prompt, and the machine lease stays free while it waits (a package's check holds the "
|
|
||||||
"machine no more than the package does). The operator's Yes reaches the service as the answer "
|
|
||||||
"yes, the run ends complete with the package's summary, and GET /wiz then lists the check as "
|
|
||||||
"done, from the package's own answer. The machine's setup record is the same before and after: "
|
|
||||||
"no pkg: wizard in it and the gate as it was. A second run is aborted from its prompt and the "
|
|
||||||
"service is told. A check of a package that is not installed is 404. Everything is put back "
|
|
||||||
"as exthost.service puts it back.")
|
|
||||||
def wizard(ctx):
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
|
|
||||||
prior = fc.settings().get("ext_enabled") or ""
|
|
||||||
raw = read_file(record_path())
|
|
||||||
found_tree = _tree(EXT_ROOT)
|
|
||||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
|
||||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
|
||||||
etag = hdrs.get("etag")
|
|
||||||
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
|
|
||||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
|
||||||
wid = "pkg:" + REF_ID
|
|
||||||
check_file = os.path.join(EXT_ROOT, "data", REF_ID, "check.json")
|
|
||||||
|
|
||||||
def own():
|
|
||||||
try:
|
|
||||||
return json.loads(read_file(check_file) or "{}")
|
|
||||||
except ValueError:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def listed():
|
|
||||||
st_, w = fc.get("/wiz")
|
|
||||||
return next((e for e in (w.get("extensions") or []) if e.get("id") == wid), None) if isinstance(w, dict) else None
|
|
||||||
|
|
||||||
def dark():
|
|
||||||
st_, d = fc.get("/wiz/dark")
|
|
||||||
return d if isinstance(d, dict) else {}
|
|
||||||
|
|
||||||
def record_view():
|
|
||||||
try:
|
|
||||||
rec = json.loads(read_file(record_path()) or "{}")
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return {"wizards": sorted((rec.get("wizards") or {}).keys()), "completed": rec.get("completed")}
|
|
||||||
|
|
||||||
before = record_view()
|
|
||||||
gate_before = fc.get("/wiz")[1].get("gate")
|
|
||||||
try:
|
|
||||||
archive, pub = _pack_check(work)
|
|
||||||
shutil.copy(pub, owner_key)
|
|
||||||
os.chmod(owner_key, 0o644)
|
|
||||||
r = _forgeext("install", archive, "--consent-community")
|
|
||||||
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
|
|
||||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
|
||||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
|
||||||
x = _until(ctx, lambda: _svc(REF_ID) if _svc(REF_ID).get("state") == "running" else None, 90, poll=0.5)
|
|
||||||
ctx.check(x, "the service is not running: %s", _svc(REF_ID))
|
|
||||||
|
|
||||||
e = _until(ctx, listed, 30, poll=1.0)
|
|
||||||
ev["listed"] = e
|
|
||||||
ctx.check(e and e.get("title") == "forgetest check" and e.get("done") is False,
|
|
||||||
"GET /wiz does not list the package's check, not done: %s", e)
|
|
||||||
|
|
||||||
st, body = fc.post("/wiz/%s/start" % wid)
|
|
||||||
ctx.check(st == 200, "POST /wiz/%s/start -> %s %s", wid, st, body)
|
|
||||||
d = _until(ctx, lambda: dark() if (dark().get("prompt") or {}).get("id") == "ready" else None, 20, poll=0.3)
|
|
||||||
ev["prompt"] = d
|
|
||||||
ctx.check(d and d.get("running") and d["prompt"].get("kind") == "confirm"
|
|
||||||
and any(l.endswith("the check begins") for l in (d.get("log") or [])) and d.get("progress") == 50,
|
|
||||||
"the check's prompt, log, and progress: %s", d)
|
|
||||||
lease = (fc.status().get("lease") or {}).get("holder")
|
|
||||||
ev["lease_while_waiting"] = lease
|
|
||||||
ctx.check(not lease, "a package's check holds the machine: %s", lease)
|
|
||||||
st, body = fc.post("/wiz/%s/answer" % wid, data={"seq": str(d["prompt"]["seq"]), "value": "Yes"})
|
|
||||||
ctx.check(st == 200, "the answer -> %s %s", st, body)
|
|
||||||
end = _until(ctx, lambda: dark() if not dark().get("running") else None, 20, poll=0.3)
|
|
||||||
ev["end"] = end
|
|
||||||
ctx.log("the check ended: %s", end)
|
|
||||||
ctx.check(end and not end.get("error") and (end.get("result") or {}).get("summary") == "ready",
|
|
||||||
"the check did not end complete with the package's summary: %s", end)
|
|
||||||
mine = own()
|
|
||||||
ctx.check(mine.get("done") is True and ["POST", "/wizard/answer", {"id": "ready", "answer": "yes"}] in
|
|
||||||
(mine.get("calls") or []), "the service was not answered yes: %s", mine)
|
|
||||||
ctx.sleep(11.0) # the Setup page's list is asked again at most every 10 s
|
|
||||||
e = listed()
|
|
||||||
ev["listed_after"] = e
|
|
||||||
ctx.check(e and e.get("done") is True, "GET /wiz does not list the check as done: %s", e)
|
|
||||||
|
|
||||||
after = record_view()
|
|
||||||
ev["record"] = {"before": before, "after": after}
|
|
||||||
ctx.check(after == before and not any(w.startswith("pkg:") for w in (after or {}).get("wizards", [])),
|
|
||||||
"the machine's setup record changed: %s -> %s", before, after)
|
|
||||||
ctx.check(fc.get("/wiz")[1].get("gate") == gate_before, "the setup gate moved")
|
|
||||||
|
|
||||||
# A second run, aborted from its prompt.
|
|
||||||
st, body = fc.post("/wiz/%s/start" % wid)
|
|
||||||
d = _until(ctx, lambda: dark() if (dark().get("prompt") or {}).get("id") == "ready" else None, 20, poll=0.3)
|
|
||||||
ctx.check(st == 200 and d, "the second run did not reach its prompt: %s %s", st, body)
|
|
||||||
fc.post("/wiz/%s/abort" % wid)
|
|
||||||
end = _until(ctx, lambda: dark() if not dark().get("running") else None, 20, poll=0.3)
|
|
||||||
ctx.check(end and end.get("error") == "aborted", "the abort: %s", end)
|
|
||||||
ctx.check(["POST", "/wizard/abort", {}] in (own().get("calls") or []), "the service was not told of the abort")
|
|
||||||
|
|
||||||
st, body = fc.post("/wiz/pkg:org.forgetest.nothere/start")
|
|
||||||
ctx.check(st == 404, "a check of a package that is not installed -> %s %s", st, body)
|
|
||||||
finally:
|
|
||||||
# A check that failed mid-run leaves the package's check waiting at its prompt.
|
|
||||||
if dark().get("running") and dark().get("id") == wid:
|
|
||||||
fc.post("/wiz/%s/abort" % wid)
|
|
||||||
_until(ctx, lambda: not dark().get("running"), 20, poll=0.3)
|
|
||||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
|
||||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
|
||||||
@@ -4,21 +4,17 @@
|
|||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import socket
|
import socket
|
||||||
import time
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
from .. import hw
|
|
||||||
|
|
||||||
# The write guard reads the account and the session store (a machine with
|
# The write guard reads the account and the session store (a machine with
|
||||||
# an account needs a session from the LAN; this host and the dev image
|
# an account needs a session from the LAN; this host and the dev image
|
||||||
# write with the token), so both are the guard's domain.
|
# write with the token), so both are the guard's domain. The fuse view
|
||||||
|
# is two-factor: the token and the physical button, read by button.c.
|
||||||
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
||||||
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"), ("forgectrl", "src/button.*"),
|
||||||
("forgectrl", "src/ui/login.js"), ("forgectrl", "src/ui/wizard.js")]
|
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/wizard.js")]
|
||||||
|
|
||||||
|
|
||||||
def lan_ip():
|
def lan_ip():
|
||||||
@@ -213,695 +209,3 @@ def auth(ctx):
|
|||||||
if st == 302:
|
if st == 302:
|
||||||
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
|
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
|
||||||
"the login redirect does not carry the path: %r", loc)
|
"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, with a policy that lets no frame navigate anywhere "
|
|
||||||
"(frame-src 'none': a package's page renders in a frame of it, and a frame that "
|
|
||||||
"could go to another address could carry what it was shown in the URL; what the "
|
|
||||||
"header does in each browser is the frame-isolation harness's to prove, and this "
|
|
||||||
"holds the machine's own page to carrying it), /status carries the machine "
|
|
||||||
"telemetry the panel and "
|
|
||||||
"the acceptance tool read (including the sys block: CPU busy percent over the "
|
|
||||||
"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")
|
|
||||||
from .setup import request
|
|
||||||
st_h, _page, hdrs = request(fc.base, "GET", "/", headers={"Host": fc.host_header()})
|
|
||||||
ev["page_policy"] = hdrs.get("content-security-policy")
|
|
||||||
ctx.log("GET / Content-Security-Policy: %s", ev["page_policy"])
|
|
||||||
ctx.check(st_h == 200 and "frame-src 'none'" in (ev["page_policy"] or ""),
|
|
||||||
"the panel page lets a frame navigate: %s %r", st_h, ev["page_policy"])
|
|
||||||
|
|
||||||
s = fc.status()
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class _EventStream:
|
|
||||||
"""GET /events over a raw socket bound to a chosen loopback source
|
|
||||||
address. Every address in 127.0.0.0/8 is this host, so the test can be
|
|
||||||
several peers at once, and the daemon counts streams per peer address."""
|
|
||||||
|
|
||||||
def __init__(self, base, source, timeout=5.0):
|
|
||||||
u = urllib.parse.urlsplit(base)
|
|
||||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
self.sock.bind((source, 0))
|
|
||||||
self.sock.settimeout(timeout)
|
|
||||||
self.sock.connect((u.hostname or "127.0.0.1", u.port or 80))
|
|
||||||
self.sock.sendall(b"GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n"
|
|
||||||
b"Connection: close\r\n\r\n" % (u.netloc or "127.0.0.1").encode())
|
|
||||||
self.buf = b""
|
|
||||||
self.eof = False # the response is over: the last chunk, or the socket closed
|
|
||||||
head = self._until(b"\r\n\r\n")
|
|
||||||
self.status = int(head.split(b" ", 2)[1]) if head.startswith(b"HTTP/") else 0
|
|
||||||
self.head = head.decode("latin-1", "replace")
|
|
||||||
|
|
||||||
def _fill(self):
|
|
||||||
try:
|
|
||||||
chunk = self.sock.recv(4096)
|
|
||||||
except socket.timeout:
|
|
||||||
return False
|
|
||||||
if not chunk:
|
|
||||||
self.eof = True
|
|
||||||
return False
|
|
||||||
self.buf += chunk
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _until(self, mark):
|
|
||||||
while mark not in self.buf:
|
|
||||||
if not self._fill():
|
|
||||||
break
|
|
||||||
i = self.buf.find(mark)
|
|
||||||
if i < 0:
|
|
||||||
out, self.buf = self.buf, b""
|
|
||||||
return out
|
|
||||||
out, self.buf = self.buf[:i], self.buf[i + len(mark):]
|
|
||||||
return out
|
|
||||||
|
|
||||||
def text(self, seconds):
|
|
||||||
"""Everything the stream says in the next `seconds` (chunk framing
|
|
||||||
and all: the checks look for event names inside it)."""
|
|
||||||
end = time.time() + seconds
|
|
||||||
self.sock.settimeout(0.2) # the window is the caller's, not one long read
|
|
||||||
while time.time() < end and not self.eof:
|
|
||||||
self._fill()
|
|
||||||
if self.buf.endswith(b"\r\n0\r\n\r\n"):
|
|
||||||
self.eof = True
|
|
||||||
out, self.buf = self.buf, b""
|
|
||||||
return out.decode("utf-8", "replace")
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
try:
|
|
||||||
self.sock.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@test("events.stream", title="The event stream: edges arrive, and the cap holds",
|
|
||||||
subsystem="forgectrl", kind="auto", mode="grbl", est_min=2,
|
|
||||||
covers=[("forgectrl", "src/events.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"),
|
|
||||||
("forgectrl", "src/grblport.*")],
|
|
||||||
description="GET /events from three loopback source addresses: each gets 200, "
|
|
||||||
"text/event-stream, and the hello event. A fourth address gets 503 with the "
|
|
||||||
"reason, and GET /settings still answers while the three are held. A second "
|
|
||||||
"stream from the first address is served, and the older one gets bye and the "
|
|
||||||
"end of its response. An edge arrives on an open stream: POST /motion/release "
|
|
||||||
"and /motion/energize show up as motors.released and motors.energized, with "
|
|
||||||
"ids that count up (X and Y lose their reference, as after any release). With "
|
|
||||||
"every stream closed, the places come back within two keep-alive intervals.")
|
|
||||||
def events_stream(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
fc = hw.Forgectrl()
|
|
||||||
streams = []
|
|
||||||
try:
|
|
||||||
# A stream an earlier test closed keeps its place until the daemon's
|
|
||||||
# next write to it (its keep-alive), so the three places may not be
|
|
||||||
# free yet: that is the daemon as documented, and the last part of
|
|
||||||
# this test measures it. The three are opened once they can be.
|
|
||||||
deadline = time.time() + 25
|
|
||||||
while True:
|
|
||||||
for src in ("127.0.0.2", "127.0.0.3", "127.0.0.4"):
|
|
||||||
streams.append(_EventStream(fc.base, src))
|
|
||||||
if all(s.status == 200 for s in streams) or time.time() > deadline:
|
|
||||||
break
|
|
||||||
ctx.log("an earlier stream still holds a place (%s): waiting for it to be given back",
|
|
||||||
[s.status for s in streams])
|
|
||||||
for s in streams:
|
|
||||||
s.close()
|
|
||||||
streams = []
|
|
||||||
ctx.sleep(3)
|
|
||||||
for s, src in zip(streams, ("127.0.0.2", "127.0.0.3", "127.0.0.4")):
|
|
||||||
hello = s.text(1.0)
|
|
||||||
ctx.check(s.status == 200 and "text/event-stream" in s.head and "event: hello" in hello,
|
|
||||||
"GET /events from %s -> %s, %r", src, s.status, hello[:120])
|
|
||||||
fourth = _EventStream(fc.base, "127.0.0.5")
|
|
||||||
body = fourth.text(1.0)
|
|
||||||
fourth.close()
|
|
||||||
ev["fourth"] = [fourth.status, body.strip()[-120:]]
|
|
||||||
ctx.log("the fourth stream -> %s %s", fourth.status, ev["fourth"][1])
|
|
||||||
ctx.check(fourth.status == 503 and "every event stream is taken" in body,
|
|
||||||
"the fourth stream -> %s %r, expected 503 and the reason", fourth.status, body[:160])
|
|
||||||
st, _body = fc.get("/settings")
|
|
||||||
ctx.check(st == 200, "GET /settings -> %s with three event streams held", st)
|
|
||||||
|
|
||||||
# One per address, by replacement.
|
|
||||||
newer = _EventStream(fc.base, "127.0.0.2")
|
|
||||||
hello = newer.text(1.0)
|
|
||||||
ctx.check(newer.status == 200 and "event: hello" in hello,
|
|
||||||
"a second stream from one address -> %s %r", newer.status, hello[:120])
|
|
||||||
bye = streams[0].text(2.0)
|
|
||||||
ev["replaced"] = {"bye": "event: bye" in bye, "ended": streams[0].eof}
|
|
||||||
ctx.check("event: bye" in bye and "replaced" in bye, "the older stream was not told: %r", bye[-160:])
|
|
||||||
ctx.check(streams[0].eof, "the older stream was not ended")
|
|
||||||
streams[0].close()
|
|
||||||
streams[0] = newer
|
|
||||||
|
|
||||||
# An edge, on a stream that has been open all along.
|
|
||||||
st, body = fc.post("/motion/release")
|
|
||||||
ctx.check(st == 200, "POST /motion/release -> %s %s", st, body)
|
|
||||||
ctx.sleep(1.0)
|
|
||||||
st, body = fc.post("/motion/energize")
|
|
||||||
ctx.check(st == 200, "POST /motion/energize -> %s %s", st, body)
|
|
||||||
text = streams[1].text(2.0)
|
|
||||||
ev["edges"] = [l for l in text.splitlines() if l.startswith(("id:", "event:"))]
|
|
||||||
ctx.log("the stream carried: %s", ev["edges"])
|
|
||||||
i_rel, i_en = text.find("event: motors.released"), text.find("event: motors.energized")
|
|
||||||
ctx.check(0 <= i_rel < i_en, "the release and the energize did not arrive in order: %r", text[-300:])
|
|
||||||
ids = [int(l.split(":")[1]) for l in text.splitlines() if l.startswith("id:")]
|
|
||||||
ctx.check(ids and ids == sorted(ids) and len(set(ids)) == len(ids), "the ids do not count up: %s", ids)
|
|
||||||
finally:
|
|
||||||
for s in streams:
|
|
||||||
s.close()
|
|
||||||
if fc.status().get("motors_released"):
|
|
||||||
fc.post("/motion/energize")
|
|
||||||
|
|
||||||
# A closed client is noticed at the daemon's next write to it.
|
|
||||||
def reopened():
|
|
||||||
s = _EventStream(fc.base, "127.0.0.5")
|
|
||||||
try:
|
|
||||||
return s.status == 200
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
took = ctx.wait_for(reopened, 25, poll=1.0)
|
|
||||||
ev["place_back_s"] = took
|
|
||||||
ctx.check(took is not None, "no stream could be opened 25 s after every stream was closed")
|
|
||||||
ctx.log("PASS: three streams, the fourth refused in words with the settings route answering, a "
|
|
||||||
"replacement told and ended, two edges in order, and a place back after %.0f s", took)
|
|
||||||
|
|
||||||
|
|
||||||
def _lease(fc):
|
|
||||||
return (fc.status().get("lease") or {})
|
|
||||||
|
|
||||||
|
|
||||||
def _text(body):
|
|
||||||
return body if isinstance(body, str) else json.dumps(body)
|
|
||||||
|
|
||||||
|
|
||||||
@test("forgectrl.lease", title="The machine lease: a holder refuses everything else, by name",
|
|
||||||
subsystem="forgectrl", kind="auto", est_min=2,
|
|
||||||
covers=[("forgectrl", "src/lease.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"),
|
|
||||||
("forgectrl", "src/wizdark.*"), ("forgectrl", "src/diag.*"), ("forgectrl", "src/curverec.*"),
|
|
||||||
("forgectrl", "src/jobrun.*"), ("forgectrl", "src/update.*"), ("forgectrl", "src/logs.*"), ("forgectrl", "src/super.*"),
|
|
||||||
("forgectrl", "src/events.*")],
|
|
||||||
description="The switches check is started and left waiting at its first prompt: it moves "
|
|
||||||
"nothing, and it holds the machine lease for as long as it waits. /status must "
|
|
||||||
"name it as the holder (wizard:switches, kind hardware), the event stream must "
|
|
||||||
"report lease.changed, and everything that asks the lease must be refused with "
|
|
||||||
"409 and the holder's name: a diagnostic, the dose-curve recorder, a log export, a "
|
|
||||||
"mode switch to the mode already in force, POST /controller/start, and POST "
|
|
||||||
"/settings (with 'settings are locked'). Only requests that would do no harm if "
|
|
||||||
"the lease failed are made: no reboot, no boot-slot change, no update job. The "
|
|
||||||
"check is then aborted, and the lease must read free, lease.changed must say so, "
|
|
||||||
"and POST /settings must be accepted again. Whatever a failed refusal started is "
|
|
||||||
"stopped on the way out.")
|
|
||||||
def lease(ctx):
|
|
||||||
from .setup_dark import dark
|
|
||||||
|
|
||||||
ev = ctx.evidence
|
|
||||||
fc = hw.Forgectrl()
|
|
||||||
wid, owner = "switches", "wizard:switches"
|
|
||||||
rest = _lease(fc)
|
|
||||||
ctx.check("holder" in rest and rest["holder"] is None, "the machine is not free at the start: %s", rest)
|
|
||||||
ctx.check(set(rest.get("observed") or {}) == {"sender", "motors_released"},
|
|
||||||
"what is observed outside the lease is not reported: %s", rest)
|
|
||||||
mode = (fc.get("/mode")[1] or {}).get("mode")
|
|
||||||
units = fc.settings().get("ui_units") or "metric"
|
|
||||||
stream = _EventStream(fc.base, "127.0.0.6")
|
|
||||||
ctx.check(stream.status == 200, "GET /events -> %s", stream.status)
|
|
||||||
stream.text(0.5)
|
|
||||||
started = False
|
|
||||||
try:
|
|
||||||
st, body = fc.post("/wiz/%s/start" % wid)
|
|
||||||
ctx.check(st == 200, "the %s check did not start (%s %s)", wid, st, _text(body))
|
|
||||||
started = True
|
|
||||||
ok = ctx.wait_for(lambda: (_lease(fc).get("holder") or {}).get("owner") == owner, 10, poll=0.2)
|
|
||||||
held = _lease(fc).get("holder") or {}
|
|
||||||
ev["holder"] = held
|
|
||||||
ctx.log("the lease while the check waits: %s", held)
|
|
||||||
ctx.check(ok is not None and held.get("kind") == "hardware" and "switches" in (held.get("words") or ""),
|
|
||||||
"the lease does not name the running check: %s", held)
|
|
||||||
|
|
||||||
refused = {}
|
|
||||||
for name, path, kw in (
|
|
||||||
("a diagnostic", "/diag/flow-verify", {}),
|
|
||||||
("the recorder", "/curve/record", {}),
|
|
||||||
("a log export", "/logs/export", {}),
|
|
||||||
("a mode switch", "/mode", {"params": {"controller": mode}}),
|
|
||||||
("the controller's start", "/controller/start", {}),
|
|
||||||
("a settings write", "/settings", {"params": {"ui_units": units}})):
|
|
||||||
st, body = fc.post(path, **kw)
|
|
||||||
refused[name] = [st, _text(body)[:160]]
|
|
||||||
ctx.check(st == 409 and "holds the machine" in _text(body) and "switches" in _text(body),
|
|
||||||
"%s under the wizard's hold -> %s %s", name, st, _text(body)[:160])
|
|
||||||
ctx.check("settings are locked" in refused["a settings write"][1],
|
|
||||||
"the settings refusal does not say they are locked: %s", refused["a settings write"])
|
|
||||||
ev["refused"] = refused
|
|
||||||
ctx.check((_lease(fc).get("holder") or {}).get("owner") == owner,
|
|
||||||
"a refused request took the lease away: %s", _lease(fc))
|
|
||||||
finally:
|
|
||||||
if started:
|
|
||||||
fc.post("/wiz/%s/abort" % wid)
|
|
||||||
ctx.wait_for(lambda: not dark(fc).get("running"), 30)
|
|
||||||
# Nothing a failed refusal may have started is left running.
|
|
||||||
fc.post("/diag/abort")
|
|
||||||
fc.post("/curve/stop")
|
|
||||||
|
|
||||||
ok = ctx.wait_for(lambda: _lease(fc).get("holder") is None, 30, poll=0.5)
|
|
||||||
ctx.check(ok is not None, "the lease was not given back after the abort: %s", _lease(fc))
|
|
||||||
st, body = fc.post("/settings", params={"ui_units": units})
|
|
||||||
ctx.check(st == 200, "POST /settings with the machine free again -> %s %s", st, _text(body)[:120])
|
|
||||||
text = stream.text(2.0)
|
|
||||||
stream.close()
|
|
||||||
changes = [l for l in text.splitlines() if l.startswith("data:") and "owner" in l]
|
|
||||||
ev["lease_changed"] = changes
|
|
||||||
ctx.log("the event stream: %s", changes)
|
|
||||||
i_take, i_free = text.find('"owner":"%s"' % owner), text.find('"owner":null')
|
|
||||||
ctx.check("event: lease.changed" in text and 0 <= i_take < i_free,
|
|
||||||
"lease.changed did not report the hold and then its end: %r", text[-300:])
|
|
||||||
ctx.log("PASS: %s held the machine, six requests were refused by name, the abort freed it, and the "
|
|
||||||
"event stream said both", owner)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- scoped tokens
|
|
||||||
|
|
||||||
def _words_of(body):
|
|
||||||
b = body if isinstance(body, (dict, str)) else ""
|
|
||||||
return b.get("error", "") if isinstance(b, dict) else b
|
|
||||||
|
|
||||||
|
|
||||||
@test("forgectrl.tokens", title="Scoped API tokens: a token reaches what it was granted, and nothing else",
|
|
||||||
subsystem="forgectrl", kind="auto", mode="grbl", est_min=2,
|
|
||||||
covers=_COVERS_AUTH + [("forgectrl", "src/tokens.*"), ("forgectrl", "src/grblport.*"),
|
|
||||||
("forgectrl", "src/settings.*")],
|
|
||||||
steps=["Bed clear; the head needs 2 mm of free travel toward +X."],
|
|
||||||
description="Three tokens are created through the panel's route: one holding machine.read "
|
|
||||||
"and camera.lid, one holding motion.jog, one holding camera.lid alone. Each is then "
|
|
||||||
"used the way a client on the network uses it, over HTTPS to the machine's own LAN "
|
|
||||||
"address, so the daemon sees a peer that is not this host. The first reads /status "
|
|
||||||
"as a bearer token, /mode in the panel token's header, and /cam/status as a bearer "
|
|
||||||
"token; the camera-only token reads /cam/status as ?key=. Refused, each with 403 "
|
|
||||||
"and the reason in words: the first token in a URL (it holds more than a camera), "
|
|
||||||
"the camera-only token on the head camera, and for the first token a jog, a "
|
|
||||||
"settings write, a mode switch, and the token routes. The second jogs the head 1 mm with no "
|
|
||||||
"session and no panel token (the kernel's counters are the witness) and is refused "
|
|
||||||
"/status. A token presented over plain HTTP from the LAN is refused, and the "
|
|
||||||
"daemon's log names it. A token that is not this machine's is refused. With "
|
|
||||||
"panel_open_reads=0 the LAN reads /status with machine.read and with nothing else. "
|
|
||||||
"The list names both tokens with a last-used time and carries neither a token nor "
|
|
||||||
"a hash, and the store is mode 0600 and holds neither token. A revoked token is "
|
|
||||||
"refused at once, with reads open again. Both tokens are revoked, the setting "
|
|
||||||
"restored, and the head returned on the way out. No token is written to the log "
|
|
||||||
"or the evidence.")
|
|
||||||
def scoped_tokens(ctx):
|
|
||||||
from .setup import request, decode
|
|
||||||
from .motion import kernel_xy_mm, machine_idle
|
|
||||||
|
|
||||||
ev = ctx.evidence
|
|
||||||
fc = hw.Forgectrl()
|
|
||||||
ip = lan_ip()
|
|
||||||
ctx.check(ip, "cannot determine the board's LAN address")
|
|
||||||
tls, plain = "https://%s" % ip, "http://%s" % ip
|
|
||||||
machine_idle(ctx)
|
|
||||||
x0 = kernel_xy_mm(ctx)[0]
|
|
||||||
reads_were = str(fc.settings().get("panel_open_reads", "1"))
|
|
||||||
mode = (fc.get("/mode")[1] or {}).get("mode")
|
|
||||||
units = fc.settings().get("ui_units") or "metric"
|
|
||||||
made = {}
|
|
||||||
|
|
||||||
def create(name, caps):
|
|
||||||
st, body = fc.post("/tokens", data={"name": name, "caps": caps})
|
|
||||||
ctx.check(st == 200 and isinstance(body, dict) and str(body.get("token", "")).startswith("fft_")
|
|
||||||
and len(body["token"]) == 36, "POST /tokens %s -> %s", name, st)
|
|
||||||
made[name] = body
|
|
||||||
return body["token"]
|
|
||||||
|
|
||||||
def lan(token, method, path, how="bearer", base=None, data=None):
|
|
||||||
headers = {}
|
|
||||||
if token and how == "bearer":
|
|
||||||
headers["Authorization"] = "Bearer " + token
|
|
||||||
elif token and how == "header":
|
|
||||||
headers["X-ForgeFIRM-Token"] = token
|
|
||||||
elif token and how == "key":
|
|
||||||
path += ("&" if "?" in path else "?") + "key=" + token
|
|
||||||
st, content, _h = request(base or tls, method, path, data=data, headers=headers)
|
|
||||||
return st, decode(content)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Whatever an earlier, interrupted run left behind.
|
|
||||||
st, listed = fc.get("/tokens")
|
|
||||||
ctx.check(st == 200 and isinstance(listed, dict), "GET /tokens -> %s", st)
|
|
||||||
for t in listed.get("tokens", []):
|
|
||||||
if t.get("name", "").startswith("forgetest "):
|
|
||||||
fc.post("/tokens/revoke", data={"id": t["id"]})
|
|
||||||
st, body = fc.post("/tokens", data={"name": "forgetest bad", "caps": "settings.write"})
|
|
||||||
ev["outside_the_list"] = [st, _words_of(body)[:120]]
|
|
||||||
ctx.check(st == 400 and "not a capability" in _words_of(body),
|
|
||||||
"a capability outside the list -> %s %s", st, _words_of(body)[:120])
|
|
||||||
hub = create("forgetest hub", "machine.read,camera.lid")
|
|
||||||
pend = create("forgetest jogger", "motion.jog")
|
|
||||||
view = create("forgetest viewer", "camera.lid")
|
|
||||||
|
|
||||||
passed = {}
|
|
||||||
for name, call in (("bearer GET /status", lambda: lan(hub, "GET", "/status")),
|
|
||||||
("header GET /mode", lambda: lan(hub, "GET", "/mode", how="header")),
|
|
||||||
("bearer GET /cam/status", lambda: lan(hub, "GET", "/cam/status")),
|
|
||||||
("camera-only ?key= GET /cam/status",
|
|
||||||
lambda: lan(view, "GET", "/cam/status", how="key"))):
|
|
||||||
st, body = call()
|
|
||||||
passed[name] = st
|
|
||||||
ctx.check(st == 200 and isinstance(body, dict), "the hub token, %s -> %s %s", name, st,
|
|
||||||
_words_of(body)[:120])
|
|
||||||
ev["hub_passed"] = passed
|
|
||||||
|
|
||||||
refused = {}
|
|
||||||
for name, want, call in (
|
|
||||||
("the head camera", "does not hold camera.head",
|
|
||||||
lambda: lan(view, "GET", "/cam/snapshot?cam=head", how="key")),
|
|
||||||
("a token with more than a camera, in a URL", "in a URL may hold camera capabilities only",
|
|
||||||
lambda: lan(hub, "GET", "/cam/status", how="key")),
|
|
||||||
("a jog", "does not hold motion.jog", lambda: lan(hub, "POST", "/motion/jog", data={"x": "1"})),
|
|
||||||
("a settings write", "no scoped token reaches", lambda: lan(hub, "POST", "/settings",
|
|
||||||
data={"ui_units": units})),
|
|
||||||
("a mode switch", "no scoped token reaches", lambda: lan(hub, "POST", "/mode",
|
|
||||||
data={"controller": mode})),
|
|
||||||
("the token list", "no scoped token reaches", lambda: lan(hub, "GET", "/tokens")),
|
|
||||||
("a token of its own", "no scoped token reaches",
|
|
||||||
lambda: lan(hub, "POST", "/tokens", data={"name": "forgetest child", "caps": "machine.read"})),
|
|
||||||
("the panel's ?token= form", "authentication required",
|
|
||||||
lambda: lan(None, "POST", "/settings?token=" + hub, data={"ui_units": units})),
|
|
||||||
("a stranger's token", "authentication required",
|
|
||||||
lambda: lan("fft_" + "0" * 32, "GET", "/status")),
|
|
||||||
("the jog token on /status", "does not hold machine.read", lambda: lan(pend, "GET", "/status"))):
|
|
||||||
st, body = call()
|
|
||||||
refused[name] = [st, _words_of(body)[:120]]
|
|
||||||
ctx.check(st == 403 and want in _words_of(body), "%s -> %s %s (wanted 403 %r)", name, st,
|
|
||||||
_words_of(body)[:120], want)
|
|
||||||
ev["refused"] = refused
|
|
||||||
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.01, "a refused jog moved the head")
|
|
||||||
|
|
||||||
# A write with a scoped token and nothing else: no session, no panel token.
|
|
||||||
st, body = lan(pend, "POST", "/motion/jog", data={"x": "1", "feed": "1200"})
|
|
||||||
ctx.check(st == 200, "the jog token's jog -> %s %s", st, _words_of(body)[:120])
|
|
||||||
ctx.wait_for(lambda: abs(kernel_xy_mm(ctx)[0] - x0 - 1.0) < 0.02, 10, poll=0.1)
|
|
||||||
machine_idle(ctx)
|
|
||||||
moved = kernel_xy_mm(ctx)[0] - x0
|
|
||||||
ev["jog_mm"] = round(moved, 3)
|
|
||||||
ctx.log("the jog token jogged the head %.3f mm from the LAN with no session", moved)
|
|
||||||
ctx.check(abs(moved - 1.0) < 0.02, "the jog moved %.3f mm, not 1", moved)
|
|
||||||
|
|
||||||
# Plain HTTP from the LAN: refused, and the log says which token crossed in the clear.
|
|
||||||
log_path = "/data/log/forgefirm/forgectrl/forgectrl.log"
|
|
||||||
try:
|
|
||||||
log_from = os.path.getsize(log_path)
|
|
||||||
except OSError:
|
|
||||||
log_from = 0
|
|
||||||
st, body = lan(hub, "GET", "/status", base=plain)
|
|
||||||
ev["plain_http"] = [st, _words_of(body)[:120]]
|
|
||||||
ctx.check(st == 403 and "HTTPS only" in _words_of(body), "a token over plain HTTP -> %s %s", st,
|
|
||||||
_words_of(body)[:120])
|
|
||||||
|
|
||||||
def logged():
|
|
||||||
try:
|
|
||||||
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
f.seek(log_from)
|
|
||||||
return "scoped token %s was presented over plain HTTP" % made["forgetest hub"]["id"] in f.read()
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
ctx.check(ctx.wait_for(logged, 10, poll=0.5) is not None, "the log does not name the token sent in the clear")
|
|
||||||
|
|
||||||
# Reads closed to the LAN: machine.read opens them, nothing else does.
|
|
||||||
st, body = fc.post("/settings", params={"panel_open_reads": "0"})
|
|
||||||
ctx.check(st == 200, "closing the reads -> %s", st)
|
|
||||||
closed = {"nobody": lan(None, "GET", "/status")[0], "machine.read": lan(hub, "GET", "/status")[0],
|
|
||||||
"motion.jog": lan(pend, "GET", "/status")[0]}
|
|
||||||
ev["reads_closed"] = closed
|
|
||||||
ctx.check(closed == {"nobody": 403, "machine.read": 200, "motion.jog": 403},
|
|
||||||
"with the reads closed: %s", closed)
|
|
||||||
st, body = fc.post("/settings", params={"panel_open_reads": reads_were})
|
|
||||||
ctx.check(st == 200, "restoring panel_open_reads=%s -> %s", reads_were, st)
|
|
||||||
|
|
||||||
# The list and the store: names and times, never a token or a hash.
|
|
||||||
st, listed = fc.get("/tokens")
|
|
||||||
mine = {t["name"]: t for t in listed.get("tokens", []) if t.get("name", "").startswith("forgetest ")}
|
|
||||||
ev["listed"] = {n: {k: t[k] for k in ("id", "caps", "last_used")} for n, t in mine.items()}
|
|
||||||
ctx.check(set(mine) == {"forgetest hub", "forgetest jogger", "forgetest viewer"},
|
|
||||||
"the list: %s", sorted(mine))
|
|
||||||
ctx.check(mine["forgetest hub"]["caps"] == ["machine.read", "camera.lid"] and
|
|
||||||
mine["forgetest jogger"]["caps"] == ["motion.jog"], "the capabilities listed: %s", ev["listed"])
|
|
||||||
ctx.check(all(t["last_used"] > 0 for t in mine.values()), "a used token reads as never used: %s", ev["listed"])
|
|
||||||
text = json.dumps(listed)
|
|
||||||
ctx.check(hub[4:] not in text and pend[4:] not in text and view[4:] not in text and "sha" not in text and
|
|
||||||
all(set(t) == {"id", "name", "caps", "created", "last_used"} for t in mine.values()),
|
|
||||||
"the list carries more than names, capabilities, and times")
|
|
||||||
store = "/data/forgefirm/tokens"
|
|
||||||
mode_bits = os.stat(store).st_mode & 0o777
|
|
||||||
with open(store, "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
kept = f.read()
|
|
||||||
ev["store"] = {"mode": oct(mode_bits),
|
|
||||||
"holds_a_token": hub[4:] in kept or pend[4:] in kept or view[4:] in kept}
|
|
||||||
ctx.check(mode_bits == 0o600 and not ev["store"]["holds_a_token"], "the store: %s", ev["store"])
|
|
||||||
|
|
||||||
# A revoke ends it at once, and the open reads are no way around it.
|
|
||||||
st, body = fc.post("/tokens/revoke", data={"id": made["forgetest hub"]["id"]})
|
|
||||||
ctx.check(st == 200, "the revoke -> %s %s", st, _words_of(body)[:120])
|
|
||||||
st, body = lan(hub, "GET", "/status")
|
|
||||||
ev["after_revoke"] = [st, _words_of(body)[:120]]
|
|
||||||
ctx.check(st == 403 and "authentication required" in _words_of(body),
|
|
||||||
"a revoked token -> %s %s", st, _words_of(body)[:120])
|
|
||||||
ctx.check(lan(pend, "POST", "/motion/cancel")[0] == 200, "the revoke took the other token too")
|
|
||||||
finally:
|
|
||||||
fc.post("/settings", params={"panel_open_reads": reads_were})
|
|
||||||
for t in made.values():
|
|
||||||
fc.post("/tokens/revoke", data={"id": t.get("id", "")})
|
|
||||||
back = x0 - kernel_xy_mm(ctx)[0]
|
|
||||||
if abs(back) > 0.02:
|
|
||||||
machine_idle(ctx)
|
|
||||||
fc.post("/motion/jog", params={"x": "%.3f" % back, "feed": "1200"})
|
|
||||||
ctx.wait_for(lambda: abs(kernel_xy_mm(ctx)[0] - x0) < 0.02, 10, poll=0.1)
|
|
||||||
machine_idle(ctx)
|
|
||||||
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.05, "the head was not returned: %.3f mm off",
|
|
||||||
kernel_xy_mm(ctx)[0] - x0)
|
|
||||||
st, listed = fc.get("/tokens")
|
|
||||||
ctx.check(not [t for t in listed.get("tokens", []) if t.get("name", "").startswith("forgetest ")],
|
|
||||||
"a test token was left on the machine")
|
|
||||||
ctx.log("PASS: each token reached what it was granted from the LAN with no session, was refused "
|
|
||||||
"everything else in words, was refused over plain HTTP and once revoked, and the machine "
|
|
||||||
"keeps no token")
|
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
# https://community.openglow.org
|
# https://community.openglow.org
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""A camera home's offset pair, on the machine.
|
"""The helpers for a test that lets the web service move the head.
|
||||||
|
|
||||||
Its own module, so that no other test's fingerprint moves. The homing is
|
The homing is the web-service session cloud.mode-switch runs. These judge
|
||||||
the web-service session cloud.mode-switch runs. The helpers for a test
|
that its motions ran whole, and take the head back to where the test found
|
||||||
that lets the service move the head live here too: the motions ran whole,
|
it by the travel the service's motions logged. No counter reading can say
|
||||||
and the head goes back to where the test found it, by the travel the
|
where that was: every
|
||||||
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
|
service motion zeroes the step counters at its start, and a home and every
|
||||||
controller start zero them again.
|
controller start zero them again.
|
||||||
"""
|
"""
|
||||||
@@ -18,13 +17,11 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from ..baseline import XY_STEPS_PER_MM, counter_frame, counter_steps_per_mm, read_position
|
from ..baseline import XY_STEPS_PER_MM, counter_frame, counter_steps_per_mm, read_position
|
||||||
from ..catalog import test
|
|
||||||
from ..runner import Failed
|
from ..runner import Failed
|
||||||
from . import cloud as logs # the log paths, read at each call
|
from . import cloud as logs # the log paths, read at each call
|
||||||
from .cloud import _HOMING_PATH, gfhome_homing, log_lines_since, log_size
|
from .cloud import log_lines_since, log_size
|
||||||
from .motion import _drop_reference, clean_slate, machine_idle, wait_idle, wait_state
|
from .motion import _drop_reference, clean_slate, machine_idle, wait_idle
|
||||||
|
|
||||||
HOME_X, HOME_Y = 4.5, -3.25
|
|
||||||
RETURN_MAX_MM = 600.0 # no hand-back jogs farther on an axis: the bed is smaller
|
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 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
|
# the motion zeroed at its start (the "(actual/expected)" line of the same
|
||||||
@@ -169,73 +166,3 @@ def _jog_back(ctx, rec, travel, since):
|
|||||||
_drop_reference(ctx, ctx.forgectrl)
|
_drop_reference(ctx, ctx.forgectrl)
|
||||||
ctx.counters_rezeroed()
|
ctx.counters_rezeroed()
|
||||||
ctx.log("head returned to where the test found it (%.3f/%.3f mm)", -dx, -dy)
|
ctx.log("head returned to where the test found it (%.3f/%.3f mm)", -dx, -dy)
|
||||||
|
|
||||||
|
|
||||||
@test("homing.cloud-offsets", title="A camera home declares its offset pair, and the envelope reaches back to a "
|
|
||||||
"negative one",
|
|
||||||
subsystem="homing", kind="auto", mode="grbl", est_min=7,
|
|
||||||
covers=_HOMING_PATH + [("grblhal-glowforge", "src/glowforge_homing.*"),
|
|
||||||
("grblhal-glowforge", "src/glowforge_cooling.*")],
|
|
||||||
requires=["forgectrl.auth", "motion.pacing"],
|
|
||||||
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. The head "
|
|
||||||
"travels to the camera home and back to where it was."],
|
|
||||||
description="With cloud mode on, homing_mode = gfcloud and the camera-home offsets set to (4.5, -3.25), "
|
|
||||||
"$H runs the web-service homing session. Every service motion of the session runs whole: "
|
|
||||||
"the runner reports to the cooling engine in the controller's place, and one stopped short "
|
|
||||||
"(the engine's stop, a fault) leaves the head short of the home and the position declared "
|
|
||||||
"there false. When the session ends, the controller declares the position (4.5, -3.25): a "
|
|
||||||
"camera-home offset may be negative, and the declared position is snapped to the step "
|
|
||||||
"grid. The work envelope reaches back to the home on the negative axis, since the head "
|
|
||||||
"stands there: a jog 2 mm out in +Y and 2 mm back ends at the home, accepted, and the same "
|
|
||||||
"in X. The homing mode and the offsets are put back as found, the camera home is dropped, "
|
|
||||||
"and the head is jogged back to where the test found it by the travel the session's "
|
|
||||||
"motions logged; a travel that cannot be known fails the test and moves nothing. That an "
|
|
||||||
"offset past the axis travel is refused, and the envelope's edge on the grid, are the "
|
|
||||||
"driver's unit and harness cases.")
|
|
||||||
def cloud_offsets(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
s0 = fc.settings() or {}
|
|
||||||
ctx.check(s0.get("cloud_enabled") == "1", "cloud mode is off: a camera home needs it on")
|
|
||||||
found = {k: s0.get(k) or "" for k in ("homing_mode", "gfcloud_home_x", "gfcloud_home_y")}
|
|
||||||
ev["found"] = found
|
|
||||||
session_at = None
|
|
||||||
try:
|
|
||||||
for k, v in (("homing_mode", "gfcloud"), ("gfcloud_home_x", str(HOME_X)), ("gfcloud_home_y", str(HOME_Y))):
|
|
||||||
st, body = fc.post("/settings", data={k: v})
|
|
||||||
ctx.check(st == 200, "%s=%s -> %s %s", k, v, st, body)
|
|
||||||
with ctx.grbl() as g:
|
|
||||||
session_at = session_mark()
|
|
||||||
gfhome_homing(ctx, ev, g)
|
|
||||||
judge_whole_motions(ctx, ev, session_at)
|
|
||||||
mpos = g.status_report().get("MPos") or (None, None, None)
|
|
||||||
ev["declared"] = mpos
|
|
||||||
ctx.log("the camera home declared %s", mpos)
|
|
||||||
ctx.check(mpos[0] is not None and abs(mpos[0] - HOME_X) < 0.02 and abs(mpos[1] - HOME_Y) < 0.02,
|
|
||||||
"the camera home did not declare (%.2f, %.2f): %s", HOME_X, HOME_Y, mpos)
|
|
||||||
jogs = {}
|
|
||||||
for axis, home in (("Y", HOME_Y), ("X", HOME_X)):
|
|
||||||
out = g.command("$J=G91 G21 %s2 F1000" % axis, timeout=10)
|
|
||||||
wait_state(ctx, g, "Idle", 20)
|
|
||||||
back = g.command("$J=G91 G21 %s-2 F1000" % axis, timeout=10)
|
|
||||||
wait_state(ctx, g, "Idle", 20)
|
|
||||||
at = g.status_report().get("MPos")
|
|
||||||
jogs[axis] = {"out": out[-1:], "back": back[-1:], "at": at}
|
|
||||||
ctx.check(out[-1:] == ["ok"] and back[-1:] == ["ok"],
|
|
||||||
"a jog out and back to the home in %s was refused: %s", axis, jogs[axis])
|
|
||||||
i = "XY".index(axis)
|
|
||||||
ctx.check(at and abs(at[i] - home) < 0.02, "the jog back in %s did not end at the home: %s", axis, at)
|
|
||||||
ev["jogs"] = jogs
|
|
||||||
ctx.log("out and back to the home: %s", jogs)
|
|
||||||
finally:
|
|
||||||
# The settings are refused for a moment after the suite's Grbl client closes; each is put back when taken.
|
|
||||||
for k, v in found.items():
|
|
||||||
def put():
|
|
||||||
st_, body_ = (fc.post("/settings", params={k: ""}) if v == "" else fc.post("/settings", data={k: v}))
|
|
||||||
if st_ != 200:
|
|
||||||
ctx.log("restore %s=%r -> %s %s", k, v, st_, body_)
|
|
||||||
return st_ == 200 or None
|
|
||||||
took = ctx.wait_for(put, 20, poll=0.5)
|
|
||||||
ctx.log("restore %s=%r -> %s", k, v, "taken" if took is not None else "not taken")
|
|
||||||
if session_at is not None:
|
|
||||||
camera_home_return(ctx, ev, session_at)
|
|
||||||
|
|||||||
@@ -4,11 +4,9 @@
|
|||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""image.* - the post-flash health of the running image (always-required)."""
|
"""image.* - the post-flash health of the running image (always-required)."""
|
||||||
import glob
|
|
||||||
import gzip
|
import gzip
|
||||||
import mmap
|
import mmap
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import struct
|
import struct
|
||||||
import re
|
import re
|
||||||
import stat
|
import stat
|
||||||
@@ -112,6 +110,7 @@ def fds_of(pid):
|
|||||||
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
|
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
|
||||||
always=True, est_min=1,
|
always=True, est_min=1,
|
||||||
covers=[("forgectrl", "init/**"), ("forgeext", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
|
covers=[("forgectrl", "init/**"), ("forgeext", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
|
||||||
|
("forgectrl", "src/status.*"),
|
||||||
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
|
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
|
||||||
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
|
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
|
||||||
("linux-fslc", "**")],
|
("linux-fslc", "**")],
|
||||||
@@ -119,12 +118,20 @@ def fds_of(pid):
|
|||||||
"version stamped in /etc/forgefirm-version and under the machine mark in the "
|
"version stamped in /etc/forgefirm-version and under the machine mark in the "
|
||||||
"console banner and the motd, "
|
"console banner and the motd, "
|
||||||
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
|
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
|
||||||
"the daemon ownership, "
|
"the daemon ownership, the board temperatures /status reports (the chassis and "
|
||||||
"the init ordering, the extension host as one process that starts after forgectrl and "
|
"the SoC die in a plausible range, the supply as a 10-bit count, the CPU "
|
||||||
"stops before it on a machine with no package installed and nothing running under a "
|
"unthrottled), "
|
||||||
"pool account, the data directory open for search on a machine with an account, the file modes the release depends on, and the mounts: the "
|
"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 "
|
"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):
|
def image_health(ctx):
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
manifest = ctx.runner.manifest
|
manifest = ctx.runner.manifest
|
||||||
@@ -260,17 +267,23 @@ def image_health(ctx):
|
|||||||
"controller is %r (expected running or standby)", mode.get("controller"))
|
"controller is %r (expected running or standby)", mode.get("controller"))
|
||||||
ctx.check(mode.get("motion") != "fault", "supervisor reports a motion fault")
|
ctx.check(mode.get("motion") != "fault", "supervisor reports a motion fault")
|
||||||
|
|
||||||
# 5. init ordering: controllers stop before the daemon
|
# 4b. the board temperatures /status carries, watched and not gated:
|
||||||
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*"))
|
# the chassis and the SoC die in degrees, the supply as its raw count,
|
||||||
ev["rc6_kill"] = k
|
# and the CPU unthrottled at idle.
|
||||||
kg = [x for x in k if "grblhal" in x]
|
temps = (ctx.forgectrl.status() or {}).get("temps") or {}
|
||||||
kf = [x for x in k if x.endswith("forgectrl")]
|
ev["temps"] = temps
|
||||||
ctx.log("rc6.d: %s", " ".join(k))
|
ctx.log("temps: %s", temps)
|
||||||
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links")
|
ctx.check(isinstance(temps.get("chassis_c"), (int, float)) and 5.0 <= temps["chassis_c"] <= 80.0,
|
||||||
ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[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)
|
||||||
|
|
||||||
# 5b. the extension host: one process, up after forgectrl and down
|
# 5. the extension host: one process, and a campaign's machine is
|
||||||
# before it, and a campaign's machine is extension-free
|
# extension-free (its init order is the rootfs lint's)
|
||||||
host = []
|
host = []
|
||||||
for pid in hw.pidof("forgeext"):
|
for pid in hw.pidof("forgeext"):
|
||||||
argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
|
argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
|
||||||
@@ -278,13 +291,6 @@ def image_health(ctx):
|
|||||||
host.append(pid)
|
host.append(pid)
|
||||||
ev["extension_host_pids"] = host
|
ev["extension_host_pids"] = host
|
||||||
ctx.check(len(host) == 1, "the extension host is not one running process: %s", host)
|
ctx.check(len(host) == 1, "the extension host is not one running process: %s", host)
|
||||||
s5 = sorted(os.path.basename(x) for x in glob.glob("/etc/rc5.d/S*"))
|
|
||||||
sx = [x for x in s5 if x.endswith("forgeext")]
|
|
||||||
sf = [x for x in s5 if x.endswith("forgectrl")]
|
|
||||||
kx = [x for x in k if x.endswith("forgeext")]
|
|
||||||
ev["forgeext_links"] = {"start": sx, "kill": kx}
|
|
||||||
ctx.check(sx and sf and sf[0] < sx[0], "the extension host must start after forgectrl: %s, %s", sf, sx)
|
|
||||||
ctx.check(kx and kx[0] < kf[0], "the extension host must stop before forgectrl: %s, %s", kx, kf)
|
|
||||||
ev["extension_packages"] = hw.ext_packages()
|
ev["extension_packages"] = hw.ext_packages()
|
||||||
ev["pool_processes"] = len(hw.pool_pids())
|
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.log("extension host pid %s, packages %s, pool processes %d", host, ev["extension_packages"], ev["pool_processes"])
|
||||||
@@ -292,12 +298,9 @@ def image_health(ctx):
|
|||||||
ev["extension_packages"])
|
ev["extension_packages"])
|
||||||
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"])
|
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"])
|
||||||
|
|
||||||
# 6. logging lever present, no userspace watchdog daemon
|
# 6. no userspace watchdog daemon running (the logging init and the
|
||||||
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
|
# absent watchdog init script are the rootfs lint's); kernel threads
|
||||||
ev["logging_init"] = logging
|
# (the imx2_wdt kthread is expected) have an empty cmdline
|
||||||
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
|
|
||||||
user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")]
|
user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")]
|
||||||
ev["userspace_watchdog_pids"] = user_wd
|
ev["userspace_watchdog_pids"] = user_wd
|
||||||
ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", user_wd)
|
ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", user_wd)
|
||||||
@@ -327,8 +330,9 @@ def image_health(ctx):
|
|||||||
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
||||||
# the state a read-only rootfs hands off (the read-only-rootfs image
|
# the state a read-only rootfs hands off (the read-only-rootfs image
|
||||||
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
|
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
|
||||||
# sshd host keys). The
|
# sshd host keys). The dev image's factory slots under /factory are
|
||||||
# dev image alone mounts the factory slots under /factory.
|
# read-only; rcS's ROOTFS_READ_ONLY and the release fstab with no
|
||||||
|
# factory slot are the rootfs lint's.
|
||||||
mounts = {}
|
mounts = {}
|
||||||
for line in (_read("/proc/mounts", "") or "").splitlines():
|
for line in (_read("/proc/mounts", "") or "").splitlines():
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
@@ -346,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("/data"), "/data is not mounted read-write: %s", mounts.get("/data"))
|
||||||
ctx.check("rw" in mount_opts("/var/lib"),
|
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"))
|
"/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/"))
|
factory = sorted(p for p in mounts if p.startswith("/factory/"))
|
||||||
ev["factory_mounts"] = factory
|
ev["factory_mounts"] = factory
|
||||||
dev_image = os.path.exists("/etc/forgefirm-dev")
|
dev_image = os.path.exists("/etc/forgefirm-dev")
|
||||||
@@ -358,8 +360,6 @@ def image_health(ctx):
|
|||||||
if os.path.exists("/dev/mmcblk2p%d" % n):
|
if os.path.exists("/dev/mmcblk2p%d" % n):
|
||||||
p = "/factory/img%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))
|
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"):
|
if os.path.isfile("/data/forgefirm/users"):
|
||||||
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
|
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
|
||||||
if l.strip() and not l.startswith("#")]
|
if l.strip() and not l.startswith("#")]
|
||||||
@@ -381,59 +381,48 @@ def image_health(ctx):
|
|||||||
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
|
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
|
||||||
ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components))
|
ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components))
|
||||||
|
|
||||||
|
# 10. the network boot. init starts the rest of the boot, the console
|
||||||
|
# login last, only after the networking script returns, so a client
|
||||||
|
# that waits in ifup's foreground on a server's answer holds the
|
||||||
|
# whole machine. The interfaces file starts no DHCPv6 client
|
||||||
|
# (test_image.py reads the layer's file); the bounded DHCP client
|
||||||
|
# (-b, which leaves ifup after three unanswered discovers) is checked
|
||||||
|
# here, on the client that runs.
|
||||||
|
procs = _procs()
|
||||||
|
up = ifstate_names(_read(IFSTATE, ""))
|
||||||
|
ev["ifstate"] = sorted(up)
|
||||||
|
ctx.check("wlan0" in up, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(up))
|
||||||
|
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
|
||||||
|
ev["ifup_running"] = [p[3] for p in stuck]
|
||||||
|
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
|
||||||
|
logins = [p[3] for p in procs if p[2] == "getty"]
|
||||||
|
ev["getty"] = logins
|
||||||
|
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
|
||||||
|
v4 = [p for p in procs if p[2] == "udhcpc"]
|
||||||
|
ev["udhcpc"] = [p[3] for p in v4]
|
||||||
|
ctx.check(v4, "no DHCP client is running")
|
||||||
|
for pid, ppid, _comm, cmd in v4:
|
||||||
|
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
|
||||||
|
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
|
||||||
|
# no DHCPv6 client 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))
|
||||||
|
|
||||||
@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")
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------- the network boot
|
# ------------------------------------------------------- the network boot
|
||||||
@@ -506,68 +495,3 @@ def link_addrs(text, iface):
|
|||||||
return local, world
|
return local, world
|
||||||
|
|
||||||
|
|
||||||
@test("image.network-boot", title="The boot does not wait on the network",
|
|
||||||
subsystem="image", kind="auto", est_min=1,
|
|
||||||
description="The network comes up without the boot waiting on a server. init starts the "
|
|
||||||
"rest of the boot, the console login last, only after the networking script "
|
|
||||||
"returns, so a client that waits in ifup's foreground on a server's answer "
|
|
||||||
"holds the whole machine: no login, no SSH, no control panel. ifup has "
|
|
||||||
"returned for wlan0 (it is in the state file and no ifup is running) and the "
|
|
||||||
"console login is up; the DHCP client is the bounded one (-b, which leaves "
|
|
||||||
"ifup after three unanswered discovers) and has left ifup; no DHCPv6 client "
|
|
||||||
"is named in the interfaces file, running, or on the image; and IPv6 is the "
|
|
||||||
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
|
|
||||||
"address up. A global address is evidence only, because a network whose "
|
|
||||||
"router advertisement offers no SLAAC prefix gives none. What a hostile "
|
|
||||||
"server does to a client is a bench drill, not a test. No component covers "
|
|
||||||
"this: the interfaces file is layer content, in the platform identity of "
|
|
||||||
"every fingerprint.")
|
|
||||||
def network_boot(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
procs = _procs()
|
|
||||||
|
|
||||||
# 1. ifup came back, and the boot went on to the console login
|
|
||||||
state = ifstate_names(_read(IFSTATE, ""))
|
|
||||||
ev["ifstate"] = sorted(state)
|
|
||||||
ctx.check("wlan0" in state, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(state))
|
|
||||||
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
|
|
||||||
ev["ifup_running"] = [p[3] for p in stuck]
|
|
||||||
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
|
|
||||||
logins = [p[3] for p in procs if p[2] == "getty"]
|
|
||||||
ev["getty"] = logins
|
|
||||||
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
|
|
||||||
|
|
||||||
# 2. the DHCP client is the bounded one, and it has left ifup
|
|
||||||
v4 = [p for p in procs if p[2] == "udhcpc"]
|
|
||||||
ev["udhcpc"] = [p[3] for p in v4]
|
|
||||||
ctx.check(v4, "no DHCP client is running")
|
|
||||||
for pid, ppid, _comm, cmd in v4:
|
|
||||||
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
|
|
||||||
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
|
|
||||||
|
|
||||||
# 3. no DHCPv6 client: not started, not running, not on the image
|
|
||||||
text = _read(INTERFACES)
|
|
||||||
ctx.check(text is not None, "%s is missing", INTERFACES)
|
|
||||||
named = dhcp6_in_interfaces(text)
|
|
||||||
ev["interfaces_dhcp6"] = named
|
|
||||||
ctx.check(not named, "%s starts a DHCPv6 client: %s", INTERFACES, named)
|
|
||||||
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
|
|
||||||
ev["dhcp6_running"] = alive
|
|
||||||
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
|
|
||||||
for name in DHCP6_CLIENTS:
|
|
||||||
ctx.check(shutil.which(name) is None, "%s is on the image", name)
|
|
||||||
ctx.check(not os.path.exists("/usr/share/udhcpc/default6.script"),
|
|
||||||
"the DHCPv6 hook script is on the image")
|
|
||||||
|
|
||||||
# 4. IPv6 is the kernel's own
|
|
||||||
conf = "/proc/sys/net/ipv6/conf/wlan0/"
|
|
||||||
ev["disable_ipv6"] = (_read(conf + "disable_ipv6", "") or "").strip()
|
|
||||||
ev["accept_ra"] = (_read(conf + "accept_ra", "") or "").strip()
|
|
||||||
ctx.check(ev["disable_ipv6"] == "0", "IPv6 is off on wlan0 (disable_ipv6=%r)", ev["disable_ipv6"])
|
|
||||||
ctx.check(ev["accept_ra"] not in ("", "0"),
|
|
||||||
"wlan0 does not accept router advertisements (accept_ra=%r)", ev["accept_ra"])
|
|
||||||
local, world = link_addrs(_read("/proc/net/if_inet6", ""), "wlan0")
|
|
||||||
ev["link_local"] = local
|
|
||||||
ev["global"] = world
|
|
||||||
ctx.check(local, "wlan0 has no link-local IPv6 address")
|
|
||||||
ctx.log("wlan0: %d link-local, %d global IPv6 address(es)", len(local), len(world))
|
|
||||||
|
|||||||
+201
-793
File diff suppressed because it is too large
Load Diff
@@ -1,379 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
|
|
||||||
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/extpkg.*"), ("forgectrl", "src/main.c")]
|
|
||||||
|
|
||||||
LOGS_ROOT = "/data/log/forgefirm"
|
|
||||||
# What the export test plants in the installer's directory: a line to find
|
|
||||||
# again, and two addresses (documentation ranges) the sanitizer must take.
|
|
||||||
_PROBE_MARK = "forgetest export probe"
|
|
||||||
_PROBE_MAC = "02:00:5e:10:20:30"
|
|
||||||
_PROBE_IP = "192.0.2.77"
|
|
||||||
|
|
||||||
|
|
||||||
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
|
|
||||||
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. The bundle "
|
|
||||||
"also carries the installer's directory of the tree (logs/install/), which no "
|
|
||||||
"logger feeds: a probe file planted there for the export comes back in the "
|
|
||||||
"bundle with its addresses redacted, and an install log that the installer "
|
|
||||||
"left is in the bundle too. system/extensions.json is in it as well: what is "
|
|
||||||
"installed that is not the firmware, with each package's tier, whether it is "
|
|
||||||
"enabled, and what the operator granted it, which is what a report about a "
|
|
||||||
"machine running extensions has to be read against.")
|
|
||||||
def tree_tail_export(ctx):
|
|
||||||
install_dir = os.path.join(LOGS_ROOT, "install")
|
|
||||||
probe = os.path.join(install_dir, "forgetest-probe.txt")
|
|
||||||
made_dir = not os.path.isdir(install_dir)
|
|
||||||
try:
|
|
||||||
os.makedirs(install_dir, exist_ok=True)
|
|
||||||
with open(probe, "w") as f:
|
|
||||||
f.write("%s from %s at %s\n" % (_PROBE_MARK, _PROBE_MAC, _PROBE_IP))
|
|
||||||
_tree_tail_export(ctx, probe)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.unlink(probe)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
if made_dir:
|
|
||||||
try:
|
|
||||||
os.rmdir(install_dir)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _tree_tail_export(ctx, probe):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
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 installer's directory: no logger feeds it, the export carries it
|
|
||||||
by_name = dict(contents)
|
|
||||||
want = "logs/install/" + os.path.basename(probe)
|
|
||||||
got = [name for name in by_name if name.endswith(want)]
|
|
||||||
ev["install_members"] = sorted(name for name in by_name if "/logs/install/" in name)
|
|
||||||
ctx.check(got, "the bundle lacks %s: %s", want, ev["install_members"])
|
|
||||||
if got:
|
|
||||||
text = by_name[got[0]].decode("utf-8", "replace")
|
|
||||||
ctx.check(_PROBE_MARK in text, "the probe file came back without its line: %r", text)
|
|
||||||
for what, value in (("MAC address", _PROBE_MAC), ("IPv4 address", _PROBE_IP)):
|
|
||||||
ctx.check(value not in text, "the install directory left the sanitizer with its %s", what)
|
|
||||||
if os.path.isfile(os.path.join(os.path.dirname(probe), "install.log")):
|
|
||||||
ctx.check(any(name.endswith("logs/install/install.log") for name in by_name),
|
|
||||||
"the machine has an install log and the bundle does not")
|
|
||||||
|
|
||||||
# What is installed that is not the firmware. A report that starts "it
|
|
||||||
# stopped working" reads differently with packages on the machine, and
|
|
||||||
# the bundle is where that is read.
|
|
||||||
import json
|
|
||||||
|
|
||||||
ext = [name for name in by_name if name.endswith("system/extensions.json")]
|
|
||||||
ctx.check(ext, "the bundle carries no system/extensions.json")
|
|
||||||
if ext:
|
|
||||||
doc = json.loads(by_name[ext[0]].decode("utf-8", "replace"))
|
|
||||||
ev["extensions"] = {"enabled": doc.get("enabled"), "safe_mode": doc.get("safe_mode"),
|
|
||||||
"packages": [{k: p.get(k) for k in ("id", "version", "tier", "enabled", "grants")}
|
|
||||||
for p in doc.get("packages", [])]}
|
|
||||||
ctx.log("the bundle's extension listing: %s", ev["extensions"])
|
|
||||||
ctx.check(set(doc) >= {"enabled", "safe_mode", "host", "packages"},
|
|
||||||
"the listing is not the status document: %s", sorted(doc))
|
|
||||||
for p in doc.get("packages", []):
|
|
||||||
ctx.check(set(p) >= {"id", "version", "tier", "enabled", "grants"},
|
|
||||||
"a listed package does not say what it is: %s", sorted(p))
|
|
||||||
|
|
||||||
|
|
||||||
# The routing test proves the whole path every logger takes: emitter (or
|
|
||||||
# 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")]
|
|
||||||
|
|
||||||
LOGGERS =("forgectrl", "grblhal", "gfcloud", "gfhome", "forgeext", "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", "forgeext"):
|
|
||||||
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)
|
|
||||||
+229
-1257
File diff suppressed because it is too large
Load Diff
+50
-1687
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,466 @@
|
|||||||
|
# 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 baseline
|
||||||
|
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. head_at_home reads the head's place from
|
||||||
|
# /status before the wizard starts and judges it, within HOME_TOL_MM,
|
||||||
|
# against the stops as the manual home that references X and Y declared
|
||||||
|
# them (its home anchor): X0 Y0 for a home with no manual home offset,
|
||||||
|
# minus manual_home_x and manual_home_y for a home made with them.
|
||||||
|
HOME = "The head is home"
|
||||||
|
HOME_TOL_MM = 0.05
|
||||||
|
HOME_OFFSET_KEYS = ("manual_home_x", "manual_home_y")
|
||||||
|
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"]
|
||||||
|
# pic/hv_current above this: the discharge is present (the daemon's own
|
||||||
|
# rule for a lit burn, jobstream.h JOBSTREAM_HV_ON).
|
||||||
|
HV_ON = 30
|
||||||
|
|
||||||
|
|
||||||
|
class Witnesses:
|
||||||
|
"""The two emission witnesses read from sysfs by the test itself at
|
||||||
|
every poll of the burn: the kernel's LASER_ON sample count
|
||||||
|
(cnc/laser_on_sampled) and the tube current (pic/hv_current), each
|
||||||
|
at its peak, and how many polls read both. The burn's result reports
|
||||||
|
the daemon's own reading of the same two; this one is taken past the
|
||||||
|
daemon."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.laser_on_max = None
|
||||||
|
self.hv_max = None
|
||||||
|
self.reads = 0
|
||||||
|
|
||||||
|
def __call__(self, _doc):
|
||||||
|
lon = hw.sysfs_int("cnc/laser_on_sampled")
|
||||||
|
hv = hw.sysfs_int("pic/hv_current")
|
||||||
|
if lon is not None:
|
||||||
|
self.laser_on_max = lon if self.laser_on_max is None else max(self.laser_on_max, lon)
|
||||||
|
if hv is not None:
|
||||||
|
self.hv_max = hv if self.hv_max is None else max(self.hv_max, hv)
|
||||||
|
if lon is not None and hv is not None:
|
||||||
|
self.reads += 1
|
||||||
|
|
||||||
|
def record(self):
|
||||||
|
return {"laser_on_max": self.laser_on_max, "hv_max": self.hv_max, "reads": self.reads}
|
||||||
|
|
||||||
|
|
||||||
|
def anchor_stops():
|
||||||
|
"""Where the manual home that references X and Y declared the gantry
|
||||||
|
stops, in /status coordinates: the X and Y of the controller's home
|
||||||
|
anchor (baseline.ANCHOR_PATH), to which /status adds the step
|
||||||
|
counters. None when the anchor does not read as a manual home of X
|
||||||
|
and Y. The settings do not place the stops: a manual home reads
|
||||||
|
manual_home_x and manual_home_y once, and a change to them after the
|
||||||
|
home leaves its reference as it is."""
|
||||||
|
try:
|
||||||
|
with open(baseline.ANCHOR_PATH, encoding="ascii", errors="replace") as f:
|
||||||
|
fields = f.read().split()
|
||||||
|
x, y, axes, source = float(fields[0]), float(fields[1]), int(fields[3]), fields[4]
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
if (axes & 3) != 3 or source != "manual":
|
||||||
|
return None
|
||||||
|
return x, y
|
||||||
|
|
||||||
|
|
||||||
|
def home_offset_remedy(ctx):
|
||||||
|
"""What brings the head to its home against the stops, from what the
|
||||||
|
next manual home reads: with no manual home offset, a home there;
|
||||||
|
with one, Set home here moves the head off the stops to X0 Y0, so the
|
||||||
|
head is pushed there with the motors released, and the release ends
|
||||||
|
with Energize motors."""
|
||||||
|
s = ctx.forgectrl.settings() or {}
|
||||||
|
raw = [str(s.get(key) or "").strip() for key in HOME_OFFSET_KEYS]
|
||||||
|
|
||||||
|
def no_offset(v):
|
||||||
|
try:
|
||||||
|
return v == "" or float(v) == 0
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if no_offset(raw[0]) and no_offset(raw[1]):
|
||||||
|
return "home it there before the burn"
|
||||||
|
return ("with manual_home_x %s and manual_home_y %s, Set home here moves the head off the stops to X0 Y0; "
|
||||||
|
"release the motors, push the head against the stops, and end the release with Energize motors "
|
||||||
|
"($ME), not with Set home here, before the burn" % (raw[0] or "blank", raw[1] or "blank"))
|
||||||
|
|
||||||
|
|
||||||
|
def head_at_home(ctx):
|
||||||
|
"""The head's place on record before the wizard starts, read from
|
||||||
|
/status under the campaign's own controller: the wizard restarts the
|
||||||
|
controller before its home prompt, and a fresh controller drops the
|
||||||
|
X/Y reference, so a read at that prompt places nothing. When X and Y
|
||||||
|
are referenced by a manual home, the head is judged within
|
||||||
|
HOME_TOL_MM against the gantry stops, where the wizard draws from,
|
||||||
|
as that home declared them (anchor_stops): X0 Y0 for a home with no
|
||||||
|
offset, and minus manual_home_x and manual_home_y for a home made
|
||||||
|
with them, which moves the head to X0 Y0, that far in front of the
|
||||||
|
stops. Unreferenced, the head's place rests on the operator's word
|
||||||
|
at the ready gate, and the record says so."""
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
read = {}
|
||||||
|
|
||||||
|
def served():
|
||||||
|
st = fc.status()
|
||||||
|
if not isinstance(st.get("pos"), dict):
|
||||||
|
return False
|
||||||
|
read.update(st)
|
||||||
|
return True
|
||||||
|
took = ctx.wait_for(served, 10)
|
||||||
|
ctx.check(took is not None, "/status served no head position within 10 s: the head's place cannot "
|
||||||
|
"go on record before the burn")
|
||||||
|
pos = read.get("pos") or {}
|
||||||
|
axes = read.get("homed_axes")
|
||||||
|
source = read.get("home_source")
|
||||||
|
referenced = isinstance(axes, int) and (axes & 3) == 3 and source == "manual"
|
||||||
|
rec = ctx.evidence["head_at_start"] = {"pos": pos, "homed_axes": axes, "home_source": source,
|
||||||
|
"judged": referenced}
|
||||||
|
if not referenced:
|
||||||
|
ctx.log("the head's place rests on the operator's word at the ready gate (X and Y unreferenced: "
|
||||||
|
"homed_axes %s, home_source %s, pos %s)", axes, source, pos)
|
||||||
|
return
|
||||||
|
found = anchor_stops()
|
||||||
|
ctx.check(found is not None, "the home anchor (%s) does not read as the manual home /status reports: the "
|
||||||
|
"gantry stops cannot be placed before the burn", baseline.ANCHOR_PATH)
|
||||||
|
stops = rec["stops"] = {"x": found[0], "y": found[1]}
|
||||||
|
x, y = pos.get("x"), pos.get("y")
|
||||||
|
at_home = (isinstance(x, (int, float)) and isinstance(y, (int, float))
|
||||||
|
and abs(x - stops["x"]) <= HOME_TOL_MM and abs(y - stops["y"]) <= HOME_TOL_MM)
|
||||||
|
ctx.check(at_home, "the head is not at its home against the gantry stops: /status reads pos %s with X "
|
||||||
|
"and Y referenced by a manual home that declared the stops at X%.2f Y%.2f; %s", pos, stops["x"],
|
||||||
|
stops["y"], "" if at_home else home_offset_remedy(ctx))
|
||||||
|
ctx.log("the head is home on record: pos %s, X and Y referenced by a manual home, the stops at X%.2f Y%.2f",
|
||||||
|
pos, stops["x"], stops["y"])
|
||||||
|
|
||||||
|
|
||||||
|
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, seen):
|
||||||
|
"""The two witnesses of the burn, from the wizard's result and from
|
||||||
|
the test's own reads of them at every poll (`seen`, a Witnesses).
|
||||||
|
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) > HV_ON, "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"))
|
||||||
|
w = seen.record()
|
||||||
|
ctx.check(w["reads"] > 0, "the test could not read the witnesses from sysfs during the burn")
|
||||||
|
ctx.check((w["laser_on_max"] or 0) > 0,
|
||||||
|
"the kernel's LASER_ON count never read nonzero from here during the burn (%d reads); the "
|
||||||
|
"burn reports %s", w["reads"], e.get("laser_on_samples"))
|
||||||
|
ctx.check((w["hv_max"] or 0) > HV_ON,
|
||||||
|
"the tube current never read over %d from here during the burn (peak %s over %d reads); the "
|
||||||
|
"burn reports %s", HV_ON, w["hv_max"], w["reads"], e.get("hv_max"))
|
||||||
|
|
||||||
|
|
||||||
|
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 (the burn's own, and the two
|
||||||
|
read from sysfs here at every poll), 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()
|
||||||
|
seen = Witnesses()
|
||||||
|
|
||||||
|
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, on_poll=seen)
|
||||||
|
finally:
|
||||||
|
ctx.clear_notice()
|
||||||
|
# What the test read itself goes on record however the burn ends:
|
||||||
|
# reported dark, failed, timed out, or aborted.
|
||||||
|
w = seen.record()
|
||||||
|
ctx.evidence["emission_seen"] = w
|
||||||
|
ctx.log("seen from here: LASER_ON count peak %s, tube current peak %s over %d polls",
|
||||||
|
w["laser_on_max"], w["hv_max"], w["reads"])
|
||||||
|
emission_ok(ctx, last, seen)
|
||||||
|
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. Before anything starts, the head's "
|
||||||
|
"place is read from /status and recorded: judged against the gantry stops when "
|
||||||
|
"a manual home references X and Y (where that home declared them: X0 Y0, or "
|
||||||
|
"minus the manual_home_x and manual_home_y it read), else left to the "
|
||||||
|
"operator's word at the ready gate. The "
|
||||||
|
"edge setting is cleared, 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) as the burn reports them and as the test reads them "
|
||||||
|
"from sysfs itself at every poll, 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.")
|
||||||
|
head_at_home(ctx)
|
||||||
|
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")
|
||||||
@@ -17,7 +17,7 @@ import time
|
|||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
from .. import hw
|
from .. import hw
|
||||||
from ..baseline import read_position
|
from ..baseline import read_position
|
||||||
from .setup import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
|
from .setup import wiz, Restore, INTAKE_KEY
|
||||||
|
|
||||||
POLL_S = 1.0
|
POLL_S = 1.0
|
||||||
# What the machine's own press prompt says, used when it sends no text.
|
# What the machine's own press prompt says, used when it sends no text.
|
||||||
@@ -26,9 +26,33 @@ PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your
|
|||||||
# check switches it back on. The bench reference draws its off current
|
# check switches it back on. The bench reference draws its off current
|
||||||
# (74) for the first seconds and settles near 630.
|
# (74) for the first seconds and settles near 630.
|
||||||
PURGE_SPINUP_S = 30
|
PURGE_SPINUP_S = 30
|
||||||
|
# The checks' press prompts read the button (button.c) and light it
|
||||||
|
# (led.c) on the machine; the switches check reads both back.
|
||||||
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
|
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
|
||||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
||||||
|
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*")]
|
||||||
|
# The lens window: the free half-steps below and above the hall edge the
|
||||||
|
# Lens travel check writes, and the fallback the lens frame holds while
|
||||||
|
# they are unset (lens.h).
|
||||||
|
LENS_WINDOW_KEYS = ["lens_stop_below_steps", "lens_stop_above_steps"]
|
||||||
|
FALLBACK_WINDOW = (10, 12)
|
||||||
|
|
||||||
|
|
||||||
|
def intakes_counted():
|
||||||
|
"""Precheck: the intake fan count is set. The setup gate holds every
|
||||||
|
controller without it, and a test that puts the count back as found
|
||||||
|
would put an unset count back and close the gate on the tests after
|
||||||
|
it."""
|
||||||
|
try:
|
||||||
|
s = hw.Forgectrl().settings() or {}
|
||||||
|
except hw.HwError as e:
|
||||||
|
return "forgectrl unreachable: %s" % e
|
||||||
|
if not s.get(INTAKE_KEY):
|
||||||
|
return ("the intake fans are not counted, so the setup gate holds every controller; this "
|
||||||
|
"test puts the count back as it finds it, and an uncounted machine handed back "
|
||||||
|
"would hold the controllers of every test after it")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def dark(fc):
|
def dark(fc):
|
||||||
@@ -58,11 +82,13 @@ def answer(fc, wid, prompt, value):
|
|||||||
return fc.post("/wiz/%s/answer" % wid, data={"seq": str(prompt["seq"]), "value": value})
|
return fc.post("/wiz/%s/answer" % wid, data={"seq": str(prompt["seq"]), "value": value})
|
||||||
|
|
||||||
|
|
||||||
def run_check(ctx, wid, on_prompt, timeout_s):
|
def run_check(ctx, wid, on_prompt, timeout_s, on_poll=None):
|
||||||
"""Start `wid` and follow it to its end. on_prompt(prompt) is called
|
"""Start `wid` and follow it to its end. on_prompt(prompt) is called
|
||||||
once per prompt (by sequence number) and returns the value to answer
|
once per prompt (by sequence number) and returns the value to answer
|
||||||
with, or None when it handled the prompt another way (a fixture
|
with, or None when it handled the prompt another way (a fixture
|
||||||
action). Returns the final status document."""
|
action). on_poll(doc), when given, is called once per poll of
|
||||||
|
/wiz/dark with the status document: a caller's own witness read
|
||||||
|
beside the check's. Returns the final status document."""
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
start(ctx, fc, wid)
|
start(ctx, fc, wid)
|
||||||
@@ -76,6 +102,8 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
|||||||
d = dark(fc)
|
d = dark(fc)
|
||||||
if d.get("id") != wid:
|
if d.get("id") != wid:
|
||||||
raise hw.HwError("another check took the slot: %s" % d.get("id"))
|
raise hw.HwError("another check took the slot: %s" % d.get("id"))
|
||||||
|
if on_poll is not None:
|
||||||
|
on_poll(d)
|
||||||
if d.get("phase") and (not phases or phases[-1] != d["phase"]):
|
if d.get("phase") and (not phases or phases[-1] != d["phase"]):
|
||||||
phases.append(d["phase"])
|
phases.append(d["phase"])
|
||||||
ctx.log("%s: %s", wid, d["phase"])
|
ctx.log("%s: %s", wid, d["phase"])
|
||||||
@@ -104,10 +132,11 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
|||||||
raise
|
raise
|
||||||
ev["elapsed_s"] = round(time.time() - t0, 1)
|
ev["elapsed_s"] = round(time.time() - t0, 1)
|
||||||
ev["phases"] = phases[-12:]
|
ev["phases"] = phases[-12:]
|
||||||
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
|
|
||||||
if last is None:
|
if last is None:
|
||||||
|
# A check this test stops following is aborted before the test
|
||||||
|
# fails: no check runs on behind the FAIL.
|
||||||
fc.post("/wiz/%s/abort" % wid)
|
fc.post("/wiz/%s/abort" % wid)
|
||||||
return {}
|
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
|
||||||
ev["log"] = last.get("log", [])[-12:]
|
ev["log"] = last.get("log", [])[-12:]
|
||||||
ev["error"] = last.get("error")
|
ev["error"] = last.get("error")
|
||||||
ev["result"] = last.get("result")
|
ev["result"] = last.get("result")
|
||||||
@@ -121,7 +150,7 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
|||||||
@test("setup.check-switches", title="The switches check follows the lid and the button",
|
@test("setup.check-switches", title="The switches check follows the lid and the button",
|
||||||
subsystem="setup", kind="operator", est_min=3,
|
subsystem="setup", kind="operator", est_min=3,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
||||||
requires=["forgectrl.auth"], actions=["lid", "button"],
|
requires=["forgectrl.auth"], actions=["lid", "button", "interlock"],
|
||||||
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
||||||
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
|
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
|
||||||
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
|
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
|
||||||
@@ -153,58 +182,48 @@ def check_switches(ctx):
|
|||||||
ctx.log("switches: %s", json.dumps(r))
|
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",
|
@test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
|
||||||
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
||||||
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
||||||
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
requires=["forgectrl.auth"], precheck=intakes_counted,
|
||||||
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
|
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
|
||||||
"for 35 s, the purge is measured on and off, and five settings are written: the "
|
"for 35 s, the purge is measured on and off, and the five floors are written: the "
|
||||||
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
|
"four at 55 percent of steady and the grace from the slowest spin-up. The intake "
|
||||||
"fan must read at least 1000 rpm. The five settings are put back as found at the "
|
"fan count is the machine's: intake 2 turning proves two fans, both intakes are "
|
||||||
"end, the purge fan is on again and drawing over the floor the check just wrote, "
|
"judged, and the count is written as 2; intake 2 silent on a machine counted with "
|
||||||
"and the controller must be running again.")
|
"one is reported not fitted and not turning, intake 1 alone sets the intake "
|
||||||
|
"floor, and the count is not written; intake 2 silent on a machine counted with "
|
||||||
|
"two fails the check. Both intakes turn on the bench reference, so the bench "
|
||||||
|
"path writes 2. Each fan judged must read at least 1000 rpm. The settings are put "
|
||||||
|
"back as found at the end, the purge fan is on again and drawing over the floor "
|
||||||
|
"the check just wrote, and the controller must be running again.")
|
||||||
def check_airflow(ctx):
|
def check_airflow(ctx):
|
||||||
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
|
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
|
||||||
"cool_purge_min_current", "cool_fan_grace_s"]
|
"cool_purge_min_current", "cool_fan_grace_s"]
|
||||||
with Restore(ctx, keys):
|
with Restore(ctx, keys + ["cool_intake_fans"]) as held:
|
||||||
|
counted = held.before.get("cool_intake_fans")
|
||||||
last = run_check(ctx, "airflow", lambda p: None, 240)
|
last = run_check(ctx, "airflow", lambda p: None, 240)
|
||||||
r = last.get("result") or {}
|
r = last.get("result") or {}
|
||||||
fans = r.get("fans") or {}
|
fans = r.get("fans") or {}
|
||||||
|
intakes = r.get("intake_fans")
|
||||||
|
silent2 = (fans.get("intake 2") or {}).get("turning") is False
|
||||||
|
ctx.evidence["intakes"] = {"counted": counted, "found": intakes, "intake_2_silent": silent2}
|
||||||
|
ctx.check(intakes in (1, 2), "the result carries no intake fan count: %r", intakes)
|
||||||
|
if silent2:
|
||||||
|
# one intake fan, as the machine is counted (on a machine
|
||||||
|
# counted with two the check itself fails, and run_check says so)
|
||||||
|
ctx.check(counted == "1" and intakes == 1,
|
||||||
|
"intake 2 gave no reading on a machine counted with %r intake fans, and the check "
|
||||||
|
"found %r", counted, intakes)
|
||||||
|
else:
|
||||||
|
ctx.check(intakes == 2, "intake 2 turned, and the check found %r intake fans", intakes)
|
||||||
|
ctx.log("intake fans: %s (the machine counted %s)", intakes, counted)
|
||||||
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
||||||
f = fans.get(name) or {}
|
f = fans.get(name) or {}
|
||||||
|
if name == "intake 2" and silent2:
|
||||||
|
ctx.check(f == {"fitted": False, "turning": False}, "intake 2 with one intake fan: %s", f)
|
||||||
|
continue
|
||||||
ctx.check(f.get("ok") is True, "%s: %s", name, f)
|
ctx.check(f.get("ok") is True, "%s: %s", name, f)
|
||||||
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
|
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
|
||||||
floors = r.get("floors") or {}
|
floors = r.get("floors") or {}
|
||||||
@@ -213,6 +232,12 @@ def check_airflow(ctx):
|
|||||||
s = ctx.forgectrl.settings() or {}
|
s = ctx.forgectrl.settings() or {}
|
||||||
for k in keys:
|
for k in keys:
|
||||||
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
|
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
|
||||||
|
# The count: 2 written when intake 2 turned; left as counted when it
|
||||||
|
# was silent.
|
||||||
|
want = counted if silent2 else "2"
|
||||||
|
ctx.check(s.get("cool_intake_fans") == want,
|
||||||
|
"cool_intake_fans reads %r, expected %r (intake 2 %s, the machine counted %r)",
|
||||||
|
s.get("cool_intake_fans"), want, "silent" if silent2 else "turning", counted)
|
||||||
ctx.log("floors: %s", json.dumps(floors))
|
ctx.log("floors: %s", json.dumps(floors))
|
||||||
# The check switches purge air off to read its off current. Leaving
|
# The check switches purge air off to read its off current. Leaving
|
||||||
# it off costs the next job an airflow hold mid-cut, judged against
|
# it off costs the next job an airflow hold mid-cut, judged against
|
||||||
@@ -244,41 +269,6 @@ def check_airflow(ctx):
|
|||||||
ctx.check(ok is not None, "the controller did not come back after the check")
|
ctx.check(ok is not None, "the controller did not come back after the check")
|
||||||
|
|
||||||
|
|
||||||
@test("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",
|
@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,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
||||||
@@ -320,22 +310,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()})
|
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",
|
@test("setup.check-lens", title="The lens travel check finds the stops and writes the window",
|
||||||
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=4,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
covers=DARK_COVERS + [("forgectrl", "src/lensfind.*"), ("forgectrl", "src/lens.*"),
|
||||||
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
("forgectrl", "src/lenshome.*"), ("forgectrl", "src/accel.c"),
|
||||||
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
("forgectrl", "src/cool.c"), ("forgectrl", "src/super.c")],
|
||||||
"check runner: the diagnostic's phases show as the check's, its result becomes the "
|
requires=["forgectrl.auth", "setup.check-motion"],
|
||||||
"check's result with pass true, and the record carries cooling.flow-verify at "
|
description="POST /wiz/lens.travel/start: the controller stops, the lens takes its hall-edge "
|
||||||
"version 1. A thin margin recommends the calibration in the record's flags. The "
|
"reference, the fans stop and the head accelerometer listens while the lens "
|
||||||
"air-assist offset calibration is named first among the prerequisites so a queue "
|
"steps to each stop and back (nothing fires), the two window settings are "
|
||||||
"keeps it ahead of every heater tool this check pulls forward.")
|
"written, and the controller comes back. No question is asked. The result "
|
||||||
def check_flow_verify(ctx):
|
"carries the reference passes, the stops (found, the free half-steps each way, "
|
||||||
last = run_check(ctx, "cooling.flow-verify", lambda p: None, 600)
|
"the contact counts, or why not), and the window: the stops found, or the "
|
||||||
r = last.get("result") or {}
|
"fallback (10 half-steps below, 12 above) when they were not. The settings and "
|
||||||
ctx.check(r.get("pass") is True, "the flow check did not pass: %s", r)
|
"the lens block of /status hold that window, the record carries lens.travel at "
|
||||||
ctx.log("flow check: threshold %s, flow %s, no-flow %s, thin %s", r.get("threshold"),
|
"version 1, and the two settings are put back as found. grblHAL reads the "
|
||||||
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
|
"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(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",
|
@test("setup.cloud-header-capture", title="The cloud header check takes one print's envelope",
|
||||||
|
|||||||
@@ -1,283 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""setup.sheet - the setup's sheet (the live wizards) in one run,
|
|
||||||
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
|
|
||||||
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/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"),
|
|
||||||
("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")
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""The crumb tray's mode, on the machine.
|
|
||||||
|
|
||||||
Its own module, so that no other test's fingerprint moves. With the tray
|
|
||||||
out, the GRBL controller's Z is the focal height above the floor of the cut
|
|
||||||
area: its Z position and its Z range move up by tray_offset_mm on the lens
|
|
||||||
step grid, and nothing moves. The mode persists as the controller's marker
|
|
||||||
in the data directory. That it survives a controller restart is the
|
|
||||||
driver's harness case (tray_test.py): a restart here would drop the X and
|
|
||||||
Y reference, and the machine would not be handed back as it was found.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .. import hw
|
|
||||||
from .motion import (_kernel_position, _port_state, _words, clean_slate, drain_text, machine_idle)
|
|
||||||
|
|
||||||
TRAY_MARKER = "/data/forgefirm/tray.out"
|
|
||||||
LENS_SPM = 2.922 # $102: the lens screw's half-steps per mm
|
|
||||||
SETUP_CARD = "sheet.place" # the first setup card: it asks before it moves anything
|
|
||||||
|
|
||||||
|
|
||||||
def _grid_mm(offset_mm):
|
|
||||||
"""The offset on the lens step grid, the controller's way (lroundf)."""
|
|
||||||
return math.floor(offset_mm * LENS_SPM + 0.5) / LENS_SPM
|
|
||||||
|
|
||||||
|
|
||||||
def _lens(fc):
|
|
||||||
return fc.status().get("lens") or {}
|
|
||||||
|
|
||||||
|
|
||||||
def _judge(ctx, fc, ev, key, mode, found, shift):
|
|
||||||
"""The machine reads the tray `mode`, with Z and the reach moved by
|
|
||||||
`shift` mm from what the test found."""
|
|
||||||
s = fc.status()
|
|
||||||
lens, pos = s.get("lens") or {}, s.get("pos") or {}
|
|
||||||
port = _port_state(fc)
|
|
||||||
rec = ev[key] = {"lens": lens, "pos_z": pos.get("z"), "port_tray": port.get("tray"),
|
|
||||||
"port_z": port.get("mpos", [None] * 3)[2], "marker": os.path.exists(TRAY_MARKER),
|
|
||||||
"home": [s.get("homed_axes"), s.get("home_source")]}
|
|
||||||
ctx.log("%s: %s", key, rec)
|
|
||||||
ctx.check(lens.get("tray") == mode and port.get("tray") == mode,
|
|
||||||
"%s: the status says tray %s and the port %s, not %s", key, lens.get("tray"), port.get("tray"), mode)
|
|
||||||
ctx.check(rec["marker"] == (mode == "out"), "%s: the marker %s is %s with the tray %s", key, TRAY_MARKER,
|
|
||||||
"present" if rec["marker"] else "absent", mode)
|
|
||||||
for k in ("reach_min", "reach_max"):
|
|
||||||
ctx.check(lens.get(k) is not None and abs(lens[k] - (found["lens"][k] + shift)) < 0.011,
|
|
||||||
"%s: %s reads %s, not %.2f", key, k, lens.get(k), found["lens"][k] + shift)
|
|
||||||
ctx.check(pos.get("z") is not None and abs(pos["z"] - (found["pos_z"] + shift)) < 0.006,
|
|
||||||
"%s: the status Z reads %s, not %.2f", key, pos.get("z"), found["pos_z"] + shift)
|
|
||||||
ctx.check(rec["port_z"] is not None and abs(rec["port_z"] - (found["port_z"] + shift)) < 0.002,
|
|
||||||
"%s: the controller's Z reads %s, not %.3f", key, rec["port_z"], found["port_z"] + shift)
|
|
||||||
ctx.check(rec["home"] == found["home"], "%s: the reference changed across the switch: %s, not %s",
|
|
||||||
key, rec["home"], found["home"])
|
|
||||||
|
|
||||||
|
|
||||||
def _tray_route(fc, mode):
|
|
||||||
st, body = fc.post("/motion/tray", params={"tray": mode})
|
|
||||||
return st, _words(body)
|
|
||||||
|
|
||||||
|
|
||||||
def _card_refused(ctx, fc, ev):
|
|
||||||
"""A setup card is refused while the tray is out. The machine is idle a
|
|
||||||
moment after the suite's Grbl client closes; until then the refusal is
|
|
||||||
the idle rule's. A card that started anyway is ended at once: the first
|
|
||||||
one asks before it moves anything."""
|
|
||||||
path = "/wiz/%s/start" % SETUP_CARD
|
|
||||||
deadline = time.time() + 20
|
|
||||||
while True:
|
|
||||||
st, body = fc.post(path)
|
|
||||||
words = _words(body)
|
|
||||||
if st == 200:
|
|
||||||
fc.post("/wiz/%s/abort" % SETUP_CARD)
|
|
||||||
ctx.wait_for(lambda: not (fc.get("/wiz/dark")[1] or {}).get("running"), 20, poll=0.5)
|
|
||||||
if st != 409 or "not idle" not in words or time.time() > deadline:
|
|
||||||
break
|
|
||||||
ctx.sleep(0.5)
|
|
||||||
ev["setup_card"] = [st, words]
|
|
||||||
ctx.log("a setup card started with the tray out -> %s %s", st, words)
|
|
||||||
ctx.check(st == 409 and "crumb tray" in words, "a setup card started with the tray out -> %s %s", st, words)
|
|
||||||
|
|
||||||
|
|
||||||
@test("motion.tray", title="The crumb tray: tray out moves Z and its range up by the offset, and nothing moves",
|
|
||||||
subsystem="motion", kind="auto", mode="grbl", est_min=2,
|
|
||||||
covers=[("grblhal-glowforge", "src/glowforge_tray.*"), ("grblhal-glowforge", "src/glowforge_homing.*"),
|
|
||||||
("grblhal-glowforge", "src/ctlport.*"), ("grblhal-glowforge", "src/glowforge_io.*"),
|
|
||||||
("forgectrl", "src/tray.*"), ("forgectrl", "src/grblport.*"), ("forgectrl", "src/status.*"),
|
|
||||||
("forgectrl", "src/main.c"), ("forgectrl", "src/wizdark.*"), ("forgectrl", "src/ui/panel.js"),
|
|
||||||
("forgectrl", "src/ui/index.html")],
|
|
||||||
requires=["motion.port-jog"],
|
|
||||||
steps=["Nothing moves; the crumb tray can stay where it is."],
|
|
||||||
description="The tray mode is switched to the other one and back, through the panel's route and "
|
|
||||||
"through M103 from a Grbl client, both ways. Each switch changes the numbers and "
|
|
||||||
"moves nothing: /status says the tray, its Z and the lens reach move by the offset on "
|
|
||||||
"the lens step grid (1.35 in is 100 half-steps, 34.22 mm), the reference and what set it "
|
|
||||||
"stand, the controller port's state "
|
|
||||||
"agrees, the marker in the data directory follows, the client is told [MSG:Tray in] or "
|
|
||||||
"[MSG:Tray out] and its next line draws ok, and the kernel's position and byte counters "
|
|
||||||
"do not move. In the other mode, a jog to the Z the lens read before the switch is "
|
|
||||||
"refused (error:15): the two Z ranges never overlap; back in the mode found, the same "
|
|
||||||
"jog is taken. A setup card started with the tray out is refused in the operator's "
|
|
||||||
"words, with nothing sent. The page "
|
|
||||||
"the panel serves carries the switch. The tray mode is put back as found in a finally. "
|
|
||||||
"The controller is not restarted: that would drop the X and Y reference. That the mode "
|
|
||||||
"survives a restart, the errors of M103, and its wait for the move before it are the "
|
|
||||||
"driver's harness cases.")
|
|
||||||
def tray(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
s0 = fc.status()
|
|
||||||
found_mode = (s0.get("lens") or {}).get("tray")
|
|
||||||
ctx.check(found_mode in ("in", "out"), "the status carries no tray mode: %s", s0.get("lens"))
|
|
||||||
other = "out" if found_mode == "in" else "in"
|
|
||||||
p_found = "P1" if found_mode == "out" else "P0"
|
|
||||||
p_other = "P1" if other == "out" else "P0"
|
|
||||||
shift = _grid_mm((s0.get("lens") or {}).get("tray_offset_mm") or 34.29) * (1 if other == "out" else -1)
|
|
||||||
machine_idle(ctx)
|
|
||||||
found = {"lens": s0.get("lens"), "pos_z": (s0.get("pos") or {}).get("z"),
|
|
||||||
"port_z": _port_state(fc)["mpos"][2], "kernel": _kernel_position(),
|
|
||||||
"home": [s0.get("homed_axes"), s0.get("home_source")]}
|
|
||||||
ev["found"] = {"tray": found_mode, "shift_mm": round(shift, 4), **found}
|
|
||||||
ctx.log("found the tray %s; switching to %s moves Z %+.3f mm", found_mode, other, shift)
|
|
||||||
ctx.check(found["pos_z"] is not None, "the status carries no Z")
|
|
||||||
|
|
||||||
st, page = fc.get("/", raw=True)
|
|
||||||
text = page.decode("utf-8", "replace") if isinstance(page, bytes) else str(page)
|
|
||||||
for want in ('id="traybtn"', "function trayToggle", "/motion/tray?tray="):
|
|
||||||
ctx.check(st == 200 and want in text, "the panel page carries no %s", want)
|
|
||||||
|
|
||||||
switched = False
|
|
||||||
try:
|
|
||||||
if found_mode == "out":
|
|
||||||
_card_refused(ctx, fc, ev)
|
|
||||||
with ctx.grbl() as g:
|
|
||||||
clean_slate(ctx, g)
|
|
||||||
ctx.sleep(0.6) # past the port's sender-quiet interval
|
|
||||||
|
|
||||||
# Through the panel's route.
|
|
||||||
st, words = _tray_route(fc, other)
|
|
||||||
ctx.check(st == 200, "POST /motion/tray tray=%s -> %s %s", other, st, words)
|
|
||||||
switched = True
|
|
||||||
said = drain_text(g, 0.5)
|
|
||||||
ctx.check("[MSG:Tray %s]" % other in said, "the client was not told the tray is %s: %r", other, said)
|
|
||||||
_judge(ctx, fc, ev, "route_" + other, other, found, shift)
|
|
||||||
r = g.command("G4 P0")
|
|
||||||
ctx.check(r[-1:] == ["ok"], "the client's line after the switch drew %s", r)
|
|
||||||
|
|
||||||
# The Z the lens read before the switch is outside this mode's range.
|
|
||||||
jog = "$J=G90 G21 Z%.3f F200" % found["port_z"]
|
|
||||||
r = g.command(jog)
|
|
||||||
ev["jog_" + other] = [jog, r]
|
|
||||||
ctx.check(r[-1:] == ["error:15"], "with the tray %s, %s drew %s, not error:15", other, jog, r)
|
|
||||||
g.command("") # the empty line that acknowledges the error
|
|
||||||
|
|
||||||
# Back through M103, from the client.
|
|
||||||
r = g.command("M103 " + p_found)
|
|
||||||
ev["m103_" + found_mode] = r
|
|
||||||
ctx.check(r[-1:] == ["ok"] and "[MSG:Tray %s]" % found_mode in r,
|
|
||||||
"M103 %s drew %s", p_found, r)
|
|
||||||
switched = False
|
|
||||||
_judge(ctx, fc, ev, "m103_" + found_mode, found_mode, found, 0.0)
|
|
||||||
r = g.command(jog)
|
|
||||||
ev["jog_" + found_mode] = [jog, r]
|
|
||||||
ctx.check(r[-1:] == ["ok"], "with the tray %s again, %s drew %s", found_mode, jog, r)
|
|
||||||
machine_idle(ctx)
|
|
||||||
|
|
||||||
# And the other way: M103 out, the route back.
|
|
||||||
r = g.command("M103 " + p_other)
|
|
||||||
ev["m103_" + other] = r
|
|
||||||
ctx.check(r[-1:] == ["ok"] and "[MSG:Tray %s]" % other in r, "M103 %s drew %s", p_other, r)
|
|
||||||
switched = True
|
|
||||||
_judge(ctx, fc, ev, "m103_" + other, other, found, shift)
|
|
||||||
|
|
||||||
if other == "out":
|
|
||||||
_card_refused(ctx, fc, ev)
|
|
||||||
|
|
||||||
st, words = _tray_route(fc, found_mode)
|
|
||||||
ctx.check(st == 200, "POST /motion/tray tray=%s -> %s %s", found_mode, st, words)
|
|
||||||
switched = False
|
|
||||||
_judge(ctx, fc, ev, "route_" + found_mode, found_mode, found, 0.0)
|
|
||||||
finally:
|
|
||||||
if switched:
|
|
||||||
# The route is refused for a moment after the suite's Grbl client closes.
|
|
||||||
first = sys.exc_info()[1]
|
|
||||||
took = ctx.wait_for(lambda: _tray_route(fc, found_mode)[0] == 200 or None, 20, poll=0.5)
|
|
||||||
ctx.log("put the tray back %s -> %s", found_mode, "taken" if took is not None else "not taken")
|
|
||||||
ev["put_back"] = took is not None
|
|
||||||
if first is None:
|
|
||||||
ctx.check(took is not None, "the tray mode is left %s, not put back %s", other, found_mode)
|
|
||||||
machine_idle(ctx)
|
|
||||||
k1 = _kernel_position()
|
|
||||||
ev["kernel"] = [found["kernel"], k1]
|
|
||||||
ctx.check(k1 == found["kernel"], "the kernel moved across the switches: %s -> %s", found["kernel"], k1)
|
|
||||||
ctx.log("PASS: the tray mode switched both ways through the route and through M103, Z and the reach "
|
|
||||||
"moved by %.3f mm and back, nothing moved, and it is %s as found", abs(shift), found_mode)
|
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
"""update.* - the A/B slot inventory and the firmware verification path."""
|
"""update.* - the A/B slot inventory and the firmware verification path."""
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import shutil
|
import shutil
|
||||||
@@ -25,7 +24,8 @@ _UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
|
|||||||
"answers; `fwup` refuses a garbage archive. A tiny archive signed with a "
|
"answers; `fwup` refuses a garbage archive. A tiny archive signed with a "
|
||||||
"throwaway key made on the spot verifies with its own key and fails against the "
|
"throwaway key made on the spot verifies with its own key and fails against the "
|
||||||
"shipped release key, and an apply of it without confirm_unsigned is refused by "
|
"shipped release key, and an apply of it without confirm_unsigned is refused by "
|
||||||
"the update job before anything is written. Nothing is written to any slot.")
|
"the update job before anything is written, and the forgectrl log records the "
|
||||||
|
"job's start and its refusal. Nothing is written to any slot.")
|
||||||
def slots_and_signature(ctx):
|
def slots_and_signature(ctx):
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
@@ -96,6 +96,11 @@ def slots_and_signature(ctx):
|
|||||||
target = name
|
target = name
|
||||||
ctx.check(target is not None, "no inactive firmware slot in /slots: %s", slots)
|
ctx.check(target is not None, "no inactive firmware slot in /slots: %s", slots)
|
||||||
shutil.copyfile(signed, staged)
|
shutil.copyfile(signed, staged)
|
||||||
|
log_path = "/data/log/forgefirm/forgectrl/forgectrl.log"
|
||||||
|
try:
|
||||||
|
log_from = os.path.getsize(log_path)
|
||||||
|
except OSError:
|
||||||
|
log_from = 0
|
||||||
st, body = fc.post("/update/apply", params={"slot": target, "file": "upload"})
|
st, body = fc.post("/update/apply", params={"slot": target, "file": "upload"})
|
||||||
ev["apply"] = {"status": st, "body": body}
|
ev["apply"] = {"status": st, "body": body}
|
||||||
ctx.log("POST /update/apply slot=%s file=upload (no confirm_unsigned) -> %s %s", target, st, body)
|
ctx.log("POST /update/apply slot=%s file=upload (no confirm_unsigned) -> %s %s", target, st, body)
|
||||||
@@ -118,164 +123,27 @@ def slots_and_signature(ctx):
|
|||||||
ctx.check(isinstance(result, dict) and result.get("ok") is False
|
ctx.check(isinstance(result, dict) and result.get("ok") is False
|
||||||
and "not signed" in str(result.get("error", "")),
|
and "not signed" in str(result.get("error", "")),
|
||||||
"the apply of a foreign-signed archive was not refused for its signature: %s", result)
|
"the apply of a foreign-signed archive was not refused for its signature: %s", result)
|
||||||
|
|
||||||
|
# The forgectrl log records the job: what it set out to write
|
||||||
|
# and why it stopped.
|
||||||
|
want = ["update: writing the uploaded archive into slot %s" % target,
|
||||||
|
"update: the apply failed: archive is not signed"]
|
||||||
|
|
||||||
|
def log_text():
|
||||||
|
try:
|
||||||
|
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
f.seek(log_from)
|
||||||
|
return f.read()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
ctx.wait_for(lambda: all(w in log_text() for w in want), 10, poll=0.5)
|
||||||
|
text = log_text()
|
||||||
|
ev["apply_log"] = [ln for ln in text.splitlines() if "update: " in ln][-4:]
|
||||||
|
for w in want:
|
||||||
|
ctx.check(w in text, "the forgectrl log lacks %r: %s", w, ev["apply_log"])
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
os.unlink(staged)
|
os.unlink(staged)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
shutil.rmtree(work, ignore_errors=True)
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
@test("update.product-gate", title="The firmware upload takes firmware only: an extension package is refused by name",
|
|
||||||
subsystem="update", kind="auto", est_min=1,
|
|
||||||
covers=_UPDATE_COVERS + [("forgectrl", "src/fwproduct.c"), ("forgectrl", "src/fwproduct.h")],
|
|
||||||
requires=["forgectrl.auth"],
|
|
||||||
description="Firmware and extension packages are the same container, and a signature says who "
|
|
||||||
"made an archive, never what it is. Archives made on the spot with the machine's own "
|
|
||||||
"fwup go to POST /update/upload: an extension package unsigned, one signed with a "
|
|
||||||
"throwaway key, and one that carries a task are each refused with 400 in words that "
|
|
||||||
"say what they are, and nothing is staged; an archive of some other product is "
|
|
||||||
"refused as not firmware; and one whose product is ForgeFIRM firmware is taken as an "
|
|
||||||
"unsigned upload, which is what it is. Nothing is applied: the apply calls the same "
|
|
||||||
"gate before fwup -a, and its proof is the daemon's host test, because a catalog test "
|
|
||||||
"that could fail there would write a slot.")
|
|
||||||
def product_gate(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
staged = "/data/forgefirm/upload.fw"
|
|
||||||
ctx.check(not os.path.exists(staged), "an upload is already staged: %s", staged)
|
|
||||||
work = tempfile.mkdtemp(prefix="forgetest-gate-")
|
|
||||||
|
|
||||||
def archive(name, product, task=False, sign=False):
|
|
||||||
conf = os.path.join(work, name + ".conf")
|
|
||||||
with open(conf, "w") as f:
|
|
||||||
f.write('meta-product = "%s"\nmeta-version = "0.0.0-test"\n'
|
|
||||||
'file-resource payload.tar.gz { host-path = "payload.tar.gz" }\n' % product)
|
|
||||||
if task:
|
|
||||||
f.write('task upgrade.a { on-resource payload.tar.gz { raw_write(0) } }\n')
|
|
||||||
out = name + ".fw"
|
|
||||||
cmd = "cd %s && fwup -c -f %s.conf -o %s" % (work, name, out)
|
|
||||||
if sign:
|
|
||||||
cmd += " && fwup -S -s fwup-key.priv -i %s -o %s.signed && mv %s.signed %s" % (out, out, out, out)
|
|
||||||
rc, text = hw.run(["sh", "-c", cmd], timeout=60)
|
|
||||||
ctx.check(rc == 0, "could not make %s (rc %s): %s", name, rc, text.strip()[:200])
|
|
||||||
return os.path.join(work, out)
|
|
||||||
|
|
||||||
def upload(path):
|
|
||||||
mark = "forgetestGateBoundary9c2"
|
|
||||||
body = ('--%s\r\nContent-Disposition: form-data; name="file"; filename="%s"\r\n'
|
|
||||||
'Content-Type: application/octet-stream\r\n\r\n' % (mark, os.path.basename(path))).encode()
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
body += f.read() + ("\r\n--%s--\r\n" % mark).encode()
|
|
||||||
return fc.post("/update/upload", data=body,
|
|
||||||
headers={"Content-Type": "multipart/form-data; boundary=%s" % mark})
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(os.path.join(work, "payload.tar.gz"), "wb") as f:
|
|
||||||
f.write(b"forgetest product-gate drill\n" * 8)
|
|
||||||
rc, text = hw.run(["sh", "-c", "cd %s && fwup -g" % work], timeout=60)
|
|
||||||
ctx.check(rc == 0, "fwup -g did not make a key pair (rc %s): %s", rc, text.strip()[:200])
|
|
||||||
seen = {}
|
|
||||||
for name, product, task, sign in (("extension-unsigned", "ForgeFIRM extension", False, False),
|
|
||||||
("extension-signed", "ForgeFIRM extension", False, True),
|
|
||||||
("extension-with-task", "ForgeFIRM extension", True, False)):
|
|
||||||
st, body = upload(archive(name, product, task, sign))
|
|
||||||
words = body.get("error", "") if isinstance(body, dict) else str(body)
|
|
||||||
seen[name] = [st, words]
|
|
||||||
ctx.log("upload %s -> %s %s", name, st, words)
|
|
||||||
ctx.check(st == 400 and "an extension package, not firmware" in words,
|
|
||||||
"%s at the firmware upload -> %s %s", name, st, words)
|
|
||||||
ctx.check(not os.path.exists(staged), "%s was refused and is staged all the same", name)
|
|
||||||
st, body = upload(archive("another-product", "forgetest"))
|
|
||||||
words = body.get("error", "") if isinstance(body, dict) else str(body)
|
|
||||||
seen["another-product"] = [st, words]
|
|
||||||
ctx.log("upload another-product -> %s %s", st, words)
|
|
||||||
ctx.check(st == 400 and "not firmware" in words and not os.path.exists(staged),
|
|
||||||
"an archive of another product -> %s %s", st, words)
|
|
||||||
st, body = upload(archive("firmware-unsigned", "ForgeFIRM firmware"))
|
|
||||||
seen["firmware-unsigned"] = [st, body]
|
|
||||||
ctx.log("upload firmware-unsigned -> %s %s", st, body)
|
|
||||||
ctx.check(st == 200 and isinstance(body, dict) and body.get("signature") == "unsigned"
|
|
||||||
and os.path.exists(staged), "an unsigned archive that is firmware -> %s %s", st, body)
|
|
||||||
ev["uploads"] = seen
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.unlink(staged)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
shutil.rmtree(work, ignore_errors=True)
|
|
||||||
ctx.log("PASS: three extension packages and one foreign product refused at the firmware upload with "
|
|
||||||
"nothing staged; firmware taken as the unsigned upload it is")
|
|
||||||
|
|
||||||
|
|
||||||
_VERSION_RX = re.compile(r"^v?\d+\.\d+\.\d+")
|
|
||||||
|
|
||||||
|
|
||||||
@test("update.release-check", title="Release check answers from the releases API; the alert dismissal is per release",
|
|
||||||
subsystem="update", kind="auto", est_min=1,
|
|
||||||
covers=_UPDATE_COVERS + [("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html")],
|
|
||||||
requires=["forgectrl.auth"],
|
|
||||||
description="GET /update/release answers the kept answer of the daily check (available, version, "
|
|
||||||
"current, new, checked, dismissed). POST /update/check asks the releases API now and "
|
|
||||||
"answers the same shape; a machine with no route to the API answers 502, which the drill "
|
|
||||||
"records and steps over. A published release is a v<semver> tag with the firmware "
|
|
||||||
"file's size and its notes, and `new` follows the version order: a development build "
|
|
||||||
"sees every release as newer. POST /update/dismiss marks that version dismissed, an "
|
|
||||||
"empty version undoes it, and a version that is not a tag is refused. The dismissal "
|
|
||||||
"the machine had before the drill is put back.")
|
|
||||||
def release_check(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
keys = ("available", "version", "current", "new", "checked", "dismissed", "detail", "bytes")
|
|
||||||
|
|
||||||
st, before = fc.get("/update/release")
|
|
||||||
ctx.log("GET /update/release -> %s %s", st, {k: before.get(k) for k in keys} if isinstance(before, dict) else before)
|
|
||||||
ctx.check(st == 200 and isinstance(before, dict) and "available" in before and "checked" in before,
|
|
||||||
"GET /update/release -> %s", st)
|
|
||||||
ev["before"] = {k: before.get(k) for k in keys}
|
|
||||||
prior = before.get("version", "") if before.get("dismissed") else ""
|
|
||||||
|
|
||||||
st, now = fc.post("/update/check")
|
|
||||||
ctx.log("POST /update/check -> %s %s", st, {k: now.get(k) for k in keys} if isinstance(now, dict) else now)
|
|
||||||
if st == 502:
|
|
||||||
ctx.log("the machine has no route to the releases API; the kept answer stands")
|
|
||||||
now = before
|
|
||||||
else:
|
|
||||||
ctx.check(st == 200 and isinstance(now, dict) and "available" in now, "POST /update/check -> %s", st)
|
|
||||||
ctx.check(isinstance(now.get("checked"), int) and now["checked"] >= before.get("checked", 0),
|
|
||||||
"the check did not move `checked`: %s", now.get("checked"))
|
|
||||||
ev["after_check"] = {k: now.get(k) for k in keys}
|
|
||||||
|
|
||||||
if now.get("available"):
|
|
||||||
ctx.check(_VERSION_RX.match(str(now.get("version", ""))) is not None,
|
|
||||||
"the release version is not a v<semver> tag: %r", now.get("version"))
|
|
||||||
ctx.check(isinstance(now.get("bytes"), int) and now["bytes"] > 0,
|
|
||||||
"the firmware file's size is missing: %r", now.get("bytes"))
|
|
||||||
ctx.check(isinstance(now.get("notes"), str), "the notes are not a string")
|
|
||||||
ctx.check(isinstance(now.get("new"), bool), "`new` is not a bool")
|
|
||||||
if not _VERSION_RX.match(str(now.get("current", ""))):
|
|
||||||
ctx.check(now.get("new") is True,
|
|
||||||
"a development build (%r) must see release %s as newer", now.get("current"), now["version"])
|
|
||||||
else:
|
|
||||||
ctx.log("no release available: %s", now.get("detail"))
|
|
||||||
ctx.check(isinstance(now.get("detail"), str) and now["detail"], "an unavailable release names no reason")
|
|
||||||
|
|
||||||
try:
|
|
||||||
st, body = fc.post("/update/dismiss", params={"version": "v0.0.1; rm -rf /"})
|
|
||||||
ctx.log("POST /update/dismiss version=<not a tag> -> %s %s", st, body)
|
|
||||||
ctx.check(st == 400, "a version that is not a tag was not refused: %s", st)
|
|
||||||
|
|
||||||
tag = now["version"] if now.get("available") else "v0.0.0"
|
|
||||||
st, body = fc.post("/update/dismiss", params={"version": tag})
|
|
||||||
ctx.log("POST /update/dismiss version=%s -> %s %s", tag, st,
|
|
||||||
{k: body.get(k) for k in keys} if isinstance(body, dict) else body)
|
|
||||||
ctx.check(st == 200 and isinstance(body, dict), "POST /update/dismiss -> %s", st)
|
|
||||||
if now.get("available"):
|
|
||||||
ctx.check(body.get("dismissed") is True, "the dismissal of %s was not recorded: %r", tag, body.get("dismissed"))
|
|
||||||
st, body = fc.post("/update/dismiss", params={"version": ""})
|
|
||||||
ctx.log("POST /update/dismiss version= -> %s", st)
|
|
||||||
ctx.check(st == 200 and isinstance(body, dict) and body.get("dismissed") is False,
|
|
||||||
"the empty version did not undo the dismissal: %s %r", st, body.get("dismissed") if isinstance(body, dict) else body)
|
|
||||||
finally:
|
|
||||||
st, body = fc.post("/update/dismiss", params={"version": prior})
|
|
||||||
ctx.log("dismissal put back to %r -> %s", prior, st)
|
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""An update job holds the machine, on the machine.
|
|
||||||
|
|
||||||
Its own module, so that no other test's fingerprint moves. The job is a
|
|
||||||
probe download of the published release: the download job itself, run on
|
|
||||||
the release's acceptance record instead of its firmware file, because
|
|
||||||
GitHub counts every request of the firmware file as an install. The
|
|
||||||
verification refuses the record and the probe keeps nothing; a download
|
|
||||||
the machine had staged is left as it was.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .motion import _job_post, _words
|
|
||||||
|
|
||||||
DL_FW = "/data/forgefirm/download.fw"
|
|
||||||
DL_PROBE = "/data/forgefirm/download-probe"
|
|
||||||
HELD = "an update job (download) holds the machine"
|
|
||||||
|
|
||||||
|
|
||||||
def _as_found(path):
|
|
||||||
try:
|
|
||||||
st = os.stat(path)
|
|
||||||
return [st.st_size, st.st_mtime_ns]
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@test("update.job-locks", title="An update job holds the machine: the settings are locked while it runs",
|
|
||||||
subsystem="update", kind="auto", est_min=2,
|
|
||||||
covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/relcheck.*"), ("forgectrl", "src/lease.*")],
|
|
||||||
requires=["update.release-check"],
|
|
||||||
description="With a release published on the releases API, POST /update/download?probe=1 starts the "
|
|
||||||
"download job (202) on the release's acceptance record in place of its firmware file, so "
|
|
||||||
"the test adds nothing to the firmware file's download count. The machine lease is taken "
|
|
||||||
"before the 202: a settings write and a posted job right after it are each refused in the "
|
|
||||||
"job's name (409, an update job (download) holds the machine, the settings locked), and "
|
|
||||||
"/status names the job (update:download, of kind system) as the holder while it runs. The "
|
|
||||||
"job fetches the record through the download's own path and ends refused by the signature "
|
|
||||||
"check with the record discarded. When it ends, the lease is free and the same settings "
|
|
||||||
"write is taken. A download the machine had staged is untouched and the probe leaves no "
|
|
||||||
"file. That a log export does not lock the settings while every other holder does is "
|
|
||||||
"lease_test's; that a probe never names the firmware file is relcheck_test's.")
|
|
||||||
def job_locks(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
|
|
||||||
st, rel = fc.get("/update/release")
|
|
||||||
if st == 200 and isinstance(rel, dict) and not rel.get("checked"):
|
|
||||||
# the kept answer does not outlive a restart of the daemon: ask now
|
|
||||||
st, rel = fc.post("/update/check")
|
|
||||||
ctx.log("the release was not checked since the daemon started: POST /update/check -> %s", st)
|
|
||||||
ev["release"] = {k: (rel or {}).get(k) for k in ("available", "version")} if isinstance(rel, dict) else rel
|
|
||||||
ctx.check(st == 200 and isinstance(rel, dict) and rel.get("available"), "no release is published to probe: %s", rel)
|
|
||||||
staged = _as_found(DL_FW)
|
|
||||||
ev["staged_download"] = staged
|
|
||||||
ctx.check(_as_found(DL_PROBE) is None, "a probe's file is left over from before: %s", DL_PROBE)
|
|
||||||
units = fc.settings().get("ui_units") or "metric"
|
|
||||||
|
|
||||||
def holder():
|
|
||||||
return ((fc.status().get("lease") or {}).get("holder") or {})
|
|
||||||
|
|
||||||
def job():
|
|
||||||
st_, j = fc.get("/update/status")
|
|
||||||
return j if isinstance(j, dict) else {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# The record is small, so the job can end within a second. The
|
|
||||||
# lease is taken before the 202, so the settings write goes first
|
|
||||||
# and at once: it is the same value, so a probe that ended before
|
|
||||||
# it only costs another probe. The posted job goes only while the
|
|
||||||
# settings are refused in the job's name.
|
|
||||||
refused = None
|
|
||||||
for attempt in (1, 2, 3):
|
|
||||||
st, body = fc.post("/update/download", params={"probe": "1"})
|
|
||||||
ctx.check(st == 202, "POST /update/download probe=1 -> %s %s", st, _words(body)[:200])
|
|
||||||
st, body = fc.post("/settings", params={"ui_units": units})
|
|
||||||
if st == 200:
|
|
||||||
ctx.wait_for(lambda: job().get("running") is False or None, 120, poll=0.5)
|
|
||||||
result = job().get("result") or {}
|
|
||||||
ctx.log("probe %d ended before the settings write was read (%s); again", attempt, result)
|
|
||||||
ctx.check("signature verification failed" in str(result.get("error")),
|
|
||||||
"the probe ended, and not refused by the signature check: %s", result)
|
|
||||||
continue
|
|
||||||
st2, body2 = _job_post(fc, "G21\n", name="beside-an-update")
|
|
||||||
held = holder()
|
|
||||||
refused = {"a settings write": [st, _words(body)[:200]], "a posted job": [st2, _words(body2)[:200]]}
|
|
||||||
break
|
|
||||||
ctx.check(refused is not None, "three probes each ended before a settings write could be refused")
|
|
||||||
ev["refused"] = refused
|
|
||||||
ev["holder"] = held
|
|
||||||
ctx.log("beside the probe: %s; the lease: %s", refused, held)
|
|
||||||
ctx.check(st == 409 and HELD in _words(body) and "settings are locked" in _words(body),
|
|
||||||
"a settings write beside the probe -> %s %s", st, _words(body)[:200])
|
|
||||||
ctx.check(st2 == 409 and HELD in _words(body2), "a job beside the probe -> %s %s", st2, _words(body2)[:200])
|
|
||||||
if held:
|
|
||||||
ctx.check(held.get("owner") == "update:download" and held.get("kind") == "system",
|
|
||||||
"the probe does not hold the machine as the download job: %s", held)
|
|
||||||
else:
|
|
||||||
ctx.log("the probe had ended before /status was read; its refusals name it")
|
|
||||||
ctx.wait_for(lambda: job().get("running") is False or None, 600, poll=1.0)
|
|
||||||
done = job()
|
|
||||||
ev["job"] = done
|
|
||||||
ctx.log("the probe ended: %s", done)
|
|
||||||
result = done.get("result") or {}
|
|
||||||
ctx.check(result.get("ok") is False and "signature verification failed" in str(result.get("error")),
|
|
||||||
"the probe did not end refused by the signature check (the record fetched and discarded): %s",
|
|
||||||
result)
|
|
||||||
ctx.check(not holder(), "the lease was not given back: %s", holder())
|
|
||||||
st, body = fc.post("/settings", params={"ui_units": units})
|
|
||||||
ctx.check(st == 200, "the same settings write after the job -> %s %s", st, _words(body)[:200])
|
|
||||||
finally:
|
|
||||||
# The job runs on after a check that failed: its end is waited for.
|
|
||||||
ctx.wait_for(lambda: job().get("running") is False or None, 600, poll=2.0)
|
|
||||||
ctx.check(_as_found(DL_FW) == staged, "the staged download moved: %s, and %s before", _as_found(DL_FW), staged)
|
|
||||||
ctx.check(_as_found(DL_PROBE) is None, "the probe left its file: %s", DL_PROBE)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
1
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
1
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -21,7 +21,10 @@ from forgetest import baseline
|
|||||||
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
|
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
|
||||||
|
|
||||||
|
|
||||||
class BaselineTests(unittest.TestCase):
|
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."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-bl-")
|
self.tmp = tempfile.mkdtemp(prefix="forgetest-bl-")
|
||||||
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
|
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
|
||||||
@@ -65,13 +68,6 @@ class BaselineTests(unittest.TestCase):
|
|||||||
# the class interface writes 'target'; the fake mirrors it into brightness
|
# the class interface writes 'target'; the fake mirrors it into brightness
|
||||||
# only when the test asks (see _sync_leds)
|
# only when the test asks (see _sync_leds)
|
||||||
|
|
||||||
def _led2(self, name, target, brightness):
|
|
||||||
"""Both attributes of one button LED: the trigger's commanded
|
|
||||||
level and the fade that follows it."""
|
|
||||||
for a, v in (("target", target), ("brightness", brightness)):
|
|
||||||
with open(self.leds + name + "/" + a, "w") as f:
|
|
||||||
f.write(v)
|
|
||||||
|
|
||||||
def _sync_leds(self):
|
def _sync_leds(self):
|
||||||
for name in baseline.BUTTON_LEDS:
|
for name in baseline.BUTTON_LEDS:
|
||||||
p = self.leds + name + "/target"
|
p = self.leds + name + "/target"
|
||||||
@@ -88,6 +84,19 @@ class BaselineTests(unittest.TestCase):
|
|||||||
def bl(self):
|
def bl(self):
|
||||||
return baseline.Baseline(self.lines.append)
|
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):
|
def test_clean_machine_has_no_leftovers(self):
|
||||||
left = self.bl().enforce("pre", captured=None)
|
left = self.bl().enforce("pre", captured=None)
|
||||||
self.assertEqual(left, [])
|
self.assertEqual(left, [])
|
||||||
@@ -226,10 +235,6 @@ class BaselineTests(unittest.TestCase):
|
|||||||
for k in ("GRBL_HOST", "GRBL_PORT"):
|
for k in ("GRBL_HOST", "GRBL_PORT"):
|
||||||
os.environ.pop(k, None)
|
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):
|
def test_ring_residue_is_a_leftover_and_blocks_the_return_jog(self):
|
||||||
b = self.bl()
|
b = self.bl()
|
||||||
cap = b.capture()
|
cap = b.capture()
|
||||||
@@ -308,13 +313,6 @@ class BaselineTests(unittest.TestCase):
|
|||||||
|
|
||||||
# -- the reference is taken after the controller applied its config -----
|
# -- 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):
|
def test_wait_configured_returns_once_the_controller_wrote_its_config(self):
|
||||||
self._probe_state()
|
self._probe_state()
|
||||||
calls = {"n": 0}
|
calls = {"n": 0}
|
||||||
@@ -460,11 +458,7 @@ class BaselineTests(unittest.TestCase):
|
|||||||
os.environ.pop("FORGETEST_BOOT_ID", None)
|
os.environ.pop("FORGETEST_BOOT_ID", None)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
class ExtensionFreeTests(BaselineFixture, unittest.TestCase):
|
||||||
unittest.main()
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionFreeTests(BaselineTests):
|
|
||||||
"""A campaign's machine is extension-free: what a test made is removed,
|
"""A campaign's machine is extension-free: what a test made is removed,
|
||||||
what is the operator's is reported and left alone."""
|
what is the operator's is reported and left alone."""
|
||||||
|
|
||||||
@@ -529,104 +523,6 @@ class ExtensionFreeTests(BaselineTests):
|
|||||||
self.assertTrue(os.path.isdir(os.path.join(self.root, "pkg", "org.example.notify")))
|
self.assertTrue(os.path.isdir(os.path.join(self.root, "pkg", "org.example.notify")))
|
||||||
|
|
||||||
|
|
||||||
class TransientNotLeftoverTests(BaselineTests):
|
|
||||||
"""A leftover is what a run left behind, not the machine part-way
|
|
||||||
through its own work. Found on the bench reference, where
|
|
||||||
laser.arm-wait-lid passed every check it makes and failed its
|
|
||||||
hand-back on the post-job smoke clear."""
|
|
||||||
|
|
||||||
def test_a_fading_button_led_is_not_a_leftover(self):
|
|
||||||
# the trigger fades brightness toward target: target 0 with
|
|
||||||
# brightness still high is the fade from a level the machine ended
|
|
||||||
for name in baseline.BUTTON_LEDS:
|
|
||||||
self._led2(name, target="0", brightness="900")
|
|
||||||
left = self.bl().enforce("post", captured=None)
|
|
||||||
self.assertEqual([x for x in left if x.item.startswith("leds/")], [])
|
|
||||||
|
|
||||||
def test_a_lit_button_led_is_a_leftover(self):
|
|
||||||
# a target the run left standing is dirt, whatever the fade reads
|
|
||||||
self._led2(baseline.BUTTON_LEDS[0], target="1014", brightness="0")
|
|
||||||
left = self.bl().enforce("post", captured=None)
|
|
||||||
items = [x for x in left if x.item.startswith("leds/")]
|
|
||||||
self.assertEqual(len(items), 1)
|
|
||||||
self.assertEqual(items[0].found, "1014")
|
|
||||||
|
|
||||||
|
|
||||||
class CoolPublishTests(unittest.TestCase):
|
|
||||||
"""The cooling engine publishes once a tick. A status read inside the
|
|
||||||
tick after a run ended is the engine's last word on the run, not the
|
|
||||||
machine as the run left it. Found on the bench reference: a diagnostic
|
|
||||||
that finished clean (cooling.aa-offset-calibrate) and a fail tier that
|
|
||||||
did its work (cooling.fail-tier-stop) both failed their hand-back on a
|
|
||||||
hold the engine's next tick cleared."""
|
|
||||||
|
|
||||||
IDLE = {"phase": "idle", "armed": False, "hold": False}
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.lines = []
|
|
||||||
|
|
||||||
def bl(self, statuses):
|
|
||||||
"""A baseline whose /cool/status answers play in order (the last
|
|
||||||
one repeats) and whose waits count reads, not seconds: three for
|
|
||||||
the engine's next tick, as the real wait makes, and enough for the
|
|
||||||
cooldown to let a slow script run out."""
|
|
||||||
b = baseline.Baseline(self.lines.append)
|
|
||||||
seq = list(statuses)
|
|
||||||
b.fc_get = lambda path: (200, dict(seq.pop(0) if len(seq) > 1 else seq[0]))
|
|
||||||
|
|
||||||
def wait(what, pred, timeout):
|
|
||||||
for i in range(3 if what == "cool publish" else 50):
|
|
||||||
if pred():
|
|
||||||
return float(i)
|
|
||||||
return None
|
|
||||||
b._wait = wait
|
|
||||||
b.stood_down = []
|
|
||||||
b.stand_down = b.stood_down.append
|
|
||||||
return b
|
|
||||||
|
|
||||||
def test_a_diagnostic_hold_the_next_tick_clears_is_not_a_leftover(self):
|
|
||||||
left = []
|
|
||||||
b = self.bl([{"phase": "diag", "armed": False, "hold": True}, self.IDLE])
|
|
||||||
b._cool_side(left)
|
|
||||||
self.assertEqual(left, [])
|
|
||||||
self.assertTrue(any("last word on the run" in ln for ln in self.lines), self.lines)
|
|
||||||
|
|
||||||
def test_a_fail_tier_hold_clears_into_the_engines_own_post_job_phase(self):
|
|
||||||
left = []
|
|
||||||
b = self.bl([{"phase": "run", "armed": False, "hold": True},
|
|
||||||
{"phase": "smoke", "armed": False, "hold": False},
|
|
||||||
{"phase": "smoke", "armed": False, "hold": False}, self.IDLE])
|
|
||||||
b._cool_side(left)
|
|
||||||
self.assertEqual(left, [])
|
|
||||||
self.assertTrue(any("not a leftover" in ln for ln in self.lines), self.lines)
|
|
||||||
|
|
||||||
def test_a_hold_that_outlives_the_tick_is_the_runs_doing(self):
|
|
||||||
left = []
|
|
||||||
b = self.bl([{"phase": "run", "armed": True, "hold": True}])
|
|
||||||
b._cool_side(left)
|
|
||||||
self.assertEqual([x.item for x in left], ["cool"])
|
|
||||||
self.assertEqual(left[0].found, "run/armed=True/hold=True")
|
|
||||||
self.assertTrue(left[0].action.startswith("failed: still"), left[0].action)
|
|
||||||
self.assertEqual(len(b.stood_down), 1)
|
|
||||||
|
|
||||||
def test_a_hold_that_clears_only_later_is_still_recorded(self):
|
|
||||||
left = []
|
|
||||||
held = {"phase": "run", "armed": False, "hold": True}
|
|
||||||
b = self.bl([held] * 10 + [self.IDLE])
|
|
||||||
b._cool_side(left)
|
|
||||||
self.assertEqual([x.item for x in left], ["cool"])
|
|
||||||
self.assertEqual(left[0].action, "waited")
|
|
||||||
self.assertEqual(b.stood_down, [])
|
|
||||||
|
|
||||||
def test_an_idle_engine_and_a_silent_daemon_leave_nothing(self):
|
|
||||||
left = []
|
|
||||||
self.bl([self.IDLE])._cool_side(left)
|
|
||||||
b = baseline.Baseline(self.lines.append)
|
|
||||||
b.fc_get = lambda path: (None, None)
|
|
||||||
b._cool_side(left)
|
|
||||||
self.assertEqual(left, [])
|
|
||||||
|
|
||||||
|
|
||||||
class CloudOwnedSysfsTests(unittest.TestCase):
|
class CloudOwnedSysfsTests(unittest.TestCase):
|
||||||
"""In cloud mode the cloud client configures the machine from the
|
"""In cloud mode the cloud client configures the machine from the
|
||||||
pulse header it is playing, the lens included: z_mode comes from
|
pulse header it is playing, the lens included: z_mode comes from
|
||||||
@@ -651,52 +547,35 @@ class PositionDeadbandTests(unittest.TestCase):
|
|||||||
where motion.lid-cancel-home returned the head twice, each within
|
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."""
|
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):
|
def test_past_the_dead_band_is_a_leftover(self):
|
||||||
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
|
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], [over, 0, 0]))
|
||||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 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):
|
def test_an_unreadable_reading_is_never_quantization(self):
|
||||||
self.assertFalse(baseline.position_quantized(None, [0, 0, 0]))
|
self.assertFalse(baseline.position_quantized(None, [0, 0, 0]))
|
||||||
self.assertFalse(baseline.position_quantized([0, 0, 0], None))
|
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
|
"""The baseline against a fake forgectrl: what the mode in force
|
||||||
owns. Reuses the fake sysfs tree of BaselineTests; only the new
|
owns, over the same fake sysfs tree."""
|
||||||
tests run here (the inherited ones are skipped)."""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
import helpers
|
import helpers
|
||||||
self.fc = helpers.FakeForgectrl().start()
|
self.fc = helpers.FakeForgectrl().start()
|
||||||
baseline.Baseline._unreachable_until = 0.0
|
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):
|
def tearDown(self):
|
||||||
|
baseline.LID_LAMP_SETTLE_S, baseline.LID_LAMP_POLL_S = self._lamp_timing
|
||||||
self.fc.stop()
|
self.fc.stop()
|
||||||
super().tearDown()
|
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):
|
def cloud(self):
|
||||||
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
|
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
|
||||||
self.fc.state["settings"]["controller_mode"] = "cloud"
|
self.fc.state["settings"]["controller_mode"] = "cloud"
|
||||||
@@ -843,3 +722,7 @@ class BaselineModeTests(BaselineTests):
|
|||||||
cap["mode"] = "cloud"
|
cap["mode"] = "cloud"
|
||||||
b.enforce("post", captured=cap)
|
b.enforce("post", captured=cap)
|
||||||
self.assertEqual([p for p, _ in self.fc.posts if p == "/settings"], [])
|
self.assertEqual([p for p, _ in self.fc.posts if p == "/settings"], [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|||||||
@@ -5,13 +5,11 @@
|
|||||||
|
|
||||||
"""The bench registry against scripts/bench: every python tool is
|
"""The bench registry against scripts/bench: every python tool is
|
||||||
registered (or named as not-a-tool), every registered script exists,
|
registered (or named as not-a-tool), every registered script exists,
|
||||||
every argument spec builds a command line, every script compiles, and
|
a ported tool builds a command line with its defaults, and every
|
||||||
the shared helper resolves host and local mode as documented."""
|
script compiles."""
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
import py_compile
|
import py_compile
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import helpers # noqa: F401 (sys.path)
|
import helpers # noqa: F401 (sys.path)
|
||||||
@@ -56,78 +54,10 @@ class RegistryTests(unittest.TestCase):
|
|||||||
self.assertTrue(ok, "%s: %s" % (t["id"], err))
|
self.assertTrue(ok, "%s: %s" % (t["id"], err))
|
||||||
self.assertEqual(argv[1], os.path.join(BENCH, t["script"]))
|
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):
|
def test_scripts_compile(self):
|
||||||
for s in self.scripts:
|
for s in self.scripts:
|
||||||
py_compile.compile(os.path.join(BENCH, s), doraise=True)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ session whose one motion the cooling engine stopped short, and a whole
|
|||||||
three-motion one. Runs against scratch logs and fakes for the controller."""
|
three-motion one. Runs against scratch logs and fakes for the controller."""
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
@@ -130,30 +129,6 @@ class HandBackTests(unittest.TestCase):
|
|||||||
def expect_jog(self, dx, dy):
|
def expect_jog(self, dx, dy):
|
||||||
self.assertEqual(self.ctx.jogs, ["$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy)])
|
self.assertEqual(self.ctx.jogs, ["$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy)])
|
||||||
|
|
||||||
# -- the travel ------------------------------------------------------
|
|
||||||
def test_the_travel_is_the_sum_of_the_motions_ends(self):
|
|
||||||
self.write(WHOLE)
|
|
||||||
self.assertEqual(homeoff.session_travel(0), (4774 - 71 - 13096, 4600 - 144 - 7399))
|
|
||||||
|
|
||||||
def test_the_travel_starts_at_the_mark(self):
|
|
||||||
self.write(WHOLE)
|
|
||||||
mark = homeoff.session_mark()
|
|
||||||
self.write(STOPPED)
|
|
||||||
self.assertEqual(homeoff.session_travel(mark), (4390, 2682))
|
|
||||||
|
|
||||||
def test_the_cloud_clients_log_is_read_the_same_way(self):
|
|
||||||
self.write(WHOLE.replace("gfhome[", "gfcloud["), self.cloudlog)
|
|
||||||
self.assertEqual(homeoff.session_travel(0, cloud.GFCLOUD_LOG), (4774 - 71 - 13096, 4600 - 144 - 7399))
|
|
||||||
self.assertEqual(homeoff.session_travel(0), (0, 0)) # gfhome's log is its own
|
|
||||||
|
|
||||||
def test_a_motion_with_no_end_leaves_the_travel_unknown(self):
|
|
||||||
self.write(STOPPED.split("machine:_motion_locked")[0])
|
|
||||||
self.assertIsNone(homeoff.session_travel(0))
|
|
||||||
|
|
||||||
def test_a_log_rotated_under_the_run_leaves_the_travel_unknown(self):
|
|
||||||
self.write(STOPPED)
|
|
||||||
self.assertIsNone(homeoff.session_travel(os.path.getsize(self.log) + 1))
|
|
||||||
|
|
||||||
# -- the stopped-short judge --------------------------------------------
|
# -- the stopped-short judge --------------------------------------------
|
||||||
def test_a_motion_stopped_short_fails_the_judge(self):
|
def test_a_motion_stopped_short_fails_the_judge(self):
|
||||||
self.write(STOPPED)
|
self.write(STOPPED)
|
||||||
@@ -162,12 +137,6 @@ class HandBackTests(unittest.TestCase):
|
|||||||
homeoff.judge_whole_motions(self.ctx, ev, 0)
|
homeoff.judge_whole_motions(self.ctx, ev, 0)
|
||||||
self.assertEqual(len(ev["short_motions"]), 2)
|
self.assertEqual(len(ev["short_motions"]), 2)
|
||||||
|
|
||||||
def test_whole_motions_pass_the_judge(self):
|
|
||||||
self.write(WHOLE)
|
|
||||||
ev = {}
|
|
||||||
homeoff.judge_whole_motions(self.ctx, ev, 0)
|
|
||||||
self.assertEqual(ev["short_motions"], [])
|
|
||||||
|
|
||||||
# -- after a camera home -------------------------------------------------
|
# -- after a camera home -------------------------------------------------
|
||||||
def test_homed_the_head_goes_back_by_the_travel_and_the_counters_since(self):
|
def test_homed_the_head_goes_back_by_the_travel_and_the_counters_since(self):
|
||||||
# the home zeroed the counters; the test's own jogs left a step or so
|
# the home zeroed the counters; the test's own jogs left a step or so
|
||||||
@@ -228,21 +197,6 @@ class HandBackTests(unittest.TestCase):
|
|||||||
homeoff.cloud_mode_return(self.ctx, {}, 0)
|
homeoff.cloud_mode_return(self.ctx, {}, 0)
|
||||||
self.assertEqual(self.ctx.jogs, [])
|
self.assertEqual(self.ctx.jogs, [])
|
||||||
|
|
||||||
# -- the first failure wins --------------------------------------------
|
|
||||||
def test_a_hand_back_that_fails_after_the_test_failed_is_logged_not_raised(self):
|
|
||||||
self.write(STOPPED.split("machine:_motion_locked")[0])
|
|
||||||
ev = {"homed": True}
|
|
||||||
with self.assertRaises(Failed) as cm:
|
|
||||||
try:
|
|
||||||
raise Failed("the test's own failure")
|
|
||||||
finally:
|
|
||||||
homeoff.camera_home_return(self.ctx, ev, 0)
|
|
||||||
self.assertEqual(str(cm.exception), "the test's own failure")
|
|
||||||
self.assertIn("cannot be known", ev["return"]["failed"])
|
|
||||||
self.assertTrue(any("the head is not back" in l for l in self.ctx.lines), self.ctx.lines)
|
|
||||||
self.assertEqual(self.ctx.jogs, [])
|
|
||||||
self.assertIsNone(sys.exc_info()[1])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,774 +3,16 @@
|
|||||||
# https://community.openglow.org
|
# https://community.openglow.org
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
|
"""cooling.flow-under-load reads the engine's flow verdict from what the
|
||||||
under the real runner Context against a scripted machine: a fake
|
forgectrl log gained since the job began: only the text after the offset,
|
||||||
forgectrl (/status fans, /cool/status phase and verdict, /settings with
|
never an older verdict before it, and the whole of a log rotated under
|
||||||
the gates table, /logs/tail), a fake kernel sysfs (the fan duties), and
|
the test."""
|
||||||
a fake Grbl port that answers '?' and every command.
|
|
||||||
|
|
||||||
The bench case that motivated this: the test started one second after
|
|
||||||
the engine of a previous test went idle, took the tachs still coasting
|
|
||||||
at the cooldown level as its idle reference, and then waited for the
|
|
||||||
fans to come back UP to it. The idle reference now needs the engine
|
|
||||||
idle, the idle duty applied, and tachs that have stopped changing; the
|
|
||||||
pass condition is at-or-below that level, and every sample is logged.
|
|
||||||
"""
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import helpers
|
from forgetest.suite import cooling
|
||||||
from forgetest.runner import Context, Failed, Run
|
|
||||||
from forgetest.suite import cooling, motion
|
|
||||||
|
|
||||||
|
|
||||||
class EngineLog:
|
|
||||||
"""The engine's log lines, as the tests read them: appended to a scratch
|
|
||||||
forgectrl log (a check reads the lines written after its mark) and to the
|
|
||||||
fake /logs/tail."""
|
|
||||||
|
|
||||||
def log_setup(self):
|
|
||||||
self.logdir = tempfile.mkdtemp(prefix="forgetest-cool-")
|
|
||||||
self.saved_log = motion.FORGECTRL_LOG
|
|
||||||
motion.FORGECTRL_LOG = os.path.join(self.logdir, "forgectrl.log")
|
|
||||||
open(motion.FORGECTRL_LOG, "w").close()
|
|
||||||
|
|
||||||
def log_teardown(self):
|
|
||||||
motion.FORGECTRL_LOG = self.saved_log
|
|
||||||
shutil.rmtree(self.logdir, ignore_errors=True)
|
|
||||||
|
|
||||||
def emit(self, text):
|
|
||||||
self.fc.state.setdefault("logs_tail", {"text": ""})["text"] += text
|
|
||||||
with open(motion.FORGECTRL_LOG, "a", encoding="utf-8") as f:
|
|
||||||
f.write(text)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeGrbl:
|
|
||||||
"""Answers '?' with an Idle report and every line with ok."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.sock = socket.socket()
|
|
||||||
self.sock.bind(("127.0.0.1", 0))
|
|
||||||
self.sock.listen(2)
|
|
||||||
os.environ["GRBL_HOST"] = "127.0.0.1"
|
|
||||||
os.environ["GRBL_PORT"] = str(self.sock.getsockname()[1])
|
|
||||||
self.commands = []
|
|
||||||
self.on_command = None
|
|
||||||
self._stop = False
|
|
||||||
threading.Thread(target=self._serve, daemon=True).start()
|
|
||||||
|
|
||||||
def _serve(self):
|
|
||||||
self.sock.settimeout(0.2)
|
|
||||||
while not self._stop:
|
|
||||||
try:
|
|
||||||
c, _ = self.sock.accept()
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
threading.Thread(target=self._client, args=(c,), daemon=True).start()
|
|
||||||
|
|
||||||
def _client(self, c):
|
|
||||||
c.settimeout(0.1)
|
|
||||||
buf = b""
|
|
||||||
c.sendall(b"\r\nGrbl 1.1f ['$' for help]\r\n")
|
|
||||||
while not self._stop:
|
|
||||||
try:
|
|
||||||
d = c.recv(4096)
|
|
||||||
except socket.timeout:
|
|
||||||
continue
|
|
||||||
except OSError:
|
|
||||||
break
|
|
||||||
if not d:
|
|
||||||
break
|
|
||||||
for ch in d:
|
|
||||||
if ch == 0x3F: # '?'
|
|
||||||
c.sendall(b"<Idle|MPos:0.000,0.000,0.000|FS:0,0>\r\n")
|
|
||||||
elif ch in (0x18, 0x85):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
buf += bytes([ch])
|
|
||||||
while b"\n" in buf:
|
|
||||||
line, buf = buf.split(b"\n", 1)
|
|
||||||
line = line.decode().strip()
|
|
||||||
if line:
|
|
||||||
self.commands.append(line)
|
|
||||||
if self.on_command:
|
|
||||||
self.on_command(line)
|
|
||||||
c.sendall(b"ok\r\n")
|
|
||||||
c.close()
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
self._stop = True
|
|
||||||
self.sock.close()
|
|
||||||
for k in ("GRBL_HOST", "GRBL_PORT"):
|
|
||||||
os.environ.pop(k, None)
|
|
||||||
|
|
||||||
|
|
||||||
class FansQuietTests(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-cool-")
|
|
||||||
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
|
|
||||||
os.makedirs(self.sysfs + "thermal")
|
|
||||||
os.makedirs(self.sysfs + "cnc")
|
|
||||||
with open(self.sysfs + "cnc/state", "w") as f:
|
|
||||||
f.write("idle")
|
|
||||||
os.environ["GF_SYSFS_ROOT"] = self.sysfs
|
|
||||||
self.fc = helpers.FakeForgectrl().start()
|
|
||||||
self.grbl = FakeGrbl()
|
|
||||||
self.saved = (cooling.SAMPLE_S, cooling.IDLE_REF_TIMEOUT_S, cooling.COOLDOWN_TIMEOUT_S,
|
|
||||||
cooling.RESTART_IDLE_TIMEOUT_S, cooling.hw.initd)
|
|
||||||
cooling.SAMPLE_S = 0.1
|
|
||||||
cooling.IDLE_REF_TIMEOUT_S = 3
|
|
||||||
cooling.COOLDOWN_TIMEOUT_S = 4
|
|
||||||
cooling.RESTART_IDLE_TIMEOUT_S = 1
|
|
||||||
# The daemon restart of the last phase, scripted: the init script
|
|
||||||
# is a stub, and the engine that comes up applies `after_start`
|
|
||||||
# (the idle duties by default; a daemon that keeps the busy-start
|
|
||||||
# cooldown airflow applies those instead).
|
|
||||||
self.initd_calls = []
|
|
||||||
self.after_start = (0, 0)
|
|
||||||
|
|
||||||
def initd(service, action, timeout=60):
|
|
||||||
self.initd_calls.append((service, action))
|
|
||||||
if action == "start":
|
|
||||||
self.duty(*self.after_start)
|
|
||||||
return 0, ""
|
|
||||||
cooling.hw.initd = initd
|
|
||||||
self.fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
|
|
||||||
self.duty(0, 0)
|
|
||||||
self.fans(0, 736, 733)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
(cooling.SAMPLE_S, cooling.IDLE_REF_TIMEOUT_S, cooling.COOLDOWN_TIMEOUT_S,
|
|
||||||
cooling.RESTART_IDLE_TIMEOUT_S, cooling.hw.initd) = self.saved
|
|
||||||
self.grbl.close()
|
|
||||||
self.fc.stop()
|
|
||||||
os.environ.pop("GF_SYSFS_ROOT", None)
|
|
||||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
||||||
|
|
||||||
# -- the machine -------------------------------------------------------
|
|
||||||
def duty(self, exhaust, intake):
|
|
||||||
for attr, v in (("thermal/exhaust_pwm", exhaust), ("thermal/intake_pwm", intake)):
|
|
||||||
with open(self.sysfs + attr, "w") as f:
|
|
||||||
f.write(str(v))
|
|
||||||
|
|
||||||
def fans(self, exhaust, i1, i2, air=2080):
|
|
||||||
self.fc.state["status"] = dict(self.fc.state["status"],
|
|
||||||
fans={"air_assist": air, "exhaust": exhaust, "intake_1": i1, "intake_2": i2})
|
|
||||||
|
|
||||||
def run_engine(self):
|
|
||||||
"""M8 -> run profile; M9 -> smoke phase, then idle duty, then the
|
|
||||||
tachs coast down (a few samples of spin-down), like forgectrl's
|
|
||||||
engine on the bench."""
|
|
||||||
def on_command(line):
|
|
||||||
if line == "M8":
|
|
||||||
self.duty(65535, 43278)
|
|
||||||
self.fc.state["cool"]["phase"] = "run"
|
|
||||||
self.fans(6753, 3212, 3328, air=10997)
|
|
||||||
elif line == "M9":
|
|
||||||
def cooldown():
|
|
||||||
self.fc.state["cool"]["phase"] = "smoke"
|
|
||||||
time.sleep(0.3)
|
|
||||||
self.fc.state["cool"]["phase"] = "idle"
|
|
||||||
self.duty(0, 0)
|
|
||||||
for ex, i1 in ((5030, 3319), (3100, 2200), (1200, 1300), (0, 740)):
|
|
||||||
self.fans(ex, i1, i1 + 5)
|
|
||||||
time.sleep(0.25)
|
|
||||||
threading.Thread(target=cooldown, daemon=True).start()
|
|
||||||
self.grbl.on_command = on_command
|
|
||||||
|
|
||||||
def run_test(self):
|
|
||||||
run = Run("test", "cooling.fans-quiet-after-motion", "t")
|
|
||||||
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
|
|
||||||
cooling.fans_quiet(ctx)
|
|
||||||
return run
|
|
||||||
|
|
||||||
# -- cases ------------------------------------------------------------------
|
|
||||||
def test_idle_machine_passes_and_logs_every_sample(self):
|
|
||||||
self.run_engine()
|
|
||||||
run = self.run_test()
|
|
||||||
self.assertEqual(run.evidence["before"]["exhaust"], 0)
|
|
||||||
self.assertIsNotNone(run.evidence["settle_s"])
|
|
||||||
self.assertEqual(run.evidence["duty_after"], cooling.IDLE_DUTY)
|
|
||||||
self.assertIn("M8", self.grbl.commands)
|
|
||||||
self.assertIn("M9", self.grbl.commands)
|
|
||||||
cooldown_lines = [ln for ln in run.lines if "cooldown +" in ln]
|
|
||||||
self.assertGreaterEqual(len(cooldown_lines), 2, run.lines)
|
|
||||||
|
|
||||||
def test_a_spin_down_in_progress_is_not_taken_as_the_idle_reference(self):
|
|
||||||
"""The bench case: the previous test's fans are still coasting when
|
|
||||||
this one starts. The reference waits for them to settle, and the
|
|
||||||
run passes instead of waiting for the fans to come back up."""
|
|
||||||
self.run_engine()
|
|
||||||
self.fans(5030, 3319, 3377) # coasting, engine already idle, duty 0
|
|
||||||
|
|
||||||
def coast():
|
|
||||||
for ex, i1 in ((3900, 2600), (2500, 1700), (1100, 1000), (0, 736), (0, 736)):
|
|
||||||
time.sleep(0.12)
|
|
||||||
self.fans(ex, i1, i1 + 5)
|
|
||||||
threading.Thread(target=coast, daemon=True).start()
|
|
||||||
run = self.run_test()
|
|
||||||
self.assertEqual(run.evidence["before"]["exhaust"], 0, run.evidence["before"])
|
|
||||||
self.assertGreater(run.evidence["idle_ref_s"], 0.3)
|
|
||||||
self.assertIsNotNone(run.evidence["settle_s"])
|
|
||||||
self.assertTrue(any("idle ref:" in ln for ln in run.lines))
|
|
||||||
|
|
||||||
def test_the_restart_phase_stops_disables_starts_and_sees_idle_duty(self):
|
|
||||||
self.run_engine()
|
|
||||||
run = self.run_test()
|
|
||||||
self.assertEqual(run.finished, None)
|
|
||||||
self.assertEqual(self.initd_calls, [("forgectrl", "stop"), ("forgectrl", "start")])
|
|
||||||
with open(self.sysfs + "cnc/disable") as f:
|
|
||||||
self.assertEqual(f.read(), "1")
|
|
||||||
self.assertEqual(run.evidence["duty_after_restart"], cooling.IDLE_DUTY)
|
|
||||||
self.assertIsNotNone(run.evidence["restart_idle_s"])
|
|
||||||
|
|
||||||
def test_a_daemon_that_keeps_the_busy_start_airflow_fails(self):
|
|
||||||
# The bench case: after a takeover's restart the exhaust ran at
|
|
||||||
# cooldown duty on an idle machine for good.
|
|
||||||
self.run_engine()
|
|
||||||
self.after_start = (32768, 21639)
|
|
||||||
with self.assertRaises(Failed) as cm:
|
|
||||||
self.run_test()
|
|
||||||
self.assertIn("kept the busy-start airflow", str(cm.exception))
|
|
||||||
self.assertIn("32768", str(cm.exception))
|
|
||||||
|
|
||||||
def test_fans_left_running_fail_with_the_reference_in_the_message(self):
|
|
||||||
def on_command(line):
|
|
||||||
if line == "M8":
|
|
||||||
self.duty(65535, 43278)
|
|
||||||
self.fc.state["cool"]["phase"] = "run"
|
|
||||||
self.fans(6753, 3212, 3328)
|
|
||||||
# M9 ignored: the run profile stays on
|
|
||||||
self.grbl.on_command = on_command
|
|
||||||
run = Run("test", "cooling.fans-quiet-after-motion", "t")
|
|
||||||
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
|
|
||||||
with self.assertRaises(Failed) as cm:
|
|
||||||
cooling.fans_quiet(ctx)
|
|
||||||
self.assertIn("did not return to the idle profile", str(cm.exception))
|
|
||||||
self.assertIn("idle reference", str(cm.exception))
|
|
||||||
self.assertIn("65535", str(cm.exception))
|
|
||||||
|
|
||||||
def test_a_reference_that_never_settles_fails_early_and_says_so(self):
|
|
||||||
def churn():
|
|
||||||
seq = (1000, 4000, 2500, 700, 3300, 1600, 4200) # aperiodic against the sampler
|
|
||||||
i = 0
|
|
||||||
while not self.grbl._stop:
|
|
||||||
v = seq[i % len(seq)]
|
|
||||||
i += 1
|
|
||||||
self.fans(v, v, v)
|
|
||||||
time.sleep(0.03)
|
|
||||||
threading.Thread(target=churn, daemon=True).start()
|
|
||||||
run = Run("test", "cooling.fans-quiet-after-motion", "t")
|
|
||||||
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
|
|
||||||
with self.assertRaises(Failed) as cm:
|
|
||||||
cooling.fans_quiet(ctx)
|
|
||||||
self.assertIn("never settled to an idle reference", str(cm.exception))
|
|
||||||
self.assertEqual(self.grbl.commands, []) # nothing was jogged
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|
||||||
|
|
||||||
class GateOffTests(EngineLog, unittest.TestCase):
|
|
||||||
"""cooling.gate-off against a scripted engine: the fake forgectrl
|
|
||||||
re-reads the ceiling at every M8 (as the engine reloads its tunables
|
|
||||||
at run start), trips OVERTEMP when the coolant is over it, skips the
|
|
||||||
gate and reports gates_off when the ceiling sits at its top, and
|
|
||||||
writes the run-start line the test looks for."""
|
|
||||||
|
|
||||||
TOP, BOTTOM = 60.0, 5.0
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.fc = helpers.FakeForgectrl().start()
|
|
||||||
self.log_setup()
|
|
||||||
self.grbl = FakeGrbl()
|
|
||||||
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S)
|
|
||||||
cooling.VERDICT_WAIT_S = 3
|
|
||||||
cooling.SESSION_END_WAIT_S = 3
|
|
||||||
self.fc.state["status"] = dict(self.fc.state["status"],
|
|
||||||
coolant={"down_c": 22.4, "up_c": 22.3, "pump": True, "tec": False},
|
|
||||||
temps={"chassis_c": 29.0, "supply_raw": 589, "soc_c": 42.8,
|
|
||||||
"soc_throttle": 0},
|
|
||||||
gates_off=[])
|
|
||||||
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
|
|
||||||
"gates_off": []}
|
|
||||||
self.fc.state["settings"].update({"cool_temp_max": "", "cool_temp_resume": ""})
|
|
||||||
self.log_line = True # the engine writes its run-start line
|
|
||||||
self.temps_line = True # the engine writes its run-end temperature line
|
|
||||||
self.report_off = True # the engine reports the off gate
|
|
||||||
self.trips = True # the engine trips a low ceiling
|
|
||||||
self.sessions = 0 # run sessions the engine saw (M8 with the phase not run)
|
|
||||||
self._describe()
|
|
||||||
self.grbl.on_command = self._engine
|
|
||||||
self.fc.on_post = self._on_post
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S = self.saved
|
|
||||||
self.grbl.close()
|
|
||||||
self.fc.stop()
|
|
||||||
self.log_teardown()
|
|
||||||
|
|
||||||
# -- the scripted machine --------------------------------------------------
|
|
||||||
def ceiling(self):
|
|
||||||
v = self.fc.state["settings"].get("cool_temp_max") or ""
|
|
||||||
return float(v) if v else 33.0
|
|
||||||
|
|
||||||
def _describe(self):
|
|
||||||
"""/settings carries the gates table the way forgectrl's gates.c
|
|
||||||
publishes it, classified from the stored value."""
|
|
||||||
v = self.ceiling()
|
|
||||||
state = "off" if v >= self.TOP else ("ok" if 25 <= v <= 38 else "warn")
|
|
||||||
self.fc.state["settings"]["gates"] = {
|
|
||||||
"cool_temp_max": {"gate": "coolant_max", "def": 33, "lo": self.BOTTOM, "hi": self.TOP,
|
|
||||||
"band": [25, 38], "off": "high", "value": v, "state": state}}
|
|
||||||
|
|
||||||
def _on_post(self, path, form):
|
|
||||||
"""The real settings reply re-classifies the gates from the new values."""
|
|
||||||
if path != "/settings":
|
|
||||||
return None
|
|
||||||
self.fc.state["settings"].update(form)
|
|
||||||
self._describe()
|
|
||||||
return (200, self.fc.state["settings"])
|
|
||||||
|
|
||||||
def _engine(self, line):
|
|
||||||
"""M8 opens a run session: the engine re-reads the ceiling and
|
|
||||||
ticks the gate. M9 ends it a report period later (the phase
|
|
||||||
leaves run); a hold taken against the old ceiling stands until
|
|
||||||
the next session re-reads."""
|
|
||||||
self._describe()
|
|
||||||
cool = self.fc.state["cool"]
|
|
||||||
if line == "M9":
|
|
||||||
def end():
|
|
||||||
time.sleep(0.3)
|
|
||||||
cool["phase"] = "smoke"
|
|
||||||
if self.temps_line:
|
|
||||||
self.emit(
|
|
||||||
"Aug 22 12:00:30 forgectrl: cool: temps this job: chassis 29.0..29.4 C, "
|
|
||||||
"soc 42.8..47.1 C, supply raw 587..592\n")
|
|
||||||
time.sleep(0.2)
|
|
||||||
cool["phase"] = "idle"
|
|
||||||
threading.Thread(target=end, daemon=True).start()
|
|
||||||
return
|
|
||||||
if line != "M8":
|
|
||||||
return
|
|
||||||
self.sessions += 1
|
|
||||||
cool["phase"] = "run"
|
|
||||||
v = self.ceiling()
|
|
||||||
off = v >= self.TOP
|
|
||||||
if off and self.log_line:
|
|
||||||
self.emit(
|
|
||||||
"Aug 21 12:00:00 forgectrl: cool: gate coolant_max OFF: cool_temp_max = 60 "
|
|
||||||
"(the high end of 5 to 60; recommended 25 to 38, default 33)\n")
|
|
||||||
gates_off = ["coolant_max"] if off and self.report_off else []
|
|
||||||
if not off and self.trips and 22.3 > v:
|
|
||||||
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, gates_off=gates_off)
|
|
||||||
else:
|
|
||||||
cool.update(verdict="OK", fire_ok=True, hold=False, gates_off=gates_off)
|
|
||||||
self.fc.state["status"]["gates_off"] = gates_off
|
|
||||||
|
|
||||||
def run_test(self):
|
|
||||||
run = Run("test", "cooling.gate-off", "t")
|
|
||||||
ctx = Context(run, None, helpers.make_test("cooling.gate-off", []))
|
|
||||||
cooling.gate_off(ctx)
|
|
||||||
return run
|
|
||||||
|
|
||||||
def settings_posts(self):
|
|
||||||
return [f for p, f in self.fc.posts if p == "/settings"]
|
|
||||||
|
|
||||||
# -- cases ------------------------------------------------------------------
|
|
||||||
def test_trip_then_off_then_restored_passes(self):
|
|
||||||
run = self.run_test()
|
|
||||||
self.assertEqual(run.evidence["trip"]["verdict"], "OVERTEMP")
|
|
||||||
self.assertEqual(run.evidence["off"]["gates_off"], ["coolant_max"])
|
|
||||||
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
|
|
||||||
posts = self.settings_posts()
|
|
||||||
self.assertEqual(posts[0], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0"})
|
|
||||||
self.assertEqual(posts[1], {"cool_temp_max": "60.0", "cool_temp_resume": ""})
|
|
||||||
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
|
|
||||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
|
||||||
self.assertEqual(self.grbl.commands.count("M8"), 3)
|
|
||||||
self.assertEqual(self.grbl.commands.count("M9"), 3)
|
|
||||||
|
|
||||||
def test_an_engine_that_does_not_trip_fails_and_restores(self):
|
|
||||||
self.trips = False
|
|
||||||
with self.assertRaises(Failed) as cm:
|
|
||||||
self.run_test()
|
|
||||||
self.assertIn("did not trip", str(cm.exception))
|
|
||||||
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
|
|
||||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
|
||||||
# The restore cycles a run session so the engine re-reads the
|
|
||||||
# restored values; the bench is not left holding on the test's.
|
|
||||||
self.assertEqual(self.grbl.commands.count("M8"), 2)
|
|
||||||
self.assertEqual(self.grbl.commands.count("M9"), 2)
|
|
||||||
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
|
||||||
|
|
||||||
def test_every_session_waits_for_the_previous_one_to_end(self):
|
|
||||||
"""Each M8 must find the engine out of phase run, or the engine
|
|
||||||
never re-reads: the bench failure behind this case sent M9 and
|
|
||||||
the next M8 300 ms apart and the 1 Hz report pipeline swallowed
|
|
||||||
the session end."""
|
|
||||||
seen = []
|
|
||||||
inner = self._engine
|
|
||||||
|
|
||||||
def engine(line):
|
|
||||||
if line == "M8":
|
|
||||||
seen.append(self.fc.state["cool"]["phase"])
|
|
||||||
inner(line)
|
|
||||||
self.grbl.on_command = engine
|
|
||||||
self.run_test()
|
|
||||||
self.assertEqual(seen, ["idle", "idle", "idle"])
|
|
||||||
self.assertEqual(self.sessions, 3)
|
|
||||||
|
|
||||||
def test_an_engine_that_hides_the_off_gate_fails(self):
|
|
||||||
self.report_off = False
|
|
||||||
with self.assertRaises(Failed) as cm:
|
|
||||||
self.run_test()
|
|
||||||
self.assertIn("gates_off", str(cm.exception))
|
|
||||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
|
||||||
|
|
||||||
def test_a_missing_run_start_log_line_fails(self):
|
|
||||||
self.log_line = False
|
|
||||||
with self.assertRaises(Failed) as cm:
|
|
||||||
self.run_test()
|
|
||||||
self.assertIn("run-start log line", str(cm.exception))
|
|
||||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
|
||||||
|
|
||||||
def test_an_earlier_runs_line_is_not_this_runs(self):
|
|
||||||
# The off gate's line from a run before this one is in the log, and
|
|
||||||
# the engine writes none this time: the line judged is one written
|
|
||||||
# after the test's own mark, so the old one does not pass for it.
|
|
||||||
self.emit("Aug 21 11:00:00 forgectrl: cool: gate coolant_max OFF: cool_temp_max = 60 "
|
|
||||||
"(the high end of 5 to 60; recommended 25 to 38, default 33)\n")
|
|
||||||
self.log_line = False
|
|
||||||
with self.assertRaises(Failed) as cm:
|
|
||||||
self.run_test()
|
|
||||||
self.assertIn("run-start log line", str(cm.exception))
|
|
||||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
|
||||||
|
|
||||||
def test_a_missing_run_end_temperature_line_fails(self):
|
|
||||||
self.temps_line = False
|
|
||||||
with self.assertRaises(Failed) as cm:
|
|
||||||
self.run_test()
|
|
||||||
self.assertIn("board-temperature line", str(cm.exception))
|
|
||||||
|
|
||||||
def test_a_custom_ceiling_is_restored_verbatim(self):
|
|
||||||
self.fc.state["settings"].update({"cool_temp_max": "30", "cool_temp_resume": "28"})
|
|
||||||
self._describe()
|
|
||||||
run = self.run_test()
|
|
||||||
self.assertEqual(run.evidence["orig"], {"cool_temp_max": "30", "cool_temp_resume": "28"})
|
|
||||||
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "30", "cool_temp_resume": "28"})
|
|
||||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "30")
|
|
||||||
|
|
||||||
|
|
||||||
class CriticalTierTests(EngineLog, unittest.TestCase):
|
|
||||||
"""cooling.critical-tier against a scripted engine: two tiers on the
|
|
||||||
coolant, the fail tier winning over the pause tier in a run session
|
|
||||||
and ending with it, the critical line off at its top, and the settings
|
|
||||||
cross-check refusing a critical line at or below the ceiling."""
|
|
||||||
|
|
||||||
UP = 22.3
|
|
||||||
ROWS = { # key: (gate, default, lo, hi, band_lo, band_hi, off)
|
|
||||||
"cool_temp_max": ("coolant_max", 33.0, 5.0, 60.0, 25.0, 38.0, "high"),
|
|
||||||
"cool_temp_resume": (None, 31.0, 5.0, 59.0, 20.0, 36.0, "none"),
|
|
||||||
"cool_temp_critical_c": ("coolant_critical", 38.0, 6.0, 70.0, 36.0, 45.0, "high"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.fc = helpers.FakeForgectrl().start()
|
|
||||||
self.log_setup()
|
|
||||||
self.grbl = FakeGrbl()
|
|
||||||
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S)
|
|
||||||
cooling.VERDICT_WAIT_S = 3
|
|
||||||
cooling.SESSION_END_WAIT_S = 3
|
|
||||||
self.fc.state["status"] = dict(self.fc.state["status"],
|
|
||||||
coolant={"down_c": 22.4, "up_c": self.UP, "pump": True, "tec": False},
|
|
||||||
gates_off=[])
|
|
||||||
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
|
|
||||||
"gates_off": []}
|
|
||||||
for k in self.ROWS:
|
|
||||||
self.fc.state["settings"].setdefault(k, "")
|
|
||||||
self.fc.state["logs_tail"] = {"text": ""}
|
|
||||||
self.faults = True # the engine has the critical tier
|
|
||||||
self.ends_with_session = True
|
|
||||||
self.cross_checks = True # the settings API refuses crit <= max
|
|
||||||
self._describe()
|
|
||||||
self.grbl.on_command = self._engine
|
|
||||||
self.fc.on_post = self._on_post
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S = self.saved
|
|
||||||
self.grbl.close()
|
|
||||||
self.fc.stop()
|
|
||||||
self.log_teardown()
|
|
||||||
|
|
||||||
def setting(self, key):
|
|
||||||
v = self.fc.state["settings"].get(key) or ""
|
|
||||||
return float(v) if v else self.ROWS[key][1]
|
|
||||||
|
|
||||||
def _describe(self):
|
|
||||||
gates = {}
|
|
||||||
for k, (gate, d, lo, hi, blo, bhi, off) in self.ROWS.items():
|
|
||||||
v = self.setting(k)
|
|
||||||
state = "off" if (off == "high" and v >= hi) else ("ok" if blo <= v <= bhi else "warn")
|
|
||||||
gates[k] = {"gate": gate, "def": d, "lo": lo, "hi": hi, "band": [blo, bhi], "off": off,
|
|
||||||
"value": v, "state": state}
|
|
||||||
self.fc.state["settings"]["gates"] = gates
|
|
||||||
|
|
||||||
def _on_post(self, path, form):
|
|
||||||
if path != "/settings":
|
|
||||||
return None
|
|
||||||
if self.cross_checks:
|
|
||||||
merged = dict(self.fc.state["settings"])
|
|
||||||
merged.update(form)
|
|
||||||
tmax = float(merged.get("cool_temp_max") or 33.0)
|
|
||||||
tcrit = float(merged.get("cool_temp_critical_c") or 38.0)
|
|
||||||
ceiling_off = tmax >= self.ROWS["cool_temp_max"][3]
|
|
||||||
if not ceiling_off and tcrit <= tmax:
|
|
||||||
return (400, {"error": "cool_temp_critical_c must be above cool_temp_max"})
|
|
||||||
self.fc.state["settings"].update(form)
|
|
||||||
self._describe()
|
|
||||||
return (200, self.fc.state["settings"])
|
|
||||||
|
|
||||||
def _verdict_idle(self):
|
|
||||||
"""Outside a run only the ceiling's pause tier stands."""
|
|
||||||
return "OVERTEMP" if self.UP > self.setting("cool_temp_max") else "OK"
|
|
||||||
|
|
||||||
def _engine(self, line):
|
|
||||||
self._describe()
|
|
||||||
cool = self.fc.state["cool"]
|
|
||||||
if line == "M9":
|
|
||||||
def end():
|
|
||||||
time.sleep(0.3)
|
|
||||||
cool["phase"] = "idle"
|
|
||||||
if self.ends_with_session or cool["verdict"] != "CRITICAL":
|
|
||||||
v = self._verdict_idle()
|
|
||||||
cool.update(verdict=v, fire_ok=v == "OK", hold=v != "OK", reason="")
|
|
||||||
threading.Thread(target=end, daemon=True).start()
|
|
||||||
return
|
|
||||||
if line != "M8":
|
|
||||||
return
|
|
||||||
cool["phase"] = "run"
|
|
||||||
tmax, tcrit = self.setting("cool_temp_max"), self.setting("cool_temp_critical_c")
|
|
||||||
crit_off = tcrit >= self.ROWS["cool_temp_critical_c"][3]
|
|
||||||
off = ["coolant_critical"] if crit_off else []
|
|
||||||
if crit_off:
|
|
||||||
self.emit(
|
|
||||||
"Aug 22 12:00:00 forgectrl: cool: gate coolant_critical OFF: cool_temp_critical_c = 70 "
|
|
||||||
"(the high end of 6 to 70; recommended 36 to 45, default 38)\n")
|
|
||||||
if self.faults and not crit_off and self.UP >= tcrit:
|
|
||||||
cool.update(verdict="CRITICAL", fire_ok=False, hold=True, resume_ok=False,
|
|
||||||
reason="CRITICAL: coolant %.1f C at or over the %.0f C critical line - hold, "
|
|
||||||
"no resume this job" % (self.UP, tcrit), gates_off=off)
|
|
||||||
elif self.UP > tmax:
|
|
||||||
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, resume_ok=False,
|
|
||||||
reason="coolant over the ceiling", gates_off=off)
|
|
||||||
else:
|
|
||||||
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off)
|
|
||||||
self.fc.state["status"]["gates_off"] = off
|
|
||||||
|
|
||||||
def run_test(self):
|
|
||||||
run = Run("test", "cooling.critical-tier", "t")
|
|
||||||
ctx = Context(run, None, helpers.make_test("cooling.critical-tier", []))
|
|
||||||
cooling.critical_tier(ctx)
|
|
||||||
return run
|
|
||||||
|
|
||||||
def settings_posts(self):
|
|
||||||
return [f for p, f in self.fc.posts if p == "/settings"]
|
|
||||||
|
|
||||||
# -- cases ------------------------------------------------------------------
|
|
||||||
def test_fault_then_off_then_restored_passes(self):
|
|
||||||
run = self.run_test()
|
|
||||||
self.assertEqual(run.evidence["cross_check"]["status"], 400)
|
|
||||||
self.assertEqual(run.evidence["critical"]["verdict"], "CRITICAL")
|
|
||||||
self.assertEqual(run.evidence["critical_after"]["verdict"], "OVERTEMP")
|
|
||||||
self.assertEqual(run.evidence["critical_off"]["gates_off"], ["coolant_critical"])
|
|
||||||
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
|
|
||||||
self.assertEqual(run.evidence["ceiling_off_accepted"], 200)
|
|
||||||
posts = self.settings_posts()
|
|
||||||
# refused cross-check, the ceiling at its off end (accepted), its undo, then the low leg
|
|
||||||
self.assertEqual(posts[1], {"cool_temp_max": "60.0"})
|
|
||||||
self.assertEqual(posts[3], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0",
|
|
||||||
"cool_temp_critical_c": "7.0"})
|
|
||||||
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": "", "cool_temp_critical_c": ""})
|
|
||||||
self.assertEqual(self.grbl.commands.count("M8"), 3)
|
|
||||||
self.assertEqual(self.grbl.commands.count("M9"), 3)
|
|
||||||
|
|
||||||
def test_an_engine_without_the_tier_fails_and_restores(self):
|
|
||||||
self.faults = False
|
|
||||||
with self.assertRaises(Failed) as cm:
|
|
||||||
self.run_test()
|
|
||||||
self.assertIn("did not fault", str(cm.exception))
|
|
||||||
self.assertEqual(self.settings_posts()[-1],
|
|
||||||
{"cool_temp_max": "", "cool_temp_resume": "", "cool_temp_critical_c": ""})
|
|
||||||
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
|
||||||
|
|
||||||
def test_a_fault_that_outlives_its_session_fails(self):
|
|
||||||
self.ends_with_session = False
|
|
||||||
with self.assertRaises(Failed) as cm:
|
|
||||||
self.run_test()
|
|
||||||
self.assertIn("after the faulted session", str(cm.exception))
|
|
||||||
|
|
||||||
def test_a_missing_cross_check_fails_before_any_session(self):
|
|
||||||
self.cross_checks = False
|
|
||||||
with self.assertRaises(Failed) as cm:
|
|
||||||
self.run_test()
|
|
||||||
self.assertIn("was accepted", str(cm.exception))
|
|
||||||
self.assertEqual(self.grbl.commands.count("M8"), 0)
|
|
||||||
|
|
||||||
|
|
||||||
class FanGateTests(unittest.TestCase):
|
|
||||||
"""cooling.fan-gate-trips against a scripted engine: the fake reads the
|
|
||||||
floors at every M8 (the engine reloads at run start), holds every fan
|
|
||||||
in grace for the configured seconds, then judges the bench readings
|
|
||||||
(exhaust 6753, intakes 3212/3328, air assist 10997 rpm, purge 628)
|
|
||||||
against the floors: a floor a reading cannot meet trips AIRFLOW three
|
|
||||||
ticks after the grace, a floor of zero reads off."""
|
|
||||||
|
|
||||||
READINGS = {"exhaust": 6753, "intake_1": 3212, "intake_2": 3328, "air_assist": 10997, "purge": 628}
|
|
||||||
FLOORS = {"exhaust": ("cool_tach_exhaust_min_rpm", 3700.0, 20000.0),
|
|
||||||
"intake_1": ("cool_tach_intake_min_rpm", 1800.0, 20000.0),
|
|
||||||
"intake_2": ("cool_tach_intake_min_rpm", 1800.0, 20000.0),
|
|
||||||
"air_assist": ("cool_tach_air_assist_min_rpm", 6000.0, 30000.0),
|
|
||||||
"purge": ("cool_purge_min_current", 300.0, 1023.0)}
|
|
||||||
GATE_OF = {"exhaust": "exhaust", "intake_1": "intake", "intake_2": "intake",
|
|
||||||
"air_assist": "air_assist", "purge": "purge"}
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.fc = helpers.FakeForgectrl().start()
|
|
||||||
self.grbl = FakeGrbl()
|
|
||||||
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling.FAN_TRIP_WAIT_S)
|
|
||||||
cooling.VERDICT_WAIT_S = 4
|
|
||||||
cooling.SESSION_END_WAIT_S = 3
|
|
||||||
cooling.FAN_TRIP_WAIT_S = 4
|
|
||||||
self.fc.state["status"] = dict(self.fc.state["status"], gates_off=[])
|
|
||||||
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
|
|
||||||
"gates_off": [], "fan_gates": {}}
|
|
||||||
for key, _d, _h in self.FLOORS.values():
|
|
||||||
self.fc.state["settings"].setdefault(key, "")
|
|
||||||
self.fc.state["settings"].setdefault("cool_fan_grace_s", "")
|
|
||||||
self.trips = True # the engine trips an unmeetable floor
|
|
||||||
self.reports_off = True # the engine reports a zero floor as off
|
|
||||||
self.grace_scale = 0.1 # seconds of fake grace per configured second
|
|
||||||
self._describe()
|
|
||||||
self.grbl.on_command = self._engine
|
|
||||||
self.fc.on_post = self._on_post
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling.FAN_TRIP_WAIT_S = self.saved
|
|
||||||
self.grbl.close()
|
|
||||||
self.fc.stop()
|
|
||||||
|
|
||||||
def setting(self, key, default):
|
|
||||||
v = self.fc.state["settings"].get(key) or ""
|
|
||||||
return float(v) if v else default
|
|
||||||
|
|
||||||
def _describe(self):
|
|
||||||
gates = {}
|
|
||||||
for fan, (key, default, hi) in self.FLOORS.items():
|
|
||||||
v = self.setting(key, default)
|
|
||||||
gates[key] = {"gate": self.GATE_OF[fan], "def": default, "lo": 0.0, "hi": hi,
|
|
||||||
"band": [default * 0.7, default * 1.4], "off": "low", "value": v,
|
|
||||||
"state": "off" if v <= 0 else "ok"}
|
|
||||||
g = self.setting("cool_fan_grace_s", 15.0)
|
|
||||||
gates["cool_fan_grace_s"] = {"gate": None, "def": 15.0, "lo": 0.0, "hi": 120.0, "band": [5, 30],
|
|
||||||
"off": "none", "value": g, "state": "ok"}
|
|
||||||
self.fc.state["settings"]["gates"] = gates
|
|
||||||
|
|
||||||
def _on_post(self, path, form):
|
|
||||||
if path != "/settings":
|
|
||||||
return None
|
|
||||||
self.fc.state["settings"].update(form)
|
|
||||||
self._describe()
|
|
||||||
return (200, self.fc.state["settings"])
|
|
||||||
|
|
||||||
def _engine(self, line):
|
|
||||||
self._describe()
|
|
||||||
cool = self.fc.state["cool"]
|
|
||||||
if line == "M9":
|
|
||||||
def end():
|
|
||||||
time.sleep(0.3)
|
|
||||||
cool["phase"] = "idle"
|
|
||||||
# the fan fault is the session's: it ends with it
|
|
||||||
cool.update(verdict="OK", fire_ok=True, hold=False, reason="")
|
|
||||||
for g in cool.get("fan_gates", {}).values():
|
|
||||||
g["state"] = "idle"
|
|
||||||
threading.Thread(target=end, daemon=True).start()
|
|
||||||
return
|
|
||||||
if line != "M8":
|
|
||||||
return
|
|
||||||
cool["phase"] = "run"
|
|
||||||
floors = {fan: self.setting(key, default) for fan, (key, default, _h) in self.FLOORS.items()}
|
|
||||||
off = sorted({self.GATE_OF[f] for f, v in floors.items() if v <= 0}) if self.reports_off else []
|
|
||||||
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off,
|
|
||||||
fan_gates={f: {"reading": self.READINGS[f], "floor": floors[f],
|
|
||||||
"state": "off" if floors[f] <= 0 else "grace"} for f in floors})
|
|
||||||
self.fc.state["status"]["gates_off"] = off
|
|
||||||
grace = self.setting("cool_fan_grace_s", 15.0) * self.grace_scale
|
|
||||||
|
|
||||||
def judge():
|
|
||||||
time.sleep(grace)
|
|
||||||
if cool["phase"] != "run":
|
|
||||||
return
|
|
||||||
for f in floors:
|
|
||||||
if floors[f] > 0:
|
|
||||||
cool["fan_gates"][f]["state"] = "ok" if self.READINGS[f] >= floors[f] else "under"
|
|
||||||
time.sleep(0.3)
|
|
||||||
if cool["phase"] != "run" or not self.trips:
|
|
||||||
return
|
|
||||||
for f in floors:
|
|
||||||
if 0 < floors[f] > self.READINGS[f]:
|
|
||||||
cool["fan_gates"][f]["state"] = "TRIPPED"
|
|
||||||
cool.update(verdict="AIRFLOW", fire_ok=False, hold=True, resume_ok=False,
|
|
||||||
reason="AIRFLOW: %s %d under the %d floor for 3 s - hold, no resume this job"
|
|
||||||
% (f, self.READINGS[f], floors[f]))
|
|
||||||
break
|
|
||||||
threading.Thread(target=judge, daemon=True).start()
|
|
||||||
|
|
||||||
def run_test(self):
|
|
||||||
run = Run("test", "cooling.fan-gate-trips", "t")
|
|
||||||
ctx = Context(run, None, helpers.make_test("cooling.fan-gate-trips", []))
|
|
||||||
cooling.fan_gate_trips(ctx)
|
|
||||||
return run
|
|
||||||
|
|
||||||
def settings_posts(self):
|
|
||||||
return [f for p, f in self.fc.posts if p == "/settings"]
|
|
||||||
|
|
||||||
def test_trip_purge_off_and_restore_pass(self):
|
|
||||||
run = self.run_test()
|
|
||||||
ev = run.evidence
|
|
||||||
self.assertEqual(ev["exhaust_trip"]["cool"]["verdict"], "AIRFLOW")
|
|
||||||
self.assertEqual(ev["exhaust_trip"]["gate"]["state"], "TRIPPED")
|
|
||||||
self.assertEqual(ev["purge_trip"]["gate"]["state"], "TRIPPED")
|
|
||||||
self.assertEqual(ev["exhaust_off"]["cool"]["gates_off"], ["exhaust"])
|
|
||||||
self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values()))
|
|
||||||
posts = self.settings_posts()
|
|
||||||
self.assertEqual(posts[0], {"cool_tach_exhaust_min_rpm": "20000.0", "cool_fan_grace_s": "8"})
|
|
||||||
self.assertEqual(posts[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
|
|
||||||
"cool_fan_grace_s": ""})
|
|
||||||
self.assertEqual(self.grbl.commands.count("M8"), 4)
|
|
||||||
self.assertEqual(self.grbl.commands.count("M9"), 4)
|
|
||||||
|
|
||||||
def test_an_engine_that_does_not_trip_fails_and_restores(self):
|
|
||||||
self.trips = False
|
|
||||||
with self.assertRaises(Failed) as cm:
|
|
||||||
self.run_test()
|
|
||||||
self.assertIn("did not trip", str(cm.exception))
|
|
||||||
self.assertEqual(self.settings_posts()[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
|
|
||||||
"cool_fan_grace_s": ""})
|
|
||||||
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
|
||||||
|
|
||||||
def test_an_engine_that_hides_an_off_floor_fails(self):
|
|
||||||
self.reports_off = False
|
|
||||||
with self.assertRaises(Failed) as cm:
|
|
||||||
self.run_test()
|
|
||||||
self.assertIn("lacks exhaust", str(cm.exception))
|
|
||||||
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
|
|
||||||
|
|
||||||
|
|
||||||
class FlowVerdictLogTests(unittest.TestCase):
|
class FlowVerdictLogTests(unittest.TestCase):
|
||||||
@@ -817,5 +59,6 @@ class FlowVerdictLogTests(unittest.TestCase):
|
|||||||
self.write(self.VERDICT) # the rotation: a new, shorter file
|
self.write(self.VERDICT) # the rotation: a new, shorter file
|
||||||
self.assertEqual(cooling._log_since(self.path, off), self.VERDICT)
|
self.assertEqual(cooling._log_since(self.path, off), self.VERDICT)
|
||||||
|
|
||||||
def test_a_missing_log_reads_as_nothing(self):
|
|
||||||
self.assertEqual(cooling._log_since(os.path.join(self.dir, "none.log"), 0), "")
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|||||||
+225
-230
@@ -7,19 +7,16 @@
|
|||||||
|
|
||||||
The fixture is a box on the network that opens the lid loop, pulls the
|
The fixture is a box on the network that opens the lid loop, pulls the
|
||||||
interlock loop and presses the button on request. What has to hold: the
|
interlock loop and presses the button on request. What has to hold: the
|
||||||
tool finds it by name without a resolver on the image, speaks its API
|
tool speaks its API under the key, falls back to the operator when it
|
||||||
under the key, falls back to the operator when it cannot act, moves an
|
cannot act, moves an operator test it can perform alone into the
|
||||||
operator test it can perform alone into the unattended queue (never a
|
unattended queue (never a live one, never one that needs a person for
|
||||||
live one, never one that needs a person for anything else), refuses a
|
anything else), refuses a prompt during such a run rather than hanging on
|
||||||
prompt during such a run rather than hanging on it, releases what the
|
it, releases what the box still holds after every run, and presses the
|
||||||
box still holds after every run, and presses the arm button only where
|
arm button only where the bench opted in.
|
||||||
the bench opted in.
|
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -30,7 +27,7 @@ import helpers
|
|||||||
from forgetest import fixture as fx
|
from forgetest import fixture as fx
|
||||||
from forgetest import runner as runner_mod
|
from forgetest import runner as runner_mod
|
||||||
from forgetest.log import Log
|
from forgetest.log import Log
|
||||||
from forgetest.runner import Context, Failed, Run, Runner
|
from forgetest.runner import Context, Run, Runner
|
||||||
|
|
||||||
KEY = "0123456789abcdef0123456789abcdef"
|
KEY = "0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
@@ -43,6 +40,8 @@ class FakeFixture:
|
|||||||
self.button_enabled = button_enabled
|
self.button_enabled = button_enabled
|
||||||
self.calls = []
|
self.calls = []
|
||||||
self.presses = 0 # presses the device performed
|
self.presses = 0 # presses the device performed
|
||||||
|
self.reply_delay = 0.0 # seconds between a press and its reply
|
||||||
|
self.replied = 0 # delayed replies the device is done with (sent or lost)
|
||||||
srv = self
|
srv = self
|
||||||
|
|
||||||
class H(BaseHTTPRequestHandler):
|
class H(BaseHTTPRequestHandler):
|
||||||
@@ -98,6 +97,14 @@ class FakeFixture:
|
|||||||
threading.Timer(0.05, lambda: srv.state.__setitem__("button", "idle")).start()
|
threading.Timer(0.05, lambda: srv.state.__setitem__("button", "idle")).start()
|
||||||
s = self._state()
|
s = self._state()
|
||||||
s["pulse_ms"] = 200
|
s["pulse_ms"] = 200
|
||||||
|
if srv.reply_delay:
|
||||||
|
time.sleep(srv.reply_delay) # the reply comes after the client gave up
|
||||||
|
try:
|
||||||
|
self._json(200, s)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
srv.replied += 1
|
||||||
|
return None
|
||||||
return self._json(200, s)
|
return self._json(200, s)
|
||||||
if self.path == "/release":
|
if self.path == "/release":
|
||||||
srv.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
|
srv.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
|
||||||
@@ -112,37 +119,10 @@ class FakeFixture:
|
|||||||
self.httpd.shutdown()
|
self.httpd.shutdown()
|
||||||
self.httpd.server_close()
|
self.httpd.server_close()
|
||||||
|
|
||||||
|
def wait_replied(self, n, timeout=5.0):
|
||||||
class MdnsTests(unittest.TestCase):
|
deadline = time.time() + timeout
|
||||||
def test_query_packet(self):
|
while self.replied < n and time.time() < deadline:
|
||||||
q = fx.mdns_query("forgefixture.local")
|
time.sleep(0.02)
|
||||||
self.assertEqual(q[:12], struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0))
|
|
||||||
self.assertEqual(q[12:], b"\x0cforgefixture\x05local\x00" + struct.pack("!HH", 1, 0x8001))
|
|
||||||
|
|
||||||
def _response(self, name, ip, extra_name=None):
|
|
||||||
labels = b"".join(struct.pack("B", len(p)) + p.encode() for p in name.split(".")) + b"\x00"
|
|
||||||
hdr = struct.pack("!HHHHHH", 0, 0x8400, 0, 2 if extra_name else 1, 0, 0)
|
|
||||||
rr = labels + struct.pack("!HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(ip)
|
|
||||||
if extra_name:
|
|
||||||
# a second answer naming the first through a compression pointer
|
|
||||||
# to offset 12 (the first name), plus its own first label
|
|
||||||
other = struct.pack("B", len(extra_name)) + extra_name.encode() + struct.pack("!H", 0xC000 | 12)
|
|
||||||
rr += other + struct.pack("!HHIH", 1, 0x8001, 120, 4) + socket.inet_aton("10.0.0.9")
|
|
||||||
return hdr + rr
|
|
||||||
|
|
||||||
def test_answers_are_the_named_a_records(self):
|
|
||||||
data = self._response("forgefixture.local", "192.0.2.50")
|
|
||||||
self.assertEqual(fx.mdns_answers(data, "forgefixture.local"), ["192.0.2.50"])
|
|
||||||
self.assertEqual(fx.mdns_answers(data, "FORGEFIXTURE.local."), ["192.0.2.50"])
|
|
||||||
self.assertEqual(fx.mdns_answers(data, "other.local"), [])
|
|
||||||
# a query (QR clear) is never an answer
|
|
||||||
self.assertEqual(fx.mdns_answers(fx.mdns_query("forgefixture.local"), "forgefixture.local"), [])
|
|
||||||
# compression pointers are followed; the other record is not ours
|
|
||||||
data2 = self._response("forgefixture.local", "192.0.2.50", extra_name="printer")
|
|
||||||
self.assertEqual(fx.mdns_answers(data2, "forgefixture.local"), ["192.0.2.50"])
|
|
||||||
self.assertEqual(fx.mdns_answers(data2, "printer.forgefixture.local"), ["10.0.0.9"])
|
|
||||||
# garbage does not raise
|
|
||||||
self.assertEqual(fx.mdns_answers(b"\x00\x01", "forgefixture.local"), [])
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigTests(unittest.TestCase):
|
class ConfigTests(unittest.TestCase):
|
||||||
@@ -190,11 +170,11 @@ class ClientTests(unittest.TestCase):
|
|||||||
self.resolved.append(hostname)
|
self.resolved.append(hostname)
|
||||||
return "127.0.0.1"
|
return "127.0.0.1"
|
||||||
|
|
||||||
def client(self, **over):
|
def client(self, timeout=3.0, resolver=None, **over):
|
||||||
cfg = {"hostname": "forgefixture", "key": KEY, "channels": ["lid", "interlock", "button"],
|
cfg = {"hostname": "forgefixture", "key": KEY, "channels": ["lid", "interlock", "button"],
|
||||||
"port": self.dev.port}
|
"port": self.dev.port}
|
||||||
cfg.update(over)
|
cfg.update(over)
|
||||||
return fx.Fixture(cfg, resolver=self.resolver)
|
return fx.Fixture(cfg, timeout=timeout, resolver=resolver or self.resolver)
|
||||||
|
|
||||||
def test_status_covers_act_release(self):
|
def test_status_covers_act_release(self):
|
||||||
f = self.client()
|
f = self.client()
|
||||||
@@ -208,7 +188,7 @@ class ClientTests(unittest.TestCase):
|
|||||||
f.act("button", "press")
|
f.act("button", "press")
|
||||||
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {}))
|
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {}))
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
# a check that must reach the machine while the button is down asks for the longest press
|
# a pulse length asked for goes to the box as asked; the clamp is the box's
|
||||||
f.act("button", "press", ms=500)
|
f.act("button", "press", ms=500)
|
||||||
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {"ms": 500}))
|
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {"ms": 500}))
|
||||||
time.sleep(0.15) # the fake's pulse ends
|
time.sleep(0.15) # the fake's pulse ends
|
||||||
@@ -231,36 +211,34 @@ class ClientTests(unittest.TestCase):
|
|||||||
f.act("button", "press")
|
f.act("button", "press")
|
||||||
self.assertIn("jumper", str(cm.exception))
|
self.assertIn("jumper", str(cm.exception))
|
||||||
|
|
||||||
def test_two_presses_are_spaced_so_the_controller_sees_the_release(self):
|
def test_a_failure_after_the_request_went_out_says_so(self):
|
||||||
f = self.client()
|
# The press made and its reply lost (it comes after the client's
|
||||||
f.status()
|
# timeout): sent, one press. A refusal, a failed lookup and a
|
||||||
t0 = time.time()
|
# refused connect: not sent, no press. The one retry keeps it.
|
||||||
f.act("button", "press")
|
self.dev.reply_delay = 0.6
|
||||||
f.act("button", "press")
|
|
||||||
took = time.time() - t0
|
|
||||||
posts = [c for c in self.dev.calls if c[:2] == ("POST", "/button")]
|
|
||||||
self.assertEqual(len(posts), 2) # no 409 round trip was needed
|
|
||||||
self.assertEqual(self.dev.presses, 2)
|
|
||||||
# the second waited for the first pulse (200 ms as reported) and the gap
|
|
||||||
self.assertGreaterEqual(took, 0.2 + fx.BUTTON_GAP_S - 0.05)
|
|
||||||
|
|
||||||
def test_a_pulse_in_progress_is_waited_out_then_retried(self):
|
|
||||||
f = self.client()
|
|
||||||
f.status()
|
|
||||||
self.dev.state["button"] = "pressed" # a press this client did not time
|
|
||||||
threading.Timer(0.3, lambda: self.dev.state.__setitem__("button", "idle")).start()
|
|
||||||
f.act("button", "press")
|
|
||||||
posts = [c for c in self.dev.calls if c[:2] == ("POST", "/button")]
|
|
||||||
self.assertEqual(len(posts), 2) # the 409, then the press
|
|
||||||
self.assertEqual(self.dev.presses, 1)
|
|
||||||
|
|
||||||
def test_a_pulse_that_never_ends_is_the_fixtures_error(self):
|
|
||||||
f = self.client()
|
|
||||||
f.status()
|
|
||||||
self.dev.state["button"] = "pressed"
|
|
||||||
with self.assertRaises(fx.FixtureError) as cm:
|
with self.assertRaises(fx.FixtureError) as cm:
|
||||||
f.act("button", "press")
|
self.client(timeout=0.2, ip="127.0.0.1").act("button", "press")
|
||||||
self.assertIn("in progress", str(cm.exception))
|
self.assertTrue(cm.exception.sent)
|
||||||
|
self.dev.wait_replied(1)
|
||||||
|
self.assertEqual(self.dev.presses, 1)
|
||||||
|
answers = ["127.0.0.1"] # the refresh lookup after the timeout finds nothing
|
||||||
|
with self.assertRaises(fx.FixtureError) as cm:
|
||||||
|
self.client(timeout=0.2, resolver=lambda h: answers.pop(0) if answers else None).act("button", "press")
|
||||||
|
self.assertTrue(cm.exception.sent)
|
||||||
|
self.dev.wait_replied(2)
|
||||||
|
self.assertEqual(self.dev.presses, 2)
|
||||||
|
self.dev.reply_delay = 0.0
|
||||||
|
self.dev.button_enabled = False
|
||||||
|
with self.assertRaises(fx.FixtureError) as cm:
|
||||||
|
self.client(ip="127.0.0.1").act("button", "press")
|
||||||
|
self.assertFalse(cm.exception.sent)
|
||||||
|
with self.assertRaises(fx.FixtureError) as cm:
|
||||||
|
self.client(resolver=lambda h: None).act("button", "press")
|
||||||
|
self.assertFalse(cm.exception.sent)
|
||||||
|
with self.assertRaises(fx.FixtureError) as cm:
|
||||||
|
self.client(timeout=0.5, ip="127.0.0.1", port=1).act("button", "press")
|
||||||
|
self.assertFalse(cm.exception.sent)
|
||||||
|
self.assertEqual(self.dev.presses, 2)
|
||||||
|
|
||||||
def test_a_wrong_key_is_refused(self):
|
def test_a_wrong_key_is_refused(self):
|
||||||
f = self.client(key="wrong")
|
f = self.client(key="wrong")
|
||||||
@@ -279,10 +257,9 @@ class ClientTests(unittest.TestCase):
|
|||||||
self.assertTrue(f.covers("lid"))
|
self.assertTrue(f.covers("lid"))
|
||||||
self.assertFalse(f.covers("interlock"))
|
self.assertFalse(f.covers("interlock"))
|
||||||
|
|
||||||
def test_probe_without_a_config_is_none_and_a_silent_box_is_logged(self):
|
def test_probe_without_a_config_or_with_a_silent_box_is_none(self):
|
||||||
lines = []
|
lines = []
|
||||||
self.assertIsNone(fx.probe(lines.append, path=os.path.join(tempfile.gettempdir(), "no-such-fixture.json")))
|
self.assertIsNone(fx.probe(lines.append, path=os.path.join(tempfile.gettempdir(), "no-such-fixture.json")))
|
||||||
self.assertEqual(lines, [])
|
|
||||||
tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
|
tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
|
||||||
try:
|
try:
|
||||||
p = os.path.join(tmp, "fixture.json")
|
p = os.path.join(tmp, "fixture.json")
|
||||||
@@ -291,7 +268,6 @@ class ClientTests(unittest.TestCase):
|
|||||||
# port 1 on localhost: nothing listens there
|
# port 1 on localhost: nothing listens there
|
||||||
f2 = fx.probe(lines.append, path=p, resolver=lambda h: None)
|
f2 = fx.probe(lines.append, path=p, resolver=lambda h: None)
|
||||||
self.assertIsNone(f2)
|
self.assertIsNone(f2)
|
||||||
self.assertTrue(lines and "running without it" in lines[-1], lines)
|
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmp, ignore_errors=True)
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
@@ -314,6 +290,24 @@ class CatalogTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
catalog.test("z.auto", title="t", subsystem="z", hands=("app",))(lambda ctx: None)
|
catalog.test("z.auto", title="t", subsystem="z", hands=("app",))(lambda ctx: None)
|
||||||
|
|
||||||
|
def test_every_action_a_test_takes_is_declared(self):
|
||||||
|
# The queues route a test by its kind and its actions: an auto test
|
||||||
|
# runs unattended with or without a fixture, and an operator test
|
||||||
|
# whose actions the fixture covers runs unattended only while it
|
||||||
|
# does. A test that acts on the lid, the interlock or the button
|
||||||
|
# without saying so runs unattended with no fixture and waits for a
|
||||||
|
# person who is not there.
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
from forgetest import catalog
|
||||||
|
bad = []
|
||||||
|
for t in catalog.all_tests(catalog.load_suite()):
|
||||||
|
src = inspect.getsource(t.fn)
|
||||||
|
taken = set(re.findall(r'ctx\.act\(\s*"(\w+)"', src))
|
||||||
|
if taken and (t.kind == "auto" or not taken <= set(t.actions)):
|
||||||
|
bad.append("%s (%s) acts on %s, declares %s" % (t.id, t.kind, sorted(taken), list(t.actions)))
|
||||||
|
self.assertEqual(bad, [])
|
||||||
|
|
||||||
|
|
||||||
class StubFixture:
|
class StubFixture:
|
||||||
"""What the runner needs of a fixture, scripted."""
|
"""What the runner needs of a fixture, scripted."""
|
||||||
@@ -412,39 +406,6 @@ class RoutingTests(unittest.TestCase):
|
|||||||
self.assertEqual(av["attended"], ["r.app", "r.live"]) # needs the app; live never moves
|
self.assertEqual(av["attended"], ["r.app", "r.live"]) # needs the app; live never moves
|
||||||
self.assertEqual(summary["hostname"], "forgefixture")
|
self.assertEqual(summary["hostname"], "forgefixture")
|
||||||
|
|
||||||
def test_without_the_jumper_the_button_tests_stay_attended(self):
|
|
||||||
self.stub.button_enabled = False
|
|
||||||
av, _ = self.selection()
|
|
||||||
self.assertIn("r.btn", av["attended"])
|
|
||||||
self.assertIn("r.lid", av["unattended"])
|
|
||||||
|
|
||||||
def test_no_fixture_is_the_old_routing(self):
|
|
||||||
fx.probe = lambda log, path=None, resolver=None: None
|
|
||||||
self.runner._fixture_probed = 0.0
|
|
||||||
av, summary = self.selection()
|
|
||||||
self.assertEqual(av["unattended"], ["r.auto"])
|
|
||||||
self.assertIsNone(summary)
|
|
||||||
|
|
||||||
def test_a_queue_started_during_a_probe_waits_for_the_fixture(self):
|
|
||||||
# The daemon just came up: nothing probed yet, a page poll starts
|
|
||||||
# the first probe (slow: an mDNS answer), and the queue start
|
|
||||||
# lands while it is in flight. The start must see the fixture.
|
|
||||||
stub = self.stub
|
|
||||||
|
|
||||||
def slow_probe(log, path=None, resolver=None):
|
|
||||||
time.sleep(0.5)
|
|
||||||
return stub
|
|
||||||
fx.probe = slow_probe
|
|
||||||
self.runner._fixture_probed = 0.0
|
|
||||||
self.runner.fixture = None
|
|
||||||
poll = threading.Thread(target=self.runner.probe_fixture)
|
|
||||||
poll.start()
|
|
||||||
time.sleep(0.1)
|
|
||||||
order = self.runner.batch_selection("unattended")
|
|
||||||
poll.join()
|
|
||||||
self.assertIn("r.lid", order)
|
|
||||||
self.assertIn("r.btn", order)
|
|
||||||
|
|
||||||
def run_queue(self, group, ack_live=False):
|
def run_queue(self, group, ack_live=False):
|
||||||
ok, msg, order = self.runner.start_batch(group, ack_live=ack_live)
|
ok, msg, order = self.runner.start_batch(group, ack_live=ack_live)
|
||||||
self.assertTrue(ok, msg)
|
self.assertTrue(ok, msg)
|
||||||
@@ -477,20 +438,6 @@ class RoutingTests(unittest.TestCase):
|
|||||||
asks = self.last_result("r.asks")
|
asks = self.last_result("r.asks")
|
||||||
self.assertIn("declare the step in hands=", asks["message"])
|
self.assertIn("declare the step in hands=", asks["message"])
|
||||||
|
|
||||||
def test_a_failing_box_hands_the_action_to_the_operator(self):
|
|
||||||
self.stub.fail = True
|
|
||||||
run = Run("test", "r.lid", "r.lid")
|
|
||||||
ctx = Context(run, self.runner, self.reg["r.lid"])
|
|
||||||
self.runner.probe_fixture(force=True)
|
|
||||||
seen = []
|
|
||||||
ctx.wait_for = lambda cond, timeout, poll=0.25: 1.0 # the machine shows the state
|
|
||||||
run.set_notice = lambda text: seen.append(text)
|
|
||||||
ctx.act("lid", "open")
|
|
||||||
rec = run.evidence["actions"][0]
|
|
||||||
self.assertEqual(rec["by"], "operator")
|
|
||||||
self.assertIn("the box is off", rec["fixture_error"])
|
|
||||||
self.assertTrue(seen and "lid" in seen[0].lower())
|
|
||||||
|
|
||||||
def test_a_failing_box_in_an_unattended_run_ends_the_test_as_an_error(self):
|
def test_a_failing_box_in_an_unattended_run_ends_the_test_as_an_error(self):
|
||||||
# nobody is in the room: the run must not wait ACT_TIMEOUT_S for a
|
# nobody is in the room: the run must not wait ACT_TIMEOUT_S for a
|
||||||
# hand that is not there; it ends at once, as the harness's error
|
# hand that is not there; it ends at once, as the harness's error
|
||||||
@@ -545,36 +492,6 @@ class RoutingTests(unittest.TestCase):
|
|||||||
self.assertEqual(rec[0]["by"], "operator")
|
self.assertEqual(rec[0]["by"], "operator")
|
||||||
self.assertTrue(any("press the button on the machine" in ln.lower() for ln in run.lines))
|
self.assertTrue(any("press the button on the machine" in ln.lower() for ln in run.lines))
|
||||||
|
|
||||||
def test_presence_is_proved_once_per_test_not_once_per_gate(self):
|
|
||||||
"""A test with two armed halves reaches the ready gate twice. The
|
|
||||||
second one must not ask for another press: the operator proved
|
|
||||||
presence a moment earlier and the actuator has the presses. The
|
|
||||||
setup line still goes up, because the second half may want the
|
|
||||||
scrap moved."""
|
|
||||||
run = Run("test", "r.live", "r.live")
|
|
||||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
|
||||||
self.runner.probe_fixture(force=True)
|
|
||||||
asked = []
|
|
||||||
run.ask = lambda q, o: asked.append(q)
|
|
||||||
self.press_button(False)
|
|
||||||
threading.Timer(0.3, self.press_button, args=(True,)).start()
|
|
||||||
threading.Timer(0.8, self.press_button, args=(False,)).start()
|
|
||||||
ctx.ready("First half.")
|
|
||||||
self.assertTrue(run.fixture_takeover)
|
|
||||||
presses = [r for r in run.evidence["actions"] if r["state"] == "presence"]
|
|
||||||
self.assertEqual(len(presses), 1)
|
|
||||||
|
|
||||||
# the second gate: returns at once, no new press, notice still shown
|
|
||||||
seen = []
|
|
||||||
run.set_notice = lambda text: seen.append(text)
|
|
||||||
t0 = time.time()
|
|
||||||
ctx.ready("Second half. Move the scrap.")
|
|
||||||
self.assertLess(time.time() - t0, 1.0)
|
|
||||||
self.assertEqual(asked, [])
|
|
||||||
self.assertEqual(len([r for r in run.evidence["actions"] if r["state"] == "presence"]), 1)
|
|
||||||
self.assertIn("Second half. Move the scrap.", seen)
|
|
||||||
self.assertTrue(any("presence already proved" in ln for ln in run.lines))
|
|
||||||
|
|
||||||
def test_the_presence_press_hands_the_arm_press_to_the_actuator(self):
|
def test_the_presence_press_hands_the_arm_press_to_the_actuator(self):
|
||||||
"""The bench's standing opt-in is not needed once the operator has
|
"""The bench's standing opt-in is not needed once the operator has
|
||||||
proved presence: that press is what the opt-in existed to
|
proved presence: that press is what the opt-in existed to
|
||||||
@@ -595,68 +512,6 @@ class RoutingTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
runner_mod.hw.button_lit = saved
|
runner_mod.hw.button_lit = saved
|
||||||
|
|
||||||
def test_the_press_prompt_is_what_the_actuator_presses_on(self):
|
|
||||||
"""The machine's own press prompt, not a button LED read from
|
|
||||||
here: the button is lit through parts of a live check that are
|
|
||||||
not the arm, and a press then lands before the job waits for it
|
|
||||||
and is lost. press_now presses at the moment the check asks, with
|
|
||||||
no LED reading and no waiting thread."""
|
|
||||||
run = Run("test", "r.live", "r.live")
|
|
||||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
|
||||||
self.runner.probe_fixture(force=True)
|
|
||||||
self.stub.arm_press = False
|
|
||||||
run.fixture_takeover = True
|
|
||||||
saved = runner_mod.hw.button_lit
|
|
||||||
runner_mod.hw.button_lit = lambda: False # never consulted
|
|
||||||
try:
|
|
||||||
self.assertTrue(ctx.press_now())
|
|
||||||
self.assertIn(("button", "press"), self.stub.acts)
|
|
||||||
finally:
|
|
||||||
runner_mod.hw.button_lit = saved
|
|
||||||
rec = [r for r in run.evidence["actions"] if r["state"] == "arm"][-1]
|
|
||||||
self.assertEqual(rec["by"], "fixture")
|
|
||||||
|
|
||||||
def test_the_press_prompt_goes_to_the_operator_without_an_actuator(self):
|
|
||||||
"""No actuator, or one that never took this test's presses: the
|
|
||||||
notice goes up for a person, who is looking at the same prompt."""
|
|
||||||
run = Run("test", "r.live", "r.live")
|
|
||||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
|
||||||
self.runner.probe_fixture(force=True)
|
|
||||||
self.stub.arm_press = False
|
|
||||||
run.fixture_takeover = False
|
|
||||||
seen = []
|
|
||||||
run.set_notice = lambda text: seen.append(text)
|
|
||||||
self.assertFalse(ctx.press_now("Press it now."))
|
|
||||||
self.assertNotIn(("button", "press"), self.stub.acts)
|
|
||||||
self.assertIn("Press it now.", seen)
|
|
||||||
|
|
||||||
def test_the_arm_press_waits_as_long_as_the_card_says_for_the_light(self):
|
|
||||||
"""A card that settles the coolant before it lights the button
|
|
||||||
takes minutes; the caller names the wait, and the actuator presses
|
|
||||||
when the light comes instead of handing the press to a person at
|
|
||||||
the default 60 s (found on the bench by the flow-load card)."""
|
|
||||||
run = Run("test", "r.live", "r.live")
|
|
||||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
|
||||||
self.runner.probe_fixture(force=True)
|
|
||||||
run.fixture_takeover = True
|
|
||||||
lit_at = time.time() + 0.6
|
|
||||||
saved = runner_mod.hw.button_lit
|
|
||||||
runner_mod.hw.button_lit = lambda: time.time() >= lit_at
|
|
||||||
seen = []
|
|
||||||
run.set_notice = lambda text: seen.append(text)
|
|
||||||
try:
|
|
||||||
self.assertTrue(ctx.arm_press(lit_timeout=5))
|
|
||||||
deadline = time.time() + 5
|
|
||||||
while ("button", "press") not in self.stub.acts and time.time() < deadline:
|
|
||||||
time.sleep(0.05)
|
|
||||||
self.assertIn(("button", "press"), self.stub.acts)
|
|
||||||
self.assertEqual([n for n in seen if n], []) # nobody was asked
|
|
||||||
rec = run.evidence["actions"][-1]
|
|
||||||
self.assertEqual(rec["by"], "fixture")
|
|
||||||
self.assertGreaterEqual(rec["took_s"], 0.5)
|
|
||||||
finally:
|
|
||||||
runner_mod.hw.button_lit = saved
|
|
||||||
|
|
||||||
def test_an_actuator_lost_after_the_takeover_is_said_out_loud(self):
|
def test_an_actuator_lost_after_the_takeover_is_said_out_loud(self):
|
||||||
"""Falling back to the operator without a word is how one dropped
|
"""Falling back to the operator without a word is how one dropped
|
||||||
actuator becomes a press nobody can account for afterwards."""
|
actuator becomes a press nobody can account for afterwards."""
|
||||||
@@ -671,21 +526,6 @@ class RoutingTests(unittest.TestCase):
|
|||||||
rec = run.evidence["actions"][-1]
|
rec = run.evidence["actions"][-1]
|
||||||
self.assertTrue(rec.get("fixture_lost"))
|
self.assertTrue(rec.get("fixture_lost"))
|
||||||
self.assertEqual(rec["by"], "operator")
|
self.assertEqual(rec["by"], "operator")
|
||||||
self.assertTrue(any("WARNING" in ln and "now gone" in ln for ln in run.lines))
|
|
||||||
|
|
||||||
def test_ready_still_asks_the_page_with_no_actuator(self):
|
|
||||||
run = Run("test", "r.live", "r.live")
|
|
||||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
|
||||||
self.runner.fixture = None
|
|
||||||
asked = []
|
|
||||||
|
|
||||||
def ask(q, o):
|
|
||||||
asked.append((q, list(o)))
|
|
||||||
return "Ready"
|
|
||||||
run.ask = ask
|
|
||||||
ctx.ready("LIVE FIRE. Scrap under the head.")
|
|
||||||
self.assertEqual(asked, [("LIVE FIRE. Scrap under the head.", ["Ready", "Cannot"])])
|
|
||||||
self.assertFalse(run.fixture_takeover)
|
|
||||||
|
|
||||||
def test_the_arm_press_is_the_operators_unless_the_bench_opted_in(self):
|
def test_the_arm_press_is_the_operators_unless_the_bench_opted_in(self):
|
||||||
run = Run("test", "r.live", "r.live")
|
run = Run("test", "r.live", "r.live")
|
||||||
@@ -716,6 +556,161 @@ class RoutingTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
runner_mod.hw.button_lit = saved
|
runner_mod.hw.button_lit = saved
|
||||||
|
|
||||||
|
@unittest.skipUnless(hasattr(os, "mkfifo"), "needs a FIFO for the switches' event device")
|
||||||
|
def test_ready_reads_a_short_press_as_an_edge(self):
|
||||||
|
"""The presence press is the switches' input events, so a press
|
||||||
|
far shorter than a quarter second counts: the press and release
|
||||||
|
land back to back, and the status the old poll read never shows
|
||||||
|
the button down at all."""
|
||||||
|
dev = os.path.join(self.tmp, "switches")
|
||||||
|
os.mkfifo(dev)
|
||||||
|
os.environ["GF_SWITCH_DEV"] = dev
|
||||||
|
ev = runner_mod.hw.ButtonEdges.EVENT
|
||||||
|
|
||||||
|
def press():
|
||||||
|
with open(dev, "wb") as w:
|
||||||
|
w.write(ev.pack(0, 0, runner_mod.hw.EV_SW, runner_mod.hw.SW_BUTTON, 1) +
|
||||||
|
ev.pack(0, 0, 0, 0, 0) +
|
||||||
|
ev.pack(0, 0, runner_mod.hw.EV_SW, runner_mod.hw.SW_BUTTON, 0))
|
||||||
|
try:
|
||||||
|
run = Run("test", "r.live", "r.live")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||||
|
self.runner.probe_fixture(force=True)
|
||||||
|
self.press_button(False) # the status never shows it down
|
||||||
|
threading.Timer(0.3, press).start()
|
||||||
|
t0 = time.time()
|
||||||
|
ctx.ready("LIVE FIRE. Scrap under the head.")
|
||||||
|
self.assertLess(time.time() - t0, 5)
|
||||||
|
self.assertTrue(run.fixture_takeover)
|
||||||
|
rec = [r for r in run.evidence["actions"] if r["state"] == "presence"]
|
||||||
|
self.assertEqual(len(rec), 1)
|
||||||
|
self.assertIsNone(run.notice) # the notice is down with the press
|
||||||
|
finally:
|
||||||
|
os.environ.pop("GF_SWITCH_DEV", None)
|
||||||
|
|
||||||
|
def test_the_arm_notice_comes_down_when_the_press_is_taken(self):
|
||||||
|
"""The controller puts the button out at the press: the operator's
|
||||||
|
arm notice goes down then, not at the test's next step, so nobody
|
||||||
|
presses again (a press during the job pauses it)."""
|
||||||
|
run = Run("test", "r.live", "r.live")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||||
|
self.runner.probe_fixture(force=True)
|
||||||
|
lit = {"v": False}
|
||||||
|
saved = runner_mod.hw.button_lit
|
||||||
|
runner_mod.hw.button_lit = lambda: lit["v"]
|
||||||
|
try:
|
||||||
|
self.assertFalse(ctx.arm_press()) # the operator's press
|
||||||
|
self.assertIsNotNone(run.notice)
|
||||||
|
time.sleep(0.2)
|
||||||
|
self.assertIsNotNone(run.notice) # dark before the arm: still asked for
|
||||||
|
lit["v"] = True
|
||||||
|
time.sleep(0.2)
|
||||||
|
self.assertIsNotNone(run.notice) # lit: the arm waits for the press
|
||||||
|
lit["v"] = False # the press taken
|
||||||
|
deadline = time.time() + 2
|
||||||
|
while run.notice is not None and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
self.assertIsNone(run.notice)
|
||||||
|
# A notice put up meanwhile is not the arm's: it stays.
|
||||||
|
lit["v"] = False
|
||||||
|
self.assertFalse(ctx.arm_press())
|
||||||
|
ctx.notice("Something else.")
|
||||||
|
lit["v"] = True
|
||||||
|
time.sleep(0.2)
|
||||||
|
lit["v"] = False
|
||||||
|
time.sleep(0.3)
|
||||||
|
self.assertEqual((run.notice or {}).get("text"), "Something else.")
|
||||||
|
# The watch ends with the run: a test that ends before the press
|
||||||
|
# (a FAIL on the way to the arm, say) leaves no thread polling the
|
||||||
|
# LEDs for up to the arm's whole timeout.
|
||||||
|
def watching():
|
||||||
|
return [t for t in threading.enumerate() if t.name == "forgetest-arm-notice"]
|
||||||
|
deadline = time.time() + 2
|
||||||
|
while watching() and time.time() < deadline:
|
||||||
|
time.sleep(0.02) # the earlier watches are gone
|
||||||
|
self.assertFalse(watching())
|
||||||
|
lit["v"] = False
|
||||||
|
self.assertFalse(ctx.arm_press())
|
||||||
|
self.assertTrue(watching()) # up while the arm is still dark
|
||||||
|
run.finished = {"result": "FAIL", "message": "", "duration_s": 0}
|
||||||
|
deadline = time.time() + 2
|
||||||
|
while watching() and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
self.assertFalse(watching())
|
||||||
|
self.assertIsNotNone(run.notice) # the finished run's notice is left as it was
|
||||||
|
# A press the fixture could not make is asked of the operator, and
|
||||||
|
# that notice comes down at the press like any other: press_now and
|
||||||
|
# arm_press with the box refusing, and arm_press with the button
|
||||||
|
# never lit for the box.
|
||||||
|
self.stub.arm_press = True
|
||||||
|
self.stub.fail = True
|
||||||
|
run = Run("test", "r.live", "r.live")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||||
|
|
||||||
|
def settles(up):
|
||||||
|
deadline = time.time() + 2
|
||||||
|
while (run.notice is not None) != up and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
return (run.notice is not None) == up
|
||||||
|
lit["v"] = True # the machine waits for the press
|
||||||
|
self.assertFalse(ctx.press_now()) # the box is off: the operator is asked
|
||||||
|
self.assertIsNotNone(run.notice)
|
||||||
|
time.sleep(0.2) # lit: the arm waits for the press
|
||||||
|
lit["v"] = False # the operator's press is taken
|
||||||
|
self.assertTrue(settles(False))
|
||||||
|
lit["v"] = True
|
||||||
|
self.assertTrue(ctx.arm_press()) # the fixture is asked, and refuses
|
||||||
|
self.assertTrue(settles(True))
|
||||||
|
time.sleep(0.2)
|
||||||
|
lit["v"] = False
|
||||||
|
self.assertTrue(settles(False))
|
||||||
|
self.assertTrue(ctx.arm_press(lit_timeout=0)) # the button never lit for the fixture
|
||||||
|
self.assertTrue(settles(True))
|
||||||
|
lit["v"] = True
|
||||||
|
time.sleep(0.2)
|
||||||
|
lit["v"] = False
|
||||||
|
self.assertTrue(settles(False))
|
||||||
|
# A press the box made and reported failed (its reply lost after
|
||||||
|
# the press): the button is out when the error arrives, so the
|
||||||
|
# press was taken, and no notice asks for a second one.
|
||||||
|
def pressed_then_failed(channel, state, ms=None):
|
||||||
|
lit["v"] = False # the controller takes the press
|
||||||
|
raise fx.FixtureError("timed out", sent=True)
|
||||||
|
self.stub.act = pressed_then_failed
|
||||||
|
lit["v"] = True
|
||||||
|
self.assertTrue(ctx.press_now())
|
||||||
|
self.assertIsNone(run.notice)
|
||||||
|
self.assertEqual(ctx.evidence["actions"][-1]["by"], "fixture")
|
||||||
|
lit["v"] = True
|
||||||
|
self.assertTrue(ctx.arm_press())
|
||||||
|
deadline = time.time() + 2
|
||||||
|
while ctx.evidence["actions"][-1]["by"] != "fixture" and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
time.sleep(0.1)
|
||||||
|
self.assertEqual(ctx.evidence["actions"][-1]["by"], "fixture")
|
||||||
|
self.assertIsNone(run.notice)
|
||||||
|
# A refusal (or a request that never reached the box) while the
|
||||||
|
# button went out: a press at the machine was taken, so no notice
|
||||||
|
# goes up either, and the record does not give it to the box.
|
||||||
|
def refused_while_pressed(channel, state, ms=None):
|
||||||
|
lit["v"] = False # a person's press is taken
|
||||||
|
raise fx.FixtureError("button press -> 409 button disabled: the enable jumper is out")
|
||||||
|
self.stub.act = refused_while_pressed
|
||||||
|
lit["v"] = True
|
||||||
|
self.assertFalse(ctx.press_now())
|
||||||
|
self.assertIsNone(run.notice)
|
||||||
|
self.assertEqual(ctx.evidence["actions"][-1]["by"], "operator")
|
||||||
|
lit["v"] = True
|
||||||
|
self.assertTrue(ctx.arm_press())
|
||||||
|
deadline = time.time() + 2
|
||||||
|
while "fixture_error" not in ctx.evidence["actions"][-1] and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
time.sleep(0.1)
|
||||||
|
self.assertEqual(ctx.evidence["actions"][-1]["by"], "operator")
|
||||||
|
self.assertIsNone(run.notice)
|
||||||
|
finally:
|
||||||
|
runner_mod.hw.button_lit = saved
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+386
-19
@@ -3,27 +3,205 @@
|
|||||||
# https://community.openglow.org
|
# https://community.openglow.org
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from forgetest import catalog
|
|
||||||
from forgetest.suite import image
|
from forgetest.suite import image
|
||||||
|
|
||||||
|
# The layer's scripts, when the tests run from a checkout of forgefirm.
|
||||||
|
LAYER = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"meta-forgefirm", "recipes-forgefirm")
|
||||||
|
# The BSP layer's interfaces file, from the meta-openglow checkout beside
|
||||||
|
# forgefirm's (forgetest-ci checks out its scarthgap branch there).
|
||||||
|
BSP_INTERFACES = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))),
|
||||||
|
"meta-openglow", "meta-openglow-core", "recipes-core", "init-ifupdown", "init-ifupdown", "interfaces")
|
||||||
|
|
||||||
class KernelIdentTests(unittest.TestCase):
|
# A layer script run as the image runs it, in private mount, UTS and
|
||||||
def test_localversion_hash_is_stripped(self):
|
# network namespaces: /run a fresh tmpfs, the scratch tree's net directory
|
||||||
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc-g72a0b1431a9d"), "6.12.20-fslc-fslc")
|
# over /sys/class/net, and /etc read-only with the scratch tree's files
|
||||||
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc+g72a0b1431a9d"), "6.12.20-fslc-fslc")
|
# over the host's (an overlay with no upper layer, as the image's rootfs is
|
||||||
|
# read-only), so the script makes its own copy under /run/forgefirm and
|
||||||
|
# writes through its own bind mount. With "net", wlan0 is a veth carrying a
|
||||||
|
# global IPv4 and IPv6 address and a link-local one. Exit 97: the
|
||||||
|
# namespace could not be set up.
|
||||||
|
NAMESPACE = """
|
||||||
|
T=$1; SCRIPT=$2
|
||||||
|
mount --make-rprivate / && mount -t tmpfs tmpfs /run &&
|
||||||
|
mount --bind "$T/net" /sys/class/net &&
|
||||||
|
mount -t overlay overlay -o "lowerdir=$T/etc:/etc" /etc || exit 97
|
||||||
|
if [ "$3" = net ]; then
|
||||||
|
ip link add wlan0 type veth peer name vpeer0 &&
|
||||||
|
ip addr add 192.0.2.5/24 dev wlan0 &&
|
||||||
|
ip -6 addr add 2001:db8::5/64 dev wlan0 nodad &&
|
||||||
|
ip -6 addr add fe80::5/64 dev wlan0 nodad &&
|
||||||
|
ip link set wlan0 up || exit 97
|
||||||
|
fi
|
||||||
|
sh "$SCRIPT"
|
||||||
|
echo "rc=$?"
|
||||||
|
echo "name=$(hostname)"
|
||||||
|
echo "file=$(cat /etc/hostname)"
|
||||||
|
echo "--- issue"
|
||||||
|
cat /etc/issue
|
||||||
|
"""
|
||||||
|
|
||||||
def test_plain_name_is_unchanged(self):
|
|
||||||
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc"), "6.12.20-fslc-fslc")
|
|
||||||
self.assertEqual(image.kernel_ident("6.12.20-fslc-g12"), "6.12.20-fslc-g12")
|
|
||||||
|
|
||||||
def test_release_matches_manifest_with_or_without_hash(self):
|
def run_layer_script(script, macs, etc, net=False):
|
||||||
rel = "6.12.20-fslc-fslc-g72a0b1431a9d"
|
"""Run a layer script over a scratch tree: macs {interface: address or
|
||||||
self.assertTrue(image.kernel_matches(rel, ["6.12.20-fslc-fslc"]))
|
None for a directory with no address file}, etc {file: text}. Returns
|
||||||
self.assertTrue(image.kernel_matches(rel, ["6.12.20-fslc-fslc-gf52af5b522f4"]))
|
the namespace's output, or None when no namespace can be made here
|
||||||
self.assertTrue(image.kernel_matches(rel, []))
|
(unprivileged where user namespaces are open, else root via sudo -n)."""
|
||||||
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
|
tree = tempfile.mkdtemp(prefix="forgetest-net.")
|
||||||
|
try:
|
||||||
|
for dev, mac in macs.items():
|
||||||
|
os.makedirs(os.path.join(tree, "net", dev))
|
||||||
|
if mac is not None:
|
||||||
|
with open(os.path.join(tree, "net", dev, "address"), "w") as f:
|
||||||
|
f.write(mac + "\n")
|
||||||
|
os.makedirs(os.path.join(tree, "net"), exist_ok=True)
|
||||||
|
os.makedirs(os.path.join(tree, "etc"))
|
||||||
|
for name, text in etc.items():
|
||||||
|
with open(os.path.join(tree, "etc", name), "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
args = ["sh", "-c", NAMESPACE, "sh", tree, os.path.join(LAYER, script), "net" if net else ""]
|
||||||
|
setup = []
|
||||||
|
for cmd in (["unshare", "-Urmun"], ["sudo", "-n", "unshare", "-mun"]):
|
||||||
|
try:
|
||||||
|
p = subprocess.run(cmd + args, capture_output=True, text=True, timeout=60)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
continue
|
||||||
|
if "rc=" in p.stdout:
|
||||||
|
return p.stdout
|
||||||
|
if p.returncode == 97:
|
||||||
|
setup.append(p.stderr.strip())
|
||||||
|
if setup:
|
||||||
|
raise AssertionError("the namespace was made but not set up: %s" % setup)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tree, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def field(out, key):
|
||||||
|
return [ln[len(key) + 1:] for ln in out.splitlines() if ln.startswith(key + "=")][0]
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.path.isdir(LAYER) and shutil.which("unshare"), "needs Linux and a forgefirm checkout")
|
||||||
|
class MachineNameTests(unittest.TestCase):
|
||||||
|
"""forgefirm-hostname and forgefirm-banner, the layer's own files."""
|
||||||
|
|
||||||
|
def named(self, macs):
|
||||||
|
out = run_layer_script("forgefirm-hostname/files/forgefirm-hostname", macs, {"hostname": "forgefirm\n"})
|
||||||
|
if out is None:
|
||||||
|
self.skipTest("no namespace can be made here")
|
||||||
|
self.assertEqual(field(out, "rc"), "0", out)
|
||||||
|
self.assertEqual(field(out, "file"), field(out, "name"), out)
|
||||||
|
return field(out, "name")
|
||||||
|
|
||||||
|
def test_the_name_is_built_from_the_first_interface_with_an_address(self):
|
||||||
|
self.assertEqual(self.named({"wlan0": "02:00:00:00:12:AB", "eth0": "02:00:00:00:34:cd"}), "forgefirm-12ab")
|
||||||
|
self.assertEqual(self.named({"eth0": "02:00:00:00:34:cd"}), "forgefirm-34cd")
|
||||||
|
self.assertEqual(self.named({"wlan0": "00:00:00:00:00:00", "eth0": "02:00:00:00:34:cd"}), "forgefirm-34cd")
|
||||||
|
|
||||||
|
def test_the_banner_adds_the_global_addresses_and_nothing_else(self):
|
||||||
|
base = "ForgeFIRM\n"
|
||||||
|
out = run_layer_script("forgefirm-banner/files/forgefirm-banner", {"wlan0": "02:00:00:00:12:ab"},
|
||||||
|
{"issue": base}, net=True)
|
||||||
|
if out is None:
|
||||||
|
self.skipTest("no namespace can be made here")
|
||||||
|
self.assertEqual(field(out, "rc"), "0", out)
|
||||||
|
issue = out.split("--- issue\n", 1)[1]
|
||||||
|
self.assertTrue(issue.startswith(base + "\n"), issue)
|
||||||
|
heading, *urls = issue[len(base) + 1:].strip("\n").split("\n")
|
||||||
|
self.assertEqual(urls, [" https://192.0.2.5/", " https://[2001:db8::5]/"], issue)
|
||||||
|
self.assertFalse(heading.startswith("#") or ".local" in issue, issue)
|
||||||
|
|
||||||
|
|
||||||
|
# forgefirm-users rendering a record, as the image runs it, in a private
|
||||||
|
# mount namespace as root: /run a fresh tmpfs, /etc read-only with the
|
||||||
|
# scratch tree's account files over the host's, and the scratch data
|
||||||
|
# directory over /data (made for the run where the host has none, as
|
||||||
|
# fflog_e2e.sh does, and removed after). The data directory is root's, as
|
||||||
|
# on the machine, whoever made the scratch tree. Then, as the account's
|
||||||
|
# own uid, what it can do with its home and with the data directory
|
||||||
|
# around it. Exit 97: the namespace could not be set up.
|
||||||
|
ACCOUNTS = r"""
|
||||||
|
T=$1; SCRIPT=$2
|
||||||
|
made=
|
||||||
|
[ -d /data ] || { mkdir /data && made=1; } || exit 97
|
||||||
|
chown -R 0:0 "$T/data" &&
|
||||||
|
mount --make-rprivate / && mount -t tmpfs tmpfs /run &&
|
||||||
|
mount -t overlay overlay -o "lowerdir=$T/etc:/etc" /etc &&
|
||||||
|
mount --bind "$T/data" /data || { [ -z "$made" ] || rmdir /data; exit 97; }
|
||||||
|
sh "$SCRIPT" reload
|
||||||
|
echo "rc=$?"
|
||||||
|
echo "mode=$(stat -c %a /data/forgefirm)"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import errno, json, os, pwd
|
||||||
|
p = pwd.getpwnam("bench")
|
||||||
|
os.setgroups([])
|
||||||
|
os.setgid(p.pw_gid)
|
||||||
|
os.setuid(p.pw_uid)
|
||||||
|
def tried(fn):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
return "ok"
|
||||||
|
except OSError as e:
|
||||||
|
return errno.errorcode.get(e.errno, str(e.errno))
|
||||||
|
def write():
|
||||||
|
path = os.path.join(p.pw_dir, ".reach")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write("x")
|
||||||
|
os.remove(path)
|
||||||
|
print("reach=" + json.dumps({"uid": os.getuid(), "home": p.pw_dir, "cd": tried(lambda: os.chdir(p.pw_dir)),
|
||||||
|
"write": tried(write), "list_data_dir": tried(lambda: os.listdir("/data/forgefirm")),
|
||||||
|
"read_record": tried(lambda: open("/data/forgefirm/users").read())}))
|
||||||
|
PY
|
||||||
|
cd /
|
||||||
|
umount /data
|
||||||
|
[ -z "$made" ] || rmdir /data
|
||||||
|
rm -rf "$T/data"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.path.isdir(LAYER) and shutil.which("unshare"), "needs Linux and a forgefirm checkout")
|
||||||
|
class AccountRenderTests(unittest.TestCase):
|
||||||
|
"""forgefirm-users, the layer's own init script, rendering the record."""
|
||||||
|
|
||||||
|
def test_the_render_opens_the_data_directory_to_its_account_and_no_further(self):
|
||||||
|
tree = tempfile.mkdtemp(prefix="forgetest-users.")
|
||||||
|
self.addCleanup(shutil.rmtree, tree, ignore_errors=True)
|
||||||
|
os.makedirs(os.path.join(tree, "etc"))
|
||||||
|
for name, text in (("passwd", "root:x:0:0:root:/root:/bin/sh\n"), ("shadow", "root::19000:0:99999:7:::\n"),
|
||||||
|
("group", "root:x:0:\n"), ("gshadow", "root:*::\n")):
|
||||||
|
with open(os.path.join(tree, "etc", name), "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
data = os.path.join(tree, "data", "forgefirm")
|
||||||
|
os.makedirs(data)
|
||||||
|
os.chmod(data, 0o700) # as a strict umask leaves it
|
||||||
|
with open(os.path.join(data, "users"), "w") as f:
|
||||||
|
f.write("bench:$6$salt$hash:1000\n")
|
||||||
|
os.chmod(os.path.join(data, "users"), 0o600)
|
||||||
|
try:
|
||||||
|
p = subprocess.run(["sudo", "-n", "unshare", "-m", "sh", "-c", ACCOUNTS, "sh", tree,
|
||||||
|
os.path.join(LAYER, "forgefirm-users", "files", "forgefirm-users.init")],
|
||||||
|
capture_output=True, text=True, timeout=60)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
p = None
|
||||||
|
if p is None or "rc=" not in p.stdout:
|
||||||
|
if p is not None and p.returncode == 97:
|
||||||
|
raise AssertionError("the namespace was made but not set up: %s" % p.stderr.strip())
|
||||||
|
self.skipTest("no namespace can be made here (root through sudo -n)")
|
||||||
|
self.assertEqual(field(p.stdout, "rc"), "0", p.stdout + p.stderr)
|
||||||
|
self.assertEqual(field(p.stdout, "mode"), "711", p.stdout)
|
||||||
|
reach = json.loads(field(p.stdout, "reach"))
|
||||||
|
self.assertEqual((reach["uid"], reach["home"]), (1000, "/data/forgefirm/home/bench"), reach)
|
||||||
|
self.assertEqual((reach["cd"], reach["write"]), ("ok", "ok"), reach)
|
||||||
|
self.assertEqual((reach["list_data_dir"], reach["read_record"]), ("EACCES", "EACCES"), reach)
|
||||||
|
|
||||||
|
|
||||||
class NetworkBootTests(unittest.TestCase):
|
class NetworkBootTests(unittest.TestCase):
|
||||||
@@ -32,11 +210,6 @@ class NetworkBootTests(unittest.TestCase):
|
|||||||
"\twpa-driver nl80211\n"
|
"\twpa-driver nl80211\n"
|
||||||
"\tudhcpc_opts -x hostname:$(hostname)\n")
|
"\tudhcpc_opts -x hostname:$(hostname)\n")
|
||||||
|
|
||||||
def test_the_test_is_registered_and_covers_nothing_by_design(self):
|
|
||||||
t = catalog.load_suite()["image.network-boot"]
|
|
||||||
self.assertEqual(t.kind, "auto")
|
|
||||||
self.assertEqual(t.covers, ()) # the interfaces file is layer content
|
|
||||||
|
|
||||||
def test_ifstate_names_the_interfaces_ifup_finished(self):
|
def test_ifstate_names_the_interfaces_ifup_finished(self):
|
||||||
self.assertEqual(image.ifstate_names("lo=lo\nwlan0=wlan0\n"), {"lo", "wlan0"})
|
self.assertEqual(image.ifstate_names("lo=lo\nwlan0=wlan0\n"), {"lo", "wlan0"})
|
||||||
self.assertEqual(image.ifstate_names("lo=lo\n"), {"lo"})
|
self.assertEqual(image.ifstate_names("lo=lo\n"), {"lo"})
|
||||||
@@ -65,6 +238,12 @@ class NetworkBootTests(unittest.TestCase):
|
|||||||
text = self.STANZA + "# No DHCPv6 client runs; udhcpc6 is not on the image.\n"
|
text = self.STANZA + "# No DHCPv6 client runs; udhcpc6 is not on the image.\n"
|
||||||
self.assertEqual(image.dhcp6_in_interfaces(text), [])
|
self.assertEqual(image.dhcp6_in_interfaces(text), [])
|
||||||
|
|
||||||
|
def test_the_layer_interfaces_file_starts_no_dhcpv6_client(self):
|
||||||
|
self.assertTrue(os.path.isfile(BSP_INTERFACES),
|
||||||
|
"%s is missing: meta-openglow is checked out beside forgefirm" % BSP_INTERFACES)
|
||||||
|
with open(BSP_INTERFACES, encoding="utf-8") as f:
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces(f.read()), [])
|
||||||
|
|
||||||
def test_parse_stat_reads_the_parent_past_an_awkward_comm(self):
|
def test_parse_stat_reads_the_parent_past_an_awkward_comm(self):
|
||||||
self.assertEqual(image.parse_stat("371 (udhcpc) S 1 371 371 0 -1 4194624"),
|
self.assertEqual(image.parse_stat("371 (udhcpc) S 1 371 371 0 -1 4194624"),
|
||||||
(371, "udhcpc", 1))
|
(371, "udhcpc", 1))
|
||||||
@@ -82,5 +261,193 @@ class NetworkBootTests(unittest.TestCase):
|
|||||||
self.assertEqual(image.link_addrs(text, "eth0"), ([], []))
|
self.assertEqual(image.link_addrs(text, "eth0"), ([], []))
|
||||||
|
|
||||||
|
|
||||||
|
class PackLicensesTests(unittest.TestCase):
|
||||||
|
"""pack-licenses.sh, the image's license step, on a fixture tree."""
|
||||||
|
|
||||||
|
RECIPES = ("forgectrl", "grblhal-glowforge", "kernel-module-glowforge")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.root = tempfile.mkdtemp(prefix="forgetest-lic.")
|
||||||
|
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||||
|
self.tree = os.path.join(self.root, "common-licenses")
|
||||||
|
self.manifest = os.path.join(self.tree, "license.manifest")
|
||||||
|
self.out = os.path.join(self.root, "forgefirm", "licenses.tar.gz")
|
||||||
|
|
||||||
|
def make_tree(self, recipes=None):
|
||||||
|
"""The tree, with a manifest naming `recipes` (None: no manifest)."""
|
||||||
|
os.makedirs(os.path.join(self.tree, "generic_MIT"))
|
||||||
|
with open(os.path.join(self.tree, "generic_MIT", "generic_MIT"), "w") as f:
|
||||||
|
f.write("MIT License\n")
|
||||||
|
if recipes is not None:
|
||||||
|
with open(self.manifest, "w") as f:
|
||||||
|
for r in recipes:
|
||||||
|
f.write("PACKAGE NAME: %s\nPACKAGE VERSION: 1.0\nRECIPE NAME: %s\nLICENSE: MIT\n\n" % (r, r))
|
||||||
|
|
||||||
|
def pack(self):
|
||||||
|
return subprocess.run(["sh", os.path.join(LAYER, "images", "pack-licenses.sh"), self.tree, self.out],
|
||||||
|
capture_output=True, text=True, timeout=60)
|
||||||
|
|
||||||
|
def assertRefused(self, p):
|
||||||
|
self.assertNotEqual(p.returncode, 0, p.stdout)
|
||||||
|
self.assertTrue(p.stderr.strip(), "a refusal with no message on stderr")
|
||||||
|
self.assertFalse(os.path.exists(self.out), "a refusal wrote the tar")
|
||||||
|
|
||||||
|
def test_a_missing_tree_is_refused(self):
|
||||||
|
self.assertRefused(self.pack())
|
||||||
|
|
||||||
|
def test_a_tree_with_no_manifest_is_refused_and_left(self):
|
||||||
|
self.make_tree()
|
||||||
|
self.assertRefused(self.pack())
|
||||||
|
self.assertTrue(os.path.isdir(self.tree))
|
||||||
|
|
||||||
|
def test_a_manifest_that_leaves_out_a_recipe_is_refused_and_left(self):
|
||||||
|
for left_out in self.RECIPES:
|
||||||
|
with self.subTest(left_out=left_out):
|
||||||
|
shutil.rmtree(self.tree, ignore_errors=True)
|
||||||
|
self.make_tree([r for r in self.RECIPES if r != left_out])
|
||||||
|
self.assertRefused(self.pack())
|
||||||
|
self.assertTrue(os.path.isfile(self.manifest))
|
||||||
|
|
||||||
|
def test_a_manifest_that_names_all_three_is_packed_and_the_tree_removed(self):
|
||||||
|
self.make_tree(self.RECIPES)
|
||||||
|
p = self.pack()
|
||||||
|
self.assertEqual(p.returncode, 0, p.stderr)
|
||||||
|
with tarfile.open(self.out, "r:gz") as t:
|
||||||
|
self.assertIn("common-licenses/license.manifest", t.getnames())
|
||||||
|
self.assertFalse(os.path.exists(self.tree))
|
||||||
|
|
||||||
|
def test_a_license_text_tar_cannot_read_is_refused_and_the_tree_left(self):
|
||||||
|
"""A member tar cannot read fails the pack, bundle removed and tree
|
||||||
|
left: tar skips that member, and the manifest check alone passes
|
||||||
|
the bundle without it."""
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
self.skipTest("root reads every file")
|
||||||
|
self.make_tree(self.RECIPES)
|
||||||
|
d = os.path.join(self.tree, "zlib")
|
||||||
|
os.makedirs(d)
|
||||||
|
locked = os.path.join(d, "generic_Zlib")
|
||||||
|
with open(locked, "w") as f:
|
||||||
|
f.write("zlib license\n")
|
||||||
|
os.chmod(locked, 0) # rmtree still unlinks it
|
||||||
|
self.assertRefused(self.pack())
|
||||||
|
self.assertTrue(os.path.isfile(self.manifest))
|
||||||
|
|
||||||
|
|
||||||
|
class RootfsLintTests(unittest.TestCase):
|
||||||
|
"""rootfs-lint.sh, the image's last rootfs step, on a fixture tree: a
|
||||||
|
tree that holds every rule passes, and a drift fails the lint naming
|
||||||
|
its rule."""
|
||||||
|
|
||||||
|
SSHD = ("Include /etc/ssh/sshd_config.d/*.conf\n"
|
||||||
|
"PermitRootLogin no\n"
|
||||||
|
"PasswordAuthentication yes\n"
|
||||||
|
"PermitEmptyPasswords no\n"
|
||||||
|
"Subsystem\tsftp\t/usr/libexec/sftp-server\n")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.root = tempfile.mkdtemp(prefix="forgetest-lint.")
|
||||||
|
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||||
|
self.put("usr/sbin/rsyslogd", "", mode=0o755)
|
||||||
|
self.put("etc/init.d/syslog", "#!/bin/sh\nRSYSLOGD=rsyslogd\nRSYSLOGD_BIN=/usr/sbin/rsyslogd\n", mode=0o755)
|
||||||
|
os.makedirs(self.path("etc/rc5.d"))
|
||||||
|
os.symlink("../init.d/syslog", self.path("etc/rc5.d/S20syslog"))
|
||||||
|
self.put("etc/ssh/sshd_config", self.SSHD)
|
||||||
|
self.put("etc/ssh/sshd_config_readonly", self.SSHD)
|
||||||
|
self.put("etc/network/interfaces", "auto lo\niface lo inet loopback\n" + NetworkBootTests.STANZA)
|
||||||
|
self.put("etc/nsswitch.conf", "hosts: files dns\n")
|
||||||
|
for name in ("forgectrl", "forgeext", "grblhal", "forgefirm-logging"):
|
||||||
|
self.put("etc/init.d/" + name, "#!/bin/sh\n", mode=0o755)
|
||||||
|
os.makedirs(self.path("etc/rc6.d"))
|
||||||
|
for link in ("rc5.d/S90forgectrl", "rc5.d/S91forgeext", "rc6.d/K09forgeext", "rc6.d/K80grblhal",
|
||||||
|
"rc6.d/K90forgectrl"):
|
||||||
|
os.symlink("../init.d/" + link.split("/")[1][3:], self.path("etc/" + link))
|
||||||
|
self.put("etc/default/rcS", "VERBOSE=no\nROOTFS_READ_ONLY=yes\n")
|
||||||
|
self.put("etc/fstab", "/dev/root / auto ro 1 0\n/dev/mmcblk2p3 /data auto defaults 0 0\n")
|
||||||
|
|
||||||
|
def path(self, rel):
|
||||||
|
return os.path.join(self.root, rel)
|
||||||
|
|
||||||
|
def put(self, rel, text, mode=0o644):
|
||||||
|
os.makedirs(os.path.dirname(self.path(rel)), exist_ok=True)
|
||||||
|
with open(self.path(rel), "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
os.chmod(self.path(rel), mode)
|
||||||
|
|
||||||
|
def lint(self, kind="release"):
|
||||||
|
return subprocess.run(["sh", os.path.join(LAYER, "images", "rootfs-lint.sh"), self.root, kind],
|
||||||
|
capture_output=True, text=True, timeout=60)
|
||||||
|
|
||||||
|
def assertFails(self, rule, kind="release"):
|
||||||
|
p = self.lint(kind)
|
||||||
|
self.assertEqual(p.returncode, 1, p.stderr)
|
||||||
|
self.assertIn("rootfs-lint: %s:" % rule, p.stderr)
|
||||||
|
|
||||||
|
def test_a_tree_that_holds_every_rule_passes(self):
|
||||||
|
for kind in ("release", "dev"):
|
||||||
|
p = self.lint(kind)
|
||||||
|
self.assertEqual(p.returncode, 0, "%s: %s" % (kind, p.stderr))
|
||||||
|
|
||||||
|
def test_the_layer_interfaces_file_sends_the_name(self):
|
||||||
|
with open(BSP_INTERFACES, encoding="utf-8") as f:
|
||||||
|
self.put("etc/network/interfaces", f.read())
|
||||||
|
p = self.lint()
|
||||||
|
self.assertEqual(p.returncode, 0, p.stderr)
|
||||||
|
|
||||||
|
def test_busybox_syslogd_beside_rsyslog_fails(self):
|
||||||
|
os.makedirs(self.path("sbin"))
|
||||||
|
os.symlink("/bin/busybox.nosuid", self.path("sbin/syslogd"))
|
||||||
|
self.assertFails("syslog")
|
||||||
|
|
||||||
|
def test_a_pre_authentication_banner_fails_either_image(self):
|
||||||
|
self.put("etc/ssh/sshd_config_readonly", self.SSHD + "Banner /etc/issue.net\n")
|
||||||
|
for kind in ("release", "dev"):
|
||||||
|
self.assertFails("sshd", kind)
|
||||||
|
|
||||||
|
def test_root_and_an_empty_password_over_ssh_fail_a_release_image_only(self):
|
||||||
|
dev = self.SSHD.replace("PermitRootLogin no", "PermitRootLogin yes").replace(
|
||||||
|
"PermitEmptyPasswords no", "PermitEmptyPasswords yes")
|
||||||
|
self.put("etc/ssh/sshd_config", dev)
|
||||||
|
self.put("etc/ssh/sshd_config_readonly", dev)
|
||||||
|
self.assertFails("sshd", "release")
|
||||||
|
p = self.lint("dev")
|
||||||
|
self.assertEqual(p.returncode, 0, p.stderr)
|
||||||
|
|
||||||
|
def test_a_dhcp_stanza_that_does_not_send_the_name_fails(self):
|
||||||
|
self.put("etc/network/interfaces", NetworkBootTests.STANZA.replace(
|
||||||
|
"\tudhcpc_opts -x hostname:$(hostname)\n", ""))
|
||||||
|
self.assertFails("dhcp-name")
|
||||||
|
|
||||||
|
def test_an_mdns_responder_fails(self):
|
||||||
|
os.makedirs(self.path("etc/avahi"))
|
||||||
|
self.assertFails("mdns")
|
||||||
|
|
||||||
|
def test_the_extension_host_starting_before_forgectrl_fails(self):
|
||||||
|
os.rename(self.path("etc/rc5.d/S91forgeext"), self.path("etc/rc5.d/S89forgeext"))
|
||||||
|
self.assertFails("init-order")
|
||||||
|
|
||||||
|
def test_no_logging_init_script_fails(self):
|
||||||
|
os.remove(self.path("etc/init.d/forgefirm-logging"))
|
||||||
|
self.assertFails("logging")
|
||||||
|
|
||||||
|
def test_a_userspace_watchdog_init_script_fails(self):
|
||||||
|
self.put("etc/init.d/watchdog", "#!/bin/sh\n", mode=0o755)
|
||||||
|
self.assertFails("watchdog")
|
||||||
|
|
||||||
|
def test_rcs_without_the_read_only_rootfs_fails(self):
|
||||||
|
self.put("etc/default/rcS", "VERBOSE=no\n")
|
||||||
|
self.assertFails("read-only")
|
||||||
|
|
||||||
|
def test_a_dhcpv6_client_on_the_image_fails(self):
|
||||||
|
os.makedirs(self.path("sbin"))
|
||||||
|
os.symlink("/bin/busybox.nosuid", self.path("sbin/udhcpc6"))
|
||||||
|
self.assertFails("dhcpv6")
|
||||||
|
|
||||||
|
def test_a_factory_slot_in_fstab_fails_a_release_image_only(self):
|
||||||
|
self.put("etc/fstab", "/dev/root / auto ro 1 0\n/dev/mmcblk2p1 /factory/img1 auto ro,noatime 0 0\n")
|
||||||
|
self.assertFails("factory", "release")
|
||||||
|
p = self.lint("dev")
|
||||||
|
self.assertEqual(p.returncode, 0, p.stderr)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ The installer runs once per machine, on factory firmware, over whatever
|
|||||||
network the owner has. These tests run its own functions under sh with a
|
network the owner has. These tests run its own functions under sh with a
|
||||||
scripted curl in front of them: a transfer that fails resumes and is
|
scripted curl in front of them: a transfer that fails resumes and is
|
||||||
tried again, the failures that waiting cannot fix end the tries at once,
|
tried again, the failures that waiting cannot fix end the tries at once,
|
||||||
and every step leaves a line in the install log in the log tree's own
|
the reason an install stopped is in the install log, and a log that cannot
|
||||||
format."""
|
be written never fails the install."""
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -25,10 +24,6 @@ REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)
|
|||||||
SCRIPT = os.path.join(REPO, "scripts", "install-forgefirm.sh")
|
SCRIPT = os.path.join(REPO, "scripts", "install-forgefirm.sh")
|
||||||
MARK = "# The install log, before anything can fail."
|
MARK = "# The install log, before anything can fail."
|
||||||
|
|
||||||
# The log tree's line format (forgetest/suite/logs.py keeps the same one).
|
|
||||||
LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d install\[\d+\] "
|
|
||||||
r"(EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) .+$")
|
|
||||||
|
|
||||||
# A curl that plays a script: each call takes the next "rc http bytes"
|
# A curl that plays a script: each call takes the next "rc http bytes"
|
||||||
# line of $CURL_PLAN, appends that many bytes to the --output file the way
|
# line of $CURL_PLAN, appends that many bytes to the --output file the way
|
||||||
# a resumed transfer does, prints the HTTP code as -w '%{http_code}'
|
# a resumed transfer does, prints the HTTP code as -w '%{http_code}'
|
||||||
@@ -149,24 +144,11 @@ class InstallerTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(calls), 3)
|
self.assertEqual(len(calls), 3)
|
||||||
self.assertEqual(os.path.getsize(self.fw), 5000) # not 9000: the stale part went
|
self.assertEqual(os.path.getsize(self.fw), 5000) # not 9000: the stale part went
|
||||||
|
|
||||||
def test_a_tls_failure_names_the_clock(self):
|
|
||||||
out, _, _ = self.download(["60 000 0"] * 5)
|
|
||||||
self.assertRegex(out, r"TLS handshake failed \(a wrong clock does this: \d{4}-\d\d-\d\d")
|
|
||||||
|
|
||||||
def test_every_log_line_is_in_the_tree_format(self):
|
|
||||||
self.download(["7 000 0", "0 200 10"])
|
|
||||||
self.run_sh('log NOTICE "operator declined to continue; no changes made"')
|
|
||||||
lines = self.log_lines()
|
|
||||||
self.assertGreater(len(lines), 5)
|
|
||||||
for line in lines:
|
|
||||||
self.assertRegex(line, LINE_RE)
|
|
||||||
|
|
||||||
def test_die_leaves_the_reason_in_the_log(self):
|
def test_die_leaves_the_reason_in_the_log(self):
|
||||||
r = self.run_sh('die "archiving slot 1 failed"; echo not-reached')
|
r = self.run_sh('die "archiving slot 1 failed"; echo not-reached')
|
||||||
self.assertEqual(r.returncode, 1)
|
self.assertEqual(r.returncode, 1)
|
||||||
self.assertNotIn("not-reached", r.stdout)
|
self.assertNotIn("not-reached", r.stdout)
|
||||||
self.assertTrue(any(" ERR install failed: archiving slot 1 failed" in line
|
self.assertTrue(any("archiving slot 1 failed" in line for line in self.log_lines()))
|
||||||
for line in self.log_lines()))
|
|
||||||
|
|
||||||
def test_no_writable_log_never_fails_the_install(self):
|
def test_no_writable_log_never_fails_the_install(self):
|
||||||
r = self.run_sh('LOG_OK=""; log INFO "x"; LOG_OK=yes; LOG_FILE=/nonexistent/dir/x.log; '
|
r = self.run_sh('LOG_OK=""; log INFO "x"; LOG_OK=yes; LOG_FILE=/nonexistent/dir/x.log; '
|
||||||
@@ -174,21 +156,5 @@ class InstallerTests(unittest.TestCase):
|
|||||||
self.assertIn("still-running rc=0", r.stdout)
|
self.assertIn("still-running rc=0", r.stdout)
|
||||||
|
|
||||||
|
|
||||||
class InstallerShapeTests(unittest.TestCase):
|
|
||||||
"""What holds on any host, with no shell to run."""
|
|
||||||
|
|
||||||
def test_the_log_lives_in_the_tree_the_export_carries(self):
|
|
||||||
head = functions()
|
|
||||||
self.assertIn('LOG_DIR="/data/log/forgefirm/install"', head)
|
|
||||||
self.assertIn('LOG_FILE="$LOG_DIR/install.log"', head)
|
|
||||||
|
|
||||||
def test_the_download_is_the_retrying_one(self):
|
|
||||||
with open(SCRIPT, encoding="utf-8") as f:
|
|
||||||
text = f.read()
|
|
||||||
body = text.partition(MARK)[2]
|
|
||||||
self.assertIn("download_fw", body)
|
|
||||||
self.assertNotRegex(body, r"(?m)^\s*curl ") # no bare curl past the functions
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -7,7 +7,13 @@
|
|||||||
the button latch judged from the first emission through every sample
|
the button latch judged from the first emission through every sample
|
||||||
whose readback word shows the laser latch unlocked, both bits from that
|
whose readback word shows the laser latch unlocked, both bits from that
|
||||||
word, so the relocked tail (where the relock itself sets the latch and
|
word, so the relocked tail (where the relock itself sets the latch and
|
||||||
the emission counter still reads nonzero) is never judged."""
|
the emission counter still reads nonzero) is never judged. And its
|
||||||
|
read-gap verdict over the dark stretch after the M5: a sample whose
|
||||||
|
/status carried no laser block is a gap, never a dark sample, a tail the
|
||||||
|
daemon did not serve before the sampling stopped is a gap too, and a
|
||||||
|
stretch with a kernel window unread fails the rule; the sampling runs on
|
||||||
|
past the close; and the head's reports place the rapid inside the armed
|
||||||
|
window only."""
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from forgetest.suite import laser
|
from forgetest.suite import laser
|
||||||
@@ -47,5 +53,157 @@ class DwellGapTests(unittest.TestCase):
|
|||||||
self.assertFalse(g["hv_enable_dipped"])
|
self.assertFalse(g["hv_enable_dipped"])
|
||||||
|
|
||||||
|
|
||||||
|
def rapid_trail(n, dt=0.2, holes=()):
|
||||||
|
"""A dark stretch of n samples dt seconds apart, the emission count
|
||||||
|
and the HV current read on every sample but the ones in `holes`: a
|
||||||
|
/status served without its laser block, which sample() records as
|
||||||
|
None."""
|
||||||
|
out = []
|
||||||
|
for i in range(n):
|
||||||
|
s = {"t": 100.0 + i * dt, "emission": 0, "hv": 0, "armed": True}
|
||||||
|
if i in holes:
|
||||||
|
s["emission"] = None
|
||||||
|
s["hv"] = None
|
||||||
|
out.append(s)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def covered(r):
|
||||||
|
"""The rule emission_witness applies to read_gaps' report."""
|
||||||
|
return (r["read"] >= laser.RAPID_MIN_READS and r["gap_max_s"] is not None
|
||||||
|
and r["gap_max_s"] <= laser.EMISSION_READ_GAP_S)
|
||||||
|
|
||||||
|
|
||||||
|
class RapidReadTests(unittest.TestCase):
|
||||||
|
def test_every_sample_read_is_inside_the_rule(self):
|
||||||
|
r = laser.read_gaps(rapid_trail(25), "emission")
|
||||||
|
self.assertEqual((r["read"], r["unread"]), (25, 0))
|
||||||
|
self.assertAlmostEqual(r["gap_max_s"], 0.2)
|
||||||
|
self.assertTrue(covered(r))
|
||||||
|
|
||||||
|
def test_one_transient_miss_is_inside_the_rule(self):
|
||||||
|
r = laser.read_gaps(rapid_trail(25, holes=(12,)), "emission")
|
||||||
|
self.assertEqual((r["read"], r["unread"]), (24, 1))
|
||||||
|
self.assertAlmostEqual(r["gap_max_s"], 0.4)
|
||||||
|
self.assertTrue(covered(r))
|
||||||
|
|
||||||
|
def test_a_hole_shorter_than_a_window_is_inside_the_rule(self):
|
||||||
|
r = laser.read_gaps(rapid_trail(25, holes=(10, 11, 12)), "emission")
|
||||||
|
self.assertAlmostEqual(r["gap_max_s"], 0.8)
|
||||||
|
self.assertTrue(covered(r))
|
||||||
|
|
||||||
|
def test_a_hole_longer_than_a_window_fails(self):
|
||||||
|
r = laser.read_gaps(rapid_trail(25, holes=(10, 11, 12, 13, 14)), "emission")
|
||||||
|
self.assertAlmostEqual(r["gap_max_s"], 1.2)
|
||||||
|
self.assertFalse(covered(r))
|
||||||
|
|
||||||
|
def test_the_block_gone_after_the_dark_point_fails(self):
|
||||||
|
r = laser.read_gaps(rapid_trail(25, holes=tuple(range(1, 25))), "emission")
|
||||||
|
self.assertEqual((r["read"], r["unread"]), (1, 24))
|
||||||
|
self.assertAlmostEqual(r["gap_max_s"], 4.8)
|
||||||
|
self.assertFalse(covered(r))
|
||||||
|
|
||||||
|
def test_the_block_gone_for_the_last_window_and_a_half_fails(self):
|
||||||
|
# the tail is an edge: the window that latches after the close
|
||||||
|
# goes unread
|
||||||
|
r = laser.read_gaps(rapid_trail(25, holes=tuple(range(17, 25))), "emission")
|
||||||
|
self.assertAlmostEqual(r["gap_max_s"], 1.6)
|
||||||
|
self.assertFalse(covered(r))
|
||||||
|
|
||||||
|
def test_a_tail_the_daemon_never_served_fails(self):
|
||||||
|
# every sample served was read, and the served samples end at the
|
||||||
|
# close: the sampling ran on past it with nothing served, so the
|
||||||
|
# stretch from the last reading to where the sampling stopped is a gap
|
||||||
|
trail = rapid_trail(25)
|
||||||
|
tail = 2 * laser.EMISSION_WINDOW_S + laser.EMISSION_READ_GAP_S
|
||||||
|
r = laser.read_gaps(trail, "emission", trail[-1]["t"] + tail)
|
||||||
|
self.assertAlmostEqual(r["gap_max_s"], tail)
|
||||||
|
self.assertFalse(covered(r))
|
||||||
|
# served through: the sampling stops one loop after the last sample
|
||||||
|
self.assertTrue(covered(laser.read_gaps(trail, "emission", trail[-1]["t"] + 0.125)))
|
||||||
|
|
||||||
|
def test_a_stretch_of_one_sample_fails(self):
|
||||||
|
r = laser.read_gaps(rapid_trail(1), "emission")
|
||||||
|
self.assertEqual(r, {"read": 1, "unread": 0, "gap_max_s": 0.0})
|
||||||
|
self.assertFalse(covered(r))
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
"""time.time and time.sleep on a clock the test moves."""
|
||||||
|
|
||||||
|
def __init__(self, now=1000.0):
|
||||||
|
self.now = now
|
||||||
|
|
||||||
|
def time(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
def sleep(self, s):
|
||||||
|
self.now += s
|
||||||
|
|
||||||
|
|
||||||
|
class RapidTailTests(unittest.TestCase):
|
||||||
|
def test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped(self):
|
||||||
|
# The window closes 3 s in, and the daemon serves the closed
|
||||||
|
# sample and nothing after it: the sampling still runs two kernel
|
||||||
|
# windows and the read gap allowed past the close, and the time it
|
||||||
|
# stopped is the trail's end, so the tail it never served fails the
|
||||||
|
# read rule.
|
||||||
|
clock = Clock()
|
||||||
|
close = clock.now + 3.0
|
||||||
|
served = []
|
||||||
|
|
||||||
|
def sample(_ctx):
|
||||||
|
clock.now += 0.06 # the two GETs
|
||||||
|
if served and not served[-1]["armed"]:
|
||||||
|
return None # nothing served after the close
|
||||||
|
s = {"t": clock.now, "armed": clock.now < close, "emission": 0, "hv": 0}
|
||||||
|
served.append(s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
class Ctx:
|
||||||
|
def checkpoint(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Grbl:
|
||||||
|
def status_report(self):
|
||||||
|
# the time of the read, as the head's X
|
||||||
|
return {"state": "Idle", "MPos": (clock.now, 0.0, 0.0)}
|
||||||
|
saved = laser.time, laser.sample
|
||||||
|
self.addCleanup(setattr, laser, "time", saved[0])
|
||||||
|
self.addCleanup(setattr, laser, "sample", saved[1])
|
||||||
|
laser.time, laser.sample = clock, sample
|
||||||
|
tail, t_end = laser.sample_to_disarm(Ctx(), Grbl())
|
||||||
|
closed = tail[-1]
|
||||||
|
self.assertFalse(closed["armed"])
|
||||||
|
self.assertTrue(all(s["armed"] for s in tail[:-1]))
|
||||||
|
self.assertGreaterEqual(t_end - closed["t"], 2 * laser.EMISSION_WINDOW_S + laser.EMISSION_READ_GAP_S)
|
||||||
|
self.assertFalse(covered(laser.read_gaps(tail, "emission", t_end)))
|
||||||
|
# each sample's X is read before its armed flag: an X that places
|
||||||
|
# the head was read with the window open
|
||||||
|
self.assertTrue(all(s["x"] < s["t"] for s in tail))
|
||||||
|
|
||||||
|
|
||||||
|
def place_trail(xs, closes_at):
|
||||||
|
"""A dark stretch with the head at machine X `xs` (one sample each,
|
||||||
|
0.2 s apart), the window closing at sample `closes_at`."""
|
||||||
|
return [{"t": 100.0 + i * 0.2, "x": float(x), "armed": i < closes_at} for i, x in enumerate(xs)]
|
||||||
|
|
||||||
|
|
||||||
|
class RapidPlaceTests(unittest.TestCase):
|
||||||
|
# out to +20 mm, the dwell there, back at the start, the dwell before the M2
|
||||||
|
XS = [0, 5, 20, 20, 20, 10, 0, 0, 0, 0]
|
||||||
|
|
||||||
|
def test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid(self):
|
||||||
|
# the window closes after the head is back: out at 2, back at 6, closed at 8
|
||||||
|
self.assertEqual(laser.rapid_place(place_trail(self.XS, 8), 0.0), (2, 6, 8))
|
||||||
|
# the window closes in the dwell at +20 mm and the head comes back
|
||||||
|
# after the close: the samples past the close do not place it
|
||||||
|
self.assertEqual(laser.rapid_place(place_trail(self.XS, 4), 0.0), (2, None, 4))
|
||||||
|
# the window reads closed at the first sample with the head back:
|
||||||
|
# that sample does not tell whether the head was back before the
|
||||||
|
# close, so it does not place it
|
||||||
|
self.assertEqual(laser.rapid_place(place_trail(self.XS, 6), 0.0), (2, None, 6))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
||||||
# Written by Scott Wiederhold
|
|
||||||
# https://community.openglow.org
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""The dark judge of laser.verdict-cut over synthetic trails. The judge is
|
|
||||||
a function inside the test's own body, so a change to it moves that test's
|
|
||||||
fingerprint and no other laser test's; it is reached here through the test
|
|
||||||
function's code object. A trail row is a status poll; its `fast` entry is
|
|
||||||
the burst of cnc/laser_on reads taken after that poll: [start, end, reads,
|
|
||||||
reads that saw the laser on, reads that failed]."""
|
|
||||||
import types
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from forgetest.suite import laser
|
|
||||||
|
|
||||||
DRAIN_S = 0.3
|
|
||||||
|
|
||||||
|
|
||||||
def dark_span():
|
|
||||||
code = next(c for c in laser.verdict_cut.__code__.co_consts
|
|
||||||
if isinstance(c, types.CodeType) and c.co_name == "dark_span")
|
|
||||||
assert code.co_freevars == (), "the judge must not close over the test's variables"
|
|
||||||
return types.FunctionType(code, vars(laser))
|
|
||||||
|
|
||||||
|
|
||||||
def row(t, gstate, on=None, n=350, bad=0):
|
|
||||||
r = {"t": t, "gstate": gstate}
|
|
||||||
if on is not None:
|
|
||||||
r["fast"] = [round(t + 0.01, 3), round(t + 0.11, 3), n, on, bad]
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
class DarkSpanTests(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.judge = dark_span()
|
|
||||||
|
|
||||||
def hold(self, lit_at=(), tail_lit=0):
|
|
||||||
"""Run, then a hold from 2.0 s (Hold:0) polled every 0.14 s with a
|
|
||||||
burst after each poll, then the resume at 3.4 s. lit_at: the
|
|
||||||
poll times whose burst saw the laser on; tail_lit: the burst
|
|
||||||
after the last Hold poll, which the resume ends."""
|
|
||||||
rows = [row(1.72, "Run"), row(1.86, "Run")]
|
|
||||||
t = 2.0
|
|
||||||
while t < 3.3:
|
|
||||||
rows.append(row(round(t, 2), "Hold:0", on=5 if round(t, 2) in lit_at else 0))
|
|
||||||
t += 0.14
|
|
||||||
rows[-1]["fast"][3] = tail_lit
|
|
||||||
rows.append(row(3.4, "Run"))
|
|
||||||
return rows
|
|
||||||
|
|
||||||
def test_a_dark_hold_is_judged_dark(self):
|
|
||||||
d = self.judge(self.hold(), DRAIN_S)
|
|
||||||
reads, on, bad, bursts, first, last = d
|
|
||||||
self.assertEqual((on, bad), (0, 0))
|
|
||||||
self.assertGreater(bursts, 0)
|
|
||||||
self.assertEqual(reads, bursts * 350)
|
|
||||||
self.assertGreaterEqual(first, 2.0 + DRAIN_S)
|
|
||||||
self.assertLessEqual(last, 3.4)
|
|
||||||
|
|
||||||
def test_the_lit_deceleration_inside_the_drain_is_not_judged(self):
|
|
||||||
# the pause tier's first deceleration plays out of the kernel's
|
|
||||||
# queue after Hold:0: lit bursts inside the drain are expected
|
|
||||||
d = self.judge(self.hold(lit_at=(2.0, 2.14)), DRAIN_S)
|
|
||||||
self.assertEqual(d[1], 0)
|
|
||||||
|
|
||||||
def test_emission_after_the_drain_is_caught(self):
|
|
||||||
# the negative control: a lit read inside the judged span is seen
|
|
||||||
d = self.judge(self.hold(lit_at=(2.56,)), DRAIN_S)
|
|
||||||
self.assertEqual(d[1], 5)
|
|
||||||
|
|
||||||
def test_a_burst_the_resume_ends_is_not_judged(self):
|
|
||||||
# the poll after it reads Run: the cut may have relit inside it
|
|
||||||
d = self.judge(self.hold(tail_lit=40), DRAIN_S)
|
|
||||||
self.assertEqual(d[1], 0)
|
|
||||||
|
|
||||||
def test_failed_reads_are_counted(self):
|
|
||||||
rows = self.hold()
|
|
||||||
rows[5]["fast"][4] = 3
|
|
||||||
self.assertEqual(self.judge(rows, DRAIN_S)[2], 3)
|
|
||||||
|
|
||||||
def test_the_drain_counts_from_the_first_hold_complete(self):
|
|
||||||
# Hold:1 (decelerating) rows do not start the span: a lit burst
|
|
||||||
# after a Hold:1 poll but inside the drain of the first Hold:0 is
|
|
||||||
# not judged
|
|
||||||
rows = [row(1.8, "Run"), row(1.94, "Hold:1", on=30), row(2.08, "Hold:0", on=12),
|
|
||||||
row(2.22, "Hold:0", on=0), row(2.36, "Hold:0", on=0), row(2.5, "Hold:0", on=0),
|
|
||||||
row(2.64, "Hold:0", on=0), row(2.78, "Run")]
|
|
||||||
reads, on, bad, bursts, first, last = self.judge(rows, DRAIN_S)
|
|
||||||
self.assertEqual(on, 0)
|
|
||||||
self.assertGreaterEqual(first, 2.08 + DRAIN_S)
|
|
||||||
# 2.51 only: 2.37 is inside the drain, 2.65 is ended by the resume
|
|
||||||
self.assertEqual(bursts, 1)
|
|
||||||
|
|
||||||
def test_no_hold_complete_cannot_be_judged(self):
|
|
||||||
self.assertIsNone(self.judge([row(1.0, "Run"), row(1.2, "Hold:1", on=0), row(1.4, "Run")], DRAIN_S))
|
|
||||||
|
|
||||||
def test_a_hold_too_short_for_the_drain_leaves_nothing_to_judge(self):
|
|
||||||
# the test then fails on too few reads, never passes on none
|
|
||||||
rows = [row(1.8, "Run"), row(2.0, "Hold:0", on=0), row(2.14, "Hold:0", on=0), row(2.28, "Run")]
|
|
||||||
self.assertEqual(self.judge(rows, DRAIN_S)[:4], [0, 0, 0, 0])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import helpers
|
import helpers
|
||||||
@@ -122,13 +123,14 @@ class CoverageReportTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_behavioral_paths_are_never_allowed_away(self):
|
def test_behavioral_paths_are_never_allowed_away(self):
|
||||||
# An advisory document is behavior (the consent is to its hash): the allowlist's docs/** and
|
# An advisory document is behavior (the consent is to its hash): the allowlist's docs/** and
|
||||||
# **/*.md do not take it out of the report, as they do not take it out of a fingerprint.
|
# **/*.md do not take it out of the report, as they do not take it out of a fingerprint. No
|
||||||
|
# host-tested rows here: the real table's rows name the first-run documents.
|
||||||
man = helpers.with_file(helpers.make_manifest(), "forgectrl", "docs/advisories/privacy.md", "text")
|
man = helpers.with_file(helpers.make_manifest(), "forgectrl", "docs/advisories/privacy.md", "text")
|
||||||
rest = helpers.make_test("a.one", [("forgectrl", "src/**"), ("grblhal-glowforge", "**"),
|
rest = helpers.make_test("a.one", [("forgectrl", "src/**"), ("grblhal-glowforge", "**"),
|
||||||
("kernel-module-glowforge", "**"), ("linux-fslc", "**")])
|
("kernel-module-glowforge", "**"), ("linux-fslc", "**")])
|
||||||
self.assertEqual(m.coverage_report(man, [rest]), {"forgectrl": ["docs/advisories/privacy.md"]})
|
self.assertEqual(m.coverage_report(man, [rest], host=()), {"forgectrl": ["docs/advisories/privacy.md"]})
|
||||||
doc = helpers.make_test("a.two", [("forgectrl", "docs/advisories/privacy.md")])
|
doc = helpers.make_test("a.two", [("forgectrl", "docs/advisories/privacy.md")])
|
||||||
self.assertEqual(m.coverage_report(man, [rest, doc]), {})
|
self.assertEqual(m.coverage_report(man, [rest, doc], host=()), {})
|
||||||
self.assertNotEqual(m.fingerprint(man, doc.covers),
|
self.assertNotEqual(m.fingerprint(man, doc.covers),
|
||||||
m.fingerprint(helpers.with_file(man, "forgectrl", "docs/advisories/privacy.md", "new"),
|
m.fingerprint(helpers.with_file(man, "forgectrl", "docs/advisories/privacy.md", "new"),
|
||||||
doc.covers))
|
doc.covers))
|
||||||
@@ -169,6 +171,441 @@ class CoverageReportTests(unittest.TestCase):
|
|||||||
docs = helpers.make_test("a.three", [("forgectrl", "**/*.md")])
|
docs = helpers.make_test("a.three", [("forgectrl", "**/*.md")])
|
||||||
self.assertEqual(m.empty_covers(man, [docs]), [("a.three", "forgectrl", "**/*.md")])
|
self.assertEqual(m.empty_covers(man, [docs]), [("a.three", "forgectrl", "**/*.md")])
|
||||||
|
|
||||||
|
def test_a_host_tested_path_is_covered_and_in_no_fingerprint(self):
|
||||||
|
man = helpers.with_file(helpers.make_manifest(), "forgectrl", "tests/cool_test.c", "test")
|
||||||
|
t = helpers.make_test("a.one", [("forgectrl", "src/ui.c"), ("forgectrl", "src/auth.c"),
|
||||||
|
("grblhal-glowforge", "**"), ("kernel-module-glowforge", "**"),
|
||||||
|
("linux-fslc", "**")])
|
||||||
|
self.assertEqual(m.coverage_report(man, [t], host=[]), {"forgectrl": ["src/cool.c", "src/main.c"]})
|
||||||
|
ci = "forgectrl:.github/workflows/build.yml"
|
||||||
|
host = [("forgectrl", "src/cool.c", "forgectrl:tests/cool_test.c", ci),
|
||||||
|
("forgectrl", "src/main.c", "forgectrl:tests/cool_test.c", ci)]
|
||||||
|
self.assertEqual(m.coverage_report(man, [t], host=host), {})
|
||||||
|
# a change to a host-tested path makes no acceptance run necessary
|
||||||
|
self.assertEqual(m.fingerprint(man, t.covers),
|
||||||
|
m.fingerprint(helpers.with_file(man, "forgectrl", "src/cool.c", "changed"), t.covers))
|
||||||
|
|
||||||
|
def _root(self, files):
|
||||||
|
"""A scratch checkout of this repository: {path: text}."""
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
root = tempfile.mkdtemp()
|
||||||
|
self.addCleanup(shutil.rmtree, root)
|
||||||
|
for path, text in files.items():
|
||||||
|
os.makedirs(os.path.dirname(os.path.join(root, path)), exist_ok=True)
|
||||||
|
with open(os.path.join(root, path), "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
return root
|
||||||
|
|
||||||
|
# A workflow of this repository as the scratch checkouts carry it: on
|
||||||
|
# every push, and on the pull requests that touch one bench tool, it
|
||||||
|
# checks itself out at the workspace root and runs the one harness.
|
||||||
|
CI_YML = ("on:\n push:\n pull_request:\n paths:\n - 'scripts/bench/drill.py'\n"
|
||||||
|
"jobs:\n test:\n steps:\n"
|
||||||
|
" - uses: actions/checkout@v4\n"
|
||||||
|
" # drill.py is a bench tool\n"
|
||||||
|
" - name: The stream\n"
|
||||||
|
" run: python3 scripts/bench/stream_test.py # not drill.py\n")
|
||||||
|
|
||||||
|
def test_a_host_tested_row_selects_a_path_and_names_a_real_test_and_workflow(self):
|
||||||
|
man = helpers.make_manifest()
|
||||||
|
for path in ("tests/cool_test.c", ".github/workflows/build.yml"):
|
||||||
|
man = helpers.with_file(man, "forgectrl", path, path)
|
||||||
|
root = self._root({"scripts/bench/stream_test.py": "", ".github/workflows/ci.yml": self.CI_YML})
|
||||||
|
ci = "forgectrl:.github/workflows/build.yml"
|
||||||
|
good = [("forgectrl", "src/cool.c", "forgectrl:tests/cool_test.c", ci),
|
||||||
|
("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py",
|
||||||
|
"forgefirm:.github/workflows/ci.yml")]
|
||||||
|
self.assertEqual(m.host_row_problems(man, good, root=root, sources=None), [])
|
||||||
|
bad = [("forgectrl", "cool.c", "forgectrl:tests/cool_test.c", ci), # globs anchor at the root
|
||||||
|
("forgectrl", "**/*.md", "forgectrl:tests/cool_test.c", ci), # docs only
|
||||||
|
("forgectrl", "src/main.c", "forgectrl:tests/gone_test.c", ci),
|
||||||
|
("forgectrl", "src/main.c", "tests/cool_test.c", ci),
|
||||||
|
("forgectrl", "src/main.c", "forgetest:forgetest/x.py", ci), # the acceptance tool itself
|
||||||
|
("forgectrl", "src/main.c", "forgefirm:scripts/bench/gone_test.py", ci),
|
||||||
|
("forgectrl", "src/main.c", "forgectrl:tests/cool_test.c", "forgectrl:.github/workflows/gone.yml"),
|
||||||
|
("forgectrl", "src/main.c", "forgectrl:tests/cool_test.c", "build.yml")]
|
||||||
|
self.assertEqual([r[3] for r in m.host_row_problems(man, bad, root=root, sources=None)],
|
||||||
|
["selects nothing", "selects nothing", "no such host test",
|
||||||
|
"names no repository:path for its host test", "no repository 'forgetest' for its host test",
|
||||||
|
"no such host test", "no such workflow", "names no repository:path for its workflow"])
|
||||||
|
# installed on an image the package is in no checkout: a "forgefirm:" file is not looked for
|
||||||
|
self.assertEqual(m.host_row_problems(man, bad[5:6], root=None, sources=None), [])
|
||||||
|
|
||||||
|
def test_the_workflow_has_to_run_the_host_test(self):
|
||||||
|
man = helpers.make_manifest()
|
||||||
|
root = self._root({"scripts/bench/stream_test.py": "", "scripts/bench/drill.py": "",
|
||||||
|
".github/workflows/ci.yml": self.CI_YML})
|
||||||
|
ci = "forgefirm:.github/workflows/ci.yml"
|
||||||
|
runs = ("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py", ci)
|
||||||
|
# drill.py is in the trigger's paths, a comment line and an inline comment, and run by no step
|
||||||
|
idle = ("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/drill.py", ci)
|
||||||
|
self.assertEqual(m.host_row_problems(man, [runs], root=root, sources=None), [])
|
||||||
|
self.assertEqual(m.host_row_problems(man, [idle], root=root, sources=None),
|
||||||
|
[(idle[0], idle[1], idle[2], ci + " does not run it")])
|
||||||
|
# a workflow whose own on: names neither push nor pull_request is listed, and a step whose
|
||||||
|
# failure the run tolerates does not run it
|
||||||
|
job = "jobs:\n test:\n steps:\n - uses: actions/checkout@v4\n - run: python3 scripts/bench/stream_test.py\n"
|
||||||
|
root = self._root({"scripts/bench/stream_test.py": "",
|
||||||
|
".github/workflows/manual.yml": "on:\n workflow_dispatch:\n" + job,
|
||||||
|
".github/workflows/soft.yml": "on: [push]\n" + job.replace(
|
||||||
|
" - run:", " - continue-on-error: true\n run:")})
|
||||||
|
manual = runs[:3] + ("forgefirm:.github/workflows/manual.yml",)
|
||||||
|
soft = runs[:3] + ("forgefirm:.github/workflows/soft.yml",)
|
||||||
|
self.assertEqual(m.host_row_problems(man, [manual, soft], root=root, sources=None),
|
||||||
|
[manual[:3] + (manual[3] + ": the lint reads no push or pull_request in its own on:",),
|
||||||
|
soft[:3] + (soft[3] + " does not run it",)])
|
||||||
|
# a step in a job that needs a conditional job does not run it (the checkout in a job of
|
||||||
|
# its own, as the lint reads a checkout for the whole workflow)
|
||||||
|
root = self._root({"scripts/bench/stream_test.py": "",
|
||||||
|
".github/workflows/needs.yml": "on: [push]\njobs:\n lint:\n steps:\n"
|
||||||
|
" - uses: actions/checkout@v4\n build:\n if: false\n steps:\n"
|
||||||
|
" - run: make\n test:\n needs: build\n steps:\n"
|
||||||
|
" - run: python3 scripts/bench/stream_test.py\n"})
|
||||||
|
needs = runs[:3] + ("forgefirm:.github/workflows/needs.yml",)
|
||||||
|
self.assertEqual(m.host_row_problems(man, [needs], root=root, sources=None),
|
||||||
|
[needs[:3] + (needs[3] + " does not run it",)])
|
||||||
|
|
||||||
|
def _runs(self, yml, path):
|
||||||
|
return m.workflow_runs(m.workflow_steps(yml), path)
|
||||||
|
|
||||||
|
def _block(self, *lines, keys=""):
|
||||||
|
"""A one-step workflow whose run text is these lines, the step's other keys before it."""
|
||||||
|
return "steps:\n - name: x\n%s run: |\n%s" % (keys, "".join(" %s\n" % ln for ln in lines))
|
||||||
|
|
||||||
|
def test_what_running_a_host_test_looks_like(self):
|
||||||
|
step = "steps:\n - name: x\n run: %s\n"
|
||||||
|
self.assertTrue(self._runs(step % "./build/tray_test", "tests/tray_test.c"))
|
||||||
|
self.assertTrue(self._runs(step % "sudo ./build/sandbox_test", "tests/sandbox_test.c"))
|
||||||
|
self.assertTrue(self._runs(step % "python3 -B tests/run_test.py", "tests/run_test.py"))
|
||||||
|
self.assertTrue(self._runs(step % "python3 forgefirm/scripts/bench/ctlport_test.py build/x",
|
||||||
|
"forgefirm/scripts/bench/ctlport_test.py"))
|
||||||
|
self.assertTrue(self._runs(step % "python -m pytest -q tests", "tests/test_frame.py"))
|
||||||
|
# a block, with the step's working-directory: the words are its paths (the loop as
|
||||||
|
# python3-gfhardware's CI writes it, an expansion in an assignment)
|
||||||
|
block = ("steps:\n - name: every module\n working-directory: %s\n run: |\n"
|
||||||
|
" set -e\n for t in tests/test_*.py; do\n m=$(basename \"$t\" .py)\n"
|
||||||
|
" python -m unittest -v \"tests.$m\"\n"
|
||||||
|
" done\n - working-directory: forgefirm/forgetest\n"
|
||||||
|
" run: python -B -m unittest discover -s tests -v\n")
|
||||||
|
self.assertTrue(self._runs(block % "gfh", "gfh/tests/test_feeder.py"))
|
||||||
|
self.assertTrue(self._runs(block % "gfh", "forgefirm/forgetest/tests/test_x.py"))
|
||||||
|
self.assertFalse(self._runs(block % "other", "gfh/tests/test_feeder.py"))
|
||||||
|
# what the review found accepted: a trigger's paths, an inline comment, a glob or a discover
|
||||||
|
# that the path only ends like
|
||||||
|
self.assertFalse(self._runs("on:\n push:\n paths:\n - 'kas/source-bundle.yml'\n"
|
||||||
|
+ step % "python3 x.py", "kas/source-bundle.yml"))
|
||||||
|
self.assertFalse(self._runs(step % "./build/lens_test # tray_test is manual", "tests/tray_test.c"))
|
||||||
|
self.assertFalse(self._runs("steps:\n - run: |\n for t in tests/test_*.py; do\n python $t\n"
|
||||||
|
" done\n", "tools/tests/test_feeder.py"))
|
||||||
|
self.assertFalse(self._runs(step % "python -m unittest discover -s tests",
|
||||||
|
"scripts/bench/tests/foo_test.py"))
|
||||||
|
# a longer name, another file, a step's name, a comment line
|
||||||
|
self.assertFalse(self._runs(step % "./build/tray_test_more", "tests/tray_test.c"))
|
||||||
|
self.assertFalse(self._runs(step % "./build/lens_test", "tests/fflog_e2e.sh"))
|
||||||
|
self.assertFalse(self._runs("steps:\n - name: tests/run_test.py\n run: make\n", "tests/run_test.py"))
|
||||||
|
self.assertFalse(self._runs("steps:\n - run: |\n # python3 tests/run_test.py\n make\n",
|
||||||
|
"tests/run_test.py"))
|
||||||
|
# the second review: an argument to a command that is not the test does not run it, and a
|
||||||
|
# discover or pytest runs its own directory's matching files only
|
||||||
|
self.assertFalse(self._runs(step % "echo tray_test", "tests/tray_test.c"))
|
||||||
|
self.assertFalse(self._runs(step % "echo ./build/tray_test", "tests/tray_test.c"))
|
||||||
|
self.assertFalse(self._runs(step % "command -v ./build/tray_test", "tests/tray_test.c")) # prints it
|
||||||
|
self.assertFalse(self._runs(step % "cmake --build build --target tray_test", "tests/tray_test.c"))
|
||||||
|
self.assertFalse(self._runs(step % "cat tests/run_test.py", "tests/run_test.py"))
|
||||||
|
self.assertFalse(self._runs(step % "python -m unittest discover -s forgefirm",
|
||||||
|
"forgefirm/scripts/bench/fire_test.py"))
|
||||||
|
self.assertFalse(self._runs(step % "python -m unittest discover -s tests", "tests/foo_test.py"))
|
||||||
|
self.assertFalse(self._runs(step % "python -m pytest forgefirm", "forgefirm/tests/test_a.py"))
|
||||||
|
self.assertTrue(self._runs(step % "python -m unittest discover -s tests -p '*_test.py'", "tests/foo_test.py"))
|
||||||
|
self.assertTrue(self._runs(step % "pytest -q tests/test_a.py", "tests/test_a.py"))
|
||||||
|
# a script run directly or by its shell, and a command continued across lines
|
||||||
|
self.assertTrue(self._runs(step % "./tests/fflog_e2e.sh", "tests/fflog_e2e.sh"))
|
||||||
|
self.assertTrue(self._runs(step % "bash tests/fflog_e2e.sh", "tests/fflog_e2e.sh"))
|
||||||
|
self.assertTrue(self._runs("steps:\n - run: |\n sudo -E FORGEEXT=build/forgeext \\\n"
|
||||||
|
" python3 -B tests/run_test.py\n", "tests/run_test.py"))
|
||||||
|
# python -m unittest given the test's file or its module (the third review: forgectrl's CI
|
||||||
|
# runs its mock test as a file), and not a module beside it or a -k pattern
|
||||||
|
self.assertTrue(self._runs(step % "python3 -B -m unittest -v tests/test_devserver_mock.py",
|
||||||
|
"tests/test_devserver_mock.py"))
|
||||||
|
self.assertTrue(self._runs(step % "python -m unittest tests.test_feeder", "tests/test_feeder.py"))
|
||||||
|
self.assertTrue(self._runs(step % "python -m unittest tests.test_feeder.FeederTests.test_one",
|
||||||
|
"tests/test_feeder.py"))
|
||||||
|
self.assertFalse(self._runs(step % "python -m unittest tests.test_lens", "tests/test_feeder.py"))
|
||||||
|
self.assertFalse(self._runs(step % "python -m unittest -k test_feeder tests.test_lens",
|
||||||
|
"tests/test_feeder.py"))
|
||||||
|
|
||||||
|
def test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run(self):
|
||||||
|
step = "steps:\n - name: x\n%s run: ./build/tray_test\n"
|
||||||
|
test = "tests/tray_test.c"
|
||||||
|
# the lint evaluates no condition: an if: other than the literal true counts as no step,
|
||||||
|
# always() included; literally true is no condition
|
||||||
|
self.assertFalse(self._runs(step % " if: false\n", test))
|
||||||
|
self.assertFalse(self._runs(step % " if: github.event_name == 'push'\n", test))
|
||||||
|
self.assertFalse(self._runs(step % " if: ${{ always() }}\n", test))
|
||||||
|
self.assertTrue(self._runs(step % " if: true\n", test))
|
||||||
|
self.assertTrue(self._runs(step % " if: 'true'\n", test))
|
||||||
|
# however the step is spelled: more than one space after its dash, or a quoted key
|
||||||
|
self.assertFalse(self._runs("steps:\n - run: ./build/tray_test\n if: false\n", test))
|
||||||
|
self.assertFalse(self._runs(step % " \"if\": false\n", test))
|
||||||
|
# a key with one stray quote is no key the lint reads: at a step's key (its first one on
|
||||||
|
# its dash line too), a job's key or the top level, no step of the workflow counts
|
||||||
|
self.assertFalse(self._runs(step % " if\": false\n", test))
|
||||||
|
self.assertFalse(self._runs("steps:\n - name: x\n run\": ./build/tray_test\n", test))
|
||||||
|
self.assertFalse(self._runs("steps:\n - run\": make\n - run: ./build/tray_test\n", test))
|
||||||
|
self.assertFalse(self._runs("jobs:\n t:\n if\": false\n steps:\n - run: ./build/tray_test\n",
|
||||||
|
test))
|
||||||
|
self.assertFalse(self._runs("jobs\":\n t:\n if: false\n steps:\n - run: ./build/tray_test\n",
|
||||||
|
test))
|
||||||
|
# a step whose failure the run tolerates proves nothing; literally false is no tolerance
|
||||||
|
self.assertFalse(self._runs(step % " continue-on-error: true\n", test))
|
||||||
|
self.assertFalse(self._runs(step % " continue-on-error: ${{ inputs.soft }}\n", test))
|
||||||
|
self.assertTrue(self._runs(step % " continue-on-error: false\n", test))
|
||||||
|
# the job's own if: or continue-on-error: holds for each of its steps, wherever it is
|
||||||
|
# written, over a step's own, and not for another job's steps
|
||||||
|
job = "jobs:\n t:\n%s steps:\n - run: ./build/tray_test\n%s"
|
||||||
|
self.assertFalse(self._runs(job % (" if: github.ref == 'refs/heads/main'\n", ""), test))
|
||||||
|
self.assertFalse(self._runs(job % ("", " if: false\n"), test))
|
||||||
|
self.assertFalse(self._runs(job % (" continue-on-error: true\n", ""), test))
|
||||||
|
self.assertFalse(self._runs("jobs:\n t:\n continue-on-error: true\n steps:\n"
|
||||||
|
" - continue-on-error: false\n run: ./build/tray_test\n", test))
|
||||||
|
self.assertTrue(self._runs(job % (" if: true\n runs-on: ubuntu-latest\n", ""), test))
|
||||||
|
self.assertTrue(self._runs(job % (" permissions:\n contents: read\n", ""), test))
|
||||||
|
two = "jobs:\n a:\n if: false\n steps:\n - run: %s\n b:\n steps:\n - run: %s\n"
|
||||||
|
self.assertTrue(self._runs(two % ("make", "./build/tray_test"), test))
|
||||||
|
self.assertFalse(self._runs(two % ("./build/tray_test", "make"), test))
|
||||||
|
# the run text is read as bash -e or sh -e runs it: the test counts on a line that holds it
|
||||||
|
# alone, or in a pipeline under pipefail; any other operator on the line, or a pipe with no
|
||||||
|
# pipefail, keeps it from counting
|
||||||
|
for line in ("./build/tray_test || true", "true || ./build/tray_test", "./build/tray_test; true",
|
||||||
|
"./build/tray_test &", "./build/tray_test | tee log"):
|
||||||
|
self.assertFalse(self._runs(self._block(line), test), line)
|
||||||
|
self.assertTrue(self._runs(self._block("set -o pipefail", "./build/tray_test | tee log"), test))
|
||||||
|
self.assertTrue(self._runs(self._block("./build/tray_test | tee log", keys=" shell: bash\n"), test))
|
||||||
|
# no line counts after one that ends the step, turns errexit off, sets an option the lint
|
||||||
|
# does not follow (noexec), or opens a here-document or a compound command
|
||||||
|
for before in ("exit 0", "set +e", "set -n", "python3 - <<'EOF'"):
|
||||||
|
self.assertFalse(self._runs(self._block(before, "./build/tray_test"), test), before)
|
||||||
|
self.assertFalse(self._runs(self._block("{", "./build/tray_test", "} || true"), test))
|
||||||
|
# a loop counts only with a body whose lines count
|
||||||
|
for body in ("python $t || true", "python $t | tee log"):
|
||||||
|
self.assertFalse(self._runs(self._block("for t in tests/test_*.py; do", body, "done"), "tests/test_x.py"),
|
||||||
|
body)
|
||||||
|
# a folded block, and a scalar continued under its key, are one line to the shell
|
||||||
|
self.assertFalse(self._runs("steps:\n - run: >\n ./build/tray_test\n || true\n", test))
|
||||||
|
self.assertFalse(self._runs("steps:\n - run: ./build/tray_test\n || true\n", test))
|
||||||
|
# the lint reads the text of bash and sh alone: the step's shell, or its job's or its
|
||||||
|
# workflow's default
|
||||||
|
self.assertFalse(self._runs(self._block("./build/tray_test", keys=" shell: bash {0}\n"), test))
|
||||||
|
self.assertTrue(self._runs(self._block("./build/tray_test", keys=" shell: sh\n"), test))
|
||||||
|
self.assertFalse(self._runs("jobs:\n t:\n defaults:\n run:\n shell: pwsh\n steps:\n"
|
||||||
|
" - run: ./build/tray_test\n", test))
|
||||||
|
self.assertFalse(self._runs("defaults:\n run:\n shell: pwsh\njobs:\n t:\n steps:\n"
|
||||||
|
" - run: ./build/tray_test\n", test))
|
||||||
|
# a checkout under a condition is no checkout
|
||||||
|
yml = "steps:\n - uses: actions/checkout@v4\n%s with:\n path: own\n"
|
||||||
|
self.assertEqual(m.checkout_path(m.workflow_steps(yml % ""), "x", True), "own")
|
||||||
|
self.assertIsNone(m.checkout_path(m.workflow_steps(yml % " if: false\n"), "x", True))
|
||||||
|
|
||||||
|
def test_a_job_runs_only_when_every_job_it_needs_runs(self):
|
||||||
|
test = "tests/tray_test.c"
|
||||||
|
# GitHub skips a job whose needed job did not run: a job that needs a conditional job runs
|
||||||
|
# on not every run, however the needs: is written
|
||||||
|
yml = "jobs:\n build:\n%s steps:\n - run: make\n test:\n%s steps:\n - run: ./build/tray_test\n"
|
||||||
|
for needs in (" needs: build\n", " needs: [build]\n", " needs: ['build']\n",
|
||||||
|
" needs:\n - build\n", " needs:\n - build\n",
|
||||||
|
" needs: [\n build\n ]\n"):
|
||||||
|
self.assertTrue(self._runs(yml % ("", needs), test), needs)
|
||||||
|
self.assertFalse(self._runs(yml % (" if: false\n", needs), test), needs)
|
||||||
|
self.assertFalse(self._runs(yml % (" if: github.event_name == 'push'\n", needs), test), needs)
|
||||||
|
# the needed job's continue-on-error: does not bear: its failure fails neither the run nor
|
||||||
|
# the jobs after it
|
||||||
|
self.assertTrue(self._runs(yml % (" continue-on-error: true\n", " needs: build\n"), test))
|
||||||
|
# transitively; a job the workflow does not have, a cycle, and a needs: the lint does not
|
||||||
|
# read each fail closed
|
||||||
|
chain = "jobs:\n a:\n%s steps:\n - run: make\n b:\n needs: a\n steps:\n - run: make\n" \
|
||||||
|
" c:\n needs: [b]\n steps:\n - run: ./build/tray_test\n"
|
||||||
|
self.assertTrue(self._runs(chain % "", test))
|
||||||
|
self.assertFalse(self._runs(chain % " if: false\n", test))
|
||||||
|
self.assertFalse(self._runs(yml % ("", " needs: lint\n"), test))
|
||||||
|
self.assertFalse(self._runs(yml % (" needs: test\n", " needs: build\n"), test))
|
||||||
|
self.assertFalse(self._runs(yml % ("", " needs: test\n"), test))
|
||||||
|
self.assertFalse(self._runs(yml % ("", " needs:\n build: true\n"), test))
|
||||||
|
# a flow list split after a comma keeps the name on its second line: a conditional job
|
||||||
|
# there holds the dependent job back
|
||||||
|
split = (chain % "").replace(" needs: [b]\n", " needs: [a,\n b]\n")
|
||||||
|
self.assertTrue(self._runs(split, test))
|
||||||
|
self.assertFalse(self._runs(split.replace(" b:\n needs: a\n", " b:\n needs: a\n if: false\n"), test))
|
||||||
|
# an empty needs: (which GitHub refuses), a flow list a key interrupts before its ], and
|
||||||
|
# list items at two indentations each fail closed
|
||||||
|
for needs in (" needs: []\n", " needs:\n", " needs: [\n build\n",
|
||||||
|
" needs:\n - build\n - build\n"):
|
||||||
|
self.assertFalse(self._runs(yml % ("", needs), test), needs)
|
||||||
|
# through a row: the test job needs, in a block list at the key's own indentation, a
|
||||||
|
# build job under if: false (the checkout in a job of its own, as the lint reads a
|
||||||
|
# checkout for the whole workflow)
|
||||||
|
man = helpers.make_manifest()
|
||||||
|
root = self._root({"scripts/bench/stream_test.py": "",
|
||||||
|
".github/workflows/needs.yml": "on: [push]\njobs:\n lint:\n steps:\n"
|
||||||
|
" - uses: actions/checkout@v4\n build:\n if: false\n steps:\n"
|
||||||
|
" - run: make\n test:\n needs:\n - build\n steps:\n"
|
||||||
|
" - run: python3 scripts/bench/stream_test.py\n"})
|
||||||
|
row = ("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py",
|
||||||
|
"forgefirm:.github/workflows/needs.yml")
|
||||||
|
self.assertEqual(m.host_row_problems(man, [row], root=root, sources=None),
|
||||||
|
[row[:3] + (row[3] + " does not run it",)])
|
||||||
|
# a job under always() runs on every run, and the lint evaluates no condition: it counts as
|
||||||
|
# no job for its own steps and for the jobs after it
|
||||||
|
self.assertFalse(self._runs(yml % (" if: false\n", " needs: build\n if: always()\n"), test))
|
||||||
|
self.assertFalse(self._runs((chain % " if: false\n").replace(
|
||||||
|
" b:\n needs: a\n", " b:\n needs: a\n if: ${{ always() }}\n"), test))
|
||||||
|
|
||||||
|
def test_the_events_a_workflow_runs_on(self):
|
||||||
|
self.assertEqual(m.workflow_triggers("on:\n push:\n branches: [main]\n pull_request:\njobs:\n t:\n"),
|
||||||
|
{"push", "pull_request"})
|
||||||
|
self.assertEqual(m.workflow_triggers("on: [push, pull_request]\n"), {"push", "pull_request"})
|
||||||
|
self.assertEqual(m.workflow_triggers("on: push\n"), {"push"})
|
||||||
|
self.assertEqual(m.workflow_triggers("on:\n - push\n - workflow_dispatch\n"), {"push", "workflow_dispatch"})
|
||||||
|
self.assertEqual(m.workflow_triggers("\"on\":\n workflow_dispatch:\n inputs:\n push:\n"
|
||||||
|
" type: string\n"), {"workflow_dispatch"})
|
||||||
|
self.assertEqual(m.workflow_triggers("on:\n workflow_call:\n"), {"workflow_call"})
|
||||||
|
self.assertEqual(m.workflow_triggers("name: x\njobs:\n t:\n steps: []\n"), set())
|
||||||
|
# an event key with one stray quote is no event, and the workflow then names none
|
||||||
|
self.assertEqual(m.workflow_triggers("on:\n push\":\n"), set())
|
||||||
|
self.assertEqual(m.workflow_triggers("on:\n push:\n 'pull_request:\n"), set())
|
||||||
|
|
||||||
|
def test_a_push_or_pull_request_under_a_filter_runs_on_not_every_change(self):
|
||||||
|
f = m.workflow_filters
|
||||||
|
self.assertEqual(f("on:\n push:\n branches: [main]\n pull_request:\njobs:\n t:\n"), {"push": ["branches"]})
|
||||||
|
self.assertEqual(f("on:\n push:\n branches-ignore:\n - wip/*\n paths-ignore:\n - 'docs/**'\n"
|
||||||
|
" pull_request:\n paths:\n - 'src/**'\n"),
|
||||||
|
{"push": ["branches-ignore", "paths-ignore"], "pull_request": ["paths"]})
|
||||||
|
self.assertEqual(f("on:\n push:\n tags: ['v*']\n"), {"push": ["tags"]})
|
||||||
|
self.assertEqual(f("on:\n push:\n tags-ignore: ['v*']\n"), {"push": ["tags-ignore"]})
|
||||||
|
# a pull_request under types: runs on the activity types it names and no other; the lint
|
||||||
|
# reads no type, so any types: is a filter, on the pull_request alone
|
||||||
|
self.assertEqual(f("on:\n push:\n pull_request:\n types: [closed]\n"), {"pull_request": ["types"]})
|
||||||
|
self.assertEqual(f("on:\n pull_request:\n types:\n - opened\n - synchronize\n paths-ignore:\n"
|
||||||
|
" - '**.md'\n"), {"pull_request": ["types", "paths-ignore"]})
|
||||||
|
self.assertEqual(f("on:\n push:\n types: [x]\n"), {})
|
||||||
|
# a value on the event's line, and a key with one stray quote under it, are filters the
|
||||||
|
# lint does not read
|
||||||
|
self.assertEqual(f("on:\n push: {branches: [main]}\n"), {"push": ["a value the lint does not read"]})
|
||||||
|
self.assertEqual(f("on:\n push:\n paths\":\n - x\n"), {"push": ["a key the lint does not read"]})
|
||||||
|
# a filter is read at the event's own level: a dispatch's inputs are none, and an on: list
|
||||||
|
# or scalar carries none
|
||||||
|
self.assertEqual(f("on:\n workflow_dispatch:\n inputs:\n paths:\n type: string\n push:\n"),
|
||||||
|
{})
|
||||||
|
self.assertEqual(f("on: [push, pull_request]\n"), {})
|
||||||
|
self.assertEqual(f("on: push\n"), {})
|
||||||
|
self.assertEqual(f("on:\n - push\n"), {})
|
||||||
|
# a row's workflow counts when its push or its pull_request carries no filter: a push to
|
||||||
|
# the default branch alone with every pull request does, a push with paths-ignore alone
|
||||||
|
# does not, nor do a push and a pull request each under a filter; every push with a pull
|
||||||
|
# request under types: does, a pull request under types: alone does not
|
||||||
|
man = helpers.make_manifest()
|
||||||
|
job = "jobs:\n test:\n steps:\n - uses: actions/checkout@v4\n - run: python3 scripts/bench/stream_test.py\n"
|
||||||
|
root = self._root({"scripts/bench/stream_test.py": "",
|
||||||
|
".github/workflows/main.yml": "on:\n push:\n branches: [main]\n pull_request:\n" + job,
|
||||||
|
".github/workflows/docs.yml": "on:\n push:\n paths-ignore:\n - 'docs/**'\n" + job,
|
||||||
|
".github/workflows/both.yml": "on:\n push:\n branches: [main]\n pull_request:\n"
|
||||||
|
" paths:\n - 'src/**'\n" + job,
|
||||||
|
".github/workflows/push.yml": "on:\n push:\n pull_request:\n types: [closed]\n" + job,
|
||||||
|
".github/workflows/closed.yml": "on:\n pull_request:\n types: [closed]\n" + job})
|
||||||
|
rows = [("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py",
|
||||||
|
"forgefirm:.github/workflows/%s.yml" % w) for w in ("main", "docs", "both", "push", "closed")]
|
||||||
|
filtered = ": every push or pull_request in its own on: carries a filter (%s)"
|
||||||
|
self.assertEqual(m.host_row_problems(man, rows, root=root, sources=None),
|
||||||
|
[rows[1][:3] + (rows[1][3] + filtered % "push: paths-ignore",),
|
||||||
|
rows[2][:3] + (rows[2][3] + filtered % "pull_request: paths; push: branches",),
|
||||||
|
rows[4][:3] + (rows[4][3] + filtered % "pull_request: types",)])
|
||||||
|
|
||||||
|
def test_the_lint_says_when_it_could_not_read_a_workflow(self):
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from unittest import mock
|
||||||
|
from forgetest import coverage
|
||||||
|
work = tempfile.mkdtemp()
|
||||||
|
self.addCleanup(__import__("shutil").rmtree, work)
|
||||||
|
comps = {"forgectrl": {"srcrev": "aaa", "source": "https://example.invalid/forgectrl.git", "files": [
|
||||||
|
["src/tray.c", "1" * 40], ["tests/tray_test.c", "2" * 40], [".github/workflows/build.yml", "3" * 40]]}}
|
||||||
|
path = os.path.join(work, "manifest.json")
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(helpers.make_manifest(components=comps).data))
|
||||||
|
row = ("forgectrl", "src/tray.c", "forgectrl:tests/tray_test.c", "forgectrl:.github/workflows/build.yml")
|
||||||
|
out = io.StringIO()
|
||||||
|
with mock.patch.object(m, "HOST_TESTED", [row]), contextlib.redirect_stdout(out):
|
||||||
|
rc = coverage.main(["--manifest", path, "--sources", work, "--json"])
|
||||||
|
self.assertEqual(rc, 0) # reporting only
|
||||||
|
rep = json.loads(out.getvalue())
|
||||||
|
self.assertFalse(rep["workflows_read"])
|
||||||
|
self.assertTrue(any("cannot be read" in r[3] for r in rep["host_tested"]))
|
||||||
|
out = io.StringIO()
|
||||||
|
with mock.patch.object(m, "HOST_TESTED", [row]), contextlib.redirect_stdout(out):
|
||||||
|
self.assertEqual(coverage.main(["--manifest", path, "--sources", work, "--enforce"]), 1)
|
||||||
|
|
||||||
|
def test_where_a_workflow_checks_a_repository_out(self):
|
||||||
|
steps = m.workflow_steps("jobs:\n t:\n steps:\n"
|
||||||
|
" - uses: actions/checkout@v4\n with:\n path: own\n"
|
||||||
|
" - name: Fetch the harnesses\n uses: actions/checkout@v4\n"
|
||||||
|
" with:\n repository: openglow-org/forgefirm\n path: forgefirm\n")
|
||||||
|
self.assertEqual(m.checkout_path(steps, "grblHAL-glowforge", True), "own")
|
||||||
|
self.assertEqual(m.checkout_path(steps, "forgefirm", False), "forgefirm")
|
||||||
|
self.assertIsNone(m.checkout_path(steps, "forgectrl", False))
|
||||||
|
self.assertEqual(m.checkout_path(m.workflow_steps("steps:\n - uses: actions/checkout@v4\n"), "x", True), ".")
|
||||||
|
self.assertEqual(m.repo_name(helpers.make_manifest(components={"python3-gfutilities": {
|
||||||
|
"source": "https://github.com/openglow-org/Glowforge-Utilities.git", "files": []}}),
|
||||||
|
"python3-gfutilities"), "Glowforge-Utilities")
|
||||||
|
|
||||||
|
def test_a_components_workflow_is_read_at_its_pinned_blob(self):
|
||||||
|
import hashlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
if not shutil.which("git"):
|
||||||
|
self.skipTest("no git")
|
||||||
|
sources = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def writable(func, path, _exc): # git writes its objects read-only
|
||||||
|
os.chmod(path, 0o700)
|
||||||
|
func(path)
|
||||||
|
self.addCleanup(shutil.rmtree, sources, **({"onexc": writable} if sys.version_info >= (3, 12)
|
||||||
|
else {"onerror": writable}))
|
||||||
|
url = "https://example.invalid/forgectrl.git"
|
||||||
|
cache = os.path.join(sources, hashlib.sha1(url.encode()).hexdigest()[:16])
|
||||||
|
os.makedirs(cache)
|
||||||
|
subprocess.run(["git", "init", "-q", "--bare"], cwd=cache, check=True)
|
||||||
|
|
||||||
|
def blob(text):
|
||||||
|
return subprocess.run(["git", "hash-object", "-w", "--stdin"], cwd=cache, input=text.encode(),
|
||||||
|
capture_output=True, check=True).stdout.decode().strip()
|
||||||
|
yml = "on: [push, pull_request]\nsteps:\n - uses: actions/checkout@v4\n - run: ./build/tray_test\n"
|
||||||
|
comps = {"forgectrl": {"srcrev": "aaa", "source": url, "files": [
|
||||||
|
["src/tray.c", "1" * 40], ["tests/tray_test.c", "2" * 40], ["tests/lens_test.c", "3" * 40],
|
||||||
|
[".github/workflows/build.yml", blob(yml)], [".github/workflows/lost.yml", "4" * 40]]}}
|
||||||
|
man = helpers.make_manifest(components=comps)
|
||||||
|
ci = "forgectrl:.github/workflows/build.yml"
|
||||||
|
self.assertEqual(m.read_source(man, ci, root=None, sources=sources), (yml, None))
|
||||||
|
tray = ("forgectrl", "src/tray.c", "forgectrl:tests/tray_test.c", ci)
|
||||||
|
lens = ("forgectrl", "src/tray.c", "forgectrl:tests/lens_test.c", ci)
|
||||||
|
self.assertEqual(m.host_row_problems(man, [tray, lens], root=None, sources=sources),
|
||||||
|
[(lens[0], lens[1], lens[2], ci + " does not run it")])
|
||||||
|
# without the sources the workflow cannot be read, and is not judged
|
||||||
|
self.assertEqual(m.host_row_problems(man, [lens], root=None, sources=None), [])
|
||||||
|
# with them, a workflow that should be read and is not is a problem, never a pass
|
||||||
|
lost = ("forgectrl", "src/tray.c", "forgectrl:tests/tray_test.c", "forgectrl:.github/workflows/lost.yml")
|
||||||
|
probs = m.host_row_problems(man, [lost], root=None, sources=sources)
|
||||||
|
self.assertEqual(len(probs), 1)
|
||||||
|
self.assertIn("cannot be read", probs[0][3])
|
||||||
|
|
||||||
|
def test_the_real_host_tested_rows_are_well_formed(self):
|
||||||
|
for row in m.HOST_TESTED:
|
||||||
|
self.assertEqual(len(row), 4, row)
|
||||||
|
self.assertIn(":", row[2], row)
|
||||||
|
self.assertIn(":", row[3], row)
|
||||||
|
|
||||||
|
|
||||||
class CatalogTests(unittest.TestCase):
|
class CatalogTests(unittest.TestCase):
|
||||||
def test_catalog_hash_is_definition_only(self):
|
def test_catalog_hash_is_definition_only(self):
|
||||||
@@ -203,9 +640,36 @@ class CatalogTests(unittest.TestCase):
|
|||||||
for t in catalog.all_tests(reg):
|
for t in catalog.all_tests(reg):
|
||||||
self.assertEqual(len(t.source_sha), 64)
|
self.assertEqual(len(t.source_sha), 64)
|
||||||
|
|
||||||
|
def test_the_shared_judges_exist_and_take_every_package_import_of_the_suite(self):
|
||||||
if __name__ == "__main__":
|
# The list is kept by hand: the Context reaches runner.py, hw.py
|
||||||
unittest.main()
|
# and baseline.py with no import to find. So every package module
|
||||||
|
# a suite module does import, at its top level or inside a function
|
||||||
|
# or a block, has to be on it (or be the registry, which judges
|
||||||
|
# nothing), or that judge would be in no fingerprint. The sibling
|
||||||
|
# walk follows the relative spellings alone (`from .x import` and
|
||||||
|
# `from . import x`), so a suite module imports the package by no
|
||||||
|
# absolute name.
|
||||||
|
import ast
|
||||||
|
import glob
|
||||||
|
paths = catalog.shared_judge_paths()
|
||||||
|
self.assertEqual([os.path.basename(p) for p in paths], list(catalog.SHARED_JUDGES))
|
||||||
|
for p in paths:
|
||||||
|
self.assertTrue(os.path.isfile(p), p)
|
||||||
|
allowed = set(catalog.SHARED_JUDGES) | {"catalog.py"}
|
||||||
|
for mod in sorted(glob.glob(os.path.join(catalog.suite_dir(), "*.py"))):
|
||||||
|
with open(mod, encoding="utf-8") as f:
|
||||||
|
tree = ast.parse(f.read())
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ImportFrom) and node.level == 2:
|
||||||
|
for name in ([node.module] if node.module else [a.name for a in node.names]):
|
||||||
|
self.assertIn(name + ".py", allowed,
|
||||||
|
"%s imports %s, which no fingerprint carries" % (os.path.basename(mod), name))
|
||||||
|
absolute = ([node.module or ""] if isinstance(node, ast.ImportFrom) and node.level == 0
|
||||||
|
else [a.name for a in node.names] if isinstance(node, ast.Import) else [])
|
||||||
|
for name in absolute:
|
||||||
|
self.assertFalse(name == "forgetest" or name.startswith("forgetest."),
|
||||||
|
"%s imports %s by its absolute name, which no hash follows: import it "
|
||||||
|
"relatively (from .. or from .)" % (os.path.basename(mod), name))
|
||||||
|
|
||||||
|
|
||||||
class ImplementationHashTests(unittest.TestCase):
|
class ImplementationHashTests(unittest.TestCase):
|
||||||
@@ -293,6 +757,94 @@ def two(ctx):
|
|||||||
f.write("def o():\n return 1\n")
|
f.write("def o():\n return 1\n")
|
||||||
self.assertEqual((a2, b2), self.shas())
|
self.assertEqual((a2, b2), self.shas())
|
||||||
|
|
||||||
|
def test_a_shared_judge_edit_moves_every_test(self):
|
||||||
|
# runner.py, hw.py and baseline.py are in every test's path with no
|
||||||
|
# import to follow (the Context, the baseline around each run), and
|
||||||
|
# puls.py comes in by a package import the walk does not follow, so
|
||||||
|
# their text joins every implementation hash.
|
||||||
|
judge = os.path.join(self.tmp, "hwlike.py")
|
||||||
|
|
||||||
|
def write_judge(text):
|
||||||
|
with open(judge, "w", newline="\n") as f:
|
||||||
|
f.write(text)
|
||||||
|
catalog.forget(judge)
|
||||||
|
|
||||||
|
def shas():
|
||||||
|
return tuple(catalog.implementation_sha(self.path, t, judges=[judge]) for t in ("m.one", "m.two"))
|
||||||
|
|
||||||
|
write_judge("def sysfs_int(attr, default=None):\n return default\n")
|
||||||
|
a1, b1 = shas()
|
||||||
|
write_judge("def sysfs_int(attr, default=None):\n return 8\n")
|
||||||
|
a2, b2 = shas()
|
||||||
|
self.assertNotEqual(a1, a2)
|
||||||
|
self.assertNotEqual(b1, b2)
|
||||||
|
# a package file outside the list changes nothing
|
||||||
|
other = os.path.join(self.tmp, "server.py")
|
||||||
|
with open(other, "w", newline="\n") as f:
|
||||||
|
f.write("def serve():\n return 1\n")
|
||||||
|
self.assertEqual((a2, b2), shas())
|
||||||
|
# Test.source_sha, which the fingerprint, the campaign and the gate
|
||||||
|
# read, takes the shared judges itself: a suite test's hash is the
|
||||||
|
# one with SHARED_JUDGES folded in, not the one without them.
|
||||||
|
import inspect
|
||||||
|
t = catalog.load_suite()["image.health"]
|
||||||
|
src = inspect.getsourcefile(t.fn)
|
||||||
|
self.assertEqual(t.source_sha, catalog.implementation_sha(src, t.id, judges=catalog.shared_judge_paths()))
|
||||||
|
self.assertNotEqual(t.source_sha, catalog.implementation_sha(src, t.id, judges=[]))
|
||||||
|
|
||||||
|
def test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it(self):
|
||||||
|
# A test that imports a judge inside its own function carries the
|
||||||
|
# judge in its hash, and what the judge imports in turn, at any
|
||||||
|
# depth, and no other test of its module does; a helper of the
|
||||||
|
# module that imports one inside its body carries it into every
|
||||||
|
# test of the module.
|
||||||
|
def put(name, text):
|
||||||
|
path = os.path.join(self.tmp, name)
|
||||||
|
with open(path, "w", newline="\n") as f:
|
||||||
|
f.write(text)
|
||||||
|
catalog.forget(path)
|
||||||
|
|
||||||
|
def write_judge(text):
|
||||||
|
put("judge.py", text)
|
||||||
|
write_judge("def judge(x):\n return x > 1\n")
|
||||||
|
self.write(self.MODULE.replace("def two(ctx):\n",
|
||||||
|
"def two(ctx):\n from .judge import judge\n judge(2)\n"))
|
||||||
|
a1, b1 = self.shas()
|
||||||
|
write_judge("def judge(x):\n return x > 2\n")
|
||||||
|
a2, b2 = self.shas()
|
||||||
|
self.assertEqual(a1, a2)
|
||||||
|
self.assertNotEqual(b1, b2)
|
||||||
|
# judge.py imports limit.py, which imports base.py: an edit to
|
||||||
|
# either moves m.two, and not m.one
|
||||||
|
put("base.py", "BASE = 1\n")
|
||||||
|
put("limit.py", "from .base import BASE\n\n\nLIMIT = BASE\n")
|
||||||
|
write_judge("from .limit import LIMIT\n\n\ndef judge(x):\n return x > LIMIT\n")
|
||||||
|
a5, b5 = self.shas()
|
||||||
|
put("limit.py", "from .base import BASE\n\n\nLIMIT = BASE + 1\n")
|
||||||
|
a6, b6 = self.shas()
|
||||||
|
self.assertEqual(a5, a6)
|
||||||
|
self.assertNotEqual(b5, b6)
|
||||||
|
put("base.py", "BASE = 2\n")
|
||||||
|
a7, b7 = self.shas()
|
||||||
|
self.assertEqual(a6, a7)
|
||||||
|
self.assertNotEqual(b6, b7)
|
||||||
|
self.write(self.MODULE.replace(" return x + 1\n",
|
||||||
|
" from .judge import judge\n return judge(x)\n"))
|
||||||
|
a3, b3 = self.shas()
|
||||||
|
write_judge("def judge(x):\n return x > 3\n")
|
||||||
|
a4, b4 = self.shas()
|
||||||
|
self.assertNotEqual(a3, a4)
|
||||||
|
self.assertNotEqual(b3, b4)
|
||||||
|
# the spelling with no module (`from . import x`, as homeoff.py imports cloud.py) is
|
||||||
|
# walked the same
|
||||||
|
self.write(self.MODULE.replace("def two(ctx):\n",
|
||||||
|
"def two(ctx):\n from . import judge as j\n j.judge(2)\n"))
|
||||||
|
a8, b8 = self.shas()
|
||||||
|
write_judge("def judge(x):\n return x > 4\n")
|
||||||
|
a9, b9 = self.shas()
|
||||||
|
self.assertEqual(a8, a9)
|
||||||
|
self.assertNotEqual(b8, b9)
|
||||||
|
|
||||||
def test_line_endings_do_not_count(self):
|
def test_line_endings_do_not_count(self):
|
||||||
a1, b1 = self.shas()
|
a1, b1 = self.shas()
|
||||||
catalog.forget(self.path)
|
catalog.forget(self.path)
|
||||||
@@ -309,3 +861,7 @@ def two(ctx):
|
|||||||
for t in catalog.all_tests(reg):
|
for t in catalog.all_tests(reg):
|
||||||
path = inspect.getsourcefile(t.fn)
|
path = inspect.getsourcefile(t.fn)
|
||||||
self.assertIn(t.id, catalog.module_parts(path)[1], t.id)
|
self.assertIn(t.id, catalog.module_parts(path)[1], t.id)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|||||||
@@ -83,27 +83,6 @@ class SwitchModeTests(unittest.TestCase):
|
|||||||
self.assertTrue(ok, detail)
|
self.assertTrue(ok, detail)
|
||||||
self.assertEqual(self.fc.posts, [])
|
self.assertEqual(self.fc.posts, [])
|
||||||
|
|
||||||
def test_the_supervisors_levers_get_the_slow_timeout(self):
|
|
||||||
from forgetest import hw
|
|
||||||
fc = hw.Forgectrl(timeout=1.0, slow_timeout=2.0)
|
|
||||||
self.assertEqual(fc.timeout_for("POST", "/mode"), 2.0)
|
|
||||||
self.assertEqual(fc.timeout_for("POST", "/controller/start"), 2.0)
|
|
||||||
self.assertEqual(fc.timeout_for("POST", "/controller/stop"), 2.0)
|
|
||||||
self.assertEqual(fc.timeout_for("GET", "/mode"), 1.0)
|
|
||||||
self.assertEqual(fc.timeout_for("POST", "/settings"), 1.0)
|
|
||||||
self.assertGreater(hw.SLOW_TIMEOUT_S, 15.0) # forgectrl's first-report wait
|
|
||||||
# a switch that answers after the ordinary timeout still lands
|
|
||||||
import time
|
|
||||||
|
|
||||||
def on_post(path, form):
|
|
||||||
time.sleep(1.5)
|
|
||||||
self.fc.state["mode"] = dict(self.fc.state["mode"], mode="cloud")
|
|
||||||
return None
|
|
||||||
self.fc.on_post = on_post
|
|
||||||
st, body = fc.post("/mode", data={"controller": "cloud"})
|
|
||||||
self.assertEqual(st, 200) # 1.5 s > timeout, < slow_timeout
|
|
||||||
self.assertEqual(self.fc.state["mode"]["mode"], "cloud")
|
|
||||||
|
|
||||||
def test_cloud_to_grbl_switches_and_waits_for_the_port(self):
|
def test_cloud_to_grbl_switches_and_waits_for_the_port(self):
|
||||||
cloud(self.fc)
|
cloud(self.fc)
|
||||||
b = self.bl()
|
b = self.bl()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user