mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-20 03:21:49 -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 | ||
|
|
2ea501f975 | ||
|
|
1bc15fe70a | ||
|
|
66b39c6f2b | ||
|
|
83686fc712 | ||
|
|
f47ddad839 | ||
|
|
eb9e61d3c6 | ||
|
|
55e789bc34 | ||
|
|
11031ad87f | ||
|
|
cd84786fc5 | ||
|
|
6e71ac86f0 | ||
|
|
1727b819cb | ||
|
|
b494bbecb5 | ||
|
|
b2e0f91342 | ||
|
|
a05a226305 | ||
|
|
9f7744b459 | ||
|
|
8dc7c0bc56 | ||
|
|
0eb0a16452 | ||
|
|
bfcfd7a376 | ||
|
|
d2307a899e | ||
|
|
8570c0141e | ||
|
|
800863fb09 | ||
|
|
cfef6a1b84 | ||
|
|
d40ebb090e | ||
|
|
ef2390f7d4 | ||
|
|
82869cc45c | ||
|
|
b7dd23d539 | ||
|
|
308a033146 | ||
|
|
dc9bede034 | ||
|
|
56af6eaab2 | ||
|
|
c95b5aa771 | ||
|
|
d45520c218 | ||
|
|
2870daa2aa | ||
|
|
82c354d70e | ||
|
|
853f862f03 | ||
|
|
ac2456261e | ||
|
|
8fc07c47ee | ||
|
|
69b56ec1ff | ||
|
|
99d9830991 | ||
|
|
4e47b7fbf1 | ||
|
|
675fc11cb1 | ||
|
|
71b44598eb | ||
|
|
fab8920cd9 | ||
|
|
861f5597e6 | ||
|
|
b7e7c0fb2c | ||
|
|
2f51e38178 | ||
|
|
d9cc61f198 | ||
|
|
1bdc11f6fe | ||
|
|
625d0acec7 | ||
|
|
d410c6f696 | ||
|
|
a552541acd | ||
|
|
5915408147 | ||
|
|
59887ac742 | ||
|
|
4388d69816 | ||
|
|
77e4c03369 | ||
|
|
0ba0fb112b | ||
|
|
f74382984c | ||
|
|
75ef8bbc95 | ||
|
|
2216e5d3f2 | ||
|
|
bb28c7855a | ||
|
|
447f90205d | ||
|
|
952e7dc32d | ||
|
|
eb0a1e7838 | ||
|
|
963bded9b3 | ||
|
|
9c36df9ee9 | ||
|
|
121d7d3f81 | ||
|
|
68fd35b8b7 | ||
|
|
ee89b31c07 | ||
|
|
9ee277f977 | ||
|
|
800d6890a4 | ||
|
|
1a306d5d8d | ||
|
|
1e98b37e45 | ||
|
|
2894269115 | ||
|
|
5ad7ee6faa | ||
|
|
9128f0e98e | ||
|
|
619414ed2b | ||
|
|
474bf9db09 | ||
|
|
d627ee32f1 | ||
|
|
965f7c3ca9 | ||
|
|
19ce4d78d2 | ||
|
|
ebc4222182 | ||
|
|
f0ba4b9249 | ||
|
|
888b47abe8 | ||
|
|
520bf6023d | ||
|
|
25cc42b879 | ||
|
|
8856fb0099 | ||
|
|
b29bb9e023 | ||
|
|
11f4c42705 |
@@ -11,10 +11,18 @@
|
||||
# release with source missing
|
||||
# - shared UI files: theme.css and the vendored Bootstrap the page
|
||||
# carries are byte-identical to forgectrl's at its pinned revision
|
||||
# - the extension key: the public key the image installs for the official
|
||||
# tier is byte-identical to the one the extension catalog publishes
|
||||
# (keys/forgefirm-ext.pub on its main branch)
|
||||
# - the catalog's address: EXTPKG_INDEX_URL in the pinned forgectrl's
|
||||
# src/extpkg.h is the catalog's latest-release download of the file its
|
||||
# tools/publish.py passes to gh release create; read, never requested
|
||||
# (every request of it is counted as a download)
|
||||
# - coverage lint: every source path of every component pinned by the
|
||||
# recipes must be selected by some catalog test's coverage globs (the
|
||||
# tree manifest is generated from the pins with git - no Yocto build);
|
||||
# enforced: an uncovered path fails the job (https://docs.forgefirm.org/developers/acceptance/).
|
||||
# recipes must be selected by some catalog test's coverage globs or by a
|
||||
# host-tested row naming the host test that proves it (the tree manifest
|
||||
# is generated from the pins with git - no Yocto build); enforced: an
|
||||
# uncovered path fails the job (https://docs.forgefirm.org/developers/testing/).
|
||||
|
||||
name: forgetest-ci
|
||||
|
||||
@@ -49,6 +57,13 @@ jobs:
|
||||
ref: scarthgap
|
||||
path: meta-openglow
|
||||
|
||||
- name: Checkout forgefirm-extensions-catalog (sibling)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: openglow-org/forgefirm-extensions-catalog
|
||||
ref: main
|
||||
path: forgefirm-extensions-catalog
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -58,6 +73,68 @@ jobs:
|
||||
working-directory: forgefirm
|
||||
run: python scripts/manifest-from-tree.py --out tree-manifest.json
|
||||
|
||||
- name: The image's extension key is the catalog's published key
|
||||
run: |
|
||||
image=forgefirm/meta-forgefirm/recipes-forgefirm/forgefirm-keys/files/ext/forgefirm-ext.pub
|
||||
published=forgefirm-extensions-catalog/keys/forgefirm-ext.pub
|
||||
for f in "$image" "$published"; do
|
||||
test -f "$f" || { echo "missing: $f"; exit 1; }
|
||||
done
|
||||
cmp -s "$image" "$published" || { echo "$image is not $published"; exit 1; }
|
||||
|
||||
- name: The catalog's address in the pinned forgectrl is the file the catalog publishes (read, never requested)
|
||||
run: |
|
||||
python3 - <<'EOF'
|
||||
import ast, glob, os, re, subprocess, sys
|
||||
|
||||
PREFIX = "https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/"
|
||||
|
||||
def fail(why):
|
||||
print(why)
|
||||
sys.exit(1)
|
||||
|
||||
# forgectrl's pin, and its src/extpkg.h from the tree manifest's cache
|
||||
pin = open("forgefirm/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc").read()
|
||||
m = re.search(r'^SRCREV = "([0-9a-f]{40})"$', pin, re.M)
|
||||
if not m:
|
||||
fail("forgectrl-pin.inc names no SRCREV")
|
||||
rev = m.group(1)
|
||||
header = None
|
||||
for repo in sorted(glob.glob("forgefirm/.manifest-cache/*")):
|
||||
p = subprocess.run(["git", "-C", repo, "show", rev + ":src/extpkg.h"], capture_output=True, text=True)
|
||||
if p.returncode == 0:
|
||||
header = p.stdout
|
||||
break
|
||||
if header is None:
|
||||
fail("forgectrl %s is not in forgefirm/.manifest-cache" % rev)
|
||||
m = re.search(r'#define\s+EXTPKG_INDEX_URL\s*(?:\\\n\s*)?"([^"]*)"', header)
|
||||
if not m:
|
||||
fail("forgectrl %s's src/extpkg.h defines no EXTPKG_INDEX_URL" % rev[:7])
|
||||
url = m.group(1)
|
||||
|
||||
# the file the catalog's publish tool passes to gh release create
|
||||
tree = ast.parse(open("forgefirm-extensions-catalog/tools/publish.py").read())
|
||||
assigned = {n.targets[0].id: n.value for n in ast.walk(tree)
|
||||
if isinstance(n, ast.Assign) and len(n.targets) == 1 and isinstance(n.targets[0], ast.Name)}
|
||||
|
||||
def file_name(v):
|
||||
if isinstance(v, ast.Name):
|
||||
v = assigned.get(v.id)
|
||||
if isinstance(v, ast.Call) and v.args:
|
||||
v = v.args[-1]
|
||||
return os.path.basename(v.value) if isinstance(v, ast.Constant) and isinstance(v.value, str) else None
|
||||
|
||||
names = [file_name(n.args[3]) if len(n.args) > 3 else None for n in ast.walk(tree)
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "gh"
|
||||
and [a.value for a in n.args[:2] if isinstance(a, ast.Constant)] == ["release", "create"]]
|
||||
if len(names) != 1 or not names[0]:
|
||||
fail("tools/publish.py passes no one file to gh release create: %s" % names)
|
||||
|
||||
print("forgectrl %s: %s; the catalog publishes %s" % (rev[:7], url, names[0]))
|
||||
if url != PREFIX + names[0]:
|
||||
fail("the address is not %s" % (PREFIX + names[0]))
|
||||
EOF
|
||||
|
||||
- name: Unit tests
|
||||
working-directory: forgefirm/forgetest
|
||||
# -B: no bytecode caches under the package directory (the recipe's fetch
|
||||
@@ -66,9 +143,18 @@ jobs:
|
||||
|
||||
- name: No undefined names (the live drills never execute on the host)
|
||||
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: |
|
||||
pip install pyflakes
|
||||
if python -B -m pyflakes forgetest tests | grep -E "undefined name"; then exit 1; fi
|
||||
python -m pip install pyflakes
|
||||
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
|
||||
working-directory: forgefirm
|
||||
@@ -76,7 +162,11 @@ jobs:
|
||||
|
||||
- name: Coverage lint (enforced)
|
||||
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
|
||||
working-directory: forgefirm
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# The extension sandbox's deny rules, proven on real traffic.
|
||||
#
|
||||
# scripts/sandbox-rules-test.py loads meta-forgefirm's ffx.nft into a network
|
||||
# namespace of its own, joins a second namespace to it with a veth pair (a
|
||||
# peer that is not the machine), and sends from real uids: a pool uid is
|
||||
# refused on loopback, at its own LAN address, and at the peer (TCP at once,
|
||||
# UDP with EPERM), the uids on either side of the pool and root are not
|
||||
# touched, an allowlist chain opens one port of the peer to one uid, the
|
||||
# machine's own addresses stay shut even on an allowlist, and loading the
|
||||
# file again takes the allowlist away. The
|
||||
# runner's nft is the version the image carries or newer; the kernel side is
|
||||
# the image's own business and is proven on the machine (exthost.platform).
|
||||
|
||||
name: sandbox-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'meta-forgefirm/recipes-forgefirm/forgefirm-sandbox/**'
|
||||
- 'scripts/sandbox-rules-test.py'
|
||||
- '.github/workflows/sandbox-ci.yml'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
rules:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: nft
|
||||
run: sudo apt-get update && sudo apt-get install -y nftables
|
||||
|
||||
- name: The deny rules on real traffic
|
||||
run: |
|
||||
# 77 is the script's "could not run here": on this runner that is a failure.
|
||||
sudo python3 -B scripts/sandbox-rules-test.py
|
||||
@@ -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
|
||||
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
|
||||
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,
|
||||
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
|
||||
and cloud mode runs end to end.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> ### BETA
|
||||
>
|
||||
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
|
||||
> Expect problems, and expect frequent updates. Upgrade whenever a newer
|
||||
> It's stable, but you may still hit the occasional bug, and expect frequent updates. Upgrade whenever a newer
|
||||
> release is available, and report what you find on the
|
||||
> [community forum](https://community.openglow.org).
|
||||
|
||||
|
||||
@@ -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/
|
||||
@@ -26,13 +26,6 @@ case "$1" in
|
||||
start)
|
||||
echo "Starting forgetest"
|
||||
mkdir -p "$DATA"
|
||||
# The bench seed: the setup override lifts the wizard part of
|
||||
# the controller gate until the next reboot (/run is tmpfs), so a
|
||||
# campaign runs on a machine whose required wizards are not complete.
|
||||
# Nothing lifts the agreements or the account: the bench accepts them
|
||||
# once, and that stays in /data.
|
||||
mkdir -p /run/forgefirm
|
||||
: > /run/forgefirm/setup-override
|
||||
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
|
||||
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
|
||||
;;
|
||||
|
||||
@@ -19,7 +19,9 @@ restores it after (on every exit path), so a test cannot hand the next one
|
||||
restored where the interface allows.
|
||||
|
||||
The lid lamp is fixed too, at forgectrl's `lid_lamp_idle` setting (unset =
|
||||
236): the daemon asserts it at start and at every controller spawn.
|
||||
236): the daemon asserts it at start and at every controller spawn. The
|
||||
lamp fades toward a level written to it, so a level written a moment
|
||||
before the check is read again until it holds before it is judged.
|
||||
|
||||
The controller mode in force decides what the baseline owns. In GRBL mode
|
||||
everything above applies. In cloud mode the cloud client owns what it
|
||||
@@ -41,6 +43,7 @@ that made it, not a reason to hand the dirt on.
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from . import hw
|
||||
@@ -116,6 +119,12 @@ UNPRESERVED_SETTINGS = ("controller_mode",)
|
||||
# service starts its own client with the hunt.
|
||||
NOHUNT_MARKER = os.environ.get("FORGETEST_NOHUNT_MARKER", "/run/gfcloud-nohunt")
|
||||
|
||||
# The offline service: gfcloud's one-start offline marker and the socket
|
||||
# the service listens on while it runs. A takeover that finds it running
|
||||
# makes forgectrl's start at its end under the marker again (runner.Takeover).
|
||||
OFFLINE_MARKER = os.environ.get("FORGETEST_OFFLINE_MARKER", "/run/gfcloud-offline")
|
||||
OFFLINE_SOCKET = os.environ.get("FORGETEST_OFFLINE_SOCKET", "/run/gfcloud-offline.sock")
|
||||
|
||||
# Read-only readbacks with their idle values (no direct restore: the state
|
||||
# comes right through forgectrl - see restore_forgectrl - or is fatal).
|
||||
IDLE_READBACKS = [
|
||||
@@ -133,6 +142,15 @@ PRESERVED_SYSFS = [] # sysfs attrs captured before, restored afte
|
||||
|
||||
LID_LAMP_ATTR = "pic/lid_led"
|
||||
LID_LAMP_DEFAULT = "236" # forgectrl's lid_lamp_idle default
|
||||
# The lid LED runs on the kernel's "smooth" trigger: a level written to it
|
||||
# is reached by a fade over a fraction of a second, so a read right after
|
||||
# a write (the supervisor re-applies the idle level at every controller
|
||||
# spawn) finds the lamp part-way there: on the bench reference, 232 read
|
||||
# 0.05 s after the supervisor re-applies 236. The lamp is read again every
|
||||
# LID_LAMP_POLL_S until it holds the level, for at most LID_LAMP_SETTLE_S,
|
||||
# before it is called a leftover.
|
||||
LID_LAMP_SETTLE_S = 3.0
|
||||
LID_LAMP_POLL_S = 0.1
|
||||
|
||||
BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon after boot
|
||||
SETTLE_S = 150 # the supervisor's probe + rail-off ladder
|
||||
@@ -214,6 +232,23 @@ def write_led(name, value):
|
||||
f.write(str(value))
|
||||
|
||||
|
||||
# The GRBL controller zeroes the kernel's step counters at every start (the
|
||||
# lens reference) and at every home, and rewrites its home anchor each time.
|
||||
# The anchor's identity is therefore the counters' frame: if it changed
|
||||
# during a run, the counters at the end are not in the frame of the counters
|
||||
# at the start, and their difference is not a distance the head traveled.
|
||||
ANCHOR_PATH = os.environ.get("FORGETEST_ANCHOR", "/run/grblhal.homed")
|
||||
|
||||
|
||||
def counter_frame():
|
||||
"""What identifies the frame the step counters are in, or None."""
|
||||
try:
|
||||
st = os.stat(ANCHOR_PATH)
|
||||
return [st.st_ino, st.st_mtime_ns]
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def read_position():
|
||||
"""(x, y, z) step counters, or None when unreadable."""
|
||||
try:
|
||||
@@ -272,6 +307,11 @@ def read_program_total():
|
||||
return None
|
||||
|
||||
|
||||
# The extension host stops a removed package's service on its next
|
||||
# one-second turn; its group is killed and waited for inside that turn.
|
||||
EXT_STOP_S = 8
|
||||
|
||||
|
||||
class Leftover:
|
||||
def __init__(self, item, found, expected, action):
|
||||
self.item = item
|
||||
@@ -445,7 +485,8 @@ class Baseline:
|
||||
# -- capture -------------------------------------------------------
|
||||
def capture(self):
|
||||
"""Record the preserved state before a run."""
|
||||
cap = {"sysfs": {}, "position": read_position(), "settings": None, "mode": None}
|
||||
cap = {"sysfs": {}, "position": read_position(), "frame": counter_frame(),
|
||||
"settings": None, "mode": None}
|
||||
for attr in PRESERVED_SYSFS:
|
||||
cap["sysfs"][attr] = hw.sysfs_read(attr)
|
||||
st, body = self.fc_get("/settings")
|
||||
@@ -471,6 +512,7 @@ class Baseline:
|
||||
self._forgectrl_side(left, captured)
|
||||
self._kernel_side(left)
|
||||
self._lamp_side(left)
|
||||
self._ext_side(left)
|
||||
self._preserved(left, captured)
|
||||
if left:
|
||||
self.log("%s: %d leftover(s): %s" % (phase, len(left), "; ".join(str(x) for x in left)))
|
||||
@@ -669,6 +711,37 @@ class Baseline:
|
||||
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
||||
act if w is not None else "failed: still %s" % found))
|
||||
|
||||
def _ext_side(self, left):
|
||||
"""A campaign's machine is extension-free. What a test made and
|
||||
left behind (a package under the tests' own prefix, the owner key
|
||||
it was signed with) is removed, and the host stops its service.
|
||||
The operator's own packages are theirs: running ones are reported
|
||||
and not touched."""
|
||||
for id_ in [p for p in hw.ext_packages() if p.startswith(hw.EXT_TEST_PREFIX)]:
|
||||
try:
|
||||
p = subprocess.run([hw.FORGEEXT, "remove", id_], capture_output=True, text=True, timeout=120)
|
||||
ok = p.returncode == 0 and id_ not in hw.ext_packages()
|
||||
act = "restored" if ok else "failed: %s" % (p.stdout.strip()[-120:] or p.returncode)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
act = "failed: %s" % e
|
||||
left.append(Leftover("ext.package", id_, "not installed", act))
|
||||
keys = hw.EXT_ROOT + "/keys"
|
||||
for name in sorted(os.listdir(keys)) if os.path.isdir(keys) else []:
|
||||
if not name.startswith("forgetest-"):
|
||||
continue
|
||||
try:
|
||||
os.remove(os.path.join(keys, name))
|
||||
act = "restored"
|
||||
except OSError as e:
|
||||
act = "failed: %s" % e
|
||||
left.append(Leftover("ext.owner_key", name, "absent", act))
|
||||
# the host stops a removed package's service on its next turn
|
||||
running = self._wait("no test package running", lambda: not hw.pool_pids() or None, EXT_STOP_S) is None \
|
||||
and hw.pool_pids()
|
||||
if running:
|
||||
left.append(Leftover("ext.running", "%d process(es) under pool accounts, packages %s"
|
||||
% (len(running), hw.ext_packages()), "an extension-free machine", "unrestorable"))
|
||||
|
||||
def _lamp_side(self, left):
|
||||
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
|
||||
In cloud mode the cloud client owns the lamp (its lid-image level)."""
|
||||
@@ -681,13 +754,34 @@ class Baseline:
|
||||
got = hw.sysfs_read(LID_LAMP_ATTR)
|
||||
if got is None or got == want:
|
||||
return
|
||||
held, now = self._lamp_holds(want)
|
||||
if held:
|
||||
self.log("lid lamp: read %s, then held %s; the fade toward a level just written, "
|
||||
"not a leftover" % (got, want))
|
||||
return
|
||||
found = now if now is not None else got
|
||||
try:
|
||||
hw.sysfs_write(LID_LAMP_ATTR, want)
|
||||
back = hw.sysfs_read(LID_LAMP_ATTR)
|
||||
act = "restored" if back == want else "failed: reads %s" % back
|
||||
held, back = self._lamp_holds(want)
|
||||
act = "restored" if held else "failed: reads %s" % back
|
||||
except OSError as e:
|
||||
act = "failed: %s" % e
|
||||
left.append(Leftover(LID_LAMP_ATTR, got, want, act))
|
||||
left.append(Leftover(LID_LAMP_ATTR, found, want, act))
|
||||
|
||||
def _lamp_holds(self, want):
|
||||
"""Read the lid lamp every LID_LAMP_POLL_S, for at most
|
||||
LID_LAMP_SETTLE_S, until it holds `want`: two reads in a row at
|
||||
it, so a fade that only passes through the level does not count.
|
||||
Returns (held, the last read)."""
|
||||
last = prev = None
|
||||
for _ in range(max(2, int(round(LID_LAMP_SETTLE_S / LID_LAMP_POLL_S)))):
|
||||
if self.abort():
|
||||
break
|
||||
time.sleep(LID_LAMP_POLL_S)
|
||||
prev, last = last, hw.sysfs_read(LID_LAMP_ATTR)
|
||||
if last == want and prev == want:
|
||||
return True, last
|
||||
return False, last
|
||||
|
||||
def _kernel_side(self, left):
|
||||
if hw.sysfs_read("cnc/state") is None:
|
||||
@@ -829,7 +923,19 @@ class Baseline:
|
||||
# action re-zeroes them at its start, so they preserve nothing.
|
||||
was = captured.get("position")
|
||||
now = read_position()
|
||||
if was is not None and now is not None and now != was and not self.cloud_mode():
|
||||
# A controller start or a home during the run re-zeroed the counters
|
||||
# wherever the head then stood. Unless the test said where that was
|
||||
# (ctx.counters_rezeroed), the two readings share no frame: on the
|
||||
# bench reference a head that had not moved was "returned" 30 mm into
|
||||
# the gantry stops this way, twice. Nothing is moved on a guess.
|
||||
frame_lost = ("frame" in captured and captured["frame"] != counter_frame()
|
||||
and not captured.get("rezero_declared"))
|
||||
if was is not None and now is not None and now != was and frame_lost and not self.cloud_mode():
|
||||
self.log("position: %s, and %s when the run began, but the controller re-zeroed its "
|
||||
"counters during the run (a controller start or a home): the two are not in "
|
||||
"one frame, so the head's position cannot be judged and the head is not moved"
|
||||
% (now, was))
|
||||
elif was is not None and now is not None and now != was and not self.cloud_mode():
|
||||
spm = self._counter_spm
|
||||
act = self._return_head(was, now, spm)
|
||||
if position_quantized(was, now, spm):
|
||||
@@ -837,6 +943,21 @@ class Baseline:
|
||||
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
|
||||
else:
|
||||
left.append(Leftover("position", now, was, act))
|
||||
# A controller restart during the run (a takeover's) re-zeroed the
|
||||
# counters wherever the head then stood, so a head left out before
|
||||
# it reads as home from here. The takeover judged it on the spot
|
||||
# (runner.Takeover); what it recorded is a leftover, never moved.
|
||||
for r in captured.get("restart_positions") or []:
|
||||
left.append(Leftover("position at the %s" % r["where"], r["found"], r["expected"],
|
||||
"unrestorable: a controller restart re-zeroed the counters there, "
|
||||
"so the head is not moved"))
|
||||
# The cloud client forgectrl's start brought back at the end of a
|
||||
# takeover, when it is not the one the takeover found (the same
|
||||
# judge, runner.Takeover). The client that came up is left as it
|
||||
# is: the cloud test that needs a client starts its own.
|
||||
for r in captured.get("restart_clients") or []:
|
||||
left.append(Leftover("cloud client at the %s" % r["where"], r["found"], r["expected"],
|
||||
"not restored: a cloud test starts the client it needs"))
|
||||
was = captured.get("settings")
|
||||
if was:
|
||||
st, body = self.fc_get("/settings")
|
||||
|
||||
@@ -257,6 +257,64 @@ TOOLS = [
|
||||
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
|
||||
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
|
||||
"bench-page tool."},
|
||||
{"id": "ctlport-test", "title": "Controller port and line multiplexer harness", "script": "ctlport_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "A scripted sender that counts every response, beside the controller port's one client, on the "
|
||||
"null-sink controller: a port jog's status goes to the port and never to the sender, a port "
|
||||
"error does not reach the sender's parser, a sender line cancels a port jog and gets its own ok "
|
||||
"(a line queued right behind the port's included), the refusals, the single client, a CR LF "
|
||||
"sender, a soft reset, and the client as the dead-man. A CI harness (the grblHAL repo): needs "
|
||||
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "cool-report-test", "title": "Cooling report secret harness", "script": "cool_report_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The controller's cooling reports on the null-sink controller, read by a stand-in for "
|
||||
"forgectrl's listener: every report carries the secret the supervisor hands the controller at "
|
||||
"its spawn (GF_REPORT_SECRET), none does when there is none, a value that is not 32 hex digits "
|
||||
"(a CR LF with a header behind it included) never reaches the wire, and the homing runner the "
|
||||
"controller starts is handed it (it reports in the controller's place during a gfcloud $H), "
|
||||
"and none when the controller has none. A CI harness (the grblHAL repo): needs the host-built "
|
||||
"null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "mcode-test", "title": "Package M-code barrier harness", "script": "mcode_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The M-codes extension packages answer (M160 to M179), on the null-sink controller with a "
|
||||
"scripted sender and a stand-in for the machine daemon on the port: the table and its refusals, "
|
||||
"a number nothing answers is error:20 where it is parsed, an answered one waits with the head "
|
||||
"still and a port jog refused while the port's state names it, the answer's words, an answer "
|
||||
"that the work was not done and no answer in time each hold the job, a soft reset ends the "
|
||||
"wait, and a wait under an open armed window with M3 S500 ships no FIRE tick. Imports its "
|
||||
"sender and port client from ctlport_test.py. A CI harness (the grblHAL repo): needs the "
|
||||
"host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "envelope-test", "title": "Measured envelope harness", "script": "envelope_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The measured work envelope and the bed check's port op, on the null-sink controller with a "
|
||||
"scripted sender and a port client: unset keys are the axis travel, envelope_x_mm and _y are the "
|
||||
"far edges at a home and are held to 50 mm up to the travel plus 30 mm, the port's envelope open "
|
||||
"is refused before a home and under an open armed window and lets the port's jogs reach the "
|
||||
"travel plus 30 mm, envelope apply puts the keys' edges back, and the sender's first line (never "
|
||||
"a status poll or an empty line), a soft reset, and the port client going away each close an "
|
||||
"open envelope, the sender's line only after it closed. Imports its sender and port client from "
|
||||
"ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink controller, "
|
||||
"not the machine, so it is not a bench-page tool."},
|
||||
{"id": "manual-home-test", "title": "Manual home and motor release harness", "script": "manual_home_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The manual homing provider and the motor release on the null-sink controller: $H under manual "
|
||||
"ships nothing and declares X0 Y0 with the soft limits on and Z kept; while the motors are "
|
||||
"released every motion source is refused and ships nothing, $X and a soft reset do not unlock "
|
||||
"it, and only $ME and a manual $H write the energize, once each; the port's panel operations "
|
||||
"do the same with the sender's count exact. Imports its sender and port client from "
|
||||
"ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink controller, "
|
||||
"not the machine, so it is not a bench-page tool."},
|
||||
{"id": "tray-test", "title": "Crumb tray mode harness", "script": "tray_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The crumb tray's mode on the null-sink controller: M103 P1 and P0 move Z and its envelope by "
|
||||
"tray_offset_mm on the lens step grid, tell the sender, write and remove the marker beside the "
|
||||
"shared config, and ship no step; a missing P, a P other than 0 or 1 and an axis word are "
|
||||
"errors; the switch waits for the move before it; the port's tray op, and its refusals during "
|
||||
"a program and at a package's M-code; the mode survives a soft reset and a restart, and a start "
|
||||
"over the marker references Z in the tray-out frame; the offset's range; an unreferenced lens; "
|
||||
"and a camera home in both modes hands the runner the same park. Imports its sender and port "
|
||||
"client from ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink "
|
||||
"controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
|
||||
|
||||
+149
-45
@@ -69,14 +69,21 @@ class Test:
|
||||
def source_sha(self):
|
||||
"""The hash of the test's own implementation: its function (the
|
||||
decorator included) together with the code its module shares
|
||||
among its tests - everything outside the @test functions. Part
|
||||
of the fingerprint: a change inside one test's body invalidates
|
||||
that test's earlier passes and no other; a change to a helper
|
||||
invalidates the tests of that module. A test the module does not
|
||||
define in the @test form hashes its whole file."""
|
||||
among its tests (everything outside the @test functions), the
|
||||
shared code of every sibling module that either of the two
|
||||
imports, and the shared judges (SHARED_JUDGES). Part of the
|
||||
fingerprint: a change inside one test's body invalidates that
|
||||
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:
|
||||
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
|
||||
self._source_sha = implementation_sha(path, self.id)
|
||||
# One filler at a time: a second poll that arrives while the
|
||||
# first computes waits for it rather than repeating the work.
|
||||
with _IMPL_LOCK:
|
||||
if self._source_sha is None:
|
||||
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
|
||||
self._source_sha = implementation_sha(path, self.id)
|
||||
return self._source_sha
|
||||
|
||||
def fingerprint(self, manifest):
|
||||
@@ -140,8 +147,74 @@ def source_file_sha(path):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
_PARTS = {} # path -> (shared sha, {test id: own sha}); the files never change under a run
|
||||
_PARTS_LOCK = threading.Lock()
|
||||
# The 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:
|
||||
# path -> (text, tree or None), path -> (shared sha, {test id: own sha}),
|
||||
# path -> the sibling modules it imports itself.
|
||||
_PARSED = {}
|
||||
_PARTS = {}
|
||||
_SIBLINGS = {}
|
||||
_PARTS_LOCK = threading.RLock()
|
||||
_IMPL_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def forget(path):
|
||||
"""Drop what is known about a module: its file changed (the unit tests
|
||||
edit their modules between readings)."""
|
||||
with _PARTS_LOCK:
|
||||
for cache in (_PARSED, _PARTS, _SIBLINGS):
|
||||
cache.pop(path, None)
|
||||
|
||||
|
||||
def _parsed(path):
|
||||
"""(text, tree) of a suite module, line endings normalized; the tree
|
||||
is None when the file does not parse."""
|
||||
with _PARTS_LOCK:
|
||||
hit = _PARSED.get(path)
|
||||
if hit is None:
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
tree = None
|
||||
hit = _PARSED[path] = (text, tree)
|
||||
return hit
|
||||
|
||||
|
||||
def _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):
|
||||
@@ -154,22 +227,12 @@ def module_parts(path):
|
||||
hit = _PARTS.get(path)
|
||||
if hit is not None:
|
||||
return hit
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
||||
text, tree = _parsed(path)
|
||||
lines = text.split("\n")
|
||||
spans = {}
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
tree = None
|
||||
for node in (tree.body if tree is not None else []):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
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)
|
||||
for tid, node in _test_defs(tree).items():
|
||||
start = min([dd.lineno for dd in node.decorator_list] + [node.lineno])
|
||||
spans[tid] = (start, node.end_lineno)
|
||||
owned = set()
|
||||
for a, b in spans.values():
|
||||
owned.update(range(a, b + 1))
|
||||
@@ -182,37 +245,78 @@ def module_parts(path):
|
||||
|
||||
|
||||
def sibling_imports(path, seen=None):
|
||||
"""The suite modules a module imports with `from .x import ...`, as
|
||||
paths, transitively, in a stable order. A helper a test calls from
|
||||
a sibling module is part of the judge, so its text belongs in the
|
||||
test's fingerprint."""
|
||||
"""The suite modules a module's shared text imports with `from .x
|
||||
import ...` or `from . import x`, as paths, transitively, in a stable
|
||||
order. A helper a test calls from a sibling module is part of the
|
||||
judge, so its text belongs in the test's fingerprint."""
|
||||
if seen is None:
|
||||
seen = []
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
return seen
|
||||
here = os.path.dirname(path)
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
|
||||
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
|
||||
if os.path.isfile(sib) and sib not in seen and sib != path:
|
||||
seen.append(sib)
|
||||
sibling_imports(sib, seen)
|
||||
for sib in _direct_siblings(path):
|
||||
if sib not in seen and sib != path:
|
||||
seen.append(sib)
|
||||
sibling_imports(sib, seen)
|
||||
return seen
|
||||
|
||||
|
||||
def implementation_sha(path, test_id):
|
||||
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):
|
||||
"""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:
|
||||
hit = _SIBLINGS.get(path)
|
||||
if hit is None:
|
||||
_text, tree = _parsed(path)
|
||||
tests = list(_test_defs(tree).values())
|
||||
shared = [n for n in (tree.body if tree is not None else []) if not any(n is t for t in tests)]
|
||||
hit = _SIBLINGS[path] = _imported(path, shared)
|
||||
return hit
|
||||
|
||||
|
||||
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
|
||||
test's own text, the shared text of its module, and the shared text
|
||||
of every sibling suite module the module imports."""
|
||||
test's own text, the shared text of its module, the shared text of
|
||||
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)
|
||||
if test_id not in own:
|
||||
return source_file_sha(path)
|
||||
helpers = [module_parts(p)[0] for p in sorted(sibling_imports(path))]
|
||||
return _manifest.sha256_text("%s:%s" % (":".join([shared] + helpers), own[test_id]))
|
||||
sibs = sibling_imports(path)
|
||||
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=(),
|
||||
|
||||
@@ -4,24 +4,33 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Coverage lint: every source path in the manifest must be selected by
|
||||
some test's coverage globs, except the allowlisted non-behavioral paths,
|
||||
and every coverage glob must select at least one path of its component.
|
||||
some test's coverage globs or by a host-tested row (manifest.HOST_TESTED),
|
||||
except the allowlisted non-behavioral paths; every coverage glob and every
|
||||
host-tested glob must select at least one path of its component; and every
|
||||
host-tested row must name a host test that exists and a CI workflow that
|
||||
runs it.
|
||||
|
||||
Why a hard rule: under the domain model an uncovered file is worse than an
|
||||
untested one - a change there leaves every inherited PASS valid when it
|
||||
should have invalidated them. The lint is the floor (the file is
|
||||
fingerprinted by at least one test); whether that test exercises the
|
||||
change stays with the change author. An entry that selects nothing is
|
||||
the same defect from the other side: the test's fingerprint ignores the
|
||||
file it named.
|
||||
fingerprinted by at least one test, or a named host test that CI runs
|
||||
answers for it); whether that test exercises the change stays with the
|
||||
change author. An entry that selects nothing is the same defect from the
|
||||
other side: the test's fingerprint ignores the file it named.
|
||||
|
||||
python3 -m forgetest.coverage [--manifest PATH] [--enforce] [--json]
|
||||
Whether a workflow runs its host test is read from the workflow's text at
|
||||
the pinned revision, in the sources scripts/manifest-from-tree.py fetched
|
||||
(--sources, default the checkout's .manifest-cache). Without them (on an
|
||||
image) that part is not checked, and the report says so.
|
||||
|
||||
python3 -m forgetest.coverage [--manifest PATH] [--sources DIR] [--enforce] [--json]
|
||||
|
||||
Exit 0 when nothing is uncovered (or when only reporting), 1 under
|
||||
--enforce with uncovered paths, 2 on a usage or load error.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from . import catalog as _catalog
|
||||
@@ -33,16 +42,22 @@ ALLOW = _manifest.NON_BEHAVIORAL
|
||||
|
||||
|
||||
def run(manifest, tests, allow=ALLOW):
|
||||
return _manifest.coverage_report(manifest, tests, allow)
|
||||
return _manifest.coverage_report(manifest, tests, allow, host=_manifest.HOST_TESTED)
|
||||
|
||||
|
||||
def empty(manifest, tests):
|
||||
return _manifest.empty_covers(manifest, tests)
|
||||
|
||||
|
||||
def host_problems(manifest, sources=_manifest.SOURCES):
|
||||
return _manifest.host_row_problems(manifest, host=_manifest.HOST_TESTED, sources=sources)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description="forgetest coverage lint")
|
||||
ap.add_argument("--manifest", default=None, help="manifest JSON (default: the running image's)")
|
||||
ap.add_argument("--sources", default=_manifest.SOURCES,
|
||||
help="the pinned sources manifest-from-tree.py fetched (default: the checkout's .manifest-cache)")
|
||||
ap.add_argument("--enforce", action="store_true", help="exit 1 when any path is uncovered")
|
||||
ap.add_argument("--json", action="store_true", help="machine-readable report")
|
||||
args = ap.parse_args(argv)
|
||||
@@ -56,19 +71,30 @@ def main(argv=None):
|
||||
report = run(manifest, tests)
|
||||
total = sum(len(v) for v in report.values())
|
||||
hollow = empty(manifest, tests)
|
||||
sources = args.sources if args.sources and os.path.isdir(args.sources) else None
|
||||
rows = host_problems(manifest, sources)
|
||||
if args.json:
|
||||
print(json.dumps({"uncovered": report, "total": total, "tests": len(tests),
|
||||
"empty": [list(e) for e in hollow]}, indent=1, sort_keys=True))
|
||||
"empty": [list(e) for e in hollow],
|
||||
"host_tested": [list(r) for r in rows],
|
||||
"workflows_read": sources is not None and not any("cannot be read" in r[3] for r in rows)},
|
||||
indent=1, sort_keys=True))
|
||||
else:
|
||||
if _manifest.HOST_TESTED and sources is None:
|
||||
print("host-tested: no sources, so whether a workflow runs each host test is not checked")
|
||||
for comp in sorted(report):
|
||||
print("%s: %d uncovered path(s)" % (comp, len(report[comp])))
|
||||
for p in report[comp]:
|
||||
print(" %s" % p)
|
||||
for tid, comp, pat in hollow:
|
||||
print("%s: covers (%s, %s) selects nothing" % (tid, comp, pat))
|
||||
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, %d tests"
|
||||
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(tests)))
|
||||
if args.enforce and (total or hollow):
|
||||
for comp, pat, ref, why in rows:
|
||||
print("host-tested (%s, %s) by %s: %s" % (comp, pat, ref, why))
|
||||
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, "
|
||||
"%d host-tested row problem(s), %d tests"
|
||||
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(rows),
|
||||
len(tests)))
|
||||
if args.enforce and (total or hollow or rows):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
@@ -43,7 +43,14 @@ PULSE_WAIT_S = 1.5 # a pulse in progress ends within the firmware's
|
||||
|
||||
|
||||
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
|
||||
@@ -271,11 +278,20 @@ class Fixture:
|
||||
body = {}
|
||||
return e.code, body
|
||||
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:
|
||||
# the address may have moved: one fresh lookup, one retry
|
||||
self.address(refresh=True)
|
||||
return self._request(method, path, body, retry=False)
|
||||
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e))
|
||||
# the address may have moved: one fresh lookup, one retry,
|
||||
# and a failure of either still says the first one went out
|
||||
try:
|
||||
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):
|
||||
st, body = self._request("GET", "/")
|
||||
@@ -297,11 +313,11 @@ class Fixture:
|
||||
return bool(st and st.get("button_enabled"))
|
||||
return True
|
||||
|
||||
def act(self, channel, state):
|
||||
def act(self, channel, state, ms=None):
|
||||
if channel == "button":
|
||||
if state != "press":
|
||||
raise FixtureError("the button is only ever pressed")
|
||||
st, body = self._press()
|
||||
st, body = self._press(ms)
|
||||
else:
|
||||
if state not in ("open", "close"):
|
||||
raise FixtureError("%s: unknown state %r" % (channel, state))
|
||||
@@ -312,17 +328,21 @@ class Fixture:
|
||||
self.last_state = body
|
||||
return body
|
||||
|
||||
def _press(self):
|
||||
"""One press, spaced from the last one. The controller detects
|
||||
the button's rising edge, so a press must follow a release it has
|
||||
seen: the next press waits for the last pulse to end (the
|
||||
def _press(self, ms=None):
|
||||
"""One press, spaced from the last one. `ms` is how long the box
|
||||
holds it: the box's own 200 ms when it is not said, and the box
|
||||
clamps a request into 20 to 500, so no press is ever held. Every
|
||||
reader of the button catches the default press (an edge, or a poll
|
||||
of 100 ms or less), so no check needs a longer one. The controller
|
||||
detects the button's rising edge, so a press must follow a release
|
||||
it has seen: the next press waits for the last pulse to end (the
|
||||
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
|
||||
progress (a press this client did not time) is waited out once
|
||||
against the fixture's state, then retried."""
|
||||
wait = self._pulse_end + BUTTON_GAP_S - time.time()
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
st, body = self._request("POST", "/button", {})
|
||||
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
|
||||
if st == 409 and "in progress" in str(body.get("error", "")):
|
||||
deadline = time.time() + PULSE_WAIT_S
|
||||
while time.time() < deadline:
|
||||
@@ -331,7 +351,7 @@ class Fixture:
|
||||
if s == 200 and not b.get("button_pulsing"):
|
||||
break
|
||||
time.sleep(BUTTON_GAP_S)
|
||||
st, body = self._request("POST", "/button", {})
|
||||
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
|
||||
if st == 200:
|
||||
self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0
|
||||
return st, body
|
||||
|
||||
@@ -14,9 +14,12 @@ exercised against a mock on a host:
|
||||
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
|
||||
FORGETEST_INITD default /etc/init.d
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -310,6 +313,89 @@ def button_lit():
|
||||
return any(v > 0 for v in vals)
|
||||
|
||||
|
||||
# ----------------------------------------------------------- the button
|
||||
|
||||
EV_SW = 0x05
|
||||
SW_BUTTON = 2 # the button's EV_SW code (bit 2 of the switch word)
|
||||
|
||||
|
||||
def switches_device():
|
||||
"""The machine's switches as forgectrl and the controller find them:
|
||||
GF_SWITCH_DEV, else the input device the device tree names
|
||||
"switches". None when there is none."""
|
||||
dev = os.environ.get("GF_SWITCH_DEV")
|
||||
if dev:
|
||||
return dev
|
||||
for d in sorted(glob.glob("/sys/class/input/event*")):
|
||||
try:
|
||||
with open(os.path.join(d, "device", "name")) as f:
|
||||
if f.read().strip() == "switches":
|
||||
return "/dev/input/" + os.path.basename(d)
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
class ButtonEdges:
|
||||
"""The button as edges, from the switches' input events. A press is an
|
||||
event however short it is, so nothing has to be held long enough for a
|
||||
poll to land on it: an operator's press is never longer than a quarter
|
||||
second. Open it before the press is asked for. It reads without a
|
||||
grab, so forgectrl and the controller see the switches as always."""
|
||||
|
||||
EVENT = struct.Struct("llHHi") # struct input_event: time, type, code, value
|
||||
|
||||
def __init__(self):
|
||||
self.fd = None
|
||||
self.pending = b"" # events read and not yet looked at
|
||||
dev = switches_device()
|
||||
if dev:
|
||||
try:
|
||||
self.fd = os.open(dev, os.O_RDONLY | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0))
|
||||
except OSError:
|
||||
self.fd = None
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
return self.fd is not None
|
||||
|
||||
def wait(self, value, timeout, checkpoint=None):
|
||||
"""The seconds until the button's next edge to `value` (1 pressed,
|
||||
0 released), or None at the timeout. checkpoint() is called about
|
||||
every 0.1 s (an abort raises out of it)."""
|
||||
size = self.EVENT.size
|
||||
t0 = time.time()
|
||||
while True:
|
||||
# The events already read come first: a short press arrives
|
||||
# with its release in one read, and the release is the next
|
||||
# wait's.
|
||||
while len(self.pending) >= size:
|
||||
_sec, _usec, typ, code, val = self.EVENT.unpack_from(self.pending, 0)
|
||||
self.pending = self.pending[size:]
|
||||
if typ == EV_SW and code == SW_BUTTON and val == value:
|
||||
return time.time() - t0
|
||||
if time.time() - t0 >= timeout:
|
||||
return None
|
||||
if checkpoint:
|
||||
checkpoint()
|
||||
ready, _, _ = select.select([self.fd], [], [], 0.1)
|
||||
if not ready:
|
||||
continue
|
||||
try:
|
||||
data = os.read(self.fd, size * 64)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if not data:
|
||||
time.sleep(0.02) # a FIFO with no writer (the host tests) reads empty
|
||||
continue
|
||||
self.pending += data
|
||||
|
||||
def close(self):
|
||||
if self.fd is not None:
|
||||
os.close(self.fd)
|
||||
self.fd = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------- init.d
|
||||
|
||||
def initd(service, action, timeout=60):
|
||||
@@ -324,6 +410,40 @@ def initd(service, action, timeout=60):
|
||||
return p.returncode, p.stdout.decode("utf-8", "replace")
|
||||
|
||||
|
||||
# The extension host's side of the machine (forgefirm-sandbox, forgeext).
|
||||
EXT_POOL_FIRST, EXT_POOL_LAST = 800, 831
|
||||
EXT_ROOT = "/data/forgefirm/ext"
|
||||
EXT_TEST_PREFIX = "org.forgetest." # a package a test made; every other one is the operator's
|
||||
FORGEEXT = "/usr/bin/forgeext"
|
||||
|
||||
|
||||
def pool_pids():
|
||||
"""{pid: uid} of every process that runs under an extension pool account."""
|
||||
out = {}
|
||||
try:
|
||||
pids = [p for p in os.listdir("/proc") if p.isdigit()]
|
||||
except OSError:
|
||||
return out
|
||||
for pid in pids:
|
||||
try:
|
||||
with open("/proc/%s/status" % pid) as f:
|
||||
line = next((x for x in f if x.startswith("Uid:")), "")
|
||||
uid = int(line.split()[1]) # the real uid; a service has all four alike
|
||||
except (OSError, IndexError, ValueError):
|
||||
continue
|
||||
if EXT_POOL_FIRST <= uid <= EXT_POOL_LAST:
|
||||
out[int(pid)] = uid
|
||||
return out
|
||||
|
||||
|
||||
def ext_packages():
|
||||
"""The ids of the installed extension packages."""
|
||||
try:
|
||||
return sorted(d for d in os.listdir(EXT_ROOT + "/pkg") if os.path.isdir(os.path.join(EXT_ROOT, "pkg", d)))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def pidof(comm):
|
||||
"""PIDs whose /proc/<pid>/comm equals comm (15-char kernel limit applies)."""
|
||||
out = []
|
||||
@@ -361,6 +481,44 @@ def pgrep_f(needle):
|
||||
return out
|
||||
|
||||
|
||||
def listens_on(pid, path):
|
||||
"""Process pid holds a listening UNIX socket bound at path: one of its
|
||||
own descriptors is that socket, so a socket file another process left
|
||||
behind does not count. Reads /proc (FORGETEST_PROC_ROOT for tests)."""
|
||||
if not pid:
|
||||
return False
|
||||
proc = os.environ.get("FORGETEST_PROC_ROOT") or "/proc"
|
||||
inodes = set()
|
||||
try:
|
||||
with open(os.path.join(proc, "net", "unix")) as f:
|
||||
next(f, None) # the header line
|
||||
for ln in f:
|
||||
# Num RefCount Protocol Flags Type St Inode Path
|
||||
parts = ln.split()
|
||||
try:
|
||||
if len(parts) >= 8 and parts[7] == path and int(parts[3], 16) & 0x10000:
|
||||
inodes.add(parts[6]) # __SO_ACCEPTCON: a listener
|
||||
except ValueError:
|
||||
continue
|
||||
except OSError:
|
||||
return False
|
||||
if not inodes:
|
||||
return False
|
||||
fd_dir = os.path.join(proc, str(pid), "fd")
|
||||
try:
|
||||
fds = os.listdir(fd_dir)
|
||||
except OSError:
|
||||
return False
|
||||
for fd in fds:
|
||||
try:
|
||||
link = os.readlink(os.path.join(fd_dir, fd))
|
||||
except OSError:
|
||||
continue
|
||||
if link.startswith("socket:[") and link[8:-1] in inodes:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def run(cmd, timeout=60):
|
||||
"""Run a command list; returns (rc, combined output)."""
|
||||
try:
|
||||
|
||||
+1029
-6
File diff suppressed because it is too large
Load Diff
+267
-15
@@ -125,6 +125,8 @@ ACT_TIMEOUT_S = 180
|
||||
# machine by pressing its button. Generous: they may be setting up the
|
||||
# scrap, and nothing fires until it lands.
|
||||
PRESENCE_TIMEOUT_S = 600
|
||||
ARM_LIT_WAIT_S = 1800 # an arm can wait minutes (a coolant settle) before it lights
|
||||
ARM_PRESS_WAIT_S = 3600 # the controller's own button timeout, at most
|
||||
|
||||
|
||||
class Run:
|
||||
@@ -316,17 +318,31 @@ class Context:
|
||||
|
||||
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
|
||||
if fixture is not None and fixture.covers("button"):
|
||||
# The press is read as an edge from the switches' events, opened
|
||||
# before the notice goes up: a press of any length counts, and
|
||||
# nobody holds the button for a poll to land on it.
|
||||
edges = hw.ButtonEdges()
|
||||
self.notice(text + " Then press the button on the machine to start.")
|
||||
self.log("READY: waiting for the operator's press on the machine "
|
||||
"(the bench then does every press in this test)")
|
||||
pressed = self.wait_for(lambda: self.switch("button") is True, PRESENCE_TIMEOUT_S)
|
||||
self.clear_notice()
|
||||
try:
|
||||
if edges.available:
|
||||
pressed = edges.wait(1, PRESENCE_TIMEOUT_S, self.checkpoint)
|
||||
# The release, so this press is never read as the arm
|
||||
# press or a pause toggle; a short press has queued it.
|
||||
if pressed is not None:
|
||||
edges.wait(0, 10, self.checkpoint)
|
||||
else:
|
||||
pressed = self.wait_for(lambda: self.switch("button") is True, PRESENCE_TIMEOUT_S,
|
||||
poll=0.05)
|
||||
if pressed is not None:
|
||||
self.wait_for(lambda: self.switch("button") is False, 10, poll=0.05)
|
||||
finally:
|
||||
edges.close()
|
||||
self.clear_notice()
|
||||
if pressed is None:
|
||||
raise Failed("no press on the machine within %d s: %s"
|
||||
% (PRESENCE_TIMEOUT_S, text))
|
||||
# Let the button come back up, so this press is never read as
|
||||
# the arm press or a pause toggle.
|
||||
self.wait_for(lambda: self.switch("button") is False, 10)
|
||||
self.run.fixture_takeover = True
|
||||
self.evidence.setdefault("actions", []).append(
|
||||
{"channel": "button", "state": "presence", "by": "operator",
|
||||
@@ -366,7 +382,7 @@ class Context:
|
||||
"""One of forgectrl's switch readings (lid, interlock_ok, ...)."""
|
||||
return (self.forgectrl.status().get("switches") or {}).get(name)
|
||||
|
||||
def act(self, channel, state, until=None, timeout=ACT_TIMEOUT_S, text="", fail=True):
|
||||
def act(self, channel, state, until=None, timeout=ACT_TIMEOUT_S, text="", fail=True, ms=None):
|
||||
"""A machine action by name: ("lid", "open"|"close"),
|
||||
("interlock", "open"|"close"), ("button", "press"). The bench's
|
||||
actuator performs it when one covers the channel (the runner's
|
||||
@@ -405,7 +421,7 @@ class Context:
|
||||
self.log("ACT %s %s (fixture)", channel, state)
|
||||
rec["by"] = "fixture"
|
||||
try:
|
||||
fixture.act(channel, state)
|
||||
fixture.act(channel, state, ms)
|
||||
except _fixture.FixtureError as e:
|
||||
rec["fixture_error"] = str(e)
|
||||
if self.run.unattended:
|
||||
@@ -448,8 +464,9 @@ class Context:
|
||||
|
||||
The fixture presses where the bench took this test's presses (or
|
||||
opted in standing); otherwise the notice goes up for a person,
|
||||
who is looking at the same prompt. Returns True when the fixture
|
||||
pressed."""
|
||||
who is looking at the same prompt. A press the fixture reports
|
||||
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
|
||||
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
|
||||
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 "
|
||||
"gone - asking the operator for the arm press")
|
||||
self.notice(text)
|
||||
self._clear_when_taken()
|
||||
return False
|
||||
try:
|
||||
fixture.act("button", "press")
|
||||
except _fixture.FixtureError as 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.notice(text)
|
||||
self._clear_when_taken()
|
||||
return False
|
||||
rec["by"] = "fixture"
|
||||
self.log("ARM press by the fixture, at the machine's own press prompt")
|
||||
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.",
|
||||
lit_timeout=60):
|
||||
"""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
|
||||
to the arm wait; a card that settles the coolant first takes
|
||||
minutes) and presses once, recorded as the fixture's; if the
|
||||
button never lights or the press fails, the notice goes up for a
|
||||
person. Returns True when the fixture has been asked."""
|
||||
button never lights, or the press fails with the button still lit,
|
||||
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
|
||||
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
|
||||
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 "
|
||||
"gone - asking the operator for the arm press")
|
||||
self.notice(text)
|
||||
self._clear_when_taken()
|
||||
return False
|
||||
self.log("ARM: the fixture presses when the button lights (the bench's arm_press opt-in)")
|
||||
|
||||
@@ -507,13 +592,18 @@ class Context:
|
||||
if lit is None:
|
||||
self.log("ARM: the button never lit within %d s - asking the operator", lit_timeout)
|
||||
self.notice(text)
|
||||
self._clear_when_taken()
|
||||
return
|
||||
try:
|
||||
fixture.act("button", "press")
|
||||
except _fixture.FixtureError as e:
|
||||
self.log("ARM press: fixture failed (%s) - asking the operator", 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._clear_when_taken()
|
||||
return
|
||||
rec["by"] = "fixture"
|
||||
rec["took_s"] = round(lit, 2)
|
||||
@@ -539,7 +629,7 @@ class Context:
|
||||
return hw.Grbl()
|
||||
|
||||
def takeover(self):
|
||||
return Takeover(self.run.log, self.test.id)
|
||||
return Takeover(self.run.log, self.test.id, run=self.run)
|
||||
|
||||
def counters_rezeroed(self):
|
||||
"""Tell the baseline the kernel position counters were re-zeroed
|
||||
@@ -549,6 +639,10 @@ class Context:
|
||||
cap = self.run.baseline_captured
|
||||
if cap and cap.get("position") is not None:
|
||||
cap["position"] = [0, 0, 0]
|
||||
cap["rezero_declared"] = True # the test vouches for the new frame
|
||||
# ... and it is the frame now in force, so a takeover later in
|
||||
# the run can still judge where the head stands against it
|
||||
cap["frame"] = _baseline.counter_frame()
|
||||
self.log("position counters re-zeroed at the starting position; the baseline "
|
||||
"expects (0,0,0) at the end")
|
||||
|
||||
@@ -578,11 +672,16 @@ class Takeover:
|
||||
"cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
|
||||
"pic/x_step_current", "pic/y_step_current")
|
||||
|
||||
def __init__(self, log, who):
|
||||
def __init__(self, log, who, run=None):
|
||||
self.log = log # callable(str)
|
||||
self.who = who
|
||||
self.marker = marker_path()
|
||||
self.saved = {}
|
||||
self.run = run # the test's run, whose baseline the head is judged against
|
||||
self.cloud = False
|
||||
self.offline = False # the cloud client found is the offline service
|
||||
self.frame = None # the counters' frame, read before the controller is stopped
|
||||
self.found = None # (counters, microstep mode) once the controller is gone
|
||||
|
||||
def wait_settled(self):
|
||||
return _baseline.Baseline(self.log).wait_settled()
|
||||
@@ -609,6 +708,18 @@ class Takeover:
|
||||
self.saved[attr] = v
|
||||
if self.saved:
|
||||
log("takeover: preserving %s" % ", ".join("%s=%s" % kv for kv in self.saved.items()))
|
||||
try:
|
||||
st, mode = hw.Forgectrl().get("/mode")
|
||||
self.cloud = st == 200 and isinstance(mode, dict) and mode.get("mode") == "cloud"
|
||||
self.offline = self.cloud and hw.listens_on(mode.get("pid"), _baseline.OFFLINE_SOCKET)
|
||||
except hw.HwError:
|
||||
pass
|
||||
if self.offline:
|
||||
log("takeover: the cloud client is the offline service (pid %s); forgectrl's start "
|
||||
"brings it back offline" % mode.get("pid"))
|
||||
# The frame is read while the controller runs: its exit takes the
|
||||
# anchor with it (forgectrl unlinks it), though not the counters.
|
||||
self.frame = _baseline.counter_frame()
|
||||
log("takeover: stopping the controller through forgectrl")
|
||||
try:
|
||||
st, body = hw.Forgectrl().post("/controller/stop")
|
||||
@@ -631,10 +742,146 @@ class Takeover:
|
||||
self.__exit__(None, None, None)
|
||||
raise Failed("takeover: processes still alive after stop: %s" % left)
|
||||
log("takeover: pulse device free")
|
||||
try:
|
||||
self.position_at_start()
|
||||
except Exception as e: # noqa: BLE001 - a reading never keeps forgectrl down
|
||||
log("takeover: WARNING the head's position could not be judged: %s: %s" % (type(e).__name__, e))
|
||||
return self
|
||||
|
||||
# A controller start re-zeroes the step counters where the head then
|
||||
# stands, and forgectrl's start after a takeover is one, so the
|
||||
# baseline's check after the run cannot see a head left out before it.
|
||||
# The takeover judges it at the two moments it can: as the controller
|
||||
# goes away (against where the run expects the head) and before it
|
||||
# comes back (against where the takeover found it). A miss is recorded
|
||||
# for the baseline's post pass, which fails the run; nothing is moved,
|
||||
# because a jog sized from these counters is only as good as the frame
|
||||
# they are in. Cloud mode is not judged: its counters are the cloud
|
||||
# client's, as in the baseline.
|
||||
|
||||
def _record(self, where, found, expected):
|
||||
cap = self.run.baseline_captured if self.run is not None else None
|
||||
if cap is not None:
|
||||
cap.setdefault("restart_positions", []).append(
|
||||
{"where": where, "found": found, "expected": expected})
|
||||
self.log("takeover: the head is not where it belongs at the %s: counters %s, expected %s; "
|
||||
"recorded as a leftover, nothing moved" % (where, found, expected))
|
||||
|
||||
def position_at_start(self):
|
||||
cap = self.run.baseline_captured if self.run is not None else None
|
||||
if not cap or self.cloud:
|
||||
return
|
||||
now = _baseline.read_position()
|
||||
self.found = (now, hw.sysfs_read("cnc/x_mode"))
|
||||
was = cap.get("position")
|
||||
if now is None or was is None:
|
||||
return
|
||||
if self.frame is None or cap.get("frame") != self.frame:
|
||||
self.log("takeover: the counters are not in the frame the run began in (a controller "
|
||||
"start or a home the test did not declare), so the head is not judged here")
|
||||
return
|
||||
if now != was and not _baseline.position_quantized(was, now, _baseline.counter_steps_per_mm()):
|
||||
self._record("takeover start", now, was)
|
||||
|
||||
def position_at_end(self):
|
||||
if self.found is None:
|
||||
return
|
||||
was, mode = self.found
|
||||
now = _baseline.read_position()
|
||||
if was is None or now is None:
|
||||
return
|
||||
if hw.sysfs_read("cnc/x_mode") != mode:
|
||||
self.log("takeover: the microstep mode changed under the takeover, so the counters "
|
||||
"before and after it are not one scale; the head is not judged here")
|
||||
return
|
||||
if now != was and not _baseline.position_quantized(was, now, _baseline.counter_steps_per_mm()):
|
||||
self._record("takeover end", now, was)
|
||||
|
||||
# In cloud mode forgectrl's start at the end starts a cloud client, and
|
||||
# what that client is comes only from the one-start markers gfcloud
|
||||
# reads and takes down as it starts. Started bare, it is the online
|
||||
# client with the service's connect-time hunt, and the moves the
|
||||
# service sends after the hunt are still running as the run ends and
|
||||
# the next one begins, whatever the takeover found. The start is made
|
||||
# under the no-hunt marker, as every client start the runner makes is,
|
||||
# and under the offline marker as well when the takeover found the
|
||||
# offline service; that client is then proven to be the offline
|
||||
# service, and a miss is recorded for the baseline's post pass, which
|
||||
# fails the run.
|
||||
OFFLINE_BACK_S = 60 # the start to the offline service's listener
|
||||
|
||||
def client_markers(self):
|
||||
"""(path, name) of the markers forgectrl's start is made under."""
|
||||
if not self.cloud:
|
||||
return []
|
||||
marks = [(_baseline.NOHUNT_MARKER, "no-hunt")]
|
||||
if self.offline:
|
||||
marks.append((_baseline.OFFLINE_MARKER, "offline"))
|
||||
return marks
|
||||
|
||||
def markers_on(self):
|
||||
for path, name in self.client_markers():
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
f.write("forgetest takeover %s\n" % self.who)
|
||||
except OSError as e:
|
||||
self.log("takeover: WARNING the %s marker could not be written: %s" % (name, e))
|
||||
|
||||
def wait_offline(self):
|
||||
"""The cloud client's pid once it listens on the offline socket, or
|
||||
None after OFFLINE_BACK_S."""
|
||||
deadline = time.time() + self.OFFLINE_BACK_S
|
||||
while True:
|
||||
try:
|
||||
st, m = hw.Forgectrl().get("/mode")
|
||||
except hw.HwError:
|
||||
st, m = None, None
|
||||
pid = m.get("pid") if st == 200 and isinstance(m, dict) else None
|
||||
if hw.listens_on(pid, _baseline.OFFLINE_SOCKET):
|
||||
return pid
|
||||
if time.time() >= deadline:
|
||||
return None
|
||||
time.sleep(0.5)
|
||||
|
||||
def client_back(self, mode):
|
||||
"""After forgectrl's start: a marker no client read is taken down
|
||||
(no client started - the gate, standby, a fault - and a later
|
||||
start must not come up under it), and the offline service found is
|
||||
judged against the client that came up."""
|
||||
if not self.cloud:
|
||||
return
|
||||
mode = mode if isinstance(mode, dict) else {}
|
||||
running = mode.get("mode") == "cloud" and mode.get("controller") == "running"
|
||||
pid = self.wait_offline() if self.offline and running else None
|
||||
for path, name in self.client_markers():
|
||||
if (not running or name == "offline") and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
self.log("takeover: the %s marker was never read - taken down" % name)
|
||||
except OSError:
|
||||
pass
|
||||
if not self.offline:
|
||||
return
|
||||
if pid:
|
||||
self.log("takeover: the cloud client is back as the offline service (pid %s)" % pid)
|
||||
return
|
||||
found = ("a client that is not the offline service (pid %s)" % mode.get("pid") if running
|
||||
else "no cloud client running (mode %s, controller %s)"
|
||||
% (mode.get("mode"), mode.get("controller")))
|
||||
cap = self.run.baseline_captured if self.run is not None else None
|
||||
if cap is not None:
|
||||
cap.setdefault("restart_clients", []).append(
|
||||
{"where": "takeover end", "found": found, "expected": "the offline service"})
|
||||
self.log("takeover: the offline service did not come back: %s; recorded as a leftover" % found)
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
try:
|
||||
self.position_at_end()
|
||||
except Exception as e: # noqa: BLE001 - a reading never keeps forgectrl down
|
||||
self.log("takeover: WARNING the head's position could not be judged: %s: %s"
|
||||
% (type(e).__name__, e))
|
||||
self.restore_attrs()
|
||||
self.markers_on()
|
||||
rc, out = hw.initd("forgectrl", "start")
|
||||
self.log("takeover: forgectrl start -> rc %s" % rc)
|
||||
try:
|
||||
@@ -643,7 +890,12 @@ class Takeover:
|
||||
pass
|
||||
# leave the machine settled for whatever runs next: the probe
|
||||
# move done, the controller back (or the ladder's verdict logged)
|
||||
self.wait_settled()
|
||||
mode = self.wait_settled()
|
||||
try:
|
||||
self.client_back(mode)
|
||||
except Exception as e: # noqa: BLE001 - the judge never breaks the exit path
|
||||
self.log("takeover: WARNING the cloud client could not be judged: %s: %s"
|
||||
% (type(e).__name__, e))
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -11,11 +11,14 @@ from . import kernel # noqa: F401,E402
|
||||
from . import forgectrl # noqa: F401,E402
|
||||
from . import setup # noqa: F401,E402
|
||||
from . import setup_dark # noqa: F401,E402
|
||||
from . import setup_sheet # noqa: F401,E402
|
||||
from . import logs # noqa: F401,E402
|
||||
from . import setup_burns # noqa: F401,E402
|
||||
from . import motion # noqa: F401,E402
|
||||
from . import cooling # noqa: F401,E402
|
||||
from . import laser # noqa: F401,E402
|
||||
from . import camera # noqa: F401,E402
|
||||
from . import update # noqa: F401,E402
|
||||
from . import cloud # noqa: F401,E402
|
||||
# Last: it stands on the setup record's consent and on the cloud suite's dark print.
|
||||
from . import exthost # noqa: F401,E402
|
||||
from . import homeoff # noqa: F401,E402
|
||||
from . import tlsrec # noqa: F401,E402
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""camera.* - the lid camera pipeline through forgectrl."""
|
||||
import json
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
from ..baseline import LID_LAMP_ATTR
|
||||
@@ -57,13 +55,16 @@ def _jpeg_size(data):
|
||||
|
||||
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
|
||||
kind="auto", est_min=2,
|
||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
||||
covers=_CAM_COVERS,
|
||||
steps=["Setup: lid closed."],
|
||||
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
|
||||
"far smaller), a second snapshot with the lid lamp switched off differs from it "
|
||||
"and is smaller (the capture is live, not a frame the pipeline is re-serving, "
|
||||
"and the lamp lights what the camera sees), and the MJPEG stream starts and "
|
||||
"stops.")
|
||||
"stops. A capture marked background=1 yields to a viewer: while the stream is "
|
||||
"open it is refused in words, the operator's own unmarked capture is still "
|
||||
"served, and once the viewer stops the background capture is served again. A "
|
||||
"background value that is neither 0 nor 1 is refused.")
|
||||
def snapshot(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
@@ -116,6 +117,48 @@ def snapshot(ctx):
|
||||
ev["stream_ctype"] = ctype
|
||||
ctx.log("stream: %s, first %d bytes", ctype, len(chunk))
|
||||
ctx.check("multipart" in ctype and b"\xff\xd8" in chunk, "stream is not an MJPEG multipart")
|
||||
|
||||
# A capture nobody is waiting for yields to one somebody is: a
|
||||
# snapshot borrows the mux and stutters a running stream, which is a
|
||||
# price worth paying for an operator and not for a program. The
|
||||
# stream above is closed by now, so one is opened and held here.
|
||||
bad = fc.get("/cam/snapshot", params={"cam": "lid", "background": "maybe"})
|
||||
ev["background_bad_value"] = bad
|
||||
ctx.check(bad[0] == 400, "background=maybe -> %s", bad)
|
||||
|
||||
held = urllib.request.urlopen(
|
||||
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}), timeout=10)
|
||||
try:
|
||||
held.read(8192)
|
||||
watching = None
|
||||
for _ in range(40):
|
||||
st_, body_ = fc.get("/cam/status")
|
||||
if isinstance(body_, dict) and body_.get("clients"):
|
||||
watching = body_
|
||||
break
|
||||
ctx.sleep(0.25)
|
||||
ev["clients_while_watching"] = (watching or {}).get("clients")
|
||||
ctx.check(watching, "no viewer was counted while the stream was held: %s", body_)
|
||||
st_, why = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half", "background": "1"})
|
||||
ev["background_while_watched"] = [st_, why]
|
||||
ctx.log("background capture while a viewer watches -> %s %s", st_, why)
|
||||
ctx.check(st_ == 409, "a background capture did not yield to a viewer: %s %s", st_, why)
|
||||
st_, data_ = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
|
||||
ev["operator_while_watched"] = [st_, len(data_ or b"")]
|
||||
ctx.check(st_ == 200 and data_ and data_[:2] == b"\xff\xd8",
|
||||
"the operator's own capture was refused while watching: %s", st_)
|
||||
finally:
|
||||
held.close()
|
||||
for _ in range(40):
|
||||
st_, body_ = fc.get("/cam/status")
|
||||
if isinstance(body_, dict) and not body_.get("clients"):
|
||||
break
|
||||
ctx.sleep(0.25)
|
||||
st_, data_ = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half", "background": "1"}, raw=True)
|
||||
ev["background_after_watching"] = [st_, len(data_ or b"")]
|
||||
ctx.check(st_ == 200 and data_ and data_[:2] == b"\xff\xd8",
|
||||
"a background capture was still refused after the viewer stopped: %s", st_)
|
||||
|
||||
ctx.sleep(2)
|
||||
st, body = fc.get("/cam/status")
|
||||
ev["cam_status_after"] = body
|
||||
@@ -126,7 +169,7 @@ def snapshot(ctx):
|
||||
|
||||
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera",
|
||||
kind="auto", est_min=1,
|
||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
||||
covers=_CAM_COVERS,
|
||||
description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an "
|
||||
"8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it "
|
||||
"actually returns are that size. A machine that reports 'unknown', or whose frames "
|
||||
@@ -169,7 +212,7 @@ def sensor_profile(ctx):
|
||||
|
||||
@test("camera.h264-stream", title="H.264 live stream", subsystem="camera",
|
||||
kind="auto", est_min=2,
|
||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
||||
covers=_CAM_COVERS,
|
||||
steps=["Setup: lid closed."],
|
||||
description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an "
|
||||
"init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few "
|
||||
@@ -231,13 +274,15 @@ def h264_stream(ctx):
|
||||
|
||||
@test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera",
|
||||
kind="auto", est_min=1,
|
||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
||||
covers=_CAM_COVERS,
|
||||
description="The capture queue flags a frame errored when it is short, torn, or arrived "
|
||||
"after the CSI-2 receiver lost sync; forgectrl drops those rather than "
|
||||
"demosaicing them, and restarts the stream if they persist. A healthy machine "
|
||||
"captures a burst with none of them. A nonzero corrupt count here is a real "
|
||||
"signal - a marginal camera ribbon, a mistimed D-PHY - even though the frames "
|
||||
"themselves never reach a client.")
|
||||
"demosaicing them, restarts the stream if they persist, and withholds the "
|
||||
"stream frames on either side of one. A healthy machine captures a burst of "
|
||||
"snapshots and a few seconds of stream with none of them flagged and none "
|
||||
"withheld, and reports the stream's per-stage timing while a viewer watches. "
|
||||
"A nonzero corrupt count here is a real signal - a marginal camera ribbon, a "
|
||||
"mistimed D-PHY - even though the frames themselves never reach a client.")
|
||||
def frame_health(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
@@ -274,18 +319,63 @@ def frame_health(ctx):
|
||||
ctx.check(corrupt == 0, "%d of %d captured frames came back errored", corrupt, captured)
|
||||
ctx.check(restarts == 0, "the capture stream was restarted %d time(s)", restarts)
|
||||
|
||||
# The stream path: five seconds of MJPEG, so the frame gate judges a
|
||||
# few dozen frames and the timing covers two whole windows. A frame is
|
||||
# withheld only next to one the receiver flagged, so a healthy machine
|
||||
# withholds none, and the per-stage timing is reported while the
|
||||
# viewer watches.
|
||||
import time
|
||||
import urllib.request
|
||||
s0 = health()
|
||||
stream = urllib.request.urlopen(
|
||||
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}),
|
||||
timeout=10)
|
||||
try:
|
||||
got = 0
|
||||
t_end = time.monotonic() + 5.0
|
||||
while time.monotonic() < t_end:
|
||||
got += len(stream.read(65536))
|
||||
st, body = fc.get("/cam/status")
|
||||
timing = body.get("timing") if isinstance(body, dict) else None
|
||||
finally:
|
||||
stream.close()
|
||||
s1 = health()
|
||||
ev["stream_bytes"] = got
|
||||
ev["stream_timing"] = timing
|
||||
ev["stream_health"] = [s0, s1]
|
||||
ctx.log("stream: %d bytes in 5 s, timing %s", got, timing)
|
||||
s_captured = (s1.get("captured") or 0) - (s0.get("captured") or 0)
|
||||
s_corrupt = (s1.get("corrupt") or 0) - (s0.get("corrupt") or 0)
|
||||
s_withheld = (s1.get("withheld") or 0) - (s0.get("withheld") or 0)
|
||||
ev["stream_captured"] = s_captured
|
||||
ev["stream_withheld"] = s_withheld
|
||||
ctx.check(s_captured >= 30, "only %d frames were dequeued in 5 s of stream", s_captured)
|
||||
ctx.check(s_corrupt == 0, "%d stream frames came back errored", s_corrupt)
|
||||
ctx.check("withheld" in s1, "/cam/status health carries no withheld count: %s", s1)
|
||||
ctx.check(s_withheld == 0, "%d stream frames were withheld with none flagged", s_withheld)
|
||||
ctx.check(isinstance(timing, dict) and
|
||||
all(k in timing for k in ("latency_ms", "convert_ms", "copy_ms", "encode_ms")),
|
||||
"/cam/status carries no timing block while a viewer watches: %s", body)
|
||||
latency = (timing or {}).get("latency_ms") or 0
|
||||
ctx.check(0 < latency < 250, "capture to publication took %.1f ms while streaming", latency)
|
||||
|
||||
@test("camera.lid-privacy", title="Cameras capture only with the lid closed", subsystem="camera",
|
||||
|
||||
@test("camera.lid-privacy", title="Cameras see the room only with the lid closed", subsystem="camera",
|
||||
kind="operator", est_min=3,
|
||||
covers=_PRIVACY_COVERS, requires=["forgectrl.panel-serves"], actions=["lid"],
|
||||
covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")],
|
||||
actions=["lid"],
|
||||
steps=["Start with the lid closed; you will be told to open it, then close it again.",
|
||||
"Nothing moves and the laser is not involved."],
|
||||
description="The privacy gate: with the lid open neither camera captures. A running stream "
|
||||
"stops within a frame or so of the lid opening, /cam/status reports capture as "
|
||||
"not allowed, and both the snapshot and the stream are refused with 409 and a "
|
||||
"reason naming the lid. Closing the lid restores all of it. This is what stops "
|
||||
"the machine - and in cloud mode the Glowforge service - from imaging the room "
|
||||
"through an open lid.")
|
||||
description="The privacy gate: with the lid open the lid camera does not capture, and the head "
|
||||
"camera captures only for a local viewer. A running lid-camera stream stops within a "
|
||||
"frame or so of the lid opening, /cam/status reports capture as not allowed, and the "
|
||||
"lid camera's snapshot and stream are refused with 409 and a reason naming the lid, "
|
||||
"to the panel too. The head camera, which looks down at the bed, still answers the "
|
||||
"panel (its token) and the extension host (its client header from this host), while "
|
||||
"the same snapshot and stream asked without either - the cloud client's form - are "
|
||||
"refused for the lid. Closing the lid restores all of it. This is what stops the "
|
||||
"machine - and in cloud mode the Glowforge service - from imaging the room through "
|
||||
"an open lid.")
|
||||
def lid_privacy(ctx):
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -366,9 +456,26 @@ def lid_privacy(ctx):
|
||||
ctx.check(b"lid" in data.lower(), "the refusal should name the lid: %r", data[:120])
|
||||
ctx.check(data[:2] != b"\xff\xd8", "a JPEG was returned with the lid open")
|
||||
|
||||
# The head camera looks at the bed: a local viewer still has it, the cloud client's form does not.
|
||||
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True)
|
||||
ctx.log("lid open: head snapshot -> %s", st)
|
||||
ctx.check(st == 409, "the head camera should be refused too, got %s", st)
|
||||
ctx.log("lid open: the panel's head snapshot -> %s", st)
|
||||
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "the panel's head snapshot with the lid open -> %s", st)
|
||||
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True, auth=False,
|
||||
headers={"X-ForgeFIRM-Client": "extension-host"})
|
||||
ctx.log("lid open: the extension host's head snapshot -> %s", st)
|
||||
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "the extension host's head snapshot with the lid open -> %s", st)
|
||||
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True, auth=False)
|
||||
ev["head_cloud_form_lid_open"] = [st, (data or b"")[:120].decode("utf-8", "replace")]
|
||||
ctx.log("lid open: head snapshot without a token or the header -> %s %s", st, (data or b"")[:120])
|
||||
ctx.check(st == 409 and b"lid" in (data or b"").lower(),
|
||||
"the head camera without a token or the header should be refused for the lid, got %s", st)
|
||||
try:
|
||||
hreq = urllib.request.Request(fc.base + "/cam/stream?cam=head", headers={"Host": fc.host_header()})
|
||||
with urllib.request.urlopen(hreq, timeout=10) as r:
|
||||
ctx.fail("the head stream opened with the lid open, for no local viewer (%s)", r.status)
|
||||
except urllib.error.HTTPError as e:
|
||||
ctx.log("lid open: head stream for no local viewer -> %s", e.code)
|
||||
ctx.check(e.code == 409, "the head stream for no local viewer should be refused with 409, got %s", e.code)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
@@ -387,66 +494,3 @@ def lid_privacy(ctx):
|
||||
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
|
||||
"snapshot after closing the lid -> %s", st)
|
||||
ctx.log("lid closed again: snapshot %d bytes", len(data))
|
||||
|
||||
|
||||
@test("camera.key-read", title="The camera key reads without a login", subsystem="camera",
|
||||
kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/camkey.*"), ("forgectrl", "src/auth.c"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/panel.js"),
|
||||
("forgectrl", "src/ui/index.html"), ("forgectrl", "src/ui/help.js")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="With the read routes closed to the network (panel_open_reads=0), a camera "
|
||||
"status read from the board's LAN address is refused without the key and "
|
||||
"served with it, on HTTP and on HTTPS; a wrong key is refused; the key never "
|
||||
"authorizes a write; a rotation from the panel route stops the old key and "
|
||||
"the new one reads. The setting is put back after.")
|
||||
def key_read(ctx):
|
||||
from .setup import request
|
||||
from .forgectrl import lan_ip
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ip = lan_ip()
|
||||
ctx.check(ip, "cannot determine the board's LAN address")
|
||||
http_base = "http://%s" % ip
|
||||
tls_base = "https://%s" % ip
|
||||
token = {"X-ForgeFIRM-Token": fc.token}
|
||||
|
||||
st, body, _ = request(fc.base, "GET", "/system/camera-key", headers=token)
|
||||
ctx.check(st == 200, "GET /system/camera-key from loopback -> %s", st)
|
||||
key = json.loads(body)["key"]
|
||||
ctx.check(len(key) == 32, "the key is not 32 characters: %r", key)
|
||||
ev["key_len"] = len(key)
|
||||
|
||||
before = fc.settings().get("panel_open_reads", "")
|
||||
st, _ = fc.post("/settings", params={"panel_open_reads": "0"})
|
||||
ctx.check(st == 200, "closing the reads -> %s", st)
|
||||
try:
|
||||
for base in (http_base, tls_base):
|
||||
st, _, _ = request(base, "GET", "/cam/status")
|
||||
ctx.log("GET %s/cam/status (no key) -> %s", base, st)
|
||||
ctx.check(st == 403, "a closed read without the key -> %s, expected 403", st)
|
||||
st, _, _ = request(base, "GET", "/cam/status?key=" + key)
|
||||
ctx.log("GET %s/cam/status?key=... -> %s", base, st)
|
||||
ctx.check(st == 200, "a read with the key -> %s, expected 200", st)
|
||||
st, _, _ = request(base, "GET", "/cam/status", headers={"X-ForgeFIRM-Camera-Key": key})
|
||||
ctx.check(st == 200, "a read with the key header -> %s, expected 200", st)
|
||||
wrong = ("0" if key[0] != "0" else "1") + key[1:]
|
||||
st, _, _ = request(base, "GET", "/cam/status?key=" + wrong)
|
||||
ctx.check(st == 403, "a wrong key -> %s, expected 403", st)
|
||||
# the key is a read credential only
|
||||
st, _, _ = request(tls_base, "POST", "/settings", data={"panel_open_reads": "0"},
|
||||
headers={"X-ForgeFIRM-Camera-Key": key})
|
||||
ctx.log("POST /settings with the camera key -> %s", st)
|
||||
ctx.check(st == 403, "the camera key authorized a write (%s)", st)
|
||||
# rotation
|
||||
st, body, _ = request(fc.base, "POST", "/system/camera-key?rotate=1", headers=token)
|
||||
ctx.check(st == 200, "rotate -> %s", st)
|
||||
new = json.loads(body)["key"]
|
||||
ctx.check(new != key, "rotation kept the same key")
|
||||
st, _, _ = request(http_base, "GET", "/cam/status?key=" + key)
|
||||
ctx.check(st == 403, "the old key still reads after rotation (%s)", st)
|
||||
st, _, _ = request(http_base, "GET", "/cam/status?key=" + new)
|
||||
ctx.check(st == 200, "the new key does not read (%s)", st)
|
||||
ev["rotated"] = True
|
||||
finally:
|
||||
fc.post("/settings", params={"panel_open_reads": before})
|
||||
|
||||
@@ -95,7 +95,6 @@ SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
|
||||
# The service's connect-time hunt, as the client logs its request: a few
|
||||
# seconds after the controller starts, right behind the session.
|
||||
HUNT_REQUEST = "service action request: hunt"
|
||||
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
|
||||
|
||||
|
||||
def log_size(path):
|
||||
@@ -120,39 +119,6 @@ def session_established(lines):
|
||||
return all(any(m in ln for ln in lines) for m in SESSION_MARKS)
|
||||
|
||||
|
||||
def return_head(ctx, feed=2400):
|
||||
"""Jog the head back to where the run found it: cloud mode re-zeroed the
|
||||
kernel counters at the starting position, so the counters now read the
|
||||
displacement (the home corner). Ends on the machine idle."""
|
||||
fc = ctx.forgectrl
|
||||
pos = fc.status().get("pos") or {}
|
||||
x, y = float(pos.get("x", 0.0)), float(pos.get("y", 0.0))
|
||||
ctx.log("head displacement since the switch: X %.3f Y %.3f mm", x, y)
|
||||
ctx.check(abs(x) <= RETURN_MAX_MM and abs(y) <= RETURN_MAX_MM,
|
||||
"displacement %.1f/%.1f mm exceeds %.0f mm - not jogging back", x, y, RETURN_MAX_MM)
|
||||
if abs(x) < 0.05 and abs(y) < 0.05:
|
||||
return
|
||||
with ctx.grbl() as g:
|
||||
st = g.status_report()["state"]
|
||||
if st.startswith("Alarm"):
|
||||
g.command("$X")
|
||||
r = g.command("$J=G91X%.3fY%.3fF%d" % (-x, -y, feed))
|
||||
ctx.check(not any(k.startswith("error") for k in r), "return jog refused: %s", r)
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 120:
|
||||
ctx.checkpoint()
|
||||
st = g.status_report()["state"]
|
||||
if st.startswith("Idle") and time.time() - t0 > 0.5:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
g.command("G90")
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the return jog")
|
||||
pos = fc.status().get("pos") or {}
|
||||
ctx.log("head returned: counters X %.3f Y %.3f mm", float(pos.get("x", 0)), float(pos.get("y", 0)))
|
||||
ctx.check(abs(float(pos.get("x", 0))) < 0.1 and abs(float(pos.get("y", 0))) < 0.1,
|
||||
"head not back at the start after the return jog: %s", pos)
|
||||
|
||||
|
||||
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90, poll=1.0):
|
||||
t0 = time.time()
|
||||
last = None
|
||||
@@ -254,8 +220,8 @@ def gfhome_homing(ctx, ev, g):
|
||||
"Open the lid the moment you are told, with a hand ready on it: the cloud client's "
|
||||
"first hunt begins a few seconds after its controller starts and must find the lid "
|
||||
"open. Leave it open through the connect and the hunt; close it when told. Nothing "
|
||||
"else: the switch back and the $H homing run on their own, and the head ends parked "
|
||||
"at the home corner."],
|
||||
"else: the switch back and the $H homing run on their own, and the head ends where the "
|
||||
"test found it."],
|
||||
description="One round trip with the two service-driven motions on it. POST /mode switches "
|
||||
"to the cloud controller with the lid closed (no controller starts with the "
|
||||
"enclosure open): gfcloud comes up under supervision, authenticates and "
|
||||
@@ -268,11 +234,18 @@ def gfhome_homing(ctx, ev, g):
|
||||
"is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row "
|
||||
"reads unjudged); the camera service survives the switch. The lid closed, the "
|
||||
"service's re-hunt is waited out; switching back brings grblHAL up with the Grbl "
|
||||
"port open and Idle, and the head returns to its start. Then $H with "
|
||||
"port open and Idle, and the head returns to its start by the travel the "
|
||||
"client logged for the service's motions. Then $H with "
|
||||
"homing_mode = gfcloud runs gfhome, the web-service homing session with the "
|
||||
"head-accelerometer motion witness: the controller returns to Idle with "
|
||||
"homed:true and gfhome reports the homing complete with the motion it saw.")
|
||||
"homed:true, gfhome reports the homing complete with the motion it saw, and "
|
||||
"every service motion of the session ran whole. The camera home is then "
|
||||
"dropped and the head goes back to where the test found it, by the travel the "
|
||||
"session's motions logged.")
|
||||
def mode_switch(ctx):
|
||||
# The hand-back helpers, imported here so that no other cloud test's
|
||||
# fingerprint moves.
|
||||
from .homeoff import camera_home_return, cloud_mode_return, judge_whole_motions, session_mark
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
st, m0 = fc.get("/mode")
|
||||
@@ -383,10 +356,9 @@ def mode_switch(ctx):
|
||||
st, cam2 = fc.get("/cam/status")
|
||||
ev["cam_after"] = cam2
|
||||
ctx.check(st == 200, "camera status lost after the switch back")
|
||||
# cloud mode's connect cleared the kernel counters at the starting position
|
||||
# and its hunt homed the head: bring it back
|
||||
ctx.counters_rezeroed()
|
||||
return_head(ctx)
|
||||
# The service moved the head in cloud mode, and the controller's start
|
||||
# zeroed the counters where it left it: the client's record brings it back.
|
||||
cloud_mode_return(ctx, ev, log_offset)
|
||||
# cloud mode sets its own lid-lamp level (LLvl) and leaves it: hand back the level found
|
||||
lamp1 = hw.sysfs_read("pic/lid_led")
|
||||
ev["lid_lamp"] = {"before": lamp0, "after_cloud": lamp1}
|
||||
@@ -400,9 +372,11 @@ def mode_switch(ctx):
|
||||
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
|
||||
ctx.log("homing_mode=gfcloud for the homing -> %s %s", st, body if isinstance(body, str) else "")
|
||||
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
|
||||
session_at = session_mark()
|
||||
try:
|
||||
with ctx.grbl() as g:
|
||||
gfhome_homing(ctx, ev, g)
|
||||
judge_whole_motions(ctx, ev, session_at)
|
||||
finally:
|
||||
if ev["homing_mode"] != "gfcloud":
|
||||
# Back to exactly what the machine had: unset is the empty
|
||||
@@ -413,6 +387,7 @@ def mode_switch(ctx):
|
||||
if not ev["homing_mode"]
|
||||
else fc.post("/settings", data={"homing_mode": ev["homing_mode"]}))
|
||||
ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st)
|
||||
camera_home_return(ctx, ev, session_at)
|
||||
ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> "
|
||||
"grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
|
||||
|
||||
@@ -527,9 +502,18 @@ NOHUNT_MARK = "NO-HUNT:"
|
||||
NOHUNT_MARKER = "/run/gfcloud-nohunt"
|
||||
HUNT_DONE = "hunt ["
|
||||
WS_MARKS = ("RX-EVENT: ready", "RX-EVENT: closed", "RECONNECTING", "CLOSING", OFFLINE_MARK)
|
||||
ACTIVITY_MARKS = ("start motion", "start return home", "starting run", "starting z homing cycle")
|
||||
# The service at work: every line of a service action, the requests, the image
|
||||
# uploads and the ends included. Between a lid image and its next move the
|
||||
# service is thinking and the log is silent, so the ends count: the quiet is
|
||||
# measured from the last line of any action.
|
||||
ACTIVITY_MARKS = ("service action request:", "img_upload COMPLETE", "finished with event",
|
||||
"start motion", "end motion", "start return home", "return home complete",
|
||||
"starting run", "finished run", "starting z homing cycle")
|
||||
LOG_TAIL_BYTES = 4 << 20
|
||||
QUIET_S = 8 # the re-hunt's motions are ~4 s apart (a lid image between them)
|
||||
# The service's think time after an image, on the bench reference: over 1901
|
||||
# motion, lid-image and hunt requests, half came within 1.6 s of the line before,
|
||||
# 99 percent within 12.8 s, and two after more than 30 s (32.8 and 62.2 s).
|
||||
QUIET_S = 30
|
||||
QUIET_TIMEOUT_S = 180
|
||||
HUNT_TIMEOUT_S = 180
|
||||
|
||||
@@ -586,6 +570,42 @@ def wait_log(ctx, offset, needles, timeout, poll=0.5):
|
||||
return found
|
||||
|
||||
|
||||
# What the client logs when it turns a print away before the button wait
|
||||
# (machine._safe_to_move): the lid or the interlock, a machine that is not
|
||||
# idle, the coolant above the start ceiling of its own configuration
|
||||
# (THERMAL.max_start_temp), a coolant sensor that reads nothing.
|
||||
REFUSED_BEFORE_THE_BUTTON = ("unsafe to move", "machine is not idle", "machine temp is too high",
|
||||
"coolant sensor reads invalid")
|
||||
|
||||
|
||||
def wait_button_wait(ctx, offset, timeout, also=()):
|
||||
"""Wait for the client's button wait (and the `also` needles). A print
|
||||
the client turns away never gets there: it logs why and finishes at
|
||||
once, so the wait ends on that finish line too and the failure carries
|
||||
the client's own reason, not two minutes of silence. Returns wait_log's
|
||||
result."""
|
||||
needles = list(also) + ["waiting for button"]
|
||||
found = {n: None for n in needles}
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
ctx.checkpoint()
|
||||
lines = log_lines_since(GFCLOUD_LOG, offset)
|
||||
for ln in lines:
|
||||
for n in needles:
|
||||
if found[n] is None and n in ln:
|
||||
found[n] = ln
|
||||
if all(found.values()):
|
||||
break
|
||||
if found["waiting for button"] is None and action_finish_index(lines, "print") is not None:
|
||||
why = [message(ln) for ln in lines if any(r in ln for r in REFUSED_BEFORE_THE_BUTTON)]
|
||||
fin = lines[action_finish_index(lines, "print")]
|
||||
ctx.fail("the client turned the print away before the button wait: %s (%s)",
|
||||
"; ".join(why[-3:]) or "it logged no reason", message(fin))
|
||||
time.sleep(0.5)
|
||||
ctx.check(found["waiting for button"], "the print never reached the button wait")
|
||||
return found
|
||||
|
||||
|
||||
def log_has(offset, needle):
|
||||
"""True when the gfcloud log carries needle since offset (one read;
|
||||
the condition an `act` waits on)."""
|
||||
@@ -718,9 +738,9 @@ def client_offline(pid):
|
||||
|
||||
|
||||
def wait_quiet(ctx, offset, quiet_s=None, timeout=None):
|
||||
"""The service's moves are over: the machine idle and no new service
|
||||
activity in the log (a motion, a park, a run, a lens homing) for
|
||||
quiet_s (default QUIET_S; timeout QUIET_TIMEOUT_S). False on timeout."""
|
||||
"""The service's moves are over: the machine idle and no new line of any
|
||||
service action in the log (ACTIVITY_MARKS) for quiet_s (default QUIET_S;
|
||||
timeout QUIET_TIMEOUT_S). False on timeout."""
|
||||
quiet_s = QUIET_S if quiet_s is None else quiet_s
|
||||
timeout = QUIET_TIMEOUT_S if timeout is None else timeout
|
||||
fc = ctx.forgectrl
|
||||
@@ -1043,9 +1063,7 @@ def offline_start_print(ctx, off, action_id, path, offset, settings=None):
|
||||
client loads the job, lights the button and waits; the operator's
|
||||
press arms it and the run starts. Returns the 'starting run' line."""
|
||||
off.print_ready(action_id, path, settings)
|
||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||
ctx.check(got["waiting for button"], "the offline print never reached the button wait (refused, "
|
||||
"or the job did not load)")
|
||||
wait_button_wait(ctx, offset, 120)
|
||||
ctx.act("button", "press", text="The button is lit white: the press arms the print and the "
|
||||
"head starts to move. Nothing fires: the job commands no laser.",
|
||||
until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=180)
|
||||
@@ -1244,129 +1262,11 @@ def lid_interlock_abort_body(ctx, ev, off, job, offset):
|
||||
ev.get("edge_to_stop_ms"))
|
||||
|
||||
|
||||
@test("cloud.lid-during-button-wait", title="Lid open at the cloud button prompt cancels the print",
|
||||
subsystem="cloud", kind="operator", est_min=8,
|
||||
covers=_MACHINE_RUN, requires=[], actions=["lid"],
|
||||
steps=[OFFLINE_STEP, LID_STEP,
|
||||
"The job is longer than the ring holds, so the ring is filled before the button "
|
||||
"lights; that takes a minute. When the button lights white, do NOT press it - open "
|
||||
"the lid, and close it when told. Nothing moves and nothing fires."],
|
||||
description="A cloud print waiting for the button is canceled by the lid: the wait ends "
|
||||
"with the lid named as the reason, the laser latch relocks, the armed window "
|
||||
"closes, no run starts, and the job ends ':cancelled'. The job is longer than "
|
||||
"the ring, so its feeder is alive through the wait with the rest of the print "
|
||||
"in hand: the cancel has to stop that feeder before the park clears the ring, "
|
||||
"and the ring has to stay empty afterward. A feeder left alive would refill "
|
||||
"it and the park, which ignores the lid, would play the print's path.")
|
||||
def lid_during_button_wait(ctx):
|
||||
ev = ctx.evidence
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "wait.puls", seconds=3500)
|
||||
off = Offline().__enter__()
|
||||
try:
|
||||
lid_during_button_wait_body(ctx, ev, off, job, offset)
|
||||
finally:
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
|
||||
def lid_during_button_wait_body(ctx, ev, off, job, offset):
|
||||
off.print_ready(9003, job)
|
||||
got = wait_log(ctx, offset, ["job is longer than the ring", "waiting for button"], 180)
|
||||
ctx.check(got["job is longer than the ring"],
|
||||
"the job fit the ring: this test needs a job the ring cannot hold")
|
||||
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
||||
ctx.act("lid", "open", text="The button is lit: do NOT press it.", timeout=120)
|
||||
relock = "button wait lid opened - relocking the laser"
|
||||
got = wait_log(ctx, offset, [relock], 60)
|
||||
fin = wait_action_finished(ctx, offset, "print", 60)
|
||||
ev["log"] = {relock: bool(got[relock]), "print finished": message(fin)}
|
||||
ctx.check(got[relock], "the lid did not end the button wait")
|
||||
ctx.check(fin and CANCELLED in fin, "the print did not end ':cancelled': %s",
|
||||
message(fin) or "no finish line")
|
||||
# No run may start between the button wait and the cancel: the print
|
||||
# itself, or a park (the head never moved, there is nothing to park).
|
||||
# The service's moves BEFORE the print are legitimate runs and are
|
||||
# outside this window.
|
||||
lines = log_lines_since(GFCLOUD_LOG, offset)
|
||||
wait_i = next((i for i, ln in enumerate(lines) if "waiting for button" in ln), None)
|
||||
end_i = action_finish_index(lines, "print")
|
||||
if end_i is None or wait_i is None or end_i < wait_i:
|
||||
end_i = len(lines)
|
||||
ran = ([ln for ln in lines[wait_i:end_i] if "starting run" in ln]
|
||||
if wait_i is not None else [])
|
||||
ev["runs_started_after_wait"] = len(ran)
|
||||
ctx.check(not ran, "a run started after the lid-open cancel (%d)", len(ran))
|
||||
st, cs = ctx.forgectrl.get("/cool/status")
|
||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
||||
ev["latch_locked"] = latch_locked()
|
||||
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
|
||||
ctx.check(ev["latch_locked"], "kernel latch not locked after the cancel")
|
||||
ev["button_dark"] = hw.button_lit()
|
||||
ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
|
||||
# The park cleared the ring and nothing refilled it: the feeder is gone,
|
||||
# the device is out of live-feed mode, and the program total stands at
|
||||
# zero and stays there. A feeder left alive shows here as a total that
|
||||
# comes back within its retry period.
|
||||
total = read_program_total()
|
||||
ctx.sleep(2)
|
||||
later = read_program_total()
|
||||
streaming = hw.sysfs_int("cnc/streaming", 0)
|
||||
ev["program_total_after"] = {"first": total, "later": later}
|
||||
ev["streaming_after"] = streaming
|
||||
ctx.check(total == 0 and later == 0,
|
||||
"the ring is not empty after the cancel (program total %s, then %s): the feeder "
|
||||
"outlived the job", total, later)
|
||||
ctx.check(streaming == 0, "cnc/streaming is %s after the cancel", streaming)
|
||||
ctx.act("lid", "close")
|
||||
settle_cloud(ctx, offset)
|
||||
ev["events"] = offline_events(off)
|
||||
ctx.log("PASS: lid open at the button prompt canceled the print; latch locked, armed=false, "
|
||||
"button dark, ring empty with the feed stopped")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the cooling verdict
|
||||
|
||||
HOLD_KEYS = ("cool_temp_min", "cool_temp_start")
|
||||
REFUSE_KEYS = HOLD_KEYS + ("cloud_hold_max_s",)
|
||||
HOLD_REFUSE_S = 60 # the shortest bound the client takes
|
||||
HOLD_REFUSE_LINE = "cooling hold held 60 s - relocking the laser"
|
||||
WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP"
|
||||
|
||||
|
||||
@test("cloud.dark-print", title="A dark cloud print: the latch locked until the run, unlocked "
|
||||
"for it, locked after it",
|
||||
subsystem="cloud", kind="operator", est_min=3,
|
||||
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
||||
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
|
||||
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
||||
"when it lights: the print waits for the engine, runs, and finishes."],
|
||||
description="The cooling-engine contract in cloud mode at its ordinary end, on the engine "
|
||||
"itself: the print arms on the press, waits for the engine's acknowledgment (the "
|
||||
"fans at their floors), and runs. The laser latch is locked at the button and "
|
||||
"through the wait, unlocks only for the run (immediately before it starts), and "
|
||||
"is locked again when the job ends; the print completes. A hold the engine never "
|
||||
"clears is cloud.verdict-refuse; the engine's own warm-up release is "
|
||||
"cooling.floor-and-warm-up.")
|
||||
def dark_print(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "dark.puls", seconds=30)
|
||||
off = Offline().__enter__()
|
||||
try:
|
||||
dark_print_body(ctx, ev, off, job, offset, fc)
|
||||
finally:
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
# ---------------------------------------------------------------- the dark print (exthost.armed-freeze)
|
||||
|
||||
def dark_print_body(ctx, ev, off, job, offset, fc):
|
||||
off.print_ready(9005, job)
|
||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
||||
wait_button_wait(ctx, offset, 120)
|
||||
ev["latch_locked_at_button"] = latch_locked()
|
||||
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
|
||||
ctx.act("button", "press", text="The button is lit: press it. The print runs dark and finishes.",
|
||||
@@ -1388,92 +1288,6 @@ def dark_print_body(ctx, ev, off, job, offset, fc):
|
||||
ctx.log("PASS: locked at the button, unlocked for the run, locked after it; the print completed")
|
||||
|
||||
|
||||
@test("cloud.verdict-refuse", title="A cloud print held by the engine past the bound is canceled with "
|
||||
"the latch never unlocked",
|
||||
subsystem="cloud", kind="operator", est_min=4,
|
||||
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
||||
("forgectrl", "src/cool.*")],
|
||||
requires=["cloud.dark-print"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate well above "
|
||||
"the coolant and the hold bound to its minimum, and restores both. Press the button "
|
||||
"when it lights: nothing runs; a minute later the print cancels by itself."],
|
||||
description="The other end of the cooling-engine contract before a run: a verdict that "
|
||||
"refuses fire and never clears. The start gate far above the coolant keeps the "
|
||||
"armed session under the warm-up for longer than the hold bound (cloud_hold_max_s "
|
||||
"at its minimum, 60 s). The client waits with the laser latch locked the whole "
|
||||
"time - it is never unlocked before the verdict passes, so there is nothing to "
|
||||
"relock - then cancels the print at the bound: no run, the armed window closed, "
|
||||
"the print ':cancelled'. Dark by construction.")
|
||||
def verdict_refuse(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
before = fc.settings()
|
||||
orig = {k: before.get(k, "") for k in REFUSE_KEYS}
|
||||
ev["orig"] = orig
|
||||
up = (fc.status().get("coolant") or {}).get("up_c")
|
||||
ctx.check(up is not None and 10.0 <= up <= 30.0, "coolant outside the test's range (up_c %s)", up)
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "refuse.puls", seconds=30)
|
||||
off = Offline().__enter__()
|
||||
try:
|
||||
verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig)
|
||||
finally:
|
||||
st, body = fc.post("/settings", params=orig)
|
||||
ctx.log("restore: POST /settings %s -> %s", orig, st)
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
|
||||
def verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig):
|
||||
gate = round(up + 8.0, 1) # eight degrees: the heater cannot get there in a minute
|
||||
st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
|
||||
"cool_temp_min": orig["cool_temp_min"],
|
||||
"cloud_hold_max_s": str(HOLD_REFUSE_S)})
|
||||
ctx.check(st == 200, "POST /settings cool_temp_start=%s cloud_hold_max_s=%s -> %s %s",
|
||||
gate, HOLD_REFUSE_S, st, body)
|
||||
ev["gate"] = gate
|
||||
off.print_ready(9006, job)
|
||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
||||
ev["latch_locked_at_button"] = latch_locked()
|
||||
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
|
||||
ctx.act("button", "press", text="The button is lit: press it. Nothing runs; the print cancels "
|
||||
"itself a minute later.",
|
||||
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
|
||||
got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30)
|
||||
ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up")
|
||||
t0 = time.time()
|
||||
samples = []
|
||||
ctx.notice("The engine holds the print; the client gives up at the bound. Do nothing.")
|
||||
try:
|
||||
while time.time() - t0 < HOLD_REFUSE_S + 30:
|
||||
ctx.checkpoint()
|
||||
samples.append(latch_locked())
|
||||
if log_has(offset, HOLD_REFUSE_LINE):
|
||||
break
|
||||
ctx.sleep(2)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
ev["hold_s"] = round(time.time() - t0)
|
||||
ev["latch_samples"] = {"n": len(samples), "unlocked": samples.count(False)}
|
||||
ctx.check(log_has(offset, HOLD_REFUSE_LINE), "the client did not cancel at the hold bound (%d s)",
|
||||
ev["hold_s"])
|
||||
ctx.check(all(samples), "the latch unlocked during the hold (%d of %d samples)",
|
||||
samples.count(False), len(samples))
|
||||
ctx.check(wait_print_running(ctx, offset, 1) is None, "the run started under the refused verdict")
|
||||
fin = wait_action_finished(ctx, offset, "print", 120)
|
||||
ev["print finished"] = message(fin)
|
||||
ctx.check(fin and CANCELLED in fin, "the held print did not end ':cancelled': %s",
|
||||
message(fin) or "no finish line")
|
||||
st, cs = fc.get("/cool/status")
|
||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
||||
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
|
||||
ctx.check(latch_locked(), "the latch is unlocked after the cancel")
|
||||
ev["events"] = offline_events(off)
|
||||
ctx.log("PASS: held %s s with the latch locked throughout, then canceled at the bound", ev["hold_s"])
|
||||
|
||||
|
||||
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
|
||||
subsystem="cloud", kind="live", est_min=8,
|
||||
covers=_CLOUD_ALL,
|
||||
@@ -1707,68 +1521,3 @@ def oversize_stream_body(ctx, ev, off, job, offset, before):
|
||||
"stopped it, parked, relocked and reported ':cancelled'")
|
||||
|
||||
|
||||
@test("cloud.paused-lid-cancel", title="A paused cloud print is canceled by the lid",
|
||||
subsystem="cloud", kind="live", est_min=6,
|
||||
covers=_MACHINE_RUN, requires=["cloud.pause-resume", "cloud.lid-interlock-abort"],
|
||||
actions=["button", "lid"],
|
||||
steps=[OFFLINE_STEP, ARM_STEP, LID_STEP,
|
||||
"The head needs 40 mm of free +X and +Y travel.",
|
||||
"Press the button to start; when asked, press it again (pause), then open the lid and "
|
||||
"leave it open until the head is back; close it when told."],
|
||||
description="A job paused on the button is canceled by the lid, from the state the factory "
|
||||
"ends it in - there is no resume past a lid open. The same tail as every abort: "
|
||||
"the motion stops, the head parks back at the job start, the latch relocks and "
|
||||
"the armed window closes, the button goes dark, and the print ends ':cancelled'. "
|
||||
"(The app's own cancel of a running print is exercised where a print has to be "
|
||||
"ended that way: cloud.oversize-stream.)")
|
||||
def paused_lid_cancel(ctx):
|
||||
ev = ctx.evidence
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "paused.puls", seconds=60)
|
||||
off = Offline().__enter__()
|
||||
try:
|
||||
paused_lid_cancel_body(ctx, ev, off, job, offset)
|
||||
finally:
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
|
||||
def paused_lid_cancel_body(ctx, ev, off, job, offset):
|
||||
# -- paused on the button, then the lid ---------------------------------
|
||||
offline_start_print(ctx, off, 9005, job, offset)
|
||||
ctx.act("button", "press", text="The head is moving: the press pauses the print.",
|
||||
until=lambda: log_has(offset, PAUSE_LINES[0]), timeout=PRESS_TIMEOUT_S, fail=False)
|
||||
got = wait_log(ctx, offset, list(PAUSE_LINES), 10)
|
||||
ev["paused"] = {k: bool(v) for k, v in got.items()}
|
||||
ctx.check(got["button pressed mid-run; pausing"], "the press did not pause the print")
|
||||
ctx.check(got["paused at"], "the pause did not settle (no 'paused at')")
|
||||
ctx.act("lid", "open", text="The print is paused: leave the lid open until the head has returned "
|
||||
"to the corner.", timeout=60)
|
||||
lid_stop = "lid opened mid-run; stopping motion"
|
||||
got = wait_log(ctx, offset, [lid_stop, "start return home", "return home complete"], 120)
|
||||
fin1 = wait_action_finished(ctx, offset, "print", 60)
|
||||
ev["lid_from_pause"] = {k: message(v) for k, v in got.items()}
|
||||
ev["lid_from_pause"]["print finished"] = message(fin1)
|
||||
for k, v in ev["lid_from_pause"].items():
|
||||
ctx.log(" [print] %s: %s", k, "seen" if v else "MISSING")
|
||||
ctx.check(got[lid_stop], "the lid did not end the paused print")
|
||||
ctx.check(got["return home complete"], "the paused print did not park to completion")
|
||||
ctx.check(fin1 and CANCELLED in fin1, "the print did not end ':cancelled': %s",
|
||||
message(fin1) or "no finish line")
|
||||
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the park")
|
||||
kpos = read_position()
|
||||
ev["counters_after"] = kpos
|
||||
ctx.check(kpos is not None and abs(kpos[0]) <= 3 and abs(kpos[1]) <= 3,
|
||||
"the head did not come back to the job start (kernel counters %s)", kpos)
|
||||
st, cs = ctx.forgectrl.get("/cool/status")
|
||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
||||
ev["latch_locked_after"] = latch_locked()
|
||||
ctx.check(not ev["armed_after"], "armed window still open after the paused print was canceled")
|
||||
ctx.check(ev["latch_locked_after"],
|
||||
"kernel latch not locked after the paused print was canceled")
|
||||
ev["button_dark"] = hw.button_lit()
|
||||
ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
|
||||
ctx.act("lid", "close")
|
||||
settle_cloud(ctx, offset)
|
||||
ev["events"] = offline_events(off)
|
||||
ctx.log("PASS: a paused print canceled by the lid stopped, parked, relocked and reported ':cancelled'")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,985 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""exthost.* - what holds an extension package: the image's sandbox platform."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .forgectrl import lan_ip
|
||||
from .image import kernel_config
|
||||
from .cloud import OFFLINE_STEP, Offline, dark_print_body, enter_offline, latch_locked, offline_cleanup, offline_job
|
||||
from .setup import SAFETY_PHRASE, read_file, record_path, request, write_file
|
||||
|
||||
CG = "/sys/fs/cgroup"
|
||||
POOL_GROUP = CG + "/ffx"
|
||||
PROBE_GROUP = POOL_GROUP + "/forgetest-probe"
|
||||
POOL_FIRST, POOL_SIZE = 800, 32
|
||||
CONTROLLERS = ("cpu", "memory", "pids")
|
||||
NFT = "/usr/sbin/nft"
|
||||
|
||||
# The probe: one short program, started as root. It puts itself into the
|
||||
# group named in argv, becomes the uid named there (0 stays root), gives up
|
||||
# new privileges, and then does one thing and prints one JSON line.
|
||||
PROBE = r'''
|
||||
import ctypes, errno, json, os, platform, socket, sys, time
|
||||
group, uid, mode, arg = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4]
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
if group != "-":
|
||||
with open(group + "/cgroup.procs", "w") as f:
|
||||
f.write(str(os.getpid()))
|
||||
if uid:
|
||||
os.setgroups([])
|
||||
os.setgid(uid)
|
||||
os.setuid(uid)
|
||||
libc.prctl(38, 1, 0, 0, 0) # PR_SET_NO_NEW_PRIVS
|
||||
|
||||
|
||||
def say(**kw):
|
||||
print(json.dumps(kw), flush=True)
|
||||
|
||||
|
||||
def word(e):
|
||||
return {errno.ECONNREFUSED: "refused", errno.EPERM: "eperm", errno.EACCES: "eacces"}.get(
|
||||
e.errno, errno.errorcode.get(e.errno, str(e.errno)))
|
||||
|
||||
|
||||
def reach(family, addr, port, udp):
|
||||
s = socket.socket(socket.AF_INET6 if family == 6 else socket.AF_INET,
|
||||
socket.SOCK_DGRAM if udp else socket.SOCK_STREAM)
|
||||
s.settimeout(4.0)
|
||||
t0 = time.time()
|
||||
try:
|
||||
if udp:
|
||||
s.sendto(b"x", (addr, port))
|
||||
else:
|
||||
s.connect((addr, port))
|
||||
out = "ok"
|
||||
except socket.timeout:
|
||||
out = "timeout"
|
||||
except OSError as e:
|
||||
out = word(e)
|
||||
s.close()
|
||||
return [out, round(time.time() - t0, 2)]
|
||||
|
||||
|
||||
if mode == "spin":
|
||||
say(started=True)
|
||||
while True:
|
||||
pass
|
||||
elif mode == "forks":
|
||||
kids = []
|
||||
err = ""
|
||||
for _ in range(int(arg)):
|
||||
try:
|
||||
pid = os.fork()
|
||||
except OSError as e:
|
||||
err = word(e)
|
||||
break
|
||||
if pid == 0:
|
||||
time.sleep(30)
|
||||
os._exit(0)
|
||||
kids.append(pid)
|
||||
for pid in kids:
|
||||
os.kill(pid, 9)
|
||||
os.waitpid(pid, 0)
|
||||
say(forked=len(kids), stopped_by=err)
|
||||
elif mode == "eat":
|
||||
say(started=True)
|
||||
held = []
|
||||
for _ in range(int(arg)):
|
||||
held.append(bytearray(os.urandom(4096)) * 256) # 1 MiB, every page written
|
||||
say(survived=True)
|
||||
elif mode == "net":
|
||||
say(results=[[t, reach(*t)] for t in json.loads(arg)])
|
||||
elif mode == "unix":
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(4.0)
|
||||
try:
|
||||
s.connect(arg)
|
||||
say(result="connected")
|
||||
except OSError as e:
|
||||
say(result=word(e))
|
||||
s.close()
|
||||
elif mode == "landlock":
|
||||
target = json.loads(arg)
|
||||
before = {"etc": None, "usr": None, "tcp": reach(*target)[0]}
|
||||
for key, path in (("etc", "/etc/hostname"), ("usr", sys.executable)):
|
||||
try:
|
||||
open(path, "rb").close()
|
||||
before[key] = "ok"
|
||||
except OSError as e:
|
||||
before[key] = word(e)
|
||||
abi = libc.syscall(444, None, 0, 1) # LANDLOCK_CREATE_RULESET_VERSION
|
||||
after = {}
|
||||
if abi >= 1:
|
||||
# write, read file, read dir; and from ABI 4 on, connect TCP (an older kernel takes the first field alone)
|
||||
attr = (ctypes.c_uint64 * 2)((1 << 1) | (1 << 2) | (1 << 3), 1 << 1)
|
||||
ruleset = libc.syscall(444, ctypes.byref(attr), 16 if abi >= 4 else 8, 0)
|
||||
usr = os.open("/usr", os.O_PATH | os.O_CLOEXEC)
|
||||
|
||||
class PathBeneath(ctypes.Structure):
|
||||
_pack_ = 1
|
||||
_fields_ = [("allowed_access", ctypes.c_uint64), ("parent_fd", ctypes.c_int32)]
|
||||
rule = PathBeneath((1 << 2) | (1 << 3), usr)
|
||||
added = libc.syscall(445, ruleset, 1, ctypes.byref(rule), 0) # LANDLOCK_RULE_PATH_BENEATH
|
||||
applied = libc.syscall(446, ruleset, 0)
|
||||
after = {"ruleset": ruleset >= 0, "added": added, "applied": applied, "tcp": reach(*target)[0]}
|
||||
for key, path in (("etc", "/etc/hostname"), ("usr", sys.executable)):
|
||||
try:
|
||||
open(path, "rb").close()
|
||||
after[key] = "ok"
|
||||
except OSError as e:
|
||||
after[key] = word(e)
|
||||
say(abi=abi, before=before, after=after)
|
||||
elif mode == "seccomp":
|
||||
arch, nr = {"armv7l": (0x40000028, 122), "aarch64": (0xC00000B7, 160),
|
||||
"x86_64": (0xC000003E, 63)}[platform.machine()] # uname
|
||||
|
||||
|
||||
class Filter(ctypes.Structure):
|
||||
_fields_ = [("code", ctypes.c_uint16), ("jt", ctypes.c_uint8), ("jf", ctypes.c_uint8),
|
||||
("k", ctypes.c_uint32)]
|
||||
|
||||
|
||||
class Prog(ctypes.Structure):
|
||||
_fields_ = [("len", ctypes.c_ushort), ("filter", ctypes.POINTER(Filter))]
|
||||
before = os.uname().sysname
|
||||
ops = (Filter * 7)(Filter(0x20, 0, 0, 4), # load arch
|
||||
Filter(0x15, 1, 0, arch), # ours: go on
|
||||
Filter(0x06, 0, 0, 0x80000000), # another ABI: kill the process
|
||||
Filter(0x20, 0, 0, 0), # load the syscall number
|
||||
Filter(0x15, 0, 1, nr), # uname?
|
||||
Filter(0x06, 0, 0, 0x00050000 | errno.EPERM),
|
||||
Filter(0x06, 0, 0, 0x7FFF0000)) # everything else: allow
|
||||
prog = Prog(7, ops)
|
||||
rc = libc.prctl(22, 2, ctypes.byref(prog), 0, 0) # PR_SET_SECCOMP, SECCOMP_MODE_FILTER
|
||||
try:
|
||||
os.uname()
|
||||
after = "ok"
|
||||
except OSError as e:
|
||||
after = word(e)
|
||||
mode_now = [l.split()[1] for l in open("/proc/self/status") if l.startswith("Seccomp:")]
|
||||
say(installed=rc, before=before, after=after, status=mode_now)
|
||||
'''
|
||||
|
||||
|
||||
def _read(path, default=""):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return default
|
||||
|
||||
|
||||
def _write(path, text):
|
||||
with open(path, "w") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
def _flat(path):
|
||||
"""A cgroup flat-keyed file as {key: int}."""
|
||||
out = {}
|
||||
for line in _read(path).splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 2 and parts[1].isdigit():
|
||||
out[parts[0]] = int(parts[1])
|
||||
return out
|
||||
|
||||
|
||||
def _probe(group, uid, mode, arg="", wait=30):
|
||||
"""Runs the probe to its end; (returncode, its JSON lines)."""
|
||||
p = subprocess.run([sys.executable, "-c", PROBE, group, str(uid), mode, arg],
|
||||
capture_output=True, text=True, timeout=wait)
|
||||
lines = []
|
||||
for line in p.stdout.splitlines():
|
||||
try:
|
||||
lines.append(json.loads(line))
|
||||
except ValueError:
|
||||
pass
|
||||
return p.returncode, lines, p.stderr.strip()[-300:]
|
||||
|
||||
|
||||
def _pids_of(comm):
|
||||
out = []
|
||||
for d in os.listdir("/proc"):
|
||||
if d.isdigit() and _read("/proc/%s/comm" % d).strip() == comm:
|
||||
out.append(int(d))
|
||||
return out
|
||||
|
||||
|
||||
def _pool_counters():
|
||||
"""The packet counts of chain refuse's two rules: [tcp reset, drop]."""
|
||||
p = subprocess.run([NFT, "-j", "list", "chain", "inet", "ffx", "refuse"], capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
return None
|
||||
return [e["counter"]["packets"] for c in json.loads(p.stdout)["nftables"] if "rule" in c
|
||||
for e in c["rule"]["expr"] if "counter" in e]
|
||||
|
||||
|
||||
@test("exthost.platform", title="The extension sandbox platform holds a process",
|
||||
subsystem="exthost", kind="auto", est_min=2,
|
||||
covers=[("linux-fslc", "**")],
|
||||
description="What the image holds ready before any extension package exists, proven on a "
|
||||
"probe process rather than read off a config. The kernel has the cpu, memory, "
|
||||
"and pids controllers, seccomp filters, and landlock, and has no realtime "
|
||||
"group scheduler (the pulse feeder is SCHED_FIFO and must not need a group's "
|
||||
"leave to run). cgroup v2 is mounted with the three controllers handed down "
|
||||
"to /sys/fs/cgroup/ffx, which is idle-class, and forgectrl and the controller "
|
||||
"sit in the root group. In a probe group under it, as the last pool uid: a "
|
||||
"spinning process is held to its cpu.max, stops when the group is frozen and "
|
||||
"runs again when thawed; a fork loop stops at pids.max; and a process that "
|
||||
"takes more than memory.max is killed by the group's own OOM while forgectrl "
|
||||
"keeps its pid. The account pool is ffx0 to ffx31, uid and gid 800 to 831, no "
|
||||
"shell, no home, locked, in the account files as forgefirm-users rendered "
|
||||
"them. The deny rules are in the kernel: a pool uid's TCP connect to "
|
||||
"forgectrl on loopback (both ports, IPv4 and IPv6), to the machine's LAN "
|
||||
"address, and to the Grbl port is refused at once, its UDP send gets EPERM, "
|
||||
"and the rules' counters saw every attempt, while root reaches the same "
|
||||
"listeners. A process that restricts itself with landlock loses /etc and TCP "
|
||||
"connects and keeps /usr; one that installs a seccomp filter gets EPERM from "
|
||||
"the filtered call. The layer content (the recipe, the rules, the distro "
|
||||
"option) is in the platform identity of every fingerprint.")
|
||||
def platform(ctx):
|
||||
ev = ctx.evidence
|
||||
|
||||
# 1. the kernel
|
||||
cfg = kernel_config()
|
||||
ctx.check(cfg is not None, "kernel config unavailable (/proc/config.gz, /boot/config-*)")
|
||||
want = {"CONFIG_CGROUPS": "y", "CONFIG_CGROUP_SCHED": "y", "CONFIG_FAIR_GROUP_SCHED": "y",
|
||||
"CONFIG_CFS_BANDWIDTH": "y", "CONFIG_MEMCG": "y", "CONFIG_CGROUP_PIDS": "y",
|
||||
"CONFIG_SECCOMP_FILTER": "y", "CONFIG_SECURITY_LANDLOCK": "y", "CONFIG_NF_TABLES": "y",
|
||||
"CONFIG_NF_TABLES_INET": "y", "CONFIG_NFT_REJECT_INET": "y", "CONFIG_NFT_LIMIT": "y",
|
||||
"CONFIG_RT_GROUP_SCHED": "n", "CONFIG_NF_CONNTRACK": "n"}
|
||||
got = {k: cfg.get(k, "n") for k in want}
|
||||
ev["kernel"] = got
|
||||
wrong = {k: v for k, v in got.items() if v != want[k]}
|
||||
ctx.check(not wrong, "kernel options: %s (wanted %s)", wrong, {k: want[k] for k in wrong})
|
||||
ctx.check("landlock" in cfg.get("CONFIG_LSM", ""), "landlock is not in CONFIG_LSM: %s", cfg.get("CONFIG_LSM"))
|
||||
|
||||
# 2. the cgroup tree
|
||||
mounts = [l.split() for l in _read("/proc/mounts").splitlines()]
|
||||
ctx.check(any(m[1] == CG and m[2] == "cgroup2" for m in mounts if len(m) > 2), "cgroup2 is not mounted at %s", CG)
|
||||
tree = {"controllers": _read(CG + "/cgroup.controllers").split(),
|
||||
"root_subtree": _read(CG + "/cgroup.subtree_control").split(),
|
||||
"ffx_subtree": _read(POOL_GROUP + "/cgroup.subtree_control").split(),
|
||||
"ffx_cpu_idle": _read(POOL_GROUP + "/cpu.idle").strip()}
|
||||
ev["cgroup_tree"] = tree
|
||||
ctx.log("cgroup tree: %s", tree)
|
||||
for key in ("controllers", "root_subtree", "ffx_subtree"):
|
||||
ctx.check(set(CONTROLLERS) <= set(tree[key]), "%s lacks a controller: %s", key, tree[key])
|
||||
ctx.check(tree["ffx_cpu_idle"] == "1", "the ffx group is not idle-class: cpu.idle=%r", tree["ffx_cpu_idle"])
|
||||
homes = {}
|
||||
for comm in ("forgectrl", "grblHAL_glowfor", "forgetest"):
|
||||
for pid in _pids_of(comm):
|
||||
homes["%s/%d" % (comm, pid)] = _read("/proc/%d/cgroup" % pid).strip()
|
||||
ev["firmware_groups"] = homes
|
||||
ctx.check(any(k.startswith("forgectrl/") for k in homes), "forgectrl is not running")
|
||||
ctx.check(all(v == "0::/" for v in homes.values()), "a firmware process is outside the root group: %s", homes)
|
||||
|
||||
# 3. the account pool, as the boot's render left it
|
||||
passwd = {l.split(":")[0]: l.split(":") for l in _read("/etc/passwd").splitlines() if l.count(":") >= 6}
|
||||
shadow = {l.split(":")[0]: l.split(":") for l in _read("/etc/shadow").splitlines() if ":" in l}
|
||||
group = {l.split(":")[0]: l.split(":") for l in _read("/etc/group").splitlines() if l.count(":") >= 3}
|
||||
bad = []
|
||||
for n in range(POOL_SIZE):
|
||||
name, uid = "ffx%d" % n, str(POOL_FIRST + n)
|
||||
p, s, g = passwd.get(name), shadow.get(name), group.get(name)
|
||||
if not p or p[2] != uid or p[3] != uid or p[5] != "/nonexistent" or p[6] != "/bin/false":
|
||||
bad.append("passwd %s: %s" % (name, p))
|
||||
if not s or s[1][:1] not in ("!", "*"):
|
||||
bad.append("shadow %s is not locked" % name)
|
||||
if not g or g[2] != uid or g[3] != "":
|
||||
bad.append("group %s: %s" % (name, g))
|
||||
others = [p[0] for p in passwd.values() if p[2].isdigit()
|
||||
and POOL_FIRST <= int(p[2]) < POOL_FIRST + POOL_SIZE and not re.match(r"^ffx\d+$", p[0])]
|
||||
ev["pool"] = {"accounts": sum(1 for n in passwd if re.match(r"^ffx\d+$", n)), "problems": bad, "others": others}
|
||||
ctx.check(not bad, "the account pool: %s", bad[:6])
|
||||
ctx.check(not others, "another account sits in the pool's uid range: %s", others)
|
||||
ctx.check(ev["pool"]["accounts"] == POOL_SIZE, "%d ffx accounts, expected %d", ev["pool"]["accounts"], POOL_SIZE)
|
||||
|
||||
uid = POOL_FIRST + POOL_SIZE - 1
|
||||
fc_pid = _pids_of("forgectrl")
|
||||
spin = None
|
||||
try:
|
||||
# 4. a probe group holds a process: cpu.max, the freezer, pids.max, memory.max
|
||||
os.makedirs(PROBE_GROUP, exist_ok=True)
|
||||
_write(PROBE_GROUP + "/cpu.max", "5000 100000")
|
||||
_write(PROBE_GROUP + "/pids.max", "6")
|
||||
_write(PROBE_GROUP + "/memory.max", str(24 << 20))
|
||||
if os.path.exists(PROBE_GROUP + "/memory.swap.max"):
|
||||
_write(PROBE_GROUP + "/memory.swap.max", "0")
|
||||
|
||||
spin = subprocess.Popen([sys.executable, "-c", PROBE, PROBE_GROUP, str(uid), "spin", ""],
|
||||
stdout=subprocess.PIPE, text=True)
|
||||
ctx.check("started" in (spin.stdout.readline() or ""), "the spinning probe did not start")
|
||||
ctx.check(_read("/proc/%d/cgroup" % spin.pid).strip() == "0::/ffx/forgetest-probe",
|
||||
"the probe is in %r", _read("/proc/%d/cgroup" % spin.pid).strip())
|
||||
ctx.check("Uid:\t%d\t%d" % (uid, uid) in _read("/proc/%d/status" % spin.pid), "the probe is not uid %d", uid)
|
||||
ctx.sleep(1.0)
|
||||
u0, t0 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"], time.time()
|
||||
ctx.sleep(3.0)
|
||||
u1, t1 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"], time.time()
|
||||
share = (u1 - u0) / 1e6 / (t1 - t0)
|
||||
throttled = _flat(PROBE_GROUP + "/cpu.stat").get("nr_throttled", 0)
|
||||
ctx.log("a spinning probe under cpu.max 5%%: %.1f%% of the core, throttled %d times", share * 100, throttled)
|
||||
ctx.check(0.01 < share < 0.08 and throttled > 0, "cpu.max 5%% held the probe to %.1f%% (throttled %d)",
|
||||
share * 100, throttled)
|
||||
|
||||
_write(PROBE_GROUP + "/cgroup.freeze", "1")
|
||||
ok = ctx.wait_for(lambda: _flat(PROBE_GROUP + "/cgroup.events").get("frozen") == 1, 3, poll=0.05)
|
||||
ctx.check(ok is not None, "the group did not freeze: %s", _flat(PROBE_GROUP + "/cgroup.events"))
|
||||
# The kernel reports a task frozen before its last way into
|
||||
# schedule() is charged, so the window opens once that has landed.
|
||||
ctx.sleep(0.2)
|
||||
f0 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"]
|
||||
ctx.sleep(1.5)
|
||||
f1 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"]
|
||||
_write(PROBE_GROUP + "/cgroup.freeze", "0")
|
||||
ok = ctx.wait_for(lambda: _flat(PROBE_GROUP + "/cgroup.events").get("frozen") == 0, 3, poll=0.05)
|
||||
ctx.check(ok is not None, "the group did not thaw")
|
||||
ctx.sleep(1.5)
|
||||
f2 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"]
|
||||
ctx.log("frozen for 1.5 s the probe ran %d us; thawed for 1.5 s it ran %d us", f1 - f0, f2 - f1)
|
||||
ctx.check(f1 - f0 == 0, "a frozen probe ran %d us", f1 - f0)
|
||||
ctx.check(f2 - f1 > 10000, "a thawed probe ran only %d us", f2 - f1)
|
||||
_write(PROBE_GROUP + "/cgroup.kill", "1")
|
||||
spin.wait(timeout=5)
|
||||
ctx.check(spin.returncode == -signal.SIGKILL, "cgroup.kill left the probe with %s", spin.returncode)
|
||||
spin = None
|
||||
ev["cpu"] = {"share": round(share, 4), "throttled": throttled, "frozen_us": f1 - f0, "thawed_us": f2 - f1}
|
||||
|
||||
rc, lines, err = _probe(PROBE_GROUP, uid, "forks", "12")
|
||||
forks = lines[-1] if lines else {}
|
||||
hits = _flat(PROBE_GROUP + "/pids.events").get("max", 0)
|
||||
ev["pids"] = {"result": forks, "events_max": hits}
|
||||
ctx.log("pids.max 6: the fork loop made %s of 12, stopped by %s; pids.events max %d",
|
||||
forks.get("forked"), forks.get("stopped_by"), hits)
|
||||
ctx.check(rc == 0 and forks.get("forked") == 5 and forks.get("stopped_by") == "EAGAIN" and hits > 0,
|
||||
"pids.max 6 did not stop the fork loop at 5: rc %s %s %s, events max %d", rc, forks, err, hits)
|
||||
|
||||
rc, lines, err = _probe(PROBE_GROUP, uid, "eat", "96", wait=60)
|
||||
kills = _flat(PROBE_GROUP + "/memory.events").get("oom_kill", 0)
|
||||
ev["memory"] = {"rc": rc, "lines": lines, "oom_kill": kills, "peak": _read(PROBE_GROUP + "/memory.peak").strip()}
|
||||
ctx.log("memory.max 24 MiB against a 96 MiB appetite: rc %s, oom_kill %d, peak %s", rc, kills, ev["memory"]["peak"])
|
||||
ctx.check(rc == -signal.SIGKILL and kills > 0 and not any(l.get("survived") for l in lines),
|
||||
"memory.max did not kill the probe: rc %s, oom_kill %d, %s %s", rc, kills, lines, err)
|
||||
ctx.check(_pids_of("forgectrl") == fc_pid, "forgectrl's pid moved across the group's OOM: %s -> %s",
|
||||
fc_pid, _pids_of("forgectrl"))
|
||||
|
||||
# 5. the deny rules
|
||||
p = subprocess.run([NFT, "list", "table", "inet", "ffx"], capture_output=True, text=True)
|
||||
ctx.check(p.returncode == 0, "table inet ffx is not loaded: %s", p.stderr.strip()[:200])
|
||||
ev["rules"] = p.stdout
|
||||
ctx.check("meta skuid %d-%d jump pool" % (POOL_FIRST, uid) in p.stdout and "hook output" in p.stdout
|
||||
and "policy accept" in p.stdout, "table inet ffx is not the image's: %s", p.stdout[:400])
|
||||
pool = p.stdout[p.stdout.find("chain pool"):].split("}")[0]
|
||||
ctx.check(0 <= pool.find('oifname "lo" jump refuse') < pool.find("vmap @allow"),
|
||||
"the machine itself is not refused ahead of the allow map: %s", pool)
|
||||
lan = lan_ip()
|
||||
ctx.check(lan, "the machine has no LAN address to aim at")
|
||||
tcp = [[4, "127.0.0.1", 443, False], [4, "127.0.0.1", 80, False], [6, "::1", 443, False],
|
||||
[6, "::1", 23, False], [4, lan, 443, False], [4, lan, 23, False]]
|
||||
udp = [[4, "127.0.0.1", 9, True], [4, lan, 9, True]]
|
||||
control = [[4, "127.0.0.1", 443, False], [4, lan, 443, False], [4, "127.0.0.1", 9, True]]
|
||||
rc, lines, err = _probe("-", 0, "net", json.dumps(control))
|
||||
as_root = (lines[-1] if lines else {}).get("results", [])
|
||||
ev["as_root"] = as_root
|
||||
ctx.check(len(as_root) == len(control) and all(r[1][0] == "ok" for r in as_root),
|
||||
"root does not reach its own listeners: %s %s", as_root, err)
|
||||
c0 = _pool_counters()
|
||||
heard = {}
|
||||
for who in (POOL_FIRST, uid):
|
||||
rc, lines, err = _probe("-", who, "net", json.dumps(tcp + udp))
|
||||
res = (lines[-1] if lines else {}).get("results", [])
|
||||
heard[str(who)] = res
|
||||
ctx.check(len(res) == len(tcp + udp), "the probe as uid %d said %s %s", who, lines, err)
|
||||
for target, (word, took) in res:
|
||||
if target[3]:
|
||||
ctx.check(word == "eperm", "uid %d UDP to %s -> %s", who, target[1], word)
|
||||
else:
|
||||
ctx.check(word == "refused" and took < 1.5, "uid %d TCP to %s port %d -> %s in %.2f s",
|
||||
who, target[1], target[2], word, took)
|
||||
c1 = _pool_counters()
|
||||
ev["as_pool_uid"] = heard
|
||||
ev["counters"] = [c0, c1]
|
||||
ctx.log("two pool uids: %d TCP connects each refused at once, %d UDP sends each EPERM; the rules "
|
||||
"counted %s -> %s", len(tcp), len(udp), c0, c1)
|
||||
ctx.check(c0 is not None and c1 is not None and len(c1) == 2 and c1[0] - c0[0] >= 2 * len(tcp)
|
||||
and c1[1] - c0[1] >= 2 * len(udp), "the rules did not count the attempts: %s -> %s", c0, c1)
|
||||
|
||||
# The machine itself is not a destination, whatever the allow map says: an
|
||||
# allow chain that names forgectrl on loopback and on the LAN address
|
||||
# opens neither.
|
||||
chain = "u%d" % uid
|
||||
rules = ["add chain inet ffx %s" % chain,
|
||||
"add rule inet ffx %s ip daddr { 127.0.0.1, %s } tcp dport 443 accept" % (chain, lan),
|
||||
"add element inet ffx allow { %d : jump %s }" % (uid, chain), ""]
|
||||
opened = subprocess.run([NFT, "-f", "-"], capture_output=True, text=True, input=os.linesep.join(rules))
|
||||
try:
|
||||
ctx.check(opened.returncode == 0, "could not add an allow chain: %s", opened.stderr.strip()[:200])
|
||||
rc, lines, err = _probe("-", uid, "net", json.dumps([[4, "127.0.0.1", 443, False], [4, lan, 443, False]]))
|
||||
res = (lines[-1] if lines else {}).get("results", [])
|
||||
ev["allowlisted_self"] = res
|
||||
ctx.log("uid %d with the machine's own addresses on its allowlist: %s", uid, res)
|
||||
ctx.check(len(res) == 2 and all(r[1][0] == "refused" for r in res),
|
||||
"an allowlist opened the machine itself to a pool uid: %s %s", res, err)
|
||||
finally:
|
||||
subprocess.run([NFT, "delete", "element", "inet", "ffx", "allow", "{ %d }" % uid], capture_output=True)
|
||||
subprocess.run([NFT, "flush", "chain", "inet", "ffx", chain], capture_output=True)
|
||||
subprocess.run([NFT, "delete", "chain", "inet", "ffx", chain], capture_output=True)
|
||||
left = subprocess.run([NFT, "list", "chain", "inet", "ffx", chain], capture_output=True)
|
||||
ctx.check(left.returncode != 0, "the test's allow chain stayed behind")
|
||||
|
||||
# 6. landlock and seccomp, on a root process so that neither the uid nor the rules explain the refusal
|
||||
rc, lines, err = _probe("-", 0, "landlock", json.dumps([4, "127.0.0.1", 443, False]))
|
||||
ll = lines[-1] if lines else {}
|
||||
ev["landlock"] = ll
|
||||
ctx.log("landlock ABI %s: before %s, after %s", ll.get("abi"), ll.get("before"), ll.get("after"))
|
||||
ctx.check(rc == 0 and ll.get("abi", 0) >= 4, "landlock ABI %s (4 brings the TCP rules) %s", ll.get("abi"), err)
|
||||
ctx.check(ll["before"] == {"etc": "ok", "usr": "ok", "tcp": "ok"}, "before the restriction: %s", ll["before"])
|
||||
after = ll["after"]
|
||||
ctx.check(after.get("ruleset") and after.get("added") == 0 and after.get("applied") == 0,
|
||||
"the ruleset did not apply: %s", after)
|
||||
ctx.check(after.get("etc") == "eacces" and after.get("tcp") == "eacces" and after.get("usr") == "ok",
|
||||
"after the restriction (/etc and TCP lost, /usr kept): %s", after)
|
||||
|
||||
rc, lines, err = _probe("-", 0, "seccomp")
|
||||
sc = lines[-1] if lines else {}
|
||||
ev["seccomp"] = sc
|
||||
ctx.log("seccomp: %s", sc)
|
||||
ctx.check(rc == 0 and sc.get("installed") == 0 and sc.get("before") == "Linux" and sc.get("after") == "eperm"
|
||||
and sc.get("status") == ["2"], "a seccomp filter did not take: rc %s %s %s", rc, sc, err)
|
||||
finally:
|
||||
if spin is not None:
|
||||
spin.kill()
|
||||
spin.wait(timeout=5)
|
||||
if os.path.isdir(PROBE_GROUP):
|
||||
try:
|
||||
_write(PROBE_GROUP + "/cgroup.freeze", "0")
|
||||
_write(PROBE_GROUP + "/cgroup.kill", "1")
|
||||
except OSError:
|
||||
pass
|
||||
for _ in range(50):
|
||||
if not _read(PROBE_GROUP + "/cgroup.procs").strip():
|
||||
break
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
os.rmdir(PROBE_GROUP)
|
||||
except OSError as e:
|
||||
ctx.log("the probe group stayed behind: %s", e)
|
||||
ctx.check(not os.path.isdir(PROBE_GROUP), "the probe group stayed behind")
|
||||
ctx.log("PASS: the kernel, the cgroup tree, the account pool, and the deny rules are in place, and each "
|
||||
"held a probe process the way it will hold a package")
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the host
|
||||
|
||||
FORGEEXT = "/usr/bin/forgeext"
|
||||
FWUP = "/usr/bin/fwup"
|
||||
EXT_ROOT = "/data/forgefirm/ext"
|
||||
HOST_STATUS = "/run/forgefirm/ext/status.json"
|
||||
SAFE_FILE = "/run/forgefirm/ext-safe"
|
||||
REF_ID = "org.forgetest.reference"
|
||||
REF_KEY = "forgetest-reference"
|
||||
REF_DEST = "192.0.2.1" # TEST-NET-1: declared so that port 443 is, and never dialed
|
||||
|
||||
# The reference package's service. It looks at its own confinement from the
|
||||
# inside, leaves what it found in its data directory, and stays up.
|
||||
REF_SERVICE = r'''
|
||||
import errno, json, os, socket, sys, time
|
||||
lan = sys.argv[1]
|
||||
data, pkg = os.environ["FFX_DATA"], os.environ["FFX_PKG"]
|
||||
if os.path.exists(os.path.join(data, "stop")):
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
def word(e):
|
||||
return errno.errorcode.get(e.errno, str(e.errno))
|
||||
|
||||
|
||||
def api(method, path, body=None):
|
||||
"""One request to the host over the socket named in FFX_API: (status, JSON)."""
|
||||
payload = json.dumps(body).encode() if body is not None else b""
|
||||
head = "%s %s HTTP/1.1\r\nHost: forgeext\r\n" % (method, path)
|
||||
if body is not None:
|
||||
head += "Content-Type: application/json\r\nContent-Length: %d\r\n" % len(payload)
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(5)
|
||||
try:
|
||||
s.connect(os.environ["FFX_API"])
|
||||
s.sendall(head.encode() + b"\r\n" + payload)
|
||||
buf = b""
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf += c
|
||||
top, _, text = buf.partition(b"\r\n\r\n")
|
||||
return int(top.split()[1]), json.loads(text or b"null")
|
||||
except (OSError, ValueError, IndexError) as e:
|
||||
return 599, {"error": str(e)}
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def shot(body):
|
||||
"""POST /v0/camera: (status, bytes or the error's words). A frame is
|
||||
bytes, so this one does not try to read the answer as JSON."""
|
||||
payload = json.dumps(body).encode()
|
||||
head = ("POST /v0/camera HTTP/1.1\r\nHost: forgeext\r\nContent-Type: application/json\r\n"
|
||||
"Content-Length: %d\r\n\r\n" % len(payload))
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(40)
|
||||
try:
|
||||
s.connect(os.environ["FFX_API"])
|
||||
s.sendall(head.encode() + payload)
|
||||
buf = b""
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf += c
|
||||
top, _, rest = buf.partition(b"\r\n\r\n")
|
||||
code = int(top.split()[1])
|
||||
if code == 200:
|
||||
return code, len(rest), rest[:2] == b"\xff\xd8", b"image/jpeg" in top
|
||||
try:
|
||||
return code, json.loads(rest or b"{}").get("error", ""), False, False
|
||||
except ValueError:
|
||||
return code, rest[:80].decode("utf-8", "replace"), False, False
|
||||
except (OSError, ValueError, IndexError) as e:
|
||||
return 599, str(e), False, False
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def opened(path, mode="r"):
|
||||
try:
|
||||
with open(path, mode) as f:
|
||||
if "r" in mode:
|
||||
f.read(1)
|
||||
else:
|
||||
f.write("x")
|
||||
return "ok"
|
||||
except OSError as e:
|
||||
return word(e)
|
||||
|
||||
|
||||
def dial(addr, port):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(4.0)
|
||||
try:
|
||||
s.connect((addr, port))
|
||||
return "connected"
|
||||
except socket.timeout:
|
||||
return "timeout"
|
||||
except OSError as e:
|
||||
return word(e)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def family(af):
|
||||
try:
|
||||
socket.socket(af, socket.SOCK_RAW if af == 16 else socket.SOCK_DGRAM).close()
|
||||
return "ok"
|
||||
except OSError as e:
|
||||
return word(e)
|
||||
|
||||
|
||||
report = {
|
||||
"uid": os.getuid(), "gid": os.getgid(), "groups": os.getgroups(), "cwd": os.getcwd(),
|
||||
"env": sorted(os.environ),
|
||||
"read_settings": opened("/data/forgefirm/forgefirm.conf"),
|
||||
"read_setup": opened("/data/forgefirm/setup.json"),
|
||||
"read_etc": opened("/etc/hostname"),
|
||||
"read_pkg": opened(os.path.join(pkg, "manifest.json")),
|
||||
"write_pkg": opened(os.path.join(pkg, "x"), "w"),
|
||||
"write_data": opened(os.path.join(data, "scratch"), "w"),
|
||||
"write_tmp": opened("/tmp/forgetest-reference", "w"),
|
||||
"pulse_device": opened("/dev/glowforge"),
|
||||
"loopback_declared_port": dial("127.0.0.1", 443),
|
||||
"lan_declared_port": dial(lan, 443) if lan != "-" else "skipped",
|
||||
"loopback_undeclared_port": dial("127.0.0.1", 80),
|
||||
"netlink_socket": family(16),
|
||||
"unix_socket": family(1),
|
||||
"api_camera": shot({"camera": "lid", "resolution": "half"}),
|
||||
"api_camera_head": shot({"camera": "head"}),
|
||||
"api_camera_bad": shot({"camera": "bed"}),
|
||||
"api_camera_dark": shot({"camera": "lid", "resolution": "half", "lamp": 0}),
|
||||
"api_camera_lit": shot({"camera": "lid", "resolution": "half", "lamp": 1023}),
|
||||
"api_camera_lamp_bad": shot({"camera": "lid", "lamp": 5000}),
|
||||
"api_settings": api("GET", "/v0/settings"),
|
||||
"api_settings_set": api("POST", "/v0/settings", {"threshold": 70, "note": "set from inside"}),
|
||||
"api_settings_undeclared": api("POST", "/v0/settings", {"nothere": 1}),
|
||||
"api_settings_out_of_bounds": api("POST", "/v0/settings", {"threshold": 9000}),
|
||||
"api_self": api("GET", "/v0/self"),
|
||||
"api_mode": api("GET", "/v0/machine/mode"),
|
||||
"api_hold": api("GET", "/v0/hold"),
|
||||
"api_nowhere": api("GET", "/v0/nowhere"),
|
||||
"api_traversal": api("GET", "/v0/machine/../../settings"),
|
||||
}
|
||||
with open(os.path.join(data, "report.json.new"), "w") as f:
|
||||
json.dump(report, f)
|
||||
os.rename(os.path.join(data, "report.json.new"), os.path.join(data, "report.json"))
|
||||
print("reference service up", flush=True)
|
||||
mine = api("GET", "/v0/self")[1] or {}
|
||||
watching = "events" in (mine.get("capabilities") or [])
|
||||
place, seen, polls = None, [], []
|
||||
n = 0
|
||||
while True:
|
||||
n += 1
|
||||
with open(os.path.join(data, "beat"), "w") as f:
|
||||
f.write(str(n))
|
||||
if watching:
|
||||
# A poll that waits: it comes back with an event as soon as there
|
||||
# is one, and with nothing when its two seconds are up.
|
||||
ask = {"wait": 2} if place is None else {"since": place, "wait": 2}
|
||||
began = time.time()
|
||||
st, ans = api("POST", "/v0/events", ask)
|
||||
took = round(time.time() - began, 2)
|
||||
if st == 200:
|
||||
place = ans.get("next")
|
||||
seen = (seen + [e.get("event") for e in ans.get("events") or []])[-40:]
|
||||
polls = (polls + [{"status": st, "took": took, "placed": "since" in ask,
|
||||
"got": len(ans.get("events") or []) if isinstance(ans, dict) else 0}])[-20:]
|
||||
with open(os.path.join(data, "events.json.new"), "w") as f:
|
||||
json.dump({"place": place, "seen": seen, "polls": polls, "last": ans,
|
||||
"connected": ans.get("connected") if isinstance(ans, dict) else None}, f)
|
||||
os.rename(os.path.join(data, "events.json.new"), os.path.join(data, "events.json"))
|
||||
# the test's word to the service: write a program and run it
|
||||
runf = os.path.join(data, "run")
|
||||
if os.path.exists(runf):
|
||||
with open(runf) as f:
|
||||
words = json.load(f)
|
||||
os.remove(runf)
|
||||
if words.get("program_text") is not None:
|
||||
with open(os.path.join(data, words.get("program", "job.gcode")), "w") as f:
|
||||
f.write(words["program_text"])
|
||||
body = {k: v for k, v in words.items() if k not in ("program_text",)}
|
||||
with open(os.path.join(data, "ran.new"), "w") as f:
|
||||
json.dump(api("POST", "/v0/motion/job", body), f)
|
||||
os.rename(os.path.join(data, "ran.new"), os.path.join(data, "ran"))
|
||||
# the test's word to the service: jog now
|
||||
jog = os.path.join(data, "jog")
|
||||
if os.path.exists(jog):
|
||||
with open(jog) as f:
|
||||
words = json.load(f)
|
||||
os.remove(jog)
|
||||
with open(os.path.join(data, "jogged.new"), "w") as f:
|
||||
json.dump(api("POST", "/v0/motion/jog", words), f)
|
||||
os.rename(os.path.join(data, "jogged.new"), os.path.join(data, "jogged"))
|
||||
# the test's word to the service: take a capture now
|
||||
ask = os.path.join(data, "shoot")
|
||||
if os.path.exists(ask):
|
||||
os.remove(ask)
|
||||
with open(os.path.join(data, "shot.new"), "w") as f:
|
||||
json.dump(shot({"camera": "lid", "resolution": "half"}), f)
|
||||
os.rename(os.path.join(data, "shot.new"), os.path.join(data, "shot"))
|
||||
# the test's word to the service: what to say of its hold
|
||||
say = os.path.join(data, "say")
|
||||
if os.path.exists(say):
|
||||
with open(say) as f:
|
||||
words = json.load(f)
|
||||
os.remove(say)
|
||||
with open(os.path.join(data, "said.new"), "w") as f:
|
||||
json.dump(api("POST", "/v0/hold", words), f)
|
||||
os.rename(os.path.join(data, "said.new"), os.path.join(data, "said"))
|
||||
time.sleep(0.5)
|
||||
'''
|
||||
|
||||
|
||||
def _host_pids():
|
||||
"""The extension host: /usr/bin/forgeext run, and not an install or a
|
||||
check someone has under way."""
|
||||
out = []
|
||||
for d in os.listdir("/proc"):
|
||||
if not d.isdigit():
|
||||
continue
|
||||
try:
|
||||
if os.readlink("/proc/%s/exe" % d) != FORGEEXT:
|
||||
continue
|
||||
with open("/proc/%s/cmdline" % d, "rb") as f:
|
||||
argv = f.read().split(b"\0")
|
||||
except OSError:
|
||||
continue
|
||||
if argv[:2] == [FORGEEXT.encode(), b"run"]:
|
||||
out.append(int(d))
|
||||
return out
|
||||
|
||||
|
||||
def _host_status():
|
||||
try:
|
||||
with open(HOST_STATUS) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _svc(id_):
|
||||
return next((x for x in _host_status().get("services", []) if x.get("id") == id_), {})
|
||||
|
||||
|
||||
def _forgeext(*args, wait=120):
|
||||
p = subprocess.run([FORGEEXT] + list(args), capture_output=True, text=True, timeout=wait)
|
||||
try:
|
||||
return json.loads(p.stdout)
|
||||
except ValueError:
|
||||
return {"ok": False, "error": "no JSON answer (exit %s): %s %s" % (p.returncode, p.stdout[-200:], p.stderr[-200:])}
|
||||
|
||||
|
||||
def _until(ctx, cond, seconds, poll=0.25):
|
||||
end = time.time() + seconds
|
||||
while time.time() < end:
|
||||
v = cond()
|
||||
if v:
|
||||
return v
|
||||
ctx.checkpoint()
|
||||
time.sleep(poll)
|
||||
return cond()
|
||||
|
||||
|
||||
def _tree(root):
|
||||
"""Every path under the extension root but the host's own bookkeeping."""
|
||||
out = []
|
||||
for base, dirs, files in os.walk(root):
|
||||
for n in dirs + files:
|
||||
rel = os.path.relpath(os.path.join(base, n), root)
|
||||
if rel not in ("state.json", "lock"):
|
||||
out.append(rel)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
REF_UI = ("<!doctype html><title>forgetest reference</title>\n"
|
||||
"<p>the reference package's own page</p>\n")
|
||||
|
||||
|
||||
def _pack_reference(work, lan, more_caps=(), ui=None):
|
||||
"""The reference package, signed with a key made here: (archive, public key)."""
|
||||
import io
|
||||
import tarfile
|
||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest reference", "version": "1.0.0",
|
||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||
"service": {"exec": "bin/reference.py", "args": [lan or "-"]},
|
||||
"settings": {"threshold": {"type": "number", "default": 40, "min": 0, "max": 100},
|
||||
"note": {"type": "string", "default": "", "max": 32, "label": "A note"},
|
||||
"when": {"type": "choice", "default": "end",
|
||||
"choices": ["start", "end", "never"]}},
|
||||
"capabilities": ["net.outbound:%s:443" % REF_DEST, "storage:1", "machine.read",
|
||||
"settings.own", "camera.lid"] + list(more_caps)}
|
||||
payload = os.path.join(work, "payload.tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
files = [("manifest.json", json.dumps(manifest), 0o644),
|
||||
("bin/reference.py", REF_SERVICE, 0o755)]
|
||||
if ui is None:
|
||||
ui = "ui" in (manifest.get("capabilities") or [])
|
||||
if ui:
|
||||
files.append(("ui/index.html", REF_UI, 0o644))
|
||||
for name, text, mode in files:
|
||||
info = tarfile.TarInfo(name)
|
||||
data = text.encode()
|
||||
info.size, info.mode = len(data), mode
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, "fwup.conf")
|
||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||
% (REF_ID, payload))
|
||||
key = os.path.join(work, REF_KEY)
|
||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "reference.ffx")
|
||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||
return signed, key + ".pub"
|
||||
|
||||
|
||||
def _put_back(ctx, fc, work, prior, etag, raw, dir_mode):
|
||||
"""Everything a test of the host put on the machine, taken away again:
|
||||
safe mode, the package, the owner key, the work directory, the
|
||||
setting, the data directory's mode, and the setup record (under a
|
||||
forgectrl restart)."""
|
||||
import shutil
|
||||
if os.path.exists(SAFE_FILE):
|
||||
os.remove(SAFE_FILE)
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
|
||||
r = _forgeext("remove", REF_ID)
|
||||
ctx.log("remove %s -> %s", REF_ID, "ok" if r.get("ok") else r.get("error"))
|
||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||
if os.path.exists(owner_key):
|
||||
os.remove(owner_key)
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
if prior == "1":
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
elif prior == "":
|
||||
st, reply = fc.post("/settings", params={"ext_enabled": ""})
|
||||
ctx.log("restore ext_enabled=%r -> %s", prior, st)
|
||||
if prior != "1":
|
||||
os.chmod(os.path.dirname(EXT_ROOT), dir_mode)
|
||||
with ctx.takeover():
|
||||
write_file(record_path(), raw)
|
||||
ctx.log("the previous record is back under a restart")
|
||||
|
||||
|
||||
def _as_found(ctx, fc, prior, raw, dir_mode, found_tree):
|
||||
ctx.check((fc.settings().get("ext_enabled") or "") == prior, "ext_enabled not restored: %r, was %r",
|
||||
fc.settings().get("ext_enabled"), prior)
|
||||
ctx.check(read_file(record_path()) == raw, "the setup record on disk is not the one found")
|
||||
ctx.check(prior == "1" or os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777 == dir_mode,
|
||||
"the data directory's mode is not the one found")
|
||||
left = _tree(EXT_ROOT)
|
||||
ctx.check(left == found_tree, "the extension root is not as found: %s", sorted(set(left) ^ set(found_tree)))
|
||||
ctx.check(len(_host_pids()) == 1, "the extension host is not running at the end")
|
||||
|
||||
|
||||
# ------------------------------------------------- the armed window
|
||||
|
||||
def _frozen(id_):
|
||||
ev = _read("%s/%s/cgroup.events" % (POOL_GROUP, id_))
|
||||
return "frozen 1" in ev if ev else None
|
||||
|
||||
|
||||
@test("exthost.armed-freeze", title="A package's service is frozen for the armed window",
|
||||
subsystem="exthost", kind="operator", hardware="takeover", est_min=6,
|
||||
covers=[("forgeext", "src/super.*"), ("forgeext", "src/run.*"), ("forgeext", "src/machine.*"),
|
||||
("forgeext", "src/cgroup.*"), ("forgectrl", "src/cool.*"), ("forgectrl", "src/cam.*"),
|
||||
("forgectrl", "src/main.c")],
|
||||
requires=["exthost.platform"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
||||
"when it lights."],
|
||||
description="The reference package runs, its heartbeat advancing twice a second, and a dark "
|
||||
"cloud print opens a real armed window over it: the cloud suite's dark-print "
|
||||
"helper, whose latch checks still run (locked at the button, unlocked for the run, "
|
||||
"locked after it), and the print completes. Sampled five times a second from before the button to after the end: the "
|
||||
"engine's armed flag, the group's frozen state as the kernel reports it, and the "
|
||||
"heartbeat. The window is open for at least 10 s. From 2 s after it opens to its "
|
||||
"close the group reads frozen in every sample and the heartbeat does not move, the "
|
||||
"freeze is in place before the run starts (the latch unlocks only for the run), and "
|
||||
"within 3 s of the close the group is thawed and the heartbeat advances again. The "
|
||||
"service is the same process throughout. A background capture is refused in words "
|
||||
"while the window is open and served again once it closes, which is the other half "
|
||||
"of keeping a package off the step stream: a capture costs kernel-side work that a "
|
||||
"thread priority does not cover. The package, the key, the setting and the setup "
|
||||
"record are put back as found, the record under a forgectrl restart.")
|
||||
def armed_freeze(ctx):
|
||||
import tempfile
|
||||
import threading
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(len(_host_pids()) == 1, "the extension host is not one running process: %s", _host_pids())
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||
prior = fc.settings().get("ext_enabled") or ""
|
||||
raw = read_file(record_path())
|
||||
ctx.check(raw, "no setup record at %s", record_path())
|
||||
ctx.check(not os.path.exists(SAFE_FILE), "%s exists: the machine is in safe mode", SAFE_FILE)
|
||||
found_tree = _tree(EXT_ROOT)
|
||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||
etag = hdrs.get("etag")
|
||||
ctx.check(st == 200 and etag, "GET /advisories/extensions -> %s", st)
|
||||
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
|
||||
beat_path = os.path.join(EXT_ROOT, "data", REF_ID, "beat")
|
||||
samples = []
|
||||
probe = {}
|
||||
stop = threading.Event()
|
||||
|
||||
def sampler():
|
||||
while not stop.is_set():
|
||||
st_, cool = fc.get("/cool/status")
|
||||
armed = bool(cool.get("armed")) if st_ == 200 and isinstance(cool, dict) else None
|
||||
samples.append((time.time(), armed, _frozen(REF_ID), _read(beat_path).strip(),
|
||||
(_svc(REF_ID) or {}).get("pid"), latch_locked()))
|
||||
# One background capture from inside the window. The refusal is
|
||||
# decided before any frame is taken, so it costs the cut nothing;
|
||||
# if it were served instead, that is what this case is here to
|
||||
# catch, and the capture is the one the rule forbids.
|
||||
if armed and "in" not in probe:
|
||||
probe["in"] = fc.get("/cam/snapshot", params={"cam": "lid", "background": "1"}, raw=True)
|
||||
time.sleep(0.2)
|
||||
|
||||
try:
|
||||
archive, pub = _pack_reference(work, lan_ip())
|
||||
with open(pub, "rb") as f:
|
||||
write_file(os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub"), f.read())
|
||||
os.chmod(os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub"), 0o644)
|
||||
r = _forgeext("install", archive, "--consent-community")
|
||||
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
ctx.check(st == 200, "ext_enabled=1 over the advisory -> %s %r", st, reply)
|
||||
seen = set()
|
||||
ctx.check(_until(ctx, lambda: seen.add(_read(beat_path)) or len(seen) >= 4, 120, poll=0.2),
|
||||
"the reference service's heartbeat does not advance: %s", _svc(REF_ID) or _host_status())
|
||||
pid = _svc(REF_ID).get("pid")
|
||||
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "dark.puls", seconds=30)
|
||||
off = Offline().__enter__()
|
||||
t = threading.Thread(target=sampler, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
dark_print_body(ctx, ev, off, job, offset, fc)
|
||||
ctx.sleep(6) # past the close, for the thaw
|
||||
finally:
|
||||
stop.set()
|
||||
t.join(timeout=5)
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
opens = [x for x in samples if x[1]]
|
||||
ctx.check(opens, "the engine never read armed across the print: %d samples", len(samples))
|
||||
t_open, t_close = opens[0][0], opens[-1][0]
|
||||
ev["window_s"] = round(t_close - t_open, 1)
|
||||
ctx.check(t_close - t_open >= 10, "the armed window was open for %.1f s only", t_close - t_open)
|
||||
inside = [x for x in samples if t_open + 2.0 <= x[0] <= t_close]
|
||||
thawed = [x for x in inside if x[2] is not True]
|
||||
ev["samples"] = {"all": len(samples), "inside": len(inside), "not_frozen_inside": len(thawed)}
|
||||
first_frozen = next((x[0] for x in samples if x[0] >= t_open and x[2] is True), None)
|
||||
ev["freeze_lag_s"] = round(first_frozen - t_open, 2) if first_frozen else None
|
||||
ctx.check(inside and not thawed, "inside the armed window the group read not frozen in %d of %d samples "
|
||||
"(the first %.1f s after it opened)", len(thawed), len(inside), (thawed[0][0] - t_open) if thawed else 0)
|
||||
beats = {x[3] for x in inside}
|
||||
ctx.check(len(beats) == 1, "the heartbeat moved inside the armed window: %s", sorted(beats)[:6])
|
||||
# the latch unlocks only for the run: its first unlocked sample is the run's start
|
||||
run_start = next((x[0] for x in samples if x[5] is False), None)
|
||||
ctx.check(run_start is not None, "the latch never read unlocked: the print did not run under the samples")
|
||||
ctx.check(first_frozen is not None and first_frozen <= run_start,
|
||||
"the run started before the freeze was in place (frozen %s, the latch unlocked %s after the window opened)",
|
||||
None if first_frozen is None else round(first_frozen - t_open, 2), round(run_start - t_open, 2))
|
||||
ev["freeze_led_the_run_s"] = round(run_start - first_frozen, 2)
|
||||
after = [x for x in samples if x[0] >= t_close + 3.0]
|
||||
ctx.check(after and all(x[2] is False for x in after), "3 s after the close the group is not thawed: %s",
|
||||
[x[2] for x in after][:8])
|
||||
ctx.check(len({x[3] for x in after}) >= 2, "the heartbeat did not advance again after the close")
|
||||
last_frozen = max((x[0] for x in samples if x[2] is True), default=None)
|
||||
ev["thaw_lag_s"] = round(last_frozen - t_close, 2) if last_frozen else None
|
||||
ctx.check({x[4] for x in samples if x[4]} == {pid}, "the service did not stay the same process: %s",
|
||||
sorted({x[4] for x in samples if x[4]}))
|
||||
|
||||
# The camera, inside the window and after it.
|
||||
ctx.check("in" in probe, "no background capture was tried inside the armed window")
|
||||
cam_st, cam_body = probe.get("in", (None, b""))
|
||||
words = cam_body.decode("utf-8", "replace")[:200] if isinstance(cam_body, bytes) else str(cam_body)[:200]
|
||||
ev["camera_in_window"] = {"status": cam_st, "said": words}
|
||||
ctx.check(cam_st == 409, "a background capture inside the armed window -> %s (%d bytes)", cam_st,
|
||||
len(cam_body or b""))
|
||||
ctx.check("armed" in words, "the refusal does not say why: %s", words)
|
||||
st, after_body = fc.get("/cam/snapshot", params={"cam": "lid", "background": "1"}, raw=True)
|
||||
ev["camera_after_window"] = {"status": st, "bytes": len(after_body or b"")}
|
||||
ctx.check(st == 200 and (after_body or b"")[:2].hex() == "ffd8",
|
||||
"a background capture after the window -> %s (%d bytes)", st, len(after_body or b""))
|
||||
ctx.log("a background capture inside the window -> %s (%s); after it -> %s, %d bytes", cam_st, words, st,
|
||||
len(after_body or b""))
|
||||
ctx.log("the armed window was open %.1f s; frozen %.2f s after it opened and %.2f s before the latch unlocked "
|
||||
"for the run, in every one of %d samples from 2 s in to the close, the heartbeat still; thawed %.2f s "
|
||||
"after the close", ev["window_s"], ev["freeze_lag_s"], ev["freeze_led_the_run_s"], len(inside),
|
||||
ev["thaw_lag_s"] if ev["thaw_lag_s"] is not None else -1)
|
||||
finally:
|
||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||
@@ -4,20 +4,17 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
|
||||
# The write guard reads the account and the session store (a machine with
|
||||
# an account needs a session from the LAN; this host and the dev image
|
||||
# write with the token), so both are the guard's domain.
|
||||
# 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"),
|
||||
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
||||
("forgectrl", "src/ui/login.js"), ("forgectrl", "src/ui/wizard.js")]
|
||||
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"), ("forgectrl", "src/button.*"),
|
||||
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/wizard.js")]
|
||||
|
||||
|
||||
def lan_ip():
|
||||
@@ -142,14 +139,39 @@ def auth(ctx):
|
||||
"GET /fuse-identity with the token but no button -> %s %r (expected the two-factor refusal)",
|
||||
st, body)
|
||||
|
||||
# the cooling report channel: the loopback peer is accepted. An idle
|
||||
# report is what the controller sends every period; the engine is idle
|
||||
# here, so it changes nothing. A dual-stack listener reports this peer
|
||||
# as ::ffff:127.0.0.1, which the check must recognize in full.
|
||||
st, body = fc.post("/cool/state", params={"mode": "idle", "armed": "0"})
|
||||
# the cooling report channel is the running controller's alone. A
|
||||
# loopback peer is not enough: anything on this host is one. The
|
||||
# supervisor hands the controller a secret at its spawn, and the route
|
||||
# asks for it. An idle report is what the controller sends every
|
||||
# period; the engine is idle here, so the accepted one changes nothing.
|
||||
# A dual-stack listener reports this peer as ::ffff:127.0.0.1, which
|
||||
# the loopback check must recognize in full.
|
||||
report = {"mode": "idle", "armed": "0"}
|
||||
st, body = fc.post("/cool/state", params=report)
|
||||
ev["cool_state_from_loopback_no_secret"] = st
|
||||
ctx.log("POST /cool/state from loopback, no secret -> %s %s", st, body if isinstance(body, str) else "")
|
||||
ctx.check(st == 403 and "controller's alone" in str(body),
|
||||
"/cool/state from loopback without the secret -> %s %r", st, body)
|
||||
st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": "0" * 32})
|
||||
ctx.check(st == 403, "/cool/state with a secret that is not the controller's -> %s %r", st, body)
|
||||
# The secret itself, as only root on this host can read it: out of
|
||||
# the running controller's environment. It is never logged.
|
||||
secret = None
|
||||
pid = (fc.get("/mode")[1] or {}).get("pid")
|
||||
if pid:
|
||||
try:
|
||||
with open("/proc/%d/environ" % int(pid), "rb") as f:
|
||||
for item in f.read().split(b"\0"):
|
||||
if item.startswith(b"GF_REPORT_SECRET="):
|
||||
secret = item.split(b"=", 1)[1].decode("ascii", "replace")
|
||||
except OSError:
|
||||
pass
|
||||
ev["controller_has_a_secret"] = bool(secret)
|
||||
ctx.check(secret and len(secret) == 32, "the running controller (pid %s) was handed no report secret", pid)
|
||||
st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": secret})
|
||||
ev["cool_state_from_loopback"] = st
|
||||
ctx.log("POST /cool/state from loopback -> %s %s", st, body if isinstance(body, dict) else "")
|
||||
ctx.check(st == 200, "/cool/state refused the loopback peer (%s %r): the controller's "
|
||||
ctx.log("POST /cool/state from loopback with the controller's secret -> %s", st)
|
||||
ctx.check(st == 200, "/cool/state refused the controller's own secret (%s %r): the controller's "
|
||||
"reports never reach the engine", st, body)
|
||||
|
||||
# ...and a non-loopback peer is refused, even with a token. Over HTTP
|
||||
@@ -161,8 +183,7 @@ def auth(ctx):
|
||||
ip = lan_ip()
|
||||
ev["lan_ip"] = ip
|
||||
ctx.check(ip, "cannot determine the board's LAN address")
|
||||
token = {"X-ForgeFIRM-Token": fc.token}
|
||||
report = {"mode": "idle", "armed": "0"}
|
||||
token = {"X-ForgeFIRM-Token": fc.token, "X-ForgeFIRM-Report": secret}
|
||||
st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token)
|
||||
loc = hdrs.get("location", "")
|
||||
ev["cool_state_from_lan_http"] = {"status": st, "location": loc}
|
||||
@@ -188,232 +209,3 @@ def auth(ctx):
|
||||
if st == 302:
|
||||
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
|
||||
"the login redirect does not carry the path: %r", loc)
|
||||
|
||||
|
||||
@test("forgectrl.settings-bounds", title="Settings validation and restore", subsystem="forgectrl",
|
||||
kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/settings.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/cam.c")],
|
||||
description="An over-length value and an out-of-range value are refused (400) and leave the "
|
||||
"settings byte-identical; an in-range value is accepted (200). The lid lamp "
|
||||
"idles at lid_lamp_idle (unset = 236), an out-of-range level is refused, a new "
|
||||
"level applies to the lamp at once, and clearing it returns the default.")
|
||||
def settings_bounds(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings()
|
||||
ev["keys"] = len(before)
|
||||
|
||||
st, body = fc.post("/settings", data={"gf_serial": "X" * 300})
|
||||
ev["overlong"] = st
|
||||
ctx.log("POST /settings gf_serial=<300 chars> -> %s %s", st, body if isinstance(body, dict) else "")
|
||||
ctx.check(st == 400, "over-length value -> %s, expected 400", st)
|
||||
|
||||
st, body = fc.post("/settings", data={"laser_disarm_s": "99999"})
|
||||
ev["out_of_range"] = st
|
||||
ctx.log("POST /settings laser_disarm_s=99999 -> %s", st)
|
||||
ctx.check(st == 400, "out-of-range value -> %s, expected 400", st)
|
||||
|
||||
# The cloud download guard is bytes, so its range is far wider than the
|
||||
# other numeric keys: check the far end is still a wall.
|
||||
st, body = fc.post("/settings", data={"pulse_reject_threshold_bytes": "2000000000"})
|
||||
ev["pulse_bytes_out_of_range"] = st
|
||||
ctx.log("POST /settings pulse_reject_threshold_bytes=2000000000 -> %s", st)
|
||||
ctx.check(st == 400, "out-of-range byte limit -> %s, expected 400", st)
|
||||
|
||||
st, body = fc.post("/settings", data={"no_such_key_forgetest": "1"})
|
||||
ev["unknown_key"] = st
|
||||
ctx.log("POST /settings no_such_key_forgetest=1 -> %s", st)
|
||||
ctx.check(st in (400, 404), "unknown key -> %s, expected 400", st)
|
||||
|
||||
after = fc.settings()
|
||||
ctx.check(json.dumps(after, sort_keys=True) == json.dumps(before, sort_keys=True),
|
||||
"settings changed after refused writes")
|
||||
ctx.log("settings unchanged after the refused writes")
|
||||
|
||||
# an accepted in-range write: rewrite a present key with its own value
|
||||
key = None
|
||||
for k in ("ui_units", "laser_disarm_s", "cool_flow_rise", "rail_settle_s"):
|
||||
v = before.get(k)
|
||||
if isinstance(v, str) and v != "":
|
||||
key = k
|
||||
break
|
||||
if key is None:
|
||||
key, val = "ui_units", "metric"
|
||||
ctx.log("no settable key is present; writing %s=%s (recorded in evidence)", key, val)
|
||||
else:
|
||||
val = before[key]
|
||||
st, body = fc.post("/settings", data={key: val})
|
||||
ev["accepted"] = {"key": key, "value": val, "status": st}
|
||||
ctx.log("POST /settings %s=%s -> %s", key, val, st)
|
||||
ctx.check(st == 200, "in-range write -> %s, expected 200", st)
|
||||
final = fc.settings()
|
||||
others_before = {k: v for k, v in before.items() if k != key}
|
||||
others_after = {k: v for k, v in final.items() if k != key}
|
||||
ctx.check(others_before == others_after, "other settings changed by the write")
|
||||
ctx.check(final.get(key) == val, "%s reads back %r, wrote %r", key, final.get(key), val)
|
||||
|
||||
# the lid lamp's idle level: resting at the setting, bounded, applied live
|
||||
lamp_was = (before.get("lid_lamp_idle") or "").strip()
|
||||
want = lamp_was or "236"
|
||||
got = ctx.sysfs("pic/lid_led")
|
||||
ev["lid_lamp"] = {"setting": lamp_was, "resting": got}
|
||||
ctx.log("lid lamp: setting %r, pic/lid_led=%s (expected %s)", lamp_was, got, want)
|
||||
ctx.check(got == want, "lid lamp rests at %s, lid_lamp_idle is %s", got, want)
|
||||
for bad in ("256", "-1", "bright"):
|
||||
st, body = fc.post("/settings", data={"lid_lamp_idle": bad})
|
||||
ctx.check(st == 400, "lid_lamp_idle=%s -> %s, expected 400", bad, st)
|
||||
ctx.log("lid_lamp_idle 256 / -1 / bright refused")
|
||||
try_level = "100" if want != "100" else "120"
|
||||
st, body = fc.post("/settings", data={"lid_lamp_idle": try_level})
|
||||
ctx.check(st == 200, "lid_lamp_idle=%s -> %s, expected 200", try_level, st)
|
||||
applied = None
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 5:
|
||||
applied = ctx.sysfs("pic/lid_led")
|
||||
if applied == try_level:
|
||||
break
|
||||
ctx.sleep(0.2)
|
||||
ctx.log("lid_lamp_idle=%s -> pic/lid_led=%s after %.1f s", try_level, applied, time.time() - t0)
|
||||
# an empty value clears the key: the query-string form carries it
|
||||
st, body = (fc.post("/settings", params={"lid_lamp_idle": ""}) if not lamp_was
|
||||
else fc.post("/settings", data={"lid_lamp_idle": lamp_was}))
|
||||
ctx.check(st == 200, "restoring lid_lamp_idle=%r -> %s", lamp_was, st)
|
||||
t0 = time.time()
|
||||
back = None
|
||||
while time.time() - t0 < 5:
|
||||
back = ctx.sysfs("pic/lid_led")
|
||||
if back == want:
|
||||
break
|
||||
ctx.sleep(0.2)
|
||||
ev["lid_lamp"].update({"applied": applied, "restored": back})
|
||||
ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied)
|
||||
ctx.check(back == want, "lamp did not return to %s after the restore (reads %s)", want, back)
|
||||
ctx.log("lid lamp follows the setting live and returns to %s", want)
|
||||
|
||||
|
||||
@test("forgectrl.panel-serves", title="Control panel and status endpoints", subsystem="forgectrl",
|
||||
kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"),
|
||||
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
|
||||
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
|
||||
("forgectrl", "src/curverec.*")],
|
||||
description="The panel page is served, /status carries the machine telemetry the panel and "
|
||||
"the acceptance tool read (including the sys block: CPU busy percent over the "
|
||||
"interval since the previous read, memory used percent; and homed_axes, "
|
||||
"the axes that carry a reference, Z alone once the lens has taken its "
|
||||
"own at the controller's start), and /cam/status "
|
||||
"answers. In GRBL mode with a live controller, /status also echoes the "
|
||||
"controller's published state file as the grbl block (fresh age, machine "
|
||||
"state, sender session, laser window and dose model, modal report), "
|
||||
"GET /grbl/settings serves the published $$ view, and the controller's "
|
||||
"settings store is /data/forgefirm/EEPROM-glowforge.DAT with nothing of "
|
||||
"it at the top of /data.")
|
||||
def panel_serves(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
st, body = fc.get("/", raw=True)
|
||||
ev["panel_status"] = st
|
||||
ctx.log("GET / -> %s (%d bytes)", st, len(body) if body else 0)
|
||||
ctx.check(st == 200, "GET / -> %s", st)
|
||||
text = body.decode("utf-8", "replace")
|
||||
ctx.check("<html" in text.lower() and "ForgeFIRM" in text, "the panel does not look like the panel")
|
||||
ctx.check(fc.token and fc.token in text, "the panel does not embed the bearer token")
|
||||
ctx.check("<link " not in text and "<script src=" not in text,
|
||||
"the panel references an external asset (the build did not bundle src/ui/)")
|
||||
# The daemon stores the page gzipped and inflates it once at first
|
||||
# request; what it serves is the plain page with the theme attribute
|
||||
# the head script sets and the one save bar every settings tab shares.
|
||||
ctx.check("data-bs-theme" in text, "the panel lacks the theme attribute (inflate failed?)")
|
||||
ctx.check('id="savebar"' in text, "the panel lacks the save bar")
|
||||
|
||||
s = fc.status()
|
||||
for key in ("state", "switches", "coolant", "fans"):
|
||||
ctx.check(key in s, "/status lacks %r", key)
|
||||
ev["state"] = s.get("state")
|
||||
ev["switches"] = s.get("switches")
|
||||
ctx.log("/status state=%s switches=%s", s.get("state"), s.get("switches"))
|
||||
for key in ("lid", "button", "interlock_ok", "head", "hv_enable"):
|
||||
ctx.check(key in (s.get("switches") or {}), "/status switches lacks %r", key)
|
||||
|
||||
# SoC utilization rides /status next to the temperatures. The CPU
|
||||
# number is a delta over the interval since the previous read, so
|
||||
# the read above primes it; after a beat both percents must be
|
||||
# numbers in range.
|
||||
ctx.sleep(1)
|
||||
sys_ = ctx.forgectrl.status().get("sys") or {}
|
||||
ev["sys"] = sys_
|
||||
ctx.log("/status sys=%s", sys_)
|
||||
ctx.check(isinstance(sys_.get("cpu_pct"), (int, float)) and 0.0 <= sys_["cpu_pct"] <= 100.0,
|
||||
"/status sys.cpu_pct is not a percent: %s", sys_)
|
||||
ctx.check(isinstance(sys_.get("mem_pct"), (int, float)) and 0.0 < sys_["mem_pct"] < 100.0,
|
||||
"/status sys.mem_pct is not a percent: %s", sys_)
|
||||
|
||||
# The lens takes its own reference on the hall edge at every
|
||||
# controller start, so with a controller running Z is referenced on
|
||||
# its own while X and Y wait for a home: homed_axes names the axes
|
||||
# that carry one, and Z reads inside the lens reach rather than the
|
||||
# zero an unreferenced axis would show.
|
||||
axes = s.get("homed_axes")
|
||||
ev["homed_axes"] = axes
|
||||
ctx.check(isinstance(axes, int) and 0 <= axes <= 7,
|
||||
"/status homed_axes is not an axis mask: %s", axes)
|
||||
ctx.check(bool(s.get("homed")) == (axes == 7),
|
||||
"/status homed (%s) disagrees with homed_axes (%s)",
|
||||
s.get("homed"), axes)
|
||||
st, mode0 = fc.get("/mode")
|
||||
if isinstance(mode0, dict) and mode0.get("controller") == "running":
|
||||
lens = s.get("lens") or {}
|
||||
pos = s.get("pos") or {}
|
||||
ctx.log("/status homed_axes=%s pos.z=%s reach=%s..%s", axes,
|
||||
pos.get("z"), lens.get("reach_min"), lens.get("reach_max"))
|
||||
ctx.check(axes is not None and axes & 4,
|
||||
"Z is not referenced with a controller running: %s", axes)
|
||||
ctx.check(isinstance(pos.get("z"), (int, float))
|
||||
and lens.get("reach_min") is not None
|
||||
and lens["reach_min"] <= pos["z"] <= lens["reach_max"],
|
||||
"Z %s is outside the lens reach %s..%s", pos.get("z"),
|
||||
lens.get("reach_min"), lens.get("reach_max"))
|
||||
|
||||
st, cam = fc.get("/cam/status")
|
||||
ev["cam_status"] = st
|
||||
ctx.log("GET /cam/status -> %s %s", st, cam)
|
||||
ctx.check(st == 200 and isinstance(cam, dict) and "running" in cam, "GET /cam/status -> %s", st)
|
||||
|
||||
# The controller's published state, echoed only while a live GRBL
|
||||
# controller runs (glowforge_status.c -> /run/forgefirm -> /status).
|
||||
st, mode = fc.get("/mode")
|
||||
if isinstance(mode, dict) and mode.get("mode") == "grbl" and mode.get("controller") == "running":
|
||||
g = ctx.forgectrl.status().get("grbl") or {}
|
||||
ev["grbl"] = g
|
||||
ctx.log("/status grbl=%s", g)
|
||||
ctx.check(isinstance(g.get("age_s"), (int, float)) and g["age_s"] < 30,
|
||||
"/status grbl block missing or stale: %s", g)
|
||||
rep = g.get("report") or {}
|
||||
for key in ("state", "sender", "laser", "modals"):
|
||||
ctx.check(key in rep, "/status grbl.report lacks %r", key)
|
||||
ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"),
|
||||
"grbl.report.laser carries no model: %s", rep.get("laser"))
|
||||
ctx.check((rep.get("laser") or {}).get("curve"),
|
||||
"grbl.report.laser carries no dose curve: %s", rep.get("laser"))
|
||||
st, text = fc.get("/grbl/settings", raw=True)
|
||||
ev["grbl_settings_status"] = st
|
||||
ctx.check(st == 200 and b"$35=" in (text or b""),
|
||||
"GET /grbl/settings -> %s without the $$ view", st)
|
||||
# The $-settings persist in the data directory, never loose in /data.
|
||||
nvs = "/data/forgefirm/EEPROM-glowforge.DAT"
|
||||
ev["grbl_nvs"] = os.path.isfile(nvs)
|
||||
ctx.check(os.path.isfile(nvs), "the controller's settings store is not at %s", nvs)
|
||||
ctx.check(not os.path.exists("/data/EEPROM-glowforge.DAT"),
|
||||
"a settings store remains at the top of /data")
|
||||
else:
|
||||
ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode)
|
||||
|
||||
# The dose-curve recorder's read surface answers in any mode.
|
||||
st, cs = fc.get("/curve/status")
|
||||
ev["curve_status"] = cs
|
||||
ctx.check(st == 200 and isinstance(cs, dict) and cs.get("state") in
|
||||
("idle", "waiting", "recording", "done", "failed"),
|
||||
"GET /curve/status -> %s %s", st, cs)
|
||||
st, text = fc.get("/curve/ladder.gcode", raw=True)
|
||||
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
|
||||
"GET /curve/ladder.gcode -> %s without the ladder", st)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The helpers for a test that lets the web service move the head.
|
||||
|
||||
The homing is the web-service session cloud.mode-switch runs. These judge
|
||||
that its motions ran whole, and take the head back to where the test found
|
||||
it by the travel the service's motions logged. No counter reading can say
|
||||
where that was: every
|
||||
service motion zeroes the step counters at its start, and a home and every
|
||||
controller start zero them again.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
from ..baseline import XY_STEPS_PER_MM, counter_frame, counter_steps_per_mm, read_position
|
||||
from ..runner import Failed
|
||||
from . import cloud as logs # the log paths, read at each call
|
||||
from .cloud import log_lines_since, log_size
|
||||
from .motion import _drop_reference, clean_slate, machine_idle, wait_idle
|
||||
|
||||
RETURN_MAX_MM = 600.0 # no hand-back jogs farther on an axis: the bed is smaller
|
||||
# The client's own record of a motion's end: the kernel counters, which
|
||||
# the motion zeroed at its start (the "(actual/expected)" line of the same
|
||||
# motion is not this one). A print's park keeps the counters and drives
|
||||
# them back, so the line after it is the print's net travel.
|
||||
MOTION_END = re.compile(r"end positions \((-?\d+), (-?\d+), (-?\d+)\)")
|
||||
|
||||
|
||||
def session_mark(path=None):
|
||||
"""Where a client's log ends now (gfhome's by default): the start of
|
||||
the next session's record."""
|
||||
return log_size(path or logs.GFHOME_LOG)
|
||||
|
||||
|
||||
def session_travel(offset, path=None):
|
||||
"""The X/Y travel of the service motions logged in a client's log
|
||||
(gfhome's by default) since offset, in steps, or None when it cannot be
|
||||
known. Every service motion zeroes the kernel counters at its start and
|
||||
logs where they ended, so the travel is the sum of those ends. A service
|
||||
motion runs at x8 or not at all (the client refuses any other mode), so
|
||||
the steps are x8 steps. A motion refused before it moved logs no end
|
||||
and moved nothing; a motion with no "end motion" (one that raised after
|
||||
it moved, or a client killed inside it) leaves the travel unknown, and
|
||||
so does a log rotated under the run."""
|
||||
path = path or logs.GFHOME_LOG
|
||||
if log_size(path) < offset:
|
||||
return None
|
||||
x = y = 0
|
||||
inside, end = False, None
|
||||
for ln in log_lines_since(path, offset):
|
||||
ln = ln.rstrip()
|
||||
m = MOTION_END.search(ln)
|
||||
if ln.endswith(" start motion"):
|
||||
if inside:
|
||||
return None
|
||||
inside, end = True, None
|
||||
elif m and inside:
|
||||
end = (int(m.group(1)), int(m.group(2)))
|
||||
elif ln.endswith(" end motion") and inside:
|
||||
if end:
|
||||
x, y = x + end[0], y + end[1]
|
||||
inside = False
|
||||
return None if inside else (x, y)
|
||||
|
||||
|
||||
def judge_whole_motions(ctx, ev, offset):
|
||||
"""Every service motion of the homing session since offset ran whole.
|
||||
One that stopped short (the cooling engine's stop, a fault) left the
|
||||
head short of the camera home, and the position declared there is
|
||||
false."""
|
||||
short = [ln.strip()[:160] for ln in log_lines_since(logs.GFHOME_LOG, offset)
|
||||
if "run stopped short" in ln
|
||||
or ("_finish_action motion [" in ln and 'event ":completed"' not in ln)]
|
||||
ev["short_motions"] = short
|
||||
ctx.check(not short, "a service motion of the homing did not run whole, so the head stopped short of "
|
||||
"the camera home and the declared position is false: %s", short)
|
||||
|
||||
|
||||
def camera_home_return(ctx, ev, offset):
|
||||
"""Hand the head back to where the test found it after gfhome_homing
|
||||
ran from gfhome's log offset, whatever the session decided. The head is
|
||||
the session's logged travel from there, plus, once homed, whatever the
|
||||
counters read since: the home zeroed them where the session left the
|
||||
head and wrote the anchor, and a controller start since would have
|
||||
removed it. Unhomed, the counters still hold the last motion's end,
|
||||
which the travel already counts. The camera home is dropped first,
|
||||
since its envelope need not hold the spot the head came from."""
|
||||
def back():
|
||||
now = [0, 0, 0]
|
||||
if ev.get("homed"):
|
||||
now = read_position() if counter_frame() is not None else None
|
||||
_drop_reference(ctx, ctx.forgectrl)
|
||||
# Read after the stop: a runner the stop ended has written its last line.
|
||||
travel = session_travel(offset)
|
||||
rec = ev["return"] = {"session_travel_steps": travel, "counters_since_home": now}
|
||||
ctx.check(travel is not None and now is not None,
|
||||
"the homing session's travel cannot be known (gfhome log %s, counters since the home %s): "
|
||||
"the head is left where it stands and the hand-back moves nothing", travel, now)
|
||||
_jog_back(ctx, rec, travel, now)
|
||||
_hand_back(ctx, ev, "return", back)
|
||||
|
||||
|
||||
def cloud_mode_return(ctx, ev, offset):
|
||||
"""Hand the head back to where the test found it after a stay in cloud
|
||||
mode that began at the cloud client's log offset, on the GRBL
|
||||
controller the switch back started: that start zeroed the counters
|
||||
where the service left the head, and they hold whatever moved since.
|
||||
The client's record of its motions is the rest (a hunt's lens travel is
|
||||
Z, which a hand-back never touches)."""
|
||||
def back():
|
||||
travel = session_travel(offset, logs.GFCLOUD_LOG)
|
||||
now = read_position()
|
||||
rec = ev["cloud_return"] = {"session_travel_steps": travel, "counters_since_start": now}
|
||||
ctx.check(travel is not None and now is not None,
|
||||
"the travel in cloud mode cannot be known (gfcloud log %s, counters %s): the head is left "
|
||||
"where it stands and the hand-back moves nothing", travel, now)
|
||||
_jog_back(ctx, rec, travel, now)
|
||||
_hand_back(ctx, ev, "cloud_return", back)
|
||||
|
||||
|
||||
def _hand_back(ctx, ev, key, back):
|
||||
"""Run a hand-back from a test's finally. The first failure wins: when
|
||||
the test has failed already (the exception in flight), a hand-back that
|
||||
fails as well is logged and recorded, and the test's own failure is the
|
||||
one reported."""
|
||||
first = sys.exc_info()[1]
|
||||
try:
|
||||
back()
|
||||
except Failed as e:
|
||||
if first is None:
|
||||
raise
|
||||
ev.setdefault(key, {})["failed"] = str(e)
|
||||
ctx.log("the head is not back, and the run has failed already: %s", e)
|
||||
|
||||
|
||||
def _jog_back(ctx, rec, travel, since):
|
||||
"""Jog the head back by the service's travel (x8 steps) and what the
|
||||
counters read since (the controller's own steps), on a controller
|
||||
without a home. One more start then zeroes the counters where the head
|
||||
began, which the rest of the catalog counts on."""
|
||||
spm = counter_steps_per_mm()
|
||||
dx = travel[0] / XY_STEPS_PER_MM + since[0] / spm
|
||||
dy = travel[1] / XY_STEPS_PER_MM + since[1] / spm
|
||||
rec["mm"] = [round(dx, 3), round(dy, 3)]
|
||||
ctx.log("the head is %.3f/%.3f mm from where the test found it", dx, dy)
|
||||
ctx.check(abs(dx) <= RETURN_MAX_MM and abs(dy) <= RETURN_MAX_MM,
|
||||
"a travel of %.1f/%.1f mm exceeds %.0f mm - not jogging back", dx, dy, RETURN_MAX_MM)
|
||||
if abs(dx) >= 0.05 or abs(dy) >= 0.05:
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
start = read_position()
|
||||
r = g.command("$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy))
|
||||
ctx.check(not any(x.startswith("error") for x in r), "the return jog was refused: %s", r)
|
||||
states = wait_idle(ctx, g, timeout=60)[1]
|
||||
ctx.check(states[-1:] != ["TIMEOUT"], "the return jog did not end: %s", states)
|
||||
machine_idle(ctx)
|
||||
end = read_position()
|
||||
rec["counters_across_jog"] = [start, end]
|
||||
ctx.check(start is not None and end is not None and abs((end[0] - start[0]) / spm + dx) < 0.1
|
||||
and abs((end[1] - start[1]) / spm + dy) < 0.1,
|
||||
"the return jog did not travel %.3f/%.3f mm: counters %s -> %s", -dx, -dy, start, end)
|
||||
_drop_reference(ctx, ctx.forgectrl)
|
||||
ctx.counters_rezeroed()
|
||||
ctx.log("head returned to where the test found it (%.3f/%.3f mm)", -dx, -dy)
|
||||
@@ -4,11 +4,9 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""image.* - the post-flash health of the running image (always-required)."""
|
||||
import glob
|
||||
import gzip
|
||||
import mmap
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import re
|
||||
import stat
|
||||
@@ -111,7 +109,8 @@ def fds_of(pid):
|
||||
|
||||
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
|
||||
always=True, est_min=1,
|
||||
covers=[("forgectrl", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
|
||||
covers=[("forgectrl", "init/**"), ("forgeext", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
|
||||
("forgectrl", "src/status.*"),
|
||||
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
|
||||
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
|
||||
("linux-fslc", "**")],
|
||||
@@ -119,10 +118,20 @@ def fds_of(pid):
|
||||
"version stamped in /etc/forgefirm-version and under the machine mark in the "
|
||||
"console banner and the motd, "
|
||||
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
|
||||
"the daemon ownership, "
|
||||
"the init ordering, the file modes the release depends on, and the mounts: the "
|
||||
"the daemon ownership, the board temperatures /status reports (the chassis and "
|
||||
"the SoC die in a plausible range, the supply as a 10-bit count, the CPU "
|
||||
"unthrottled), "
|
||||
"the extension host as one process on a machine with no package installed and "
|
||||
"nothing running under a pool account, no userspace watchdog running, "
|
||||
"the data directory open for search on a machine with an account, the file modes the release depends on, and the mounts: the "
|
||||
"rootfs read-only, /data writable, the account files and the banner rendered "
|
||||
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only.")
|
||||
"into tmpfs, the sshd host keys on /data, the dev image's factory slots read-only. "
|
||||
"And the boot does not wait on the network: ifup has returned for wlan0 (it is "
|
||||
"in the state file and no ifup is running) and the console login is up; each "
|
||||
"DHCP client runs with -b and has left ifup (its parent is pid 1); no DHCPv6 "
|
||||
"client is running; and IPv6 is the "
|
||||
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
|
||||
"address up (a global address is logged, not checked).")
|
||||
def image_health(ctx):
|
||||
ev = ctx.evidence
|
||||
manifest = ctx.runner.manifest
|
||||
@@ -258,21 +267,40 @@ def image_health(ctx):
|
||||
"controller is %r (expected running or standby)", mode.get("controller"))
|
||||
ctx.check(mode.get("motion") != "fault", "supervisor reports a motion fault")
|
||||
|
||||
# 5. init ordering: controllers stop before the daemon
|
||||
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*"))
|
||||
ev["rc6_kill"] = k
|
||||
kg = [x for x in k if "grblhal" in x]
|
||||
kf = [x for x in k if x.endswith("forgectrl")]
|
||||
ctx.log("rc6.d: %s", " ".join(k))
|
||||
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links")
|
||||
ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[0])
|
||||
# 4b. the board temperatures /status carries, watched and not gated:
|
||||
# the chassis and the SoC die in degrees, the supply as its raw count,
|
||||
# and the CPU unthrottled at idle.
|
||||
temps = (ctx.forgectrl.status() or {}).get("temps") or {}
|
||||
ev["temps"] = temps
|
||||
ctx.log("temps: %s", temps)
|
||||
ctx.check(isinstance(temps.get("chassis_c"), (int, float)) and 5.0 <= temps["chassis_c"] <= 80.0,
|
||||
"/status temps.chassis_c is not a plausible chassis temperature: %s", temps)
|
||||
ctx.check(isinstance(temps.get("soc_c"), (int, float)) and 10.0 <= temps["soc_c"] <= 100.0,
|
||||
"/status temps.soc_c is not a plausible die temperature: %s", temps)
|
||||
ctx.check(isinstance(temps.get("supply_raw"), int) and 0 <= temps["supply_raw"] <= 1023,
|
||||
"/status temps.supply_raw is not a 10-bit count: %s", temps)
|
||||
ctx.check(temps.get("soc_throttle") == 0,
|
||||
"the CPU is throttled (or its cooling device is missing) at idle: %s", temps)
|
||||
|
||||
# 6. logging lever present, no userspace watchdog daemon
|
||||
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
|
||||
ev["logging_init"] = logging
|
||||
ctx.check(logging, "no ForgeFIRM logging/logrotate init script")
|
||||
ctx.check(not os.path.exists("/etc/init.d/watchdog"), "a userspace watchdog init script is present")
|
||||
# kernel threads (the imx2_wdt kthread is expected) have an empty cmdline
|
||||
# 5. the extension host: one process, and a campaign's machine is
|
||||
# extension-free (its init order is the rootfs lint's)
|
||||
host = []
|
||||
for pid in hw.pidof("forgeext"):
|
||||
argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
|
||||
if argv[:2] == [hw.FORGEEXT, "run"]:
|
||||
host.append(pid)
|
||||
ev["extension_host_pids"] = host
|
||||
ctx.check(len(host) == 1, "the extension host is not one running process: %s", host)
|
||||
ev["extension_packages"] = hw.ext_packages()
|
||||
ev["pool_processes"] = len(hw.pool_pids())
|
||||
ctx.log("extension host pid %s, packages %s, pool processes %d", host, ev["extension_packages"], ev["pool_processes"])
|
||||
ctx.check(not ev["extension_packages"], "a campaign wants an extension-free machine: installed %s",
|
||||
ev["extension_packages"])
|
||||
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"])
|
||||
|
||||
# 6. no userspace watchdog daemon running (the logging init and the
|
||||
# absent watchdog init script are the rootfs lint's); kernel threads
|
||||
# (the imx2_wdt kthread is expected) have an empty cmdline
|
||||
user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")]
|
||||
ev["userspace_watchdog_pids"] = user_wd
|
||||
ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", user_wd)
|
||||
@@ -284,6 +312,14 @@ def image_health(ctx):
|
||||
ev[path] = "%o" % m
|
||||
ctx.log("%s mode %o", path, m)
|
||||
ctx.check(m == 0o600, "%s mode %o, expected 600", path, m)
|
||||
# an operator account walks through the data directory to its home, and a
|
||||
# package's account to its files: forgefirm-users opens it for search at
|
||||
# every render, whoever made it and under whatever umask
|
||||
if os.path.exists("/data/forgefirm/users"):
|
||||
m = stat.S_IMODE(os.stat("/data/forgefirm").st_mode)
|
||||
ev["/data/forgefirm"] = "%o" % m
|
||||
ctx.log("/data/forgefirm mode %o", m)
|
||||
ctx.check(m & 0o011 == 0o011, "/data/forgefirm mode %o: no account can walk through it to its home", m)
|
||||
if os.path.isdir("/data"):
|
||||
s = os.statvfs("/data")
|
||||
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
|
||||
@@ -294,8 +330,9 @@ def image_health(ctx):
|
||||
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
||||
# the state a read-only rootfs hands off (the read-only-rootfs image
|
||||
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
|
||||
# sshd host keys). The
|
||||
# dev image alone mounts the factory slots under /factory.
|
||||
# sshd host keys). The dev image's factory slots under /factory are
|
||||
# read-only; rcS's ROOTFS_READ_ONLY and the release fstab with no
|
||||
# factory slot are the rootfs lint's.
|
||||
mounts = {}
|
||||
for line in (_read("/proc/mounts", "") or "").splitlines():
|
||||
parts = line.split()
|
||||
@@ -313,8 +350,6 @@ def image_health(ctx):
|
||||
ctx.check("rw" in mount_opts("/data"), "/data is not mounted read-write: %s", mounts.get("/data"))
|
||||
ctx.check("rw" in mount_opts("/var/lib"),
|
||||
"/var/lib is not a writable copy (read-only-rootfs-hook.sh): %s", mounts.get("/var/lib"))
|
||||
rcs = _read("/etc/default/rcS", "") or ""
|
||||
ctx.check("ROOTFS_READ_ONLY=yes" in rcs.splitlines(), "/etc/default/rcS lacks ROOTFS_READ_ONLY=yes")
|
||||
factory = sorted(p for p in mounts if p.startswith("/factory/"))
|
||||
ev["factory_mounts"] = factory
|
||||
dev_image = os.path.exists("/etc/forgefirm-dev")
|
||||
@@ -325,8 +360,6 @@ def image_health(ctx):
|
||||
if os.path.exists("/dev/mmcblk2p%d" % n):
|
||||
p = "/factory/img%d" % n
|
||||
ctx.check("ro" in mount_opts(p), "%s is not mounted read-only on the dev image: %s", p, mounts.get(p))
|
||||
else:
|
||||
ctx.check(not factory, "a release image mounts the factory slots: %s", factory)
|
||||
if os.path.isfile("/data/forgefirm/users"):
|
||||
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
|
||||
if l.strip() and not l.startswith("#")]
|
||||
@@ -348,59 +381,48 @@ def image_health(ctx):
|
||||
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
|
||||
ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components))
|
||||
|
||||
# 10. the network boot. init starts the rest of the boot, the console
|
||||
# login last, only after the networking script returns, so a client
|
||||
# that waits in ifup's foreground on a server's answer holds the
|
||||
# whole machine. The interfaces file starts no DHCPv6 client
|
||||
# (test_image.py reads the layer's file); the bounded DHCP client
|
||||
# (-b, which leaves ifup after three unanswered discovers) is checked
|
||||
# here, on the client that runs.
|
||||
procs = _procs()
|
||||
up = ifstate_names(_read(IFSTATE, ""))
|
||||
ev["ifstate"] = sorted(up)
|
||||
ctx.check("wlan0" in up, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(up))
|
||||
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
|
||||
ev["ifup_running"] = [p[3] for p in stuck]
|
||||
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
|
||||
logins = [p[3] for p in procs if p[2] == "getty"]
|
||||
ev["getty"] = logins
|
||||
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
|
||||
v4 = [p for p in procs if p[2] == "udhcpc"]
|
||||
ev["udhcpc"] = [p[3] for p in v4]
|
||||
ctx.check(v4, "no DHCP client is running")
|
||||
for pid, ppid, _comm, cmd in v4:
|
||||
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
|
||||
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
|
||||
# no DHCPv6 client running (none on the image is the rootfs lint's)
|
||||
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
|
||||
ev["dhcp6_running"] = alive
|
||||
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
|
||||
# IPv6 is the kernel's own. A global address is evidence only: a
|
||||
# network whose router advertisement offers no SLAAC prefix gives none.
|
||||
conf = "/proc/sys/net/ipv6/conf/wlan0/"
|
||||
ev["disable_ipv6"] = (_read(conf + "disable_ipv6", "") or "").strip()
|
||||
ev["accept_ra"] = (_read(conf + "accept_ra", "") or "").strip()
|
||||
ctx.check(ev["disable_ipv6"] == "0", "IPv6 is off on wlan0 (disable_ipv6=%r)", ev["disable_ipv6"])
|
||||
ctx.check(ev["accept_ra"] not in ("", "0"),
|
||||
"wlan0 does not accept router advertisements (accept_ra=%r)", ev["accept_ra"])
|
||||
local, world = link_addrs(_read("/proc/net/if_inet6", ""), "wlan0")
|
||||
ev["link_local"] = local
|
||||
ev["global"] = world
|
||||
ctx.check(local, "wlan0 has no link-local IPv6 address")
|
||||
ctx.log("wlan0: %d link-local, %d global IPv6 address(es)", len(local), len(world))
|
||||
|
||||
@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
|
||||
@@ -473,68 +495,3 @@ def link_addrs(text, iface):
|
||||
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
-501
@@ -5,15 +5,14 @@
|
||||
|
||||
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
|
||||
|
||||
Every test here can emit, except `laser.power-floor`, which only reads
|
||||
settings. The page starts a firing one only with the operator's
|
||||
eye-protection / fire-watch / exhaust acknowledgment; the test then
|
||||
prompts for the scrap and the button, streams a small job through the
|
||||
controller, and the machine fires only after the operator presses the
|
||||
physical arm button - nothing here defeats that gate, and forgetest
|
||||
never touches the laser latch. One emission per test; on abort or error
|
||||
the job is soft-reset (`^X`: controlled stop, latch relocked). The
|
||||
witnesses are forgectrl's `/status` (`laser.emission_samples` = the
|
||||
The live tests here emit. The page starts a firing one only with the
|
||||
operator's eye-protection / fire-watch / exhaust acknowledgment; the
|
||||
test then prompts for the scrap and the button, streams a small job
|
||||
through the controller, and the machine fires only after the operator
|
||||
presses the physical arm button - nothing here defeats that gate, and
|
||||
forgetest never touches the laser latch. One emission per test; on abort
|
||||
or error the job is soft-reset (`^X`: controlled stop, latch relocked).
|
||||
The witnesses are forgectrl's `/status` (`laser.emission_samples` = the
|
||||
kernel's LASER_ON sample count, `hv_current_raw`, `lid_ir`),
|
||||
`/cool/status` (`armed`), the head's beam detector
|
||||
(`head/beam_detect_analog`, the tube's own emission seen from the head:
|
||||
@@ -29,41 +28,12 @@ import time
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
from ..runner import Failed
|
||||
from .motion import (kernel_xy_mm, kernel_start, check_kernel_returned, wait_state,
|
||||
wait_state_text, wait_left_state, wait_idle, drain_text, Watch)
|
||||
from .motion import kernel_start, check_kernel_returned, Watch
|
||||
|
||||
_LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
|
||||
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"),
|
||||
("forgectrl", "src/status.c"), ("forgectrl", "src/main.c")]
|
||||
|
||||
# The S-range floor the controller derives $35 from at every precompute
|
||||
# (glowforge_laser.c: laser_floor_density, this default when the config
|
||||
# carries no key): the lowest pulse density that still marks, against
|
||||
# the hardware's 127-count PWM period. Density is the only dose model.
|
||||
FLOOR_DEFAULT = 10.0
|
||||
PWM_PERIOD = 127
|
||||
PWMSAR_FLOOR_MIN = 12
|
||||
|
||||
|
||||
def _floor(fc):
|
||||
"""The configured floor percent, default applied."""
|
||||
raw = (fc.settings().get("laser_floor_density") or "").strip()
|
||||
try:
|
||||
return float(raw) if raw else FLOOR_DEFAULT
|
||||
except ValueError:
|
||||
return FLOOR_DEFAULT
|
||||
|
||||
|
||||
def _grbl_settings(lines):
|
||||
vals = {}
|
||||
for ln in lines:
|
||||
key, _, val = ln.partition("=")
|
||||
if key.startswith("$"):
|
||||
try:
|
||||
vals[key] = float(val)
|
||||
except ValueError:
|
||||
pass
|
||||
return vals
|
||||
|
||||
ARM_CUE = ("LIVE FIRE. Eye protection on. Exhaust on. Extinguisher in reach. "
|
||||
"Put scrap under the head with %s clear, and close the lid.")
|
||||
@@ -321,6 +291,77 @@ def dwell_gap(samples):
|
||||
"hv_enable_dipped": dip is not None, "hv_enable_back_lit": back_lit}
|
||||
|
||||
|
||||
def read_gaps(samples, key, t_end=None):
|
||||
"""Over a sample trail: how many samples carry a reading for `key`,
|
||||
how many do not, and the longest stretch of seconds between two
|
||||
readings, the trail's ends counted as edges (an unread lead or tail
|
||||
is a gap too; a trail with no reading is one gap, its whole span).
|
||||
The trail ends when its sampling stopped (`t_end`), not at the last
|
||||
sample served: a tail the daemon did not serve at all is a gap too.
|
||||
A reading the daemon could not serve is a gap, never a dark sample:
|
||||
/status leaves the laser block out when the kernel's count did not
|
||||
read, and the count latches once per ~1 s window, so a window with
|
||||
no read in it is a window in which a fire is not witnessed."""
|
||||
if not samples:
|
||||
return {"read": 0, "unread": 0, "gap_max_s": None}
|
||||
ts = [s["t"] for s in samples if s.get(key) is not None]
|
||||
last = samples[-1]["t"] if t_end is None else max(t_end, samples[-1]["t"])
|
||||
edges = [samples[0]["t"]] + ts + [last]
|
||||
gap = max(b - a for a, b in zip(edges, edges[1:]))
|
||||
return {"read": len(ts), "unread": len(samples) - len(ts), "gap_max_s": round(gap, 3)}
|
||||
|
||||
|
||||
def rapid_place(stretch, x0):
|
||||
"""Where the head's reports place the rapid after the M5, judged over
|
||||
the armed part of the dark stretch alone: its samples before the first
|
||||
disarmed one. Returns (i_out, i_back, i_closed): the first sample with
|
||||
the head at least 19.5 mm out, the first after it with the head back
|
||||
at x0 (within 0.05 mm), and the first disarmed sample; each None when
|
||||
there is none. A window that closes before the head is back leaves
|
||||
i_back None: only a sample that read the window open places the head
|
||||
back, and sample_to_disarm reads its X before its armed flag, so the
|
||||
head was back with the window open. The first disarmed sample does
|
||||
not tell whether the head came back before the close or after it."""
|
||||
i_closed = next((i for i, s in enumerate(stretch) if not s["armed"]), None)
|
||||
part = stretch if i_closed is None else stretch[:i_closed]
|
||||
i_out = next((i for i, s in enumerate(part) if s.get("x") is not None and s["x"] - x0 >= 19.5), None)
|
||||
i_back = (next((i for i in range(i_out + 1, len(part))
|
||||
if part[i].get("x") is not None and abs(part[i]["x"] - x0) < 0.05), None)
|
||||
if i_out is not None else None)
|
||||
return i_out, i_back, i_closed
|
||||
|
||||
|
||||
def sample_to_disarm(ctx, g, timeout=20.0, sample_hz=8):
|
||||
"""Sample until the armed window closes, or the timeout, with the
|
||||
controller's state and machine X beside each sample ("x"), read just
|
||||
before the sample: a sample that reads the window open read its X
|
||||
with the window open (rapid_place). A G4 dwell reports Idle, so Idle
|
||||
cannot end this; the window closing at the program end does, after
|
||||
two more kernel windows and the read gap allowed: the LASER_ON count
|
||||
for the window in flight at the close latches up to one window later
|
||||
and is held through the next, so the sampling runs on until that
|
||||
count has been read. Returns the samples served and the time the
|
||||
sampling stopped, the trail's end for the read rule."""
|
||||
samples = []
|
||||
period = 1.0 / sample_hz
|
||||
t0 = time.time()
|
||||
end = t0 + timeout
|
||||
t_closed = None
|
||||
while time.time() < end:
|
||||
ctx.checkpoint()
|
||||
rep = g.status_report()
|
||||
smp = sample(ctx)
|
||||
if smp:
|
||||
smp["gstate"] = rep.get("state")
|
||||
smp["x"] = (rep.get("MPos") or (None,))[0]
|
||||
samples.append(smp)
|
||||
if t_closed is None and not smp["armed"]:
|
||||
t_closed = time.time()
|
||||
end = t_closed + 2 * EMISSION_WINDOW_S + EMISSION_READ_GAP_S
|
||||
time.sleep(period)
|
||||
return samples, time.time()
|
||||
|
||||
|
||||
def run_and_sample(ctx, g, job, sample_hz=8, overall_timeout=200):
|
||||
"""Stream the job; sample forgectrl through arm -> fire -> disarm.
|
||||
Completes on: emission seen then Idle > 3 s; or armed then disarmed
|
||||
@@ -404,45 +445,6 @@ def wait_disarm(ctx, timeout):
|
||||
return None
|
||||
|
||||
|
||||
@test("laser.power-floor", title="The derived floor is the one in force",
|
||||
subsystem="laser", kind="auto", mode="grbl", est_min=1,
|
||||
covers=[("grblhal-glowforge", "src/**"), ("forgectrl", "src/main.c")],
|
||||
description="The controller derives $35 from the floor key (laser_floor_density) at "
|
||||
"every spindle precompute, boot included, and never from a typed value. "
|
||||
"Unfloored, the low end of S asks for pulses too far apart to re-strike, "
|
||||
"so a commanded 1 % would emit nothing. Reads the floor key from forgectrl "
|
||||
"and $$ from the controller, and checks $35 is that floor, that it lands at "
|
||||
"or above the measured minimum, and that $31 is 0 (the floor, not $31, sets "
|
||||
"the bottom of the range).")
|
||||
def power_floor(ctx):
|
||||
ev = ctx.evidence
|
||||
want = _floor(ctx.forgectrl)
|
||||
ev["configured_floor"] = want
|
||||
with ctx.grbl() as g:
|
||||
lines = g.command("$$", timeout=5)
|
||||
vals = _grbl_settings(lines)
|
||||
ctx.check(vals, "no $-settings in the response to $$")
|
||||
|
||||
floor_pct = vals.get("$35")
|
||||
rpm_min = vals.get("$31")
|
||||
ev["settings"] = {k: vals.get(k) for k in ("$30", "$31", "$32", "$35", "$36")}
|
||||
ctx.check(floor_pct is not None, "$35 missing from the settings report")
|
||||
counts = int(PWM_PERIOD * floor_pct / 100.0)
|
||||
ev["pwmsar_floor"] = counts
|
||||
ctx.log("$35=%.1f%% -> PWMSAR %d of %d ($31=%s, $32=%s, $36=%s)", floor_pct, counts,
|
||||
PWM_PERIOD, rpm_min, vals.get("$32"), vals.get("$36"))
|
||||
|
||||
ctx.check(abs(floor_pct - want) < 0.05,
|
||||
"$35 is %.1f %%, expected the derived floor %.1f %% from the machine config",
|
||||
floor_pct, want)
|
||||
ctx.check(counts >= PWMSAR_FLOOR_MIN,
|
||||
"the floor lands at PWMSAR %d, below the %d the tube needs",
|
||||
counts, PWMSAR_FLOOR_MIN)
|
||||
ctx.check(rpm_min == 0, "$31 is %s, not 0: the bottom of the S range is no longer the floor",
|
||||
rpm_min)
|
||||
ctx.log("PASS: nonzero S never commands less than PWMSAR %d", counts)
|
||||
|
||||
|
||||
@test("laser.emission-witness", title="Live emission witness (S400 vector mark) and job-based disarm",
|
||||
subsystem="laser", kind="live", mode="grbl", always=True, est_min=5,
|
||||
covers=_LASER_COVERS,
|
||||
@@ -456,15 +458,30 @@ def power_floor(ctx):
|
||||
"(G4) between its second and third sides: emission_samples (the kernel's "
|
||||
"LASER_ON sample count) goes nonzero during the fire window and returns to 0 "
|
||||
"at Idle, HV current rises during the burn, the head's beam detector sees the "
|
||||
"beam, the armed window is observed, the M2 program end disarms promptly at "
|
||||
"Idle (job-based, not the 60 s idle grace) and the button goes dark. Across "
|
||||
"the dwell the kernel run ends and restarts: HV_ENABLE drops with the "
|
||||
"charge-pump watchdog and is back for the third side, while the hardware "
|
||||
"button latch stays clear (cnc/button_latch 0 in every armed sample), so the "
|
||||
"second half of the square marks without a second press. The operator "
|
||||
"confirms all four sides.")
|
||||
"beam, and the armed window is observed. Across the dwell the kernel run ends "
|
||||
"and restarts: HV_ENABLE drops with the charge-pump watchdog and is back for "
|
||||
"the third side, while the hardware button latch stays clear (cnc/button_latch "
|
||||
"0 in every armed sample), so the second half of the square marks without a "
|
||||
"second press. The square ends at M5 with the window still open; once the "
|
||||
"LASER_ON count reads 0 (the dark point), a rapid runs 20 mm out over the "
|
||||
"first side and back, then the program ends. From the dark point until the "
|
||||
"window closes, and on until the count of the window in flight at the close is "
|
||||
"read, every sample reads no emission and the HV current at its idle reading, "
|
||||
"the kernel's LASER_ON count read in every ~1 s window of that stretch (a "
|
||||
"reading the daemon could not serve is a gap, never a dark sample, and so is a "
|
||||
"tail it served none of), and the head's reports place the rapid inside the "
|
||||
"window, between the dark point and the close: LASER_ON stays off with FIRE "
|
||||
"clear while the chain can fire. The M2 program end disarms promptly after the "
|
||||
"head is back (job-based, not the 60 s idle grace) and the button goes "
|
||||
"dark. The operator confirms all four sides.")
|
||||
def emission_witness(ctx):
|
||||
ev = ctx.evidence
|
||||
# The rapid after the M5 (G91 is modal from the square): out over the
|
||||
# first side and back, then the program end. The head stands back for
|
||||
# 1 s before the M2, so that a sample of a slow sampling loop still
|
||||
# reads it back with the window open (rapid_place).
|
||||
rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P1", "G90", "M2"]
|
||||
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
base = sample(ctx)
|
||||
@@ -474,21 +491,46 @@ def emission_witness(ctx):
|
||||
base["armed"], base["verdict"])
|
||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
ctx.ready(ARM_CUE % "20 mm +X and +Y")
|
||||
# The square ends at M5 with no program end: the window stays open
|
||||
# for the rapid that follows.
|
||||
job = ["G91", "G21", "M4", "S400",
|
||||
"G1 X20 F600", "G1 Y20 F600", "G4 P2", "G1 X-20 F600", "G1 Y-20 F600",
|
||||
"M5", "G90", "M2"]
|
||||
"M5"]
|
||||
ctx.arm_press()
|
||||
try:
|
||||
samples = run_and_sample(ctx, g, job)
|
||||
cut = run_and_sample(ctx, g, job)
|
||||
# The dark point: the M5 has run, the LASER_ON count reads 0, and
|
||||
# the window is still open. The rapid is sent only after it, so
|
||||
# the dark stretch covers the rapid by construction, and the
|
||||
# mid-square dwell's own zero cannot be taken for it.
|
||||
ctx.wait_for(lambda: (sample(ctx) or {}).get("emission") == 0, 10, poll=0.125)
|
||||
dark = sample(ctx)
|
||||
ctx.check(dark and dark["emission"] == 0,
|
||||
"the LASER_ON count did not return to 0 after the M5 (%s)", dark and dark["emission"])
|
||||
ctx.check(dark["armed"], "the armed window closed after the M5, before the rapid "
|
||||
"(phase %s): the rapid would not run under it", dark["phase"])
|
||||
# The engine's armed flag can still read true after the latch has
|
||||
# locked, and a dark reading with the latch locked is the unarmed
|
||||
# case this sample is here to exclude: the SoC lock line must read
|
||||
# released.
|
||||
ctx.check(dark["locked"] is False, "the laser latch is locked at the dark point "
|
||||
"(laser_locked %s): the rapid would run unarmed", dark["locked"])
|
||||
rep = g.status_report()
|
||||
dark["gstate"] = rep.get("state")
|
||||
dark["x"] = (rep.get("MPos") or (None,))[0]
|
||||
ctx.check(dark["x"] is not None, "the controller reports no machine position: %s", rep)
|
||||
stream(g, rapid_tail)
|
||||
tail, t_end = sample_to_disarm(ctx, g)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
samples = cut + [dark] + tail
|
||||
ev["trail_fields"] = list(TRAIL_FIELDS)
|
||||
ev["trail"] = trail(samples)
|
||||
beam = beam_witness(ctx, ev, samples, base)
|
||||
emis = [s["emission"] for s in samples if s["emission"] is not None]
|
||||
beam = beam_witness(ctx, ev, cut, base)
|
||||
emis = [s["emission"] for s in cut if s["emission"] is not None]
|
||||
peak = max(emis) if emis else 0
|
||||
end = emis[-1] if emis else None
|
||||
hv = [s["hv"] for s in samples if s["hv"] is not None]
|
||||
end = next((s["emission"] for s in reversed(samples) if s["emission"] is not None), None)
|
||||
hv = [s["hv"] for s in cut if s["hv"] is not None]
|
||||
ir_peak = [0, 0, 0, 0]
|
||||
for s in samples:
|
||||
if s["ir"] and len(s["ir"]) == 4:
|
||||
@@ -532,12 +574,43 @@ def emission_witness(ctx):
|
||||
ev["exhaust_floor"] = floor
|
||||
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
|
||||
ev["exhaust_at_first_fire"], floor)
|
||||
# X-3: job-based disarm at Idle after M2
|
||||
dt = wait_disarm(ctx, 75)
|
||||
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
||||
ctx.log("time-to-disarm after Idle: %s s", ev["disarm_after_idle_s"])
|
||||
# The rapid after the M5, with the window open and FIRE clear: every
|
||||
# sample from the dark point until the sampling stops (two windows and
|
||||
# the read gap allowed past the close) reads no emission and the HV
|
||||
# current at its idle reading, and the kernel's LASER_ON count was read
|
||||
# in every ~1 s window of that stretch, the one that latches after the
|
||||
# close included: the end of the sampling is the trail's last edge. A
|
||||
# reading the daemon could not serve is a gap, never a dark sample.
|
||||
# The head's reports place the rapid inside the window, from the dark
|
||||
# point to the close: out to +20 mm, then back at the start.
|
||||
stretch = [dark] + tail
|
||||
x0 = dark["x"]
|
||||
i_out, i_back, i_closed = rapid_place(stretch, x0)
|
||||
closed = stretch[i_closed] if i_closed is not None else None
|
||||
armed_part = stretch if i_closed is None else stretch[:i_closed + 1]
|
||||
relit = [s for s in stretch if s["emission"]]
|
||||
hv_dark = max((s["hv"] for s in stretch if s["hv"] is not None), default=0)
|
||||
reads = read_gaps(stretch, "emission", t_end)
|
||||
hv_reads = read_gaps(stretch, "hv", t_end)
|
||||
dt = (closed["t"] - stretch[i_back]["t"]) if (closed and i_back is not None) else None
|
||||
ev["rapid"] = {"samples": len(armed_part), "span_s": round((closed or stretch[-1])["t"] - dark["t"], 2),
|
||||
"x_start": x0, "x_max": max((s["x"] for s in armed_part if s.get("x") is not None), default=None),
|
||||
"out_at_s": round(stretch[i_out]["t"] - dark["t"], 2) if i_out is not None else None,
|
||||
"back_at_s": round(stretch[i_back]["t"] - dark["t"], 2) if i_back is not None else None,
|
||||
"relit": [(round(s["t"] - dark["t"], 2), s["emission"]) for s in relit][:8],
|
||||
"hv_max": hv_dark, "locked_at_dark": dark["locked"],
|
||||
"locked_at_out": stretch[i_out]["locked"] if i_out is not None else None,
|
||||
"sampled_past_close_s": round(t_end - closed["t"], 2) if closed else None,
|
||||
"emission_reads": reads, "hv_reads": hv_reads}
|
||||
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
||||
ctx.log("the rapid after the M5: %d samples over %.1f s from the dark point to the close, out at +%s s, "
|
||||
"back at +%s s; sampled %s s past the close, %d of %d samples with emission, HV max %s, %d of %d "
|
||||
"carried the LASER_ON count, longest unread stretch %s s; the window closed %s s after the head "
|
||||
"was back", len(armed_part), ev["rapid"]["span_s"], ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"],
|
||||
ev["rapid"]["sampled_past_close_s"], len(relit), len(stretch), hv_dark, reads["read"],
|
||||
len(stretch), reads["gap_max_s"], ev["disarm_after_idle_s"])
|
||||
ctx.check(armed_seen, "the armed window was never observed (arm refused, or no button press)")
|
||||
ctx.check(peak > 0, "no emission witnessed (emission_samples stayed 0)")
|
||||
ctx.check(peak > 0, "no emission witnessed on the square (emission_samples stayed 0)")
|
||||
first_idle_fire = ev["idle_airflow_fire"][0] if idle_fire else (None, None, None)
|
||||
ctx.check(not idle_fire,
|
||||
"the laser fired while the cooling engine had no run session, so the fans were at "
|
||||
@@ -549,8 +622,27 @@ def emission_witness(ctx):
|
||||
"emission sample, floor %.0f)", ev["exhaust_at_first_fire"], floor)
|
||||
ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)",
|
||||
ev["hv_min"], ev["hv_max"])
|
||||
ctx.check(dt is not None and dt < 10.0,
|
||||
"the M2 job did not disarm promptly at Idle (%s s; the idle grace is ~60 s)",
|
||||
ctx.check(i_out is not None and i_back is not None,
|
||||
"the rapid did not run inside the dark stretch with the window open: out at %s, back at %s "
|
||||
"(X from %s, max %s), the window closed at %s", ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"],
|
||||
x0, ev["rapid"]["x_max"], round(closed["t"] - dark["t"], 2) if closed else None)
|
||||
ctx.check(stretch[i_out]["locked"] is False, "the laser latch was locked with the head at +20 mm "
|
||||
"(laser_locked %s): the rapid ran unarmed", stretch[i_out]["locked"])
|
||||
ctx.check(reads["read"] >= RAPID_MIN_READS and reads["gap_max_s"] is not None
|
||||
and reads["gap_max_s"] <= EMISSION_READ_GAP_S,
|
||||
"the LASER_ON count was not read through the rapid: %d of %d samples carried it, the longest "
|
||||
"unread stretch %s s (the kernel latches the count once per ~1 s window, so a fire in an "
|
||||
"unread window is not witnessed)", reads["read"], len(stretch), reads["gap_max_s"])
|
||||
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%s s (emission_samples %s) "
|
||||
"- a rapid after M5 fired", len(relit), relit[0]["t"] - dark["t"] if relit else None,
|
||||
relit[0]["emission"] if relit else None)
|
||||
ctx.check(hv_reads["read"] >= RAPID_MIN_READS,
|
||||
"the HV current was readable on only %d of %d samples through the rapid (%d needed)",
|
||||
hv_reads["read"], len(stretch), RAPID_MIN_READS)
|
||||
ctx.check(hv_dark <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge ran "
|
||||
"through the rapid", hv_dark)
|
||||
ctx.check(dt is not None and 0 <= dt < 10.0,
|
||||
"the M2 job did not disarm promptly (%s s after the head was back; the idle grace is ~60 s)",
|
||||
ev["disarm_after_idle_s"])
|
||||
ctx.check(gap["button_latch_unlocked_samples"] and gap["button_latch_unlocked_max"] == 0,
|
||||
"the hardware button latch read SET while the laser latch was unlocked (max %s over %d "
|
||||
@@ -562,170 +654,19 @@ def emission_witness(ctx):
|
||||
judge_beam(ctx, beam)
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.confirm("Did the laser mark a 20 mm square outline on the scrap, all four sides?")
|
||||
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s, disarmed %.1f s after Idle, button "
|
||||
"dark, mark confirmed", peak, ev["hv_min"], ev["hv_max"], beam["delta"], dt)
|
||||
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s; the rapid after the M5 dark (HV max %s); "
|
||||
"disarmed %.1f s after the head was back, button dark, mark confirmed", peak, ev["hv_min"],
|
||||
ev["hv_max"], beam["delta"], hv_dark, dt)
|
||||
|
||||
|
||||
HV_DARK_MAX = 20 # hv_current_raw reads 0 with the tube off; hundreds under fire
|
||||
|
||||
|
||||
@test("laser.m5-rapid-dark", title="The rapids after an M5 ship dark",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=3,
|
||||
covers=_LASER_COVERS,
|
||||
requires=["laser.emission-witness"], actions=["button"],
|
||||
steps=["Scrap under the head with 20 mm of free +X travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white (the arm)."],
|
||||
description="A 20 mm line at constant power (M3 S400/F600), then M5, a dwell, a rapid back "
|
||||
"over the line, a dwell, a rapid forward, a dwell, and the program end. M5 "
|
||||
"executes with the planner drained and the kernel run over, and the core "
|
||||
"issues no per-segment laser update for moves made with the spindle off, so "
|
||||
"only the stream's own wanted state decides whether those rapids fire; a stale "
|
||||
"true there lights them at the last level, full duty under the density model. "
|
||||
"The kernel's LASER_ON sample count must go to 0 after the M5 and stay 0 "
|
||||
"through both rapids, and the HV current must stay at its idle reading.")
|
||||
def m5_rapid_dark(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
base = sample(ctx)
|
||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
ctx.ready(ARM_CUE % "20 mm +X")
|
||||
# +20 out on the cut, then the two rapids the test is about, then
|
||||
# a third that puts the head back where the job found it: the
|
||||
# first two net to +20 and a job that walks the head 20 mm and
|
||||
# leaves it there is dirt the hand-back has to jog out. The
|
||||
# return is inside the sampling window and after the M5, so it is
|
||||
# one more rapid that must be dark, which is what the test asks
|
||||
# of the other two.
|
||||
job = ["G91", "G21", "M3", "S400",
|
||||
"G1 X20 F600",
|
||||
"M5", "G4 P2.5",
|
||||
"G0 X-20", "G4 P2.5",
|
||||
"G0 X20", "G4 P2.5",
|
||||
"G0 X-20", "G4 P0.5",
|
||||
"G90", "M2"]
|
||||
ctx.arm_press()
|
||||
try:
|
||||
samples = run_and_sample(ctx, g, job)
|
||||
# The controller reports Idle inside a G4 dwell, so the sampler
|
||||
# above can return before the rapids; the window closing at M2
|
||||
# is the end of the job. Keep sampling until then.
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 30:
|
||||
ctx.checkpoint()
|
||||
smp = sample(ctx)
|
||||
if smp:
|
||||
samples.append(smp)
|
||||
if not smp["armed"]:
|
||||
break
|
||||
time.sleep(0.125)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
emis = [(s["t"], s["emission"], s["hv"]) for s in samples if s["emission"] is not None]
|
||||
peak = max((e for _t, e, _hv in emis), default=0)
|
||||
ctx.check(peak > 0, "no emission witnessed on the G1 (emission_samples stayed 0)")
|
||||
# The first zero after the peak is the dark window the M5 and its dwell
|
||||
# produce; everything after it is the two rapids and their dwells.
|
||||
i_peak = max(range(len(emis)), key=lambda i: emis[i][1])
|
||||
after = [x for x in emis[i_peak:] if x[1] == 0]
|
||||
ctx.check(after, "emission_samples never returned to 0 after the M5")
|
||||
t_dark = after[0][0]
|
||||
tail = [x for x in emis if x[0] >= t_dark]
|
||||
relit = [x for x in tail if x[1]]
|
||||
hv_tail = max((hv for _t, _e, hv in tail if hv is not None), default=0)
|
||||
ev.update({"samples": len(samples), "emission_peak": peak, "tail_samples": len(tail),
|
||||
"relit_samples": len(relit), "hv_tail_max": hv_tail,
|
||||
"relit_first": relit[0] if relit else None})
|
||||
ctx.log("emission peak %s; dark from +%.1f s; %d samples after it spanning %.1f s, %d with "
|
||||
"emission, HV max after dark %s", peak, t_dark - emis[0][0], len(tail),
|
||||
tail[-1][0] - t_dark, len(relit), hv_tail)
|
||||
ctx.check(tail[-1][0] - t_dark >= 5.0, "sampling ended %.1f s after the dark point, before "
|
||||
"both rapids and their dwells (~5.2 s) had run", tail[-1][0] - t_dark)
|
||||
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%.1f s "
|
||||
"(emission_samples %s) - a rapid after M5 fired at the last level",
|
||||
len(relit), (relit[0][0] - t_dark) if relit else 0, relit[0][1] if relit else None)
|
||||
ctx.check(hv_tail <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge "
|
||||
"ran through a rapid", hv_tail)
|
||||
ctx.log("PASS: emission peak %s on the G1, 0 through both rapids, HV %s after the M5",
|
||||
peak, hv_tail)
|
||||
|
||||
|
||||
@test("laser.disarm-in-hold", title="Disarm grace counts down in Hold", subsystem="laser",
|
||||
kind="live", mode="grbl", est_min=4,
|
||||
covers=_LASER_COVERS,
|
||||
requires=["laser.emission-witness"], actions=["button"],
|
||||
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white. Nothing else: the test holds the "
|
||||
"job itself and waits about a minute for the disarm."],
|
||||
description="Arm and start a +X move at S400/F300, feed-hold it after ~2 s of motion, and "
|
||||
"hold: the disarm grace must count down while held and close the armed window "
|
||||
"(armed -> false) without the job resuming, and the button goes dark with it.")
|
||||
def disarm_in_hold(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
k0 = kernel_start(ctx)
|
||||
ctx.ready(ARM_CUE % "40 mm +X")
|
||||
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
|
||||
ctx.log("armed; waiting for motion to start (arm + your button press)...")
|
||||
w = Watch(g)
|
||||
ctx.act("button", "press", text="The button is lit white: the press arms the job and the "
|
||||
"move starts.", until=w.in_state("Run"), timeout=180, fail=False)
|
||||
st = w.last["state"] if w.last else None
|
||||
ctx.check(st and st.startswith("Run"), "motion never started (state=%s) - arm refused or no press", st)
|
||||
ctx.log("moving under laser: %s; feed-hold in 2 s", st)
|
||||
ctx.sleep(2)
|
||||
g.realtime(ord("!"))
|
||||
t1 = time.time()
|
||||
while time.time() - t1 < 5:
|
||||
st = g.status_report()["state"]
|
||||
if st.startswith("Hold"):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
ev["held_state"] = st
|
||||
ctx.log("feed-held mid-move: %s; watching the disarm grace count down IN HOLD", st)
|
||||
ctx.check(st.startswith("Hold"), "feed hold did not park (state %s)", st)
|
||||
t0 = time.time()
|
||||
disarmed_at = None
|
||||
left_hold = None
|
||||
while time.time() - t0 < 120:
|
||||
ctx.checkpoint()
|
||||
s = sample(ctx)
|
||||
held = g.status_report()["state"].startswith("Hold")
|
||||
if s and not s["armed"]:
|
||||
disarmed_at = time.time() - t0
|
||||
break
|
||||
if not held and left_hold is None:
|
||||
left_hold = g.status_report()["state"]
|
||||
ctx.log("note: left Hold (state=%s) before disarm", left_hold)
|
||||
time.sleep(1)
|
||||
ev["disarmed_after_s"] = round(disarmed_at, 1) if disarmed_at is not None else None
|
||||
ev["left_hold"] = left_hold
|
||||
# recover: laser off, abort out of hold
|
||||
g.command("M5", timeout=1)
|
||||
g.realtime(0x18)
|
||||
ctx.sleep(1)
|
||||
if "Alarm" in g.status_report()["state"]:
|
||||
g.command("$X")
|
||||
# The hold parks the head part-way through the move and the reset
|
||||
# abandons the rest of it, so the job ends wherever the feed hold
|
||||
# caught it - about 11 mm in, on the bench reference. That is the
|
||||
# test's own doing and it goes back: the kernel counters say how
|
||||
# far the head actually went, which is what a jog has to undo.
|
||||
wait_idle(ctx, g, 15)
|
||||
dx, dy = kernel_xy_mm(ctx)
|
||||
back_x, back_y = k0[0] - dx, k0[1] - dy
|
||||
ev["left_at_mm"] = [round(dx - k0[0], 3), round(dy - k0[1], 3)]
|
||||
if abs(back_x) >= 0.02 or abs(back_y) >= 0.02:
|
||||
ctx.log("returning the head: %+.3f/%+.3f mm", back_x, back_y)
|
||||
g.command("$J=G91X%.3fY%.3fF1200" % (back_x, back_y))
|
||||
wait_idle(ctx, g, 30)
|
||||
ev["returned_mm"] = list(kernel_xy_mm(ctx))
|
||||
ctx.check(disarmed_at is not None, "still armed after 120 s in Hold")
|
||||
ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold)
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.log("PASS: disarmed in Hold after %.1f s, button dark", disarmed_at)
|
||||
# The kernel latches cnc/laser_on_sampled once per ~1 s window (cnc.c
|
||||
# LASER_SAMPLE_WINDOW) and holds the count through the next window, so a
|
||||
# trail whose valid reads are never farther apart than EMISSION_READ_GAP_S
|
||||
# has read every window's count at least once.
|
||||
EMISSION_WINDOW_S = 1.0
|
||||
EMISSION_READ_GAP_S = 0.9
|
||||
RAPID_MIN_READS = 4 # the fewest samples with the count (and with the HV current) over the rapid
|
||||
|
||||
|
||||
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
|
||||
@@ -840,253 +781,12 @@ def armed_kill(ctx):
|
||||
"respawned with the latch still locked, button dark", ev["expected"]["zero_at_s"], zero_at)
|
||||
|
||||
|
||||
@test("laser.verdict-cut", title="A blocked verdict mid-cut locks the latch and holds; the clean "
|
||||
"verdict resumes with no press",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=4,
|
||||
covers=_LASER_COVERS,
|
||||
requires=["laser.emission-witness"], actions=["button"],
|
||||
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white (the arm). Nothing else: the test "
|
||||
"takes the cooling verdict away itself and gives it back."],
|
||||
description="A 40 mm line at constant power (M3 S400/F300, 8 s). About 1.5 s in, the test "
|
||||
"pauses the machine-services daemon for 3.5 s, so the cooling verdict the "
|
||||
"controller reads goes stale: the controller's pause tier. The controller must "
|
||||
"hold the job (grbl Hold) under the still-open armed window with the stream "
|
||||
"masked dark, so the kernel's LASER_ON sample count reads 0 before the cut "
|
||||
"resumes, and it must not write the latch: a lock sets the hardware button "
|
||||
"latch, which only a press clears, so both bit 3 (the SoC lock) and bit 2 "
|
||||
"(the button latch) of interlock_circuit stay clear through the whole run. "
|
||||
"When the daemon returns (the verdict fresh, clean, resume_ok), the controller "
|
||||
"must resume the cut lit from where it stopped, with no button press and no "
|
||||
"prompt; the M2 program end then disarms as usual. The daemon's own dead-man "
|
||||
"(5 s of silence) is not reached: the controller keeps reporting through the "
|
||||
"pause. The beam detector witnesses the cut on both sides of the hold.")
|
||||
def verdict_cut(ctx):
|
||||
import os as _os
|
||||
import signal as _signal
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
# The daemon is frozen long enough for the verdict the controller
|
||||
# caches to expire (VERDICT_MAX_AGE 2.0 s), so the driver's pause tier
|
||||
# holds the job - but not so long that the engine's report dead-man
|
||||
# trips on resume. That dead-man locks the laser latch when the
|
||||
# controller's report is older than REPORT_TIMEOUT_S (5.0 s) while
|
||||
# armed, which is exactly the "latch stayed locked" this test refuses.
|
||||
# The last report is up to 1 s old when the freeze starts, so the
|
||||
# freeze must stay under 4.0 s; 3.0 s clears the stale floor with room
|
||||
# to see the hold and stays a full second under the dead-man.
|
||||
PAUSE_S = 3.0
|
||||
pids = hw.pidof("forgectrl")
|
||||
ctx.check(pids, "no forgectrl process found to pause")
|
||||
ev["daemon_pids"] = pids
|
||||
state_path = "/run/forgefirm/grbl.state"
|
||||
|
||||
def grbl_state_file():
|
||||
try:
|
||||
with open(state_path) as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
def resume_daemon():
|
||||
for p in pids:
|
||||
try:
|
||||
_os.kill(p, _signal.SIGCONT)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
trail = []
|
||||
text = ""
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
k0 = kernel_start(ctx)
|
||||
base = sample(ctx)
|
||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
ctx.ready(ARM_CUE % "40 mm +X")
|
||||
job = ["G91", "G21", "M3", "S400", "G1 X40 F300", "M5", "G0 X-40", "G90", "M2"]
|
||||
smp = arm_and_fire(ctx, g, room="40 mm +X", job=job)
|
||||
beams = [(smp.get("beam"), smp.get("beam_d"))]
|
||||
ctx.log("emission live (%s); pausing the daemon in 1 s", smp["emission"])
|
||||
ctx.sleep(1.0)
|
||||
g.drain()
|
||||
t0 = time.time()
|
||||
try:
|
||||
for p in pids:
|
||||
_os.kill(p, _signal.SIGSTOP)
|
||||
ctx.log("daemon paused (SIGSTOP %s) for %.1f s: the verdict goes stale", pids, PAUSE_S)
|
||||
# No HTTP while the daemon is paused: the controller's socket,
|
||||
# sysfs and the controller's state file are the witnesses.
|
||||
while time.time() - t0 < PAUSE_S:
|
||||
st = g.status_report()["state"]
|
||||
text += g.drain()
|
||||
il = hw.sysfs_int("cnc/interlock_circuit")
|
||||
trail.append({"t": round(time.time() - t0, 2), "gstate": st, "il": il,
|
||||
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
|
||||
"armed": '"armed":true' in grbl_state_file()})
|
||||
time.sleep(0.12)
|
||||
finally:
|
||||
resume_daemon()
|
||||
ctx.log("daemon resumed (SIGCONT) at +%.2f s", time.time() - t0)
|
||||
# Through the resume and to the end of the cut, sampling the same
|
||||
# witnesses (the daemon's own once it answers again).
|
||||
resumed_at = None
|
||||
end = time.time() + 30
|
||||
while time.time() < end:
|
||||
ctx.checkpoint()
|
||||
st = g.status_report()["state"]
|
||||
text += g.drain()
|
||||
il = hw.sysfs_int("cnc/interlock_circuit")
|
||||
row = {"t": round(time.time() - t0, 2), "gstate": st, "il": il,
|
||||
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
|
||||
"armed": '"armed":true' in grbl_state_file()}
|
||||
if time.time() - t0 > PAUSE_S + 1.0:
|
||||
s = sample(ctx)
|
||||
if s:
|
||||
row["beam"] = s.get("beam")
|
||||
beams.append((s.get("beam"), s.get("beam_d")))
|
||||
trail.append(row)
|
||||
if resumed_at is None and st.startswith("Run"):
|
||||
resumed_at = time.time() - t0
|
||||
if st.startswith("Idle") and resumed_at is not None and time.time() - t0 > resumed_at + 3.0:
|
||||
break
|
||||
time.sleep(0.12)
|
||||
for r in trail:
|
||||
ctx.log(" %s", r)
|
||||
ev["trail"] = trail
|
||||
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
|
||||
ctx.log("controller: %s", ev["messages"])
|
||||
dt = wait_disarm(ctx, 75)
|
||||
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
||||
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the job")
|
||||
check_kernel_returned(ctx, ev, k0)
|
||||
|
||||
def locked(r):
|
||||
return r["il"] is not None and bool(r["il"] & IL_LASER_LATCH)
|
||||
|
||||
def button_latched(r):
|
||||
return r["il"] is not None and bool(r["il"] & IL_BUTTON_LATCH)
|
||||
|
||||
held = [r for r in trail if r["gstate"].startswith("Hold")]
|
||||
ev.update({"held_at_s": held[0]["t"] if held else None,
|
||||
"resumed_at_s": round(resumed_at, 2) if resumed_at else None,
|
||||
"latch_locked_samples": sum(1 for r in trail if locked(r)),
|
||||
"button_latch_set_samples": sum(1 for r in trail if button_latched(r))})
|
||||
ctx.check(held and held[0]["t"] < PAUSE_S, "the stale verdict did not hold the job during the "
|
||||
"pause (first Hold at %s)", ev["held_at_s"])
|
||||
ctx.check("fire masked, job held" in text, "the controller did not report the pause tier")
|
||||
ctx.check(ev["latch_locked_samples"] == 0,
|
||||
"the pause tier wrote the latch (locked in %d samples): a lock sets the hardware "
|
||||
"button latch and the resume would need a press", ev["latch_locked_samples"])
|
||||
ctx.check(ev["button_latch_set_samples"] == 0,
|
||||
"the hardware button latch read SET in %d samples: the resume needed a press",
|
||||
ev["button_latch_set_samples"])
|
||||
ctx.check(all(r["armed"] for r in trail if r["t"] < (resumed_at or PAUSE_S + 5)),
|
||||
"the armed window closed during the pause")
|
||||
ctx.check("press the button" not in text, "the resume asked for a button press")
|
||||
ctx.check(DISARMED_MSG not in text.split("resuming")[0], "the pause tier disarmed the job")
|
||||
# Dark from the hold until the resume: the gate masked the stream.
|
||||
span = [r for r in trail if held and r["t"] >= held[0]["t"]
|
||||
and (resumed_at is None or r["t"] < resumed_at)]
|
||||
zero = next((r for r in span if r["emission"] == 0), None)
|
||||
ev["emission_zero_after_hold_s"] = round(zero["t"] - held[0]["t"], 2) if zero else None
|
||||
ctx.check(zero is not None and zero["t"] - held[0]["t"] <= 2.5,
|
||||
"emission did not read 0 within 2.5 s of the hold (before the resume)")
|
||||
ctx.check(resumed_at is not None, "the clean verdict did not resume the cut")
|
||||
ctx.check("resuming" in text, "the controller did not report the resume")
|
||||
after = [r for r in trail if resumed_at and r["t"] >= resumed_at]
|
||||
ctx.check(any(r["emission"] for r in after), "the resumed cut ran dark (no emission after the resume)")
|
||||
ctx.check("ALARM" not in text, "an alarm was raised on the pause tier")
|
||||
ctx.check(dt is not None and dt < 10.0, "the M2 job did not disarm promptly at Idle (%s s)", dt)
|
||||
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base)
|
||||
judge_beam(ctx, ev["beam"], "the cut")
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.log("PASS: held at +%s s with the latch untouched, emission 0 %s s after the hold, resumed "
|
||||
"lit at +%s s with no press, disarmed %.1f s after Idle", ev["held_at_s"],
|
||||
ev["emission_zero_after_hold_s"], ev["resumed_at_s"], dt)
|
||||
|
||||
|
||||
DISARMED_MSG = "laser disarmed - latch locked"
|
||||
|
||||
|
||||
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
|
||||
subsystem="laser", kind="operator", mode="grbl", est_min=3,
|
||||
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_laser.c"),
|
||||
("grblhal-glowforge", "src/glowforge_switches.c"),
|
||||
("grblhal-glowforge", "src/glowforge_switch_map.h")],
|
||||
requires=["kernel.latch-locked-idle", "motion.jog-roundtrip"], actions=["lid"],
|
||||
steps=["Lid closed; nothing under the head needs to be in place - the machine will not fire.",
|
||||
"When the white button lights, do NOT press it: open the lid instead, then close it "
|
||||
"when told."],
|
||||
description="Start a laser job so the controller unlocks the latch and lights the button, "
|
||||
"then open the lid while it waits. The wait must abort with the lid named as the "
|
||||
"reason, canceled with a soft reset (no alarm - nothing to unlock), the armed "
|
||||
"window closed (armed -> false), the kernel latch back to locked, and no emission; "
|
||||
"the controller ends Idle. No press is given, so nothing can fire.")
|
||||
def arm_wait_lid(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
base = sample(ctx)
|
||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||
ctx.check(not base["armed"], "armed window already open before the job")
|
||||
stream(g, ["G91", "G21", "M4", "S400", "G1 X5 F600"])
|
||||
# The prompt: the latch is unlocked and the button lit from here on.
|
||||
t0 = time.time()
|
||||
text = ""
|
||||
while time.time() - t0 < 30 and "press the button" not in text:
|
||||
ctx.checkpoint()
|
||||
text += g.drain()
|
||||
time.sleep(0.1)
|
||||
ctx.check("press the button" in text, "no arm prompt within 30 s (job did not reach the arm)")
|
||||
s = sample(ctx)
|
||||
ev["armed_during_wait"] = s["armed"] if s else None
|
||||
ctx.log("arm prompt seen; armed=%s; asking the operator to open the lid", ev["armed_during_wait"])
|
||||
ctx.act("lid", "open", text="The button is lit white: do NOT press it.", timeout=60)
|
||||
t1 = time.time()
|
||||
while time.time() - t1 < 15:
|
||||
ctx.checkpoint()
|
||||
text += g.drain()
|
||||
if "job canceled" in text and "help]" in text:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
|
||||
ctx.log("controller: %s", ev["messages"])
|
||||
ctx.check("lid opened during arm - job canceled" in text,
|
||||
"the lid open was not reported as canceling the arm")
|
||||
ctx.check("help]" in text, "no reset banner after the lid-open cancel (it must be a clean cancel)")
|
||||
ctx.check("ALARM" not in text, "an alarm was raised on the lid-open cancel")
|
||||
# Armed window closed and the kernel latch locked.
|
||||
t2 = time.time()
|
||||
s = None
|
||||
while time.time() - t2 < 10:
|
||||
s = sample(ctx)
|
||||
if s and not s["armed"]:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
ev["armed_after"] = s["armed"] if s else None
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
locked = ilk is not None and bool(ilk & (1 << 3))
|
||||
ev["latch_locked"] = locked
|
||||
ev["emission"] = s["emission"] if s else None
|
||||
ctx.check(s and not s["armed"], "the armed window stayed open after the lid-open cancel")
|
||||
ctx.check(locked, "kernel latch not locked after the lid-open cancel (interlock_circuit=%s)", ilk)
|
||||
ctx.check(not ev["emission"], "emission_samples nonzero (%s) - nothing may have fired", ev["emission"])
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.act("lid", "close")
|
||||
ctx.sleep(1)
|
||||
st = g.status_report()["state"]
|
||||
ev["state_after"] = st
|
||||
ctx.check(st.startswith("Idle"), "controller is %s after the cancel, expected Idle (no unlock needed)", st)
|
||||
ctx.log("PASS: lid open during the arm wait canceled the job (clean reset, no alarm), armed=false, latch locked")
|
||||
|
||||
|
||||
@test("laser.pause-resume-lid-cancel", title="One live cut: the button pauses and resumes it, the lid "
|
||||
"cancels it and sends the head home",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=7,
|
||||
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
|
||||
("grblhal-glowforge", "src/glowforge_switch_map.h")],
|
||||
requires=["laser.emission-witness", "motion.lid-cancel-home", "motion.button-hold-resume"],
|
||||
requires=["laser.emission-witness"],
|
||||
actions=["button", "lid"],
|
||||
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white (arm). Once the cut is under way the "
|
||||
|
||||
@@ -1,357 +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/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.")
|
||||
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")
|
||||
|
||||
|
||||
# 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", "kernel", "system")
|
||||
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
|
||||
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
|
||||
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
|
||||
|
||||
|
||||
def _tail_lines(path, n=400):
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
size = f.tell()
|
||||
f.seek(max(0, size - 256 * 1024))
|
||||
data = f.read()
|
||||
except OSError:
|
||||
return []
|
||||
return data.decode("utf-8", "replace").splitlines()[-n:]
|
||||
|
||||
|
||||
def _wait_line(path, needle, timeout=6.0):
|
||||
"""Poll a log file for a line containing needle; the matched line or None."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
for line in _tail_lines(path, 200):
|
||||
if needle in line:
|
||||
return line
|
||||
time.sleep(0.25)
|
||||
return None
|
||||
|
||||
|
||||
@test("logs.routing", title="Log routing, relays, and the rendered rules", subsystem="logs",
|
||||
kind="auto", est_min=1,
|
||||
covers=_ROUTING_COVERS, requires=["logs.tree-tail-export"],
|
||||
description="rsyslog is the only logger (no busybox syslogd/klogd), the rules rendered from "
|
||||
"the settings and the effective-levels record exist and agree with /logs, every "
|
||||
"logger has its directory, the daemon's own emitter and the per-controller "
|
||||
"`logger` relay both land in the right file in the ff_line format, a stray "
|
||||
"program's line goes to system/ and nowhere else, kernel lines reach kernel/, "
|
||||
"and nothing of ours is written outside the tree.")
|
||||
def routing(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
|
||||
# 1. one logger daemon
|
||||
rs = hw.pidof("rsyslogd")
|
||||
ev["rsyslogd_pids"] = rs
|
||||
ctx.check(rs, "rsyslogd is not running")
|
||||
for legacy in ("syslogd", "klogd"):
|
||||
pids = hw.pidof(legacy)
|
||||
ctx.check(not pids, "busybox %s is running (%s) next to rsyslog", legacy, pids)
|
||||
ctx.log("rsyslogd %s; no syslogd/klogd", rs)
|
||||
|
||||
# 2. the render step: rules + effective levels, consistent with what
|
||||
# the API reports as effective
|
||||
ctx.check(os.path.isfile("/data/forgefirm/rsyslog-forgefirm.conf"),
|
||||
"rendered rules missing: /data/forgefirm/rsyslog-forgefirm.conf")
|
||||
rules = _read("/data/forgefirm/rsyslog-forgefirm.conf") or ""
|
||||
for name in LOGGERS:
|
||||
ctx.check(("# %s:" % name) in rules, "rendered rules carry no block for %s", name)
|
||||
ctx.check(os.path.isfile("/var/run/forgefirm-loglevels"), "effective-levels record missing")
|
||||
st, body = fc.get("/logs")
|
||||
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
|
||||
ctx.check(body.get("effective_known") is True, "/logs does not know the effective levels")
|
||||
eff = {}
|
||||
for l in body.get("loggers") or []:
|
||||
eff[l["name"]] = (l.get("effective_disk"), l.get("effective_remote"))
|
||||
ev["effective"] = eff
|
||||
ctx.log("effective levels: %s", eff)
|
||||
ctx.check(set(eff) == set(LOGGERS), "loggers reported: %s", sorted(eff))
|
||||
levels = _read("/var/run/forgefirm-loglevels") or ""
|
||||
for name, (disk, _remote) in eff.items():
|
||||
ctx.check(("log_%s_disk=%s" % (name, disk)) in levels,
|
||||
"effective record and /logs disagree on %s (%s)", name, disk)
|
||||
|
||||
# 3. the tree
|
||||
for name in LOGGERS:
|
||||
ctx.check(os.path.isdir(os.path.join(LOGS_ROOT, name)), "no directory for %s", name)
|
||||
|
||||
# 4. the daemon's own emitter (fflog): a line from the running pid, if
|
||||
# its level admits info
|
||||
pids = hw.pidof("forgectrl")
|
||||
ctx.check(pids, "forgectrl is not running")
|
||||
disk_rank = _SEV_RANK.get(eff.get("forgectrl", ("info", "off"))[0], 6)
|
||||
fpath = os.path.join(LOGS_ROOT, "forgectrl", "forgectrl.log")
|
||||
if disk_rank >= 6:
|
||||
own = [l for l in _tail_lines(fpath, 2000)
|
||||
if any((" forgectrl[%d] " % p) in l for p in pids)]
|
||||
ev["own_lines"] = len(own)
|
||||
ctx.log("forgectrl[%s] lines in its file: %d", pids, len(own))
|
||||
ctx.check(own, "no line from the running daemon in %s (emitter path broken?)", fpath)
|
||||
m = _LINE_RE.match(own[-1])
|
||||
ctx.check(m and m.group("prog") == "forgectrl", "not in the ff_line format: %r", own[-1])
|
||||
else:
|
||||
ctx.log("forgectrl disk level %s: own-emitter check skipped", eff["forgectrl"][0])
|
||||
|
||||
# 5. the relay path: a `logger` probe under a controller's name lands
|
||||
# in that controller's file with the ff_line format
|
||||
nonce = "forgetest-routing-%d" % int(time.time())
|
||||
for name in ("grblhal", "gfhome"):
|
||||
rank = _SEV_RANK.get(eff.get(name, ("info", "off"))[0], 6)
|
||||
if rank < 3:
|
||||
ctx.log("%s disk level off: relay probe skipped", name)
|
||||
continue
|
||||
rc, out = hw.run(["logger", "-t", name, "-p", "daemon.err", "%s %s" % (nonce, name)])
|
||||
ctx.check(rc == 0, "logger -t %s failed: %s", name, out)
|
||||
path = os.path.join(LOGS_ROOT, name, name + ".log")
|
||||
line = _wait_line(path, nonce)
|
||||
ev["probe_" + name] = line
|
||||
ctx.check(line, "%s probe did not reach %s", name, path)
|
||||
m = _LINE_RE.match(line)
|
||||
ctx.check(m and m.group("prog") == name and m.group("sev") == "ERR",
|
||||
"%s probe line not in the ff_line format: %r", name, line)
|
||||
ctx.log("%s: %s", name, line)
|
||||
# a probe under another name must NOT leak into these files
|
||||
rc, out = hw.run(["logger", "-t", "forgetest-stray", "-p", "daemon.err", "%s stray" % nonce])
|
||||
time.sleep(1.5)
|
||||
for name in ("grblhal", "gfhome", "forgectrl"):
|
||||
path = os.path.join(LOGS_ROOT, name, name + ".log")
|
||||
ctx.check(not any((nonce + " stray") in l for l in _tail_lines(path, 100)),
|
||||
"a stray program's line landed in %s", path)
|
||||
sys_rank = _SEV_RANK.get(eff.get("system", ("info", "off"))[0], 6)
|
||||
if sys_rank >= 3:
|
||||
line = _wait_line(os.path.join(LOGS_ROOT, "system", "system.log"), nonce + " stray")
|
||||
ctx.check(line, "the stray program's line did not reach system/")
|
||||
|
||||
# 6. relay processes: the supervisor spawns one `logger` per controller
|
||||
# (its stdin is the controller's output pipe); the daemon's init
|
||||
# script keeps one on the fifo
|
||||
st, mode = fc.get("/mode")
|
||||
relays = hw.pidof("logger")
|
||||
ev["logger_relays"] = relays
|
||||
ev["controller"] = mode.get("controller") if isinstance(mode, dict) else None
|
||||
ctx.log("logger relays: %s (controller %s)", relays, ev["controller"])
|
||||
if isinstance(mode, dict) and mode.get("controller") == "running":
|
||||
ctx.check(relays, "controller running but no `logger` relay process")
|
||||
if os.path.exists("/var/run/forgectrl.stderr"):
|
||||
ctx.check(len(relays) >= 2 or ev["controller"] != "running",
|
||||
"fifo relay present but only %s logger process(es)", relays)
|
||||
|
||||
# 7. the kernel reaches kernel/ (unless filtered off)
|
||||
if _SEV_RANK.get(eff.get("kernel", ("info", "off"))[0], 6) >= 6:
|
||||
klines = _tail_lines(os.path.join(LOGS_ROOT, "kernel", "kernel.log"), 50)
|
||||
ev["kernel_lines"] = len(klines)
|
||||
ctx.check(klines and any(" kernel[" in l for l in klines), "no kernel lines in kernel/")
|
||||
|
||||
# 8. nothing outside the tree
|
||||
for stray in ("/data/forgectrl.log", "/data/gfcloud.log", "/data/gfhome.log",
|
||||
"/data/log/gfcloud", "/data/log/gfhome", "/var/log/messages"):
|
||||
ctx.check(not os.path.exists(stray), "pre-syslog log path still present: %s", stray)
|
||||
ctx.log("no ForgeFIRM log outside %s", LOGS_ROOT)
|
||||
|
||||
|
||||
@test("logs.level-settings", title="Log level and remote-target settings", subsystem="logs",
|
||||
kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/settings.*")],
|
||||
requires=["logs.tree-tail-export", "forgectrl.settings-bounds"],
|
||||
description="A log level outside off..debug, a bad remote port, a bad protocol and a bad "
|
||||
"server name are refused (400); a valid remote level is accepted and /logs shows "
|
||||
"it as configured but not effective (pending_reboot); the setting is restored to "
|
||||
"its prior value.")
|
||||
def level_settings(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||
|
||||
for key, val in (("log_grblhal_disk", "verbose"), ("log_kernel_remote", "7"),
|
||||
("syslog_port", "70000"), ("syslog_proto", "tls"),
|
||||
("syslog_server", "bad host name")):
|
||||
st, body = fc.post("/settings", data={key: val})
|
||||
ev["refused %s=%s" % (key, val)] = st
|
||||
ctx.log("POST /settings %s=%s -> %s", key, val, st)
|
||||
ctx.check(st == 400, "%s=%s -> %s, expected 400", key, val, st)
|
||||
|
||||
st, before = fc.get("/logs")
|
||||
ctx.check(st == 200 and isinstance(before, dict), "GET /logs -> %s", st)
|
||||
prior = {l["name"]: l for l in before.get("loggers") or []}
|
||||
ctx.check("system" in prior, "/logs lacks the system logger")
|
||||
was = prior["system"].get("remote") or "off"
|
||||
was_pending = bool(before.get("pending_reboot"))
|
||||
new = "warning" if was != "warning" else "error"
|
||||
ev["system_remote_before"] = was
|
||||
ev["pending_before"] = was_pending
|
||||
try:
|
||||
st, body = fc.post("/settings", data={"log_system_remote": new})
|
||||
ctx.log("POST /settings log_system_remote=%s -> %s", new, st)
|
||||
ctx.check(st == 200, "valid level refused: %s", st)
|
||||
st, after = fc.get("/logs")
|
||||
now = {l["name"]: l for l in after.get("loggers") or []}
|
||||
ctx.check(now["system"].get("remote") == new, "configured level reads %r", now["system"].get("remote"))
|
||||
ctx.check(now["system"].get("effective_remote") == prior["system"].get("effective_remote"),
|
||||
"effective level changed without a reboot")
|
||||
ctx.check(after.get("pending_reboot") is True, "pending_reboot not raised by a level change")
|
||||
ctx.log("configured %s, effective %s, pending_reboot %s",
|
||||
now["system"].get("remote"), now["system"].get("effective_remote"), after.get("pending_reboot"))
|
||||
finally:
|
||||
# restore: a value that was stored goes back as a value; a default
|
||||
# is restored by clearing the key (a query parameter - empty
|
||||
# values never reach the form body)
|
||||
stored = fc.settings().get("log_system_remote", "")
|
||||
if was == "off" and stored in ("", None, new):
|
||||
st, body = fc.post("/settings", params={"log_system_remote": ""})
|
||||
else:
|
||||
st, body = fc.post("/settings", data={"log_system_remote": was})
|
||||
ctx.log("restore log_system_remote -> %s", st)
|
||||
st, final = fc.get("/logs")
|
||||
fin = {l["name"]: l for l in final.get("loggers") or []}
|
||||
ctx.check(fin["system"].get("remote") == was, "system remote level not restored (%r)", fin["system"].get("remote"))
|
||||
ctx.check(bool(final.get("pending_reboot")) == was_pending,
|
||||
"pending_reboot did not return to %s after the restore", was_pending)
|
||||
+423
-702
File diff suppressed because it is too large
Load Diff
+50
-1434
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 .. import hw
|
||||
from ..baseline import read_position
|
||||
from .setup import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
|
||||
from .setup import wiz, Restore, INTAKE_KEY
|
||||
|
||||
POLL_S = 1.0
|
||||
# What the machine's own press prompt says, used when it sends no text.
|
||||
@@ -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
|
||||
# (74) for the first seconds and settles near 630.
|
||||
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.*"),
|
||||
("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):
|
||||
@@ -58,11 +82,13 @@ def answer(fc, wid, prompt, 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
|
||||
once per prompt (by sequence number) and returns the value to answer
|
||||
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
|
||||
ev = ctx.evidence
|
||||
start(ctx, fc, wid)
|
||||
@@ -76,6 +102,8 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
||||
d = dark(fc)
|
||||
if d.get("id") != wid:
|
||||
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"]):
|
||||
phases.append(d["phase"])
|
||||
ctx.log("%s: %s", wid, d["phase"])
|
||||
@@ -104,10 +132,11 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
||||
raise
|
||||
ev["elapsed_s"] = round(time.time() - t0, 1)
|
||||
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:
|
||||
# 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)
|
||||
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["error"] = last.get("error")
|
||||
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",
|
||||
subsystem="setup", kind="operator", est_min=3,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
||||
requires=["forgectrl.auth"], actions=["lid", "button"],
|
||||
requires=["forgectrl.auth"], actions=["lid", "button", "interlock"],
|
||||
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
||||
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
|
||||
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
|
||||
@@ -153,58 +182,48 @@ def check_switches(ctx):
|
||||
ctx.log("switches: %s", json.dumps(r))
|
||||
|
||||
|
||||
@test("setup.check-sensors", title="The sensors check reads a plausible machine at rest",
|
||||
subsystem="setup", kind="auto", est_min=2,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
|
||||
("forgectrl", "src/cool.c")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="POST /wiz/sensors/start; ten seconds of readings and no question asked. The "
|
||||
"result carries both coolant temperatures, the chassis and SoC, the lid IR "
|
||||
"maxima, the accelerometer event count, the supply power-good, the HV current, "
|
||||
"and the idle fan speeds; the record carries sensors at version 1 and the "
|
||||
"settings read as before (the check writes none).")
|
||||
def check_sensors(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings() or {}
|
||||
ev["settings_before"] = dict(before)
|
||||
|
||||
def on_prompt(p):
|
||||
ctx.fail("the sensors check asked a question: %s", p)
|
||||
last = run_check(ctx, "sensors", on_prompt, 120)
|
||||
r = last.get("result") or {}
|
||||
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
||||
"exhaust_rpm_idle", "intake_rpm_idle"):
|
||||
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
||||
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
|
||||
after = fc.settings() or {}
|
||||
changed = {k: (before.get(k), after.get(k)) for k in set(before) | set(after)
|
||||
if before.get(k) != after.get(k)}
|
||||
ctx.check(not changed, "the sensors check wrote a setting: %s", changed)
|
||||
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
|
||||
r.get("lid_ir_max"), r.get("hv_current_max"))
|
||||
|
||||
|
||||
@test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
||||
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
||||
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
||||
requires=["forgectrl.auth"], precheck=intakes_counted,
|
||||
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
|
||||
"for 35 s, the purge is measured on and off, and five settings are written: the "
|
||||
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
|
||||
"fan must read at least 1000 rpm. The five settings are put back as found at the "
|
||||
"end, the purge fan is on again and drawing over the floor the check just wrote, "
|
||||
"and the controller must be running again.")
|
||||
"for 35 s, the purge is measured on and off, and the five floors are written: the "
|
||||
"four at 55 percent of steady and the grace from the slowest spin-up. The intake "
|
||||
"fan count is the machine's: intake 2 turning proves two fans, both intakes are "
|
||||
"judged, and the count is written as 2; intake 2 silent on a machine counted with "
|
||||
"one is reported not fitted and not turning, intake 1 alone sets the intake "
|
||||
"floor, and the count is not written; intake 2 silent on a machine counted with "
|
||||
"two fails the check. Both intakes turn on the bench reference, so the bench "
|
||||
"path writes 2. Each fan judged must read at least 1000 rpm. The settings are put "
|
||||
"back as found at the end, the purge fan is on again and drawing over the floor "
|
||||
"the check just wrote, and the controller must be running again.")
|
||||
def check_airflow(ctx):
|
||||
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
|
||||
"cool_purge_min_current", "cool_fan_grace_s"]
|
||||
with Restore(ctx, keys):
|
||||
with Restore(ctx, keys + ["cool_intake_fans"]) as held:
|
||||
counted = held.before.get("cool_intake_fans")
|
||||
last = run_check(ctx, "airflow", lambda p: None, 240)
|
||||
r = last.get("result") or {}
|
||||
fans = r.get("fans") or {}
|
||||
intakes = r.get("intake_fans")
|
||||
silent2 = (fans.get("intake 2") or {}).get("turning") is False
|
||||
ctx.evidence["intakes"] = {"counted": counted, "found": intakes, "intake_2_silent": silent2}
|
||||
ctx.check(intakes in (1, 2), "the result carries no intake fan count: %r", intakes)
|
||||
if silent2:
|
||||
# one intake fan, as the machine is counted (on a machine
|
||||
# counted with two the check itself fails, and run_check says so)
|
||||
ctx.check(counted == "1" and intakes == 1,
|
||||
"intake 2 gave no reading on a machine counted with %r intake fans, and the check "
|
||||
"found %r", counted, intakes)
|
||||
else:
|
||||
ctx.check(intakes == 2, "intake 2 turned, and the check found %r intake fans", intakes)
|
||||
ctx.log("intake fans: %s (the machine counted %s)", intakes, counted)
|
||||
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
||||
f = fans.get(name) or {}
|
||||
if name == "intake 2" and silent2:
|
||||
ctx.check(f == {"fitted": False, "turning": False}, "intake 2 with one intake fan: %s", f)
|
||||
continue
|
||||
ctx.check(f.get("ok") is True, "%s: %s", name, f)
|
||||
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
|
||||
floors = r.get("floors") or {}
|
||||
@@ -213,6 +232,12 @@ def check_airflow(ctx):
|
||||
s = ctx.forgectrl.settings() or {}
|
||||
for k in keys:
|
||||
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
|
||||
# The count: 2 written when intake 2 turned; left as counted when it
|
||||
# was silent.
|
||||
want = counted if silent2 else "2"
|
||||
ctx.check(s.get("cool_intake_fans") == want,
|
||||
"cool_intake_fans reads %r, expected %r (intake 2 %s, the machine counted %r)",
|
||||
s.get("cool_intake_fans"), want, "silent" if silent2 else "turning", counted)
|
||||
ctx.log("floors: %s", json.dumps(floors))
|
||||
# The check switches purge air off to read its off current. Leaving
|
||||
# it off costs the next job an airflow hold mid-cut, judged against
|
||||
@@ -244,41 +269,6 @@ def check_airflow(ctx):
|
||||
ctx.check(ok is not None, "the controller did not come back after the check")
|
||||
|
||||
|
||||
@test("setup.check-cameras", title="The cameras check captures both cameras",
|
||||
subsystem="setup", kind="operator", est_min=2,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
|
||||
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
|
||||
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
|
||||
"head snapshot, the question. Each question is answered yes once GET /wiz/shot "
|
||||
"serves the snapshot as image/jpeg. The result names the sensor and says both "
|
||||
"views were accepted.")
|
||||
def check_cameras(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.act("lid", "close")
|
||||
shots = {}
|
||||
|
||||
def on_prompt(p):
|
||||
pid = p.get("id", "")
|
||||
if pid in ("lid-view", "head-view"):
|
||||
cam = "lid" if pid == "lid-view" else "head"
|
||||
st, body = fc.get("/wiz/shot", params={"cam": cam}, raw=True)
|
||||
shots[cam] = {"status": st, "bytes": len(body) if body else 0,
|
||||
"jpeg": bool(body) and body[:2] == b"\xff\xd8"}
|
||||
ctx.check(st == 200 and shots[cam]["jpeg"], "no %s snapshot to judge: %s", cam, shots[cam])
|
||||
return "Yes"
|
||||
if pid == "lid-close":
|
||||
ctx.act("lid", "close", until=lambda: (dark(fc).get("prompt") or {}).get("seq") != p["seq"])
|
||||
return None
|
||||
last = run_check(ctx, "cameras", on_prompt, 180)
|
||||
ev["shots"] = shots
|
||||
r = last.get("result") or {}
|
||||
ctx.check(r.get("lid_ok") is True and r.get("head_ok") is True, "a view was not accepted: %s", r)
|
||||
ctx.check(bool(r.get("sensor")) and r.get("sensor") != "unknown", "no sensor named: %s", r)
|
||||
ctx.log("cameras: %s, lid %d bytes, head %d bytes", r.get("sensor"),
|
||||
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
|
||||
|
||||
|
||||
@test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
||||
@@ -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()})
|
||||
|
||||
|
||||
@test("setup.check-flow-verify", title="The flow check runs as a setup check",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
||||
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
||||
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
||||
"check runner: the diagnostic's phases show as the check's, its result becomes the "
|
||||
"check's result with pass true, and the record carries cooling.flow-verify at "
|
||||
"version 1. A thin margin recommends the calibration in the record's flags. The "
|
||||
"air-assist offset calibration is named first among the prerequisites so a queue "
|
||||
"keeps it ahead of every heater tool this check pulls forward.")
|
||||
def check_flow_verify(ctx):
|
||||
last = run_check(ctx, "cooling.flow-verify", lambda p: None, 600)
|
||||
r = last.get("result") or {}
|
||||
ctx.check(r.get("pass") is True, "the flow check did not pass: %s", r)
|
||||
ctx.log("flow check: threshold %s, flow %s, no-flow %s, thin %s", r.get("threshold"),
|
||||
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
|
||||
@test("setup.check-lens", title="The lens travel check finds the stops and writes the window",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=4,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/lensfind.*"), ("forgectrl", "src/lens.*"),
|
||||
("forgectrl", "src/lenshome.*"), ("forgectrl", "src/accel.c"),
|
||||
("forgectrl", "src/cool.c"), ("forgectrl", "src/super.c")],
|
||||
requires=["forgectrl.auth", "setup.check-motion"],
|
||||
description="POST /wiz/lens.travel/start: the controller stops, the lens takes its hall-edge "
|
||||
"reference, the fans stop and the head accelerometer listens while the lens "
|
||||
"steps to each stop and back (nothing fires), the two window settings are "
|
||||
"written, and the controller comes back. No question is asked. The result "
|
||||
"carries the reference passes, the stops (found, the free half-steps each way, "
|
||||
"the contact counts, or why not), and the window: the stops found, or the "
|
||||
"fallback (10 half-steps below, 12 above) when they were not. The settings and "
|
||||
"the lens block of /status hold that window, the record carries lens.travel at "
|
||||
"version 1, and the two settings are put back as found. grblHAL reads the "
|
||||
"window only at its start, and the check started it before the put-back, so in "
|
||||
"GRBL mode the controller is stopped and started again on the window as found "
|
||||
"(on every exit path), and it must be running with motion verified under a new "
|
||||
"process.")
|
||||
def check_lens(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
restart = {}
|
||||
|
||||
def on_prompt(p):
|
||||
ctx.fail("the lens travel check asked a question: %s", p)
|
||||
|
||||
def restart_on_found():
|
||||
"""A fresh controller start on the settings as found, once the
|
||||
check has ended. Plain waits, not the run's, so an abort gets it
|
||||
too; a check still ending starts its own controller after the
|
||||
put-back, which reads them already."""
|
||||
try:
|
||||
t_end = time.time() + 120
|
||||
while time.time() < t_end and dark(fc).get("running"):
|
||||
time.sleep(1)
|
||||
if dark(fc).get("running"):
|
||||
restart["skipped"] = "the check is still ending"
|
||||
ctx.log("the check is still ending: its own controller start reads the settings as found")
|
||||
return
|
||||
st, m = fc.get("/mode")
|
||||
m = m if isinstance(m, dict) else {}
|
||||
restart.update(mode=m.get("mode"), pid=m.get("pid"))
|
||||
if m.get("mode") != "grbl":
|
||||
return # the cloud client reads no lens window
|
||||
restart["stop"], body = fc.post("/controller/stop")
|
||||
ctx.log("restarting the controller on the window as found: POST /controller/stop -> %s %s",
|
||||
restart["stop"], body)
|
||||
for _ in range(10):
|
||||
restart["start"], body = fc.post("/controller/start")
|
||||
if restart["start"] != 409:
|
||||
break
|
||||
time.sleep(1) # the check's lease goes just after its end
|
||||
ctx.log("POST /controller/start -> %s %s", restart["start"], body)
|
||||
except hw.HwError as e:
|
||||
restart["error"] = str(e)
|
||||
ctx.log("the controller could not be restarted on the window as found: %s", e)
|
||||
try:
|
||||
with Restore(ctx, LENS_WINDOW_KEYS):
|
||||
last = run_check(ctx, "lens.travel", on_prompt, 300)
|
||||
r = last.get("result") or {}
|
||||
ctx.check(len(r.get("z_passes") or []) >= 1, "the lens was not referenced: %s", r.get("z_passes"))
|
||||
stops, w = r.get("stops") or {}, r.get("window") or {}
|
||||
ctx.check(isinstance(stops.get("found"), bool), "the result files no stops: %s", r)
|
||||
window = (w.get("below"), w.get("above"))
|
||||
if stops.get("found"):
|
||||
ctx.check(window == (stops.get("below"), stops.get("above")) and w.get("found") is True,
|
||||
"the window %s is not the stops found %s", w, stops)
|
||||
else:
|
||||
ctx.check(window == FALLBACK_WINDOW and w.get("found") is False,
|
||||
"the stops were not found (%s), but the window is %s", stops.get("why"), w)
|
||||
s = fc.settings() or {}
|
||||
held = tuple(str(s.get(k)) for k in LENS_WINDOW_KEYS)
|
||||
lens = (fc.status() or {}).get("lens") or {}
|
||||
ev["window"] = {"stops": stops, "window": w, "settings": held, "status": lens}
|
||||
ctx.check(held == tuple(str(v) for v in window), "the settings hold %s, the check wrote %s",
|
||||
held, window)
|
||||
ctx.check((lens.get("below"), lens.get("above")) == window,
|
||||
"/status reads the window %s/%s, the check wrote %s", lens.get("below"), lens.get("above"),
|
||||
window)
|
||||
ctx.log("lens travel: %s half-steps below the edge, %s above (%s); contact %s/%s", window[0],
|
||||
window[1], "the stops found" if stops.get("found") else "the fallback: %s" % stops.get("why"),
|
||||
stops.get("contact_below"), stops.get("contact_above"))
|
||||
finally:
|
||||
restart_on_found()
|
||||
ev["restart"] = dict(restart)
|
||||
if restart.get("mode") == "grbl":
|
||||
ctx.check(restart.get("stop") == 200 and restart.get("start") == 200,
|
||||
"the controller was not restarted on the window as found: %s", restart)
|
||||
|
||||
def back():
|
||||
st, m = fc.get("/mode")
|
||||
m = m if isinstance(m, dict) else {}
|
||||
return (m.get("controller") == "running" and m.get("motion") == "verified"
|
||||
and (restart.get("start") != 200 or m.get("pid") != restart.get("pid")))
|
||||
ok = ctx.wait_for(back, 150)
|
||||
ctx.check(ok is not None, "the controller did not come back after the check")
|
||||
|
||||
|
||||
@test("setup.cloud-header-capture", title="The cloud header check takes one print's envelope",
|
||||
|
||||
@@ -1,282 +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/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
||||
("forgectrl", "src/super.c"),
|
||||
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
||||
("forgectrl", "src/accel.*"), ("forgectrl", "src/wizcalc.*"),
|
||||
("grblhal-glowforge", "src/glowforge_laser.c"),
|
||||
("grblhal-glowforge", "src/glowforge_status.c"),
|
||||
("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||
("grblhal-glowforge", "src/glowforge_io.*")]
|
||||
|
||||
# The live wizards in the order the sheet burns them: the wizard id, the
|
||||
# answers to its prompts (a middle pick where a person would look at the
|
||||
# sheet), and the keys its result must carry. The arm press is not timed
|
||||
# from here: each card opens a press prompt of its own when the button is
|
||||
# lit and the tube still dark, and run_check presses on that.
|
||||
CARDS = [
|
||||
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"]),
|
||||
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"},
|
||||
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
|
||||
"focus_range_mm", "stops"]),
|
||||
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"]),
|
||||
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"]),
|
||||
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"]),
|
||||
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"]),
|
||||
]
|
||||
# Every setting a card writes, restored as found when the run ends.
|
||||
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
|
||||
"laser_floor_density", "laser_dose_curve", "laser_corner_gamma",
|
||||
"cool_laser_heat_density", "cool_laser_heat_cw"]
|
||||
# The evidence run_check and the witnesses write at the top level, moved
|
||||
# under the card's own key once it is done.
|
||||
CARD_EVIDENCE = ("started", "elapsed_s", "phases", "log", "error", "result",
|
||||
"preview_bytes", "program_lines", "emission")
|
||||
|
||||
|
||||
def preview_ok(ctx, wid):
|
||||
"""The page's preview and the program for the wizard are served."""
|
||||
fc = ctx.forgectrl
|
||||
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
|
||||
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
|
||||
ctx.evidence["preview_bytes"] = len(body)
|
||||
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
|
||||
# The text and the frame are M4 at the mark dose; the patterns bring
|
||||
# their own M3 or M4 lines. A program is one with a laser-on command.
|
||||
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
||||
"no program for %s (%s)", wid, st)
|
||||
ctx.evidence["program_lines"] = body.count(b"\n")
|
||||
# Every Z the program commands is a height the head reaches in the
|
||||
# window the machine holds now: the focus card's own numbers once it
|
||||
# has them, and the conservative fallback on a head whose travel is
|
||||
# not known yet. The controller refuses a Z outside it with ALARM:2,
|
||||
# and it refuses it while the body is still burning, so the card dies
|
||||
# where the parser is and not where the bad line is.
|
||||
lens = (fc.status() or {}).get("lens") or {}
|
||||
lo, hi = lens.get("reach_min"), lens.get("reach_max")
|
||||
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
|
||||
ctx.evidence.setdefault("program_z", {})[wid] = {
|
||||
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
|
||||
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
|
||||
if lo is not None and hi is not None:
|
||||
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
|
||||
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
|
||||
wid, lo, hi, out)
|
||||
|
||||
|
||||
def emission_ok(ctx, last):
|
||||
"""The three witnesses of the burn, from the wizard's result."""
|
||||
e = (last.get("result") or {}).get("emission") or {}
|
||||
ctx.evidence["emission"] = e
|
||||
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
|
||||
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
|
||||
ctx.check(e.get("thermopile_delta", 0) >= 100, "the thermopile did not see the beam (%s)",
|
||||
e.get("thermopile_delta"))
|
||||
|
||||
|
||||
def file_card(ctx, wid):
|
||||
"""Move the card's evidence under its own key."""
|
||||
ev = ctx.evidence
|
||||
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
||||
|
||||
|
||||
# The lens settings: the head's numbers, which a fresh machine does not
|
||||
# have. Cleared before the cards so the run is a fresh machine's.
|
||||
LENS_SETTINGS = CARD_SETTINGS[:3]
|
||||
FALLBACK_WINDOW = (10, 12)
|
||||
|
||||
|
||||
def fresh_lens(ctx):
|
||||
"""The lens as a fresh machine has it: the head's numbers unknown, so
|
||||
the frame runs in the fallback window and the focus card has to find
|
||||
the stops and write them before its ladder burns. The machine's own
|
||||
numbers come back with the restore at the end of the run."""
|
||||
fc = ctx.forgectrl
|
||||
for k in LENS_SETTINGS:
|
||||
st, _ = fc.post("/settings", params={k: ""})
|
||||
ctx.check(st == 200, "clearing %s -> %s", k, st)
|
||||
lens = (fc.status() or {}).get("lens") or {}
|
||||
ctx.evidence["lens_fresh"] = lens
|
||||
ctx.check(lens.get("stops_found") is False and (lens.get("below"), lens.get("above")) == FALLBACK_WINDOW,
|
||||
"the lens window is not the fallback after the clear: %s", lens)
|
||||
|
||||
|
||||
def focus_window_ok(ctx):
|
||||
"""The window the focus card burned its ladder in is the one it wrote
|
||||
before its controller started, and the one the machine holds now: the
|
||||
result's window is the stops it found (or the fallback), the settings
|
||||
hold that window, every ladder height lies in its reach, and the
|
||||
program served now agrees with /status."""
|
||||
fc = ctx.forgectrl
|
||||
r = ((ctx.evidence.get("cards") or {}).get("laser.focus") or {}).get("result") or {}
|
||||
w, stops = r.get("window") or {}, r.get("stops") or {}
|
||||
s = fc.settings() or {}
|
||||
held = (str(s.get("lens_stop_below_steps")), str(s.get("lens_stop_above_steps")))
|
||||
ctx.check(held == (str(w.get("below")), str(w.get("above"))),
|
||||
"the settings hold the window %s, the ladder ran in %s/%s", held, w.get("below"), w.get("above"))
|
||||
if stops.get("found"):
|
||||
ctx.check((w.get("below"), w.get("above")) == (stops.get("below"), stops.get("above")),
|
||||
"the ladder's window %s is not the stops found %s", w, stops)
|
||||
else:
|
||||
ctx.check((w.get("below"), w.get("above")) == FALLBACK_WINDOW,
|
||||
"stops not found (%s), but the ladder's window is %s", stops.get("why"), w)
|
||||
zs = r.get("ladder_z") or []
|
||||
lo, hi = w.get("reach_min"), w.get("reach_max")
|
||||
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
|
||||
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
|
||||
ev = ctx.evidence
|
||||
before = dict(ev.get("program_z") or {}).get("laser.focus")
|
||||
preview_ok(ctx, "laser.focus")
|
||||
ev["focus_window"] = {"window": w, "stops": stops, "ladder_z": zs,
|
||||
"settings": {k: s.get(k) for k in LENS_SETTINGS},
|
||||
"program_z_before": before,
|
||||
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
|
||||
"preview_bytes": ev.pop("preview_bytes", None),
|
||||
"program_lines": ev.pop("program_lines", None)}
|
||||
|
||||
|
||||
def thickness_answer(ctx):
|
||||
"""The thickness the placement is answered with, in the machine's
|
||||
units (the number prompts read ui_units), and the millimeters the
|
||||
record must carry for it."""
|
||||
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
|
||||
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
|
||||
|
||||
|
||||
def place(ctx):
|
||||
"""The placement: the lens reference, two jogs, the origin at the
|
||||
head's position, the full sheet, a thickness. Nothing fires."""
|
||||
jogs = ["X+10", "X-10"]
|
||||
answer, want_mm = thickness_answer(ctx)
|
||||
ctx.evidence["thickness_answer"] = answer
|
||||
|
||||
def on_prompt(p):
|
||||
# Each jog answer closes the prompt and the wizard asks again with
|
||||
# the next sequence number, so the pad is answered one move at a
|
||||
# time until the origin is set.
|
||||
if p.get("id") == "place":
|
||||
return jogs.pop(0) if jogs else "Set origin"
|
||||
if p.get("id") == "sheet-kind":
|
||||
return "Full sheet"
|
||||
if p.get("id") == "thickness":
|
||||
return answer
|
||||
return None
|
||||
|
||||
last = run_check(ctx, "sheet.place", on_prompt, 300)
|
||||
r = last.get("result") or {}
|
||||
ctx.check("origin_x" in r and "origin_y" in r, "no origin in the result: %s", r)
|
||||
ctx.check((r.get("steps_per_mm") or {}).get("x", 0) > 0, "no steps per mm: %s", r)
|
||||
ctx.check(r.get("z_referenced") is True, "the lens was not referenced")
|
||||
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
|
||||
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
|
||||
ctx.check(r.get("alone") is False, "alone %s", r.get("alone"))
|
||||
ctx.log("placed: datum at %s, %s; steps per mm %s", r.get("origin_x"), r.get("origin_y"),
|
||||
r.get("steps_per_mm"))
|
||||
file_card(ctx, "sheet.place")
|
||||
|
||||
|
||||
def burn(ctx, wid, answers, want):
|
||||
"""One live card: the preview, the burn after the press with the
|
||||
prompts answered from `answers`, the witnesses, the keys of the
|
||||
result in `want`. run_check makes the arm press when the card asks
|
||||
for it."""
|
||||
preview_ok(ctx, wid)
|
||||
|
||||
def on_prompt(p):
|
||||
return answers.get(p.get("id"))
|
||||
|
||||
try:
|
||||
last = run_check(ctx, wid, on_prompt, 900)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
emission_ok(ctx, last)
|
||||
r = last.get("result") or {}
|
||||
missing = [k for k in want if k not in r]
|
||||
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
|
||||
ctx.log("%s: %s, emission %s", wid, {k: r.get(k) for k in want}, ctx.evidence.get("emission"))
|
||||
file_card(ctx, wid)
|
||||
|
||||
|
||||
@test("setup.sheet", title="The setup sheet: the placement, the frame, and the five cards",
|
||||
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=35,
|
||||
covers=SHEET_COVERS,
|
||||
requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "cooling.flow-verify"],
|
||||
actions=["button"], hands=["scrap"],
|
||||
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
|
||||
"it goes with its top edge at the top of the cut area (the head's home corner); "
|
||||
"lid closed. Eye protection on, exhaust on, extinguisher in reach.",
|
||||
"Start the test and press the machine's button once. That press says you are at the "
|
||||
"machine, and the bench actuator makes every arm press after it, each one when the "
|
||||
"card itself asks for it. With no actuator the start is the Ready answer instead, "
|
||||
"and you press the button each time the page asks: six times, the frame and the "
|
||||
"five cards.",
|
||||
"Nothing else: the test answers every prompt itself and puts back every setting the "
|
||||
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
|
||||
"tails."],
|
||||
description="One run over the sheet's seven wizards on one piece of wood, as a fresh "
|
||||
"machine runs them: the three lens settings are cleared first, so the frame "
|
||||
"runs in the fallback window and the focus card has to find the stops and write "
|
||||
"them before its ladder burns. "
|
||||
"POST /wiz/sheet.place/start references the lens, takes the controller in "
|
||||
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
|
||||
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
|
||||
"0.125 in). Then, each after the press and each with its preview "
|
||||
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the "
|
||||
"program commands inside the reach /status reports: the frame "
|
||||
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
|
||||
"lens on its hall edge, the stops found by the head accelerometer and written "
|
||||
"as the window before the controller starts, twelve lines from the top of the "
|
||||
"window to the bottom; the pick is line 11, the thickness kept; after it the "
|
||||
"settings, the result's window, and the served program must hold the window "
|
||||
"the ladder ran in); the floor card (twelve "
|
||||
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
|
||||
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
|
||||
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
|
||||
"through M102 inside one armed job; the pick is 1.50); and the flow-load card "
|
||||
"(the coolant loop settled, the box and the patch at 60 percent, the "
|
||||
"downstream peak over 100 s of dark as the coefficient). Every burn must be "
|
||||
"seen by the three witnesses (the tube current, the kernel's LASER_ON samples, "
|
||||
"the thermopile rise); every result must carry its keys; the record must "
|
||||
"carry each wizard at version 1; the seven settings the cards write are "
|
||||
"restored at the end.")
|
||||
def sheet(ctx):
|
||||
ctx.ready("The sheet: the placement, then the frame and the five cards burn after a press "
|
||||
"each. The bench presses when the button lights.")
|
||||
place(ctx)
|
||||
with Restore(ctx, CARD_SETTINGS):
|
||||
fresh_lens(ctx)
|
||||
for wid, answers, want in CARDS:
|
||||
burn(ctx, wid, answers, want)
|
||||
if wid == "laser.focus":
|
||||
focus_window_ok(ctx)
|
||||
ctx.log("PASS: the sheet's seven wizards ran on one piece as a fresh machine's; settings restored")
|
||||
@@ -0,0 +1,218 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The cipher forgectrl's HTTPS listener chooses, and where its records
|
||||
are sealed.
|
||||
|
||||
The listener puts ChaCha20-Poly1305 first, whatever the client prefers,
|
||||
and the image seals the records in the kernel: GnuTLS hands each
|
||||
connection's keys to the socket after the handshake. ChaCha20-Poly1305
|
||||
runs in the kernel's NEON code; AES-GCM, for a client without ChaCha20,
|
||||
runs its AES on the CAAM crypto engine and its GHASH in NEON. This test
|
||||
reads all of it from the outside: the cipher the listener picks for a
|
||||
client that prefers AES-GCM, the kernel's TLS counters, the crypto
|
||||
engine's interrupt, and the drivers the kernel resolved, with each
|
||||
connection carrying the same page, which must arrive identical to the
|
||||
plain-HTTP copy.
|
||||
|
||||
Its own module: a test's fingerprint holds the shared text of the module
|
||||
it lives in, so a test added to forgectrl.py would move the fingerprint
|
||||
of every test there.
|
||||
"""
|
||||
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
from ..catalog import test
|
||||
|
||||
OPENSSL = "/usr/bin/openssl"
|
||||
ENGINE_IRQ = "2101000.jr" # the CAAM's first job ring
|
||||
RECORD = 16384 # the largest TLS record's plaintext
|
||||
PAGE = "/" # the panel page: large, and the same bytes every time
|
||||
CHACHA_DRIVER = "rfc7539(chacha20-neon,poly1305-neon)"
|
||||
# A desktop browser's offer: AES-GCM first, because its CPU has AES instructions.
|
||||
DESKTOP_13 = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256"
|
||||
DESKTOP_12 = "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305"
|
||||
|
||||
|
||||
def _tls_stat():
|
||||
"""The kernel's TLS counters, by name."""
|
||||
out = {}
|
||||
with open("/proc/net/tls_stat") as f:
|
||||
for line in f:
|
||||
p = line.split()
|
||||
if len(p) == 2 and p[1].isdigit():
|
||||
out[p[0]] = int(p[1])
|
||||
return out
|
||||
|
||||
|
||||
def _passive_opens():
|
||||
"""The TCP connections the machine has accepted, on every port and both
|
||||
families (the TCP counters are shared)."""
|
||||
with open("/proc/net/snmp") as f:
|
||||
rows = [line.split() for line in f if line.startswith("Tcp:")]
|
||||
return int(rows[1][rows[0].index("PassiveOpens")])
|
||||
|
||||
|
||||
def _engine_irqs():
|
||||
with open("/proc/interrupts") as f:
|
||||
for line in f:
|
||||
p = line.split()
|
||||
if p and p[-1] == ENGINE_IRQ and len(p) > 1 and p[1].isdigit():
|
||||
return int(p[1])
|
||||
return None
|
||||
|
||||
|
||||
def _driver(name):
|
||||
"""The driver answering an algorithm name: the entry of that name with
|
||||
the highest priority in /proc/crypto."""
|
||||
best, drv, entry = -1, None, {}
|
||||
with open("/proc/crypto") as f:
|
||||
lines = f.read().splitlines() + [""]
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
if entry.get("name") == name and int(entry.get("priority", -1)) > best:
|
||||
best, drv = int(entry["priority"]), entry.get("driver")
|
||||
entry = {}
|
||||
continue
|
||||
k, _, v = line.partition(":")
|
||||
entry[k.strip()] = v.strip()
|
||||
return drv
|
||||
|
||||
|
||||
def _fetch(version, offer, work):
|
||||
"""GET the page over HTTPS on loopback with the given offer. Returns
|
||||
the suite, the status line, the body, the kernel counters' change,
|
||||
the crypto engine's interrupts during the connection, and how many
|
||||
other connections the machine accepted meanwhile."""
|
||||
req = os.path.join(work, "req")
|
||||
with open(req, "wb") as f:
|
||||
f.write(b"GET " + PAGE.encode() + b" HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
|
||||
opt = ["-tls1_3", "-ciphersuites", offer] if version == "1.3" else ["-tls1_2", "-cipher", offer]
|
||||
s0, i0, p0 = _tls_stat(), _engine_irqs(), _passive_opens()
|
||||
with open(req, "rb") as stdin:
|
||||
r = subprocess.run([OPENSSL, "s_client", "-connect", "127.0.0.1:443", "-brief", "-ign_eof"] + opt,
|
||||
stdin=stdin, capture_output=True, timeout=60)
|
||||
s1, i1, p1 = _tls_stat(), _engine_irqs(), _passive_opens()
|
||||
info = {}
|
||||
for line in r.stderr.decode("utf-8", "replace").splitlines():
|
||||
k, _, v = line.partition(":")
|
||||
info[k.strip()] = v.strip()
|
||||
head, _, body = r.stdout.partition(b"\r\n\r\n")
|
||||
delta = {k: s1.get(k, 0) - s0.get(k, 0) for k in ("TlsTxSw", "TlsRxSw", "TlsDecryptError")}
|
||||
return (info.get("Ciphersuite"), head.split(b"\r\n")[0].decode("latin-1"), body, delta,
|
||||
(i1 or 0) - (i0 or 0), p1 - p0 - 1)
|
||||
|
||||
|
||||
@test("forgectrl.tls-records", title="HTTPS: ChaCha20 chosen, records sealed in the kernel",
|
||||
subsystem="forgectrl", kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c")],
|
||||
description="forgectrl's HTTPS listener chooses the cipher, ChaCha20-Poly1305 first, and the "
|
||||
"image seals the records in the kernel: GnuTLS hands each connection's keys to the "
|
||||
"socket after the handshake (/etc/gnutls/config turns it on). A client that lists "
|
||||
"AES-GCM first, as a desktop browser does, gets ChaCha20-Poly1305 over TLS 1.3 and "
|
||||
"TLS 1.2, and the kernel takes both directions' keys; the kernel's rfc7539 is the "
|
||||
"NEON ChaCha20 and Poly1305. A client offering AES-GCM alone gets it, the kernel "
|
||||
"takes its keys, and the crypto engine's interrupt counts the records: the AES runs "
|
||||
"on the CAAM (gcm(aes) is its ctr(aes) with the NEON GHASH), which writes each "
|
||||
"record by DMA while the CPU fills the next, so those connections run three times "
|
||||
"over each protocol and every copy must be exact. A TLS 1.2 client "
|
||||
"offering a CBC suite alone still connects and stays in GnuTLS, which is the control "
|
||||
"for the kernel's counters; the ChaCha20 runs are the control for the engine's "
|
||||
"interrupt. Every copy of the panel page must be the plain-HTTP page byte for byte, "
|
||||
"and the kernel must count no decrypt error. The counters and the interrupt are the "
|
||||
"machine's, so a connection during which another client connected (a panel "
|
||||
"reconnecting after a restart) is measured again. Nothing on the machine changes.")
|
||||
def tls_records(ctx):
|
||||
ev = ctx.evidence
|
||||
|
||||
conf = ""
|
||||
if os.path.exists("/etc/gnutls/config"):
|
||||
with open("/etc/gnutls/config") as f:
|
||||
conf = f.read()
|
||||
ctx.check(any(line.split() == ["ktls", "=", "true"] for line in conf.splitlines()),
|
||||
"/etc/gnutls/config does not turn kernel TLS on: %r", conf)
|
||||
with open("/proc/sys/net/ipv4/tcp_available_ulp") as f:
|
||||
ulp = f.read().split()
|
||||
ev["tcp_available_ulp"] = ulp
|
||||
ctx.check("tls" in ulp, "the kernel has no TLS layer (tcp_available_ulp %s)", ulp)
|
||||
ctx.check(_engine_irqs() is not None, "/proc/interrupts has no %s", ENGINE_IRQ)
|
||||
|
||||
with urllib.request.urlopen("http://127.0.0.1" + PAGE, timeout=30) as r:
|
||||
plain = r.read()
|
||||
ev["page_bytes"] = len(plain)
|
||||
ctx.check(len(plain) > 8 * RECORD, "the page is %d bytes, too small to span records", len(plain))
|
||||
records = len(plain) // RECORD
|
||||
|
||||
work = tempfile.mkdtemp(prefix="forgetest-tls-")
|
||||
try:
|
||||
runs = {}
|
||||
# The AES-GCM connections run three times each: their records are written by the crypto
|
||||
# engine's DMA while the CPU fills the next one, which is where a record's last bytes were
|
||||
# once lost.
|
||||
cases = [("desktop-1.3", "1.3", DESKTOP_13, "TLS_CHACHA20_POLY1305_SHA256", True),
|
||||
("desktop-1.2", "1.2", DESKTOP_12, "ECDHE-ECDSA-CHACHA20-POLY1305", True)]
|
||||
for n in (1, 2, 3):
|
||||
cases += [("aes-1.3#%d" % n, "1.3", "TLS_AES_128_GCM_SHA256", "TLS_AES_128_GCM_SHA256", True),
|
||||
("aes-1.2#%d" % n, "1.2", "ECDHE-ECDSA-AES128-GCM-SHA256", "ECDHE-ECDSA-AES128-GCM-SHA256",
|
||||
True)]
|
||||
cases.append(("cbc-1.2", "1.2", "ECDHE-ECDSA-AES128-SHA", "ECDHE-ECDSA-AES128-SHA", False))
|
||||
for label, version, offer, want, kernel in cases:
|
||||
# The kernel's counters and the engine's interrupt are the machine's, not the
|
||||
# connection's: a window in which another client connected (a panel reconnecting
|
||||
# after a restart, a page polling this suite once a second) says nothing about this
|
||||
# one, and is measured again, after a pause that no steady poller stays in step with.
|
||||
for attempt in range(1, 31):
|
||||
suite, status, body, delta, irqs, others = _fetch(version, offer, work)
|
||||
if others <= 0:
|
||||
break
|
||||
ctx.log("%s: %d other connection(s) during the window; measured again", label, others)
|
||||
ctx.sleep(random.uniform(0.1, 0.9))
|
||||
ctx.check(others <= 0, "%s: another client connected during each of %d windows", label, attempt)
|
||||
run = {"offer": offer, "suite": suite, "status": status, "bytes": len(body),
|
||||
"same_as_http": body == plain, "tls_stat": delta, "engine_irqs": irqs, "attempts": attempt}
|
||||
runs[label] = run
|
||||
ctx.log("%s: offer %s -> %s, %s, %d bytes, same %s, tls_stat %s, %s +%d",
|
||||
label, offer, suite, status, len(body), body == plain, delta, ENGINE_IRQ, irqs)
|
||||
ctx.check(suite == want, "%s: offered %s, the listener chose %s, not %s", label, offer, suite, want)
|
||||
ctx.check(status.endswith("200 OK") and body == plain,
|
||||
"%s: the page arrived %s, %d bytes, not the plain-HTTP %d", label, status, len(body),
|
||||
len(plain))
|
||||
ctx.check(delta["TlsDecryptError"] == 0, "%s: the kernel counted %d decrypt errors", label,
|
||||
delta["TlsDecryptError"])
|
||||
if kernel:
|
||||
ctx.check(delta["TlsTxSw"] == 1 and delta["TlsRxSw"] == 1,
|
||||
"%s: the kernel did not take both directions' keys (tls_stat %s)", label, delta)
|
||||
else:
|
||||
ctx.check(delta["TlsTxSw"] == 0 and delta["TlsRxSw"] == 0,
|
||||
"%s: the kernel took keys for a cipher it does not seal (tls_stat %s)", label, delta)
|
||||
ev["runs"] = runs
|
||||
|
||||
# The AES of AES-GCM runs on the crypto engine: its interrupt counts
|
||||
# the records. ChaCha20 never touches the engine, so those runs are
|
||||
# the control.
|
||||
for label in ("aes-1.3#1", "aes-1.2#1"):
|
||||
ctx.check(runs[label]["engine_irqs"] >= records // 4,
|
||||
"%s: %d records crossed and the crypto engine interrupted %d times", label, records,
|
||||
runs[label]["engine_irqs"])
|
||||
for label in ("desktop-1.3", "desktop-1.2"):
|
||||
ctx.check(runs[label]["engine_irqs"] < max(2, records // 8),
|
||||
"%s: the crypto engine interrupted %d times for ChaCha20 records", label,
|
||||
runs[label]["engine_irqs"])
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
# The drivers the kernel resolved for the two record ciphers (both
|
||||
# exist now: the connections above instantiated them).
|
||||
chacha, gcm = _driver("rfc7539(chacha20,poly1305)"), _driver("gcm(aes)")
|
||||
ev["drivers"] = {"rfc7539(chacha20,poly1305)": chacha, "gcm(aes)": gcm}
|
||||
ctx.log("rfc7539(chacha20,poly1305) is %s; gcm(aes) is %s", chacha, gcm)
|
||||
ctx.check(chacha == CHACHA_DRIVER, "the kernel's ChaCha20-Poly1305 is %s, not %s", chacha, CHACHA_DRIVER)
|
||||
ctx.check(gcm and "ctr-aes-caam" in gcm and "ghash-ce" in gcm,
|
||||
"the kernel's gcm(aes) is %s: not the crypto engine's AES with the NEON GHASH", gcm)
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
"""update.* - the A/B slot inventory and the firmware verification path."""
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
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 "
|
||||
"throwaway key made on the spot verifies with its own key and fails against the "
|
||||
"shipped release key, and an apply of it without confirm_unsigned is refused by "
|
||||
"the update job before anything is written. Nothing is written to any slot.")
|
||||
"the update job before anything is written, and the forgectrl log records the "
|
||||
"job's start and its refusal. Nothing is written to any slot.")
|
||||
def slots_and_signature(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
@@ -96,6 +96,11 @@ def slots_and_signature(ctx):
|
||||
target = name
|
||||
ctx.check(target is not None, "no inactive firmware slot in /slots: %s", slots)
|
||||
shutil.copyfile(signed, staged)
|
||||
log_path = "/data/log/forgefirm/forgectrl/forgectrl.log"
|
||||
try:
|
||||
log_from = os.path.getsize(log_path)
|
||||
except OSError:
|
||||
log_from = 0
|
||||
st, body = fc.post("/update/apply", params={"slot": target, "file": "upload"})
|
||||
ev["apply"] = {"status": st, "body": body}
|
||||
ctx.log("POST /update/apply slot=%s file=upload (no confirm_unsigned) -> %s %s", target, st, body)
|
||||
@@ -118,82 +123,27 @@ def slots_and_signature(ctx):
|
||||
ctx.check(isinstance(result, dict) and result.get("ok") is False
|
||||
and "not signed" in str(result.get("error", "")),
|
||||
"the apply of a foreign-signed archive was not refused for its signature: %s", result)
|
||||
|
||||
# The forgectrl log records the job: what it set out to write
|
||||
# and why it stopped.
|
||||
want = ["update: writing the uploaded archive into slot %s" % target,
|
||||
"update: the apply failed: archive is not signed"]
|
||||
|
||||
def log_text():
|
||||
try:
|
||||
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
f.seek(log_from)
|
||||
return f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
ctx.wait_for(lambda: all(w in log_text() for w in want), 10, poll=0.5)
|
||||
text = log_text()
|
||||
ev["apply_log"] = [ln for ln in text.splitlines() if "update: " in ln][-4:]
|
||||
for w in want:
|
||||
ctx.check(w in text, "the forgectrl log lacks %r: %s", w, ev["apply_log"])
|
||||
finally:
|
||||
try:
|
||||
os.unlink(staged)
|
||||
except OSError:
|
||||
pass
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
_VERSION_RX = re.compile(r"^v?\d+\.\d+\.\d+")
|
||||
|
||||
|
||||
@test("update.release-check", title="Release check answers from the releases API; the alert dismissal is per release",
|
||||
subsystem="update", kind="auto", est_min=1,
|
||||
covers=_UPDATE_COVERS + [("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="GET /update/release answers the kept answer of the daily check (available, version, "
|
||||
"current, new, checked, dismissed). POST /update/check asks the releases API now and "
|
||||
"answers the same shape; a machine with no route to the API answers 502, which the drill "
|
||||
"records and steps over. A published release is a v<semver> tag with the firmware "
|
||||
"file's size and its notes, and `new` follows the version order: a development build "
|
||||
"sees every release as newer. POST /update/dismiss marks that version dismissed, an "
|
||||
"empty version undoes it, and a version that is not a tag is refused. The dismissal "
|
||||
"the machine had before the drill is put back.")
|
||||
def release_check(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
keys = ("available", "version", "current", "new", "checked", "dismissed", "detail", "bytes")
|
||||
|
||||
st, before = fc.get("/update/release")
|
||||
ctx.log("GET /update/release -> %s %s", st, {k: before.get(k) for k in keys} if isinstance(before, dict) else before)
|
||||
ctx.check(st == 200 and isinstance(before, dict) and "available" in before and "checked" in before,
|
||||
"GET /update/release -> %s", st)
|
||||
ev["before"] = {k: before.get(k) for k in keys}
|
||||
prior = before.get("version", "") if before.get("dismissed") else ""
|
||||
|
||||
st, now = fc.post("/update/check")
|
||||
ctx.log("POST /update/check -> %s %s", st, {k: now.get(k) for k in keys} if isinstance(now, dict) else now)
|
||||
if st == 502:
|
||||
ctx.log("the machine has no route to the releases API; the kept answer stands")
|
||||
now = before
|
||||
else:
|
||||
ctx.check(st == 200 and isinstance(now, dict) and "available" in now, "POST /update/check -> %s", st)
|
||||
ctx.check(isinstance(now.get("checked"), int) and now["checked"] >= before.get("checked", 0),
|
||||
"the check did not move `checked`: %s", now.get("checked"))
|
||||
ev["after_check"] = {k: now.get(k) for k in keys}
|
||||
|
||||
if now.get("available"):
|
||||
ctx.check(_VERSION_RX.match(str(now.get("version", ""))) is not None,
|
||||
"the release version is not a v<semver> tag: %r", now.get("version"))
|
||||
ctx.check(isinstance(now.get("bytes"), int) and now["bytes"] > 0,
|
||||
"the firmware file's size is missing: %r", now.get("bytes"))
|
||||
ctx.check(isinstance(now.get("notes"), str), "the notes are not a string")
|
||||
ctx.check(isinstance(now.get("new"), bool), "`new` is not a bool")
|
||||
if not _VERSION_RX.match(str(now.get("current", ""))):
|
||||
ctx.check(now.get("new") is True,
|
||||
"a development build (%r) must see release %s as newer", now.get("current"), now["version"])
|
||||
else:
|
||||
ctx.log("no release available: %s", now.get("detail"))
|
||||
ctx.check(isinstance(now.get("detail"), str) and now["detail"], "an unavailable release names no reason")
|
||||
|
||||
try:
|
||||
st, body = fc.post("/update/dismiss", params={"version": "v0.0.1; rm -rf /"})
|
||||
ctx.log("POST /update/dismiss version=<not a tag> -> %s %s", st, body)
|
||||
ctx.check(st == 400, "a version that is not a tag was not refused: %s", st)
|
||||
|
||||
tag = now["version"] if now.get("available") else "v0.0.0"
|
||||
st, body = fc.post("/update/dismiss", params={"version": tag})
|
||||
ctx.log("POST /update/dismiss version=%s -> %s %s", tag, st,
|
||||
{k: body.get(k) for k in keys} if isinstance(body, dict) else body)
|
||||
ctx.check(st == 200 and isinstance(body, dict), "POST /update/dismiss -> %s", st)
|
||||
if now.get("available"):
|
||||
ctx.check(body.get("dismissed") is True, "the dismissal of %s was not recorded: %r", tag, body.get("dismissed"))
|
||||
st, body = fc.post("/update/dismiss", params={"version": ""})
|
||||
ctx.log("POST /update/dismiss version= -> %s", st)
|
||||
ctx.check(st == 200 and isinstance(body, dict) and body.get("dismissed") is False,
|
||||
"the empty version did not undo the dismissal: %s %r", st, body.get("dismissed") if isinstance(body, dict) else body)
|
||||
finally:
|
||||
st, body = fc.post("/update/dismiss", params={"version": prior})
|
||||
ctx.log("dismissal put back to %r -> %s", prior, st)
|
||||
|
||||
@@ -451,6 +451,23 @@ footer a {
|
||||
font-size: 11.5px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.extadvdoc {
|
||||
max-height: 22rem;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.extadvdoc h1,
|
||||
.extadvdoc h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0.6rem 0 0.3rem;
|
||||
}
|
||||
.extcaps {
|
||||
margin: 0.2rem 0 0.2rem 1.1rem;
|
||||
padding: 0;
|
||||
}
|
||||
.extcaps li {
|
||||
margin: 0;
|
||||
}
|
||||
.hint {
|
||||
color: var(--og-dim);
|
||||
font-size: 12.5px;
|
||||
@@ -798,3 +815,26 @@ footer a {
|
||||
min-height: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* A package's card on the Extensions tab: the card and its heading (the
|
||||
label) are the panel's, the frame is the package's, and the frame is
|
||||
given a size rather than taking one. The frame takes the panel's color
|
||||
scheme, which the page inside reads as its prefers-color-scheme; it is
|
||||
transparent, so a page that paints no background sits on the card. */
|
||||
.extcard h2 .mono {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 400;
|
||||
color: var(--og-dim);
|
||||
}
|
||||
.extframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color-scheme: light;
|
||||
}
|
||||
[data-bs-theme='dark'] .extframe {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
-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
|
||||
+145
-147
@@ -21,7 +21,10 @@ from forgetest import baseline
|
||||
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):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-bl-")
|
||||
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
|
||||
# only when the test asks (see _sync_leds)
|
||||
|
||||
def _led2(self, name, target, brightness):
|
||||
"""Both attributes of one button LED: the trigger's commanded
|
||||
level and the fade that follows it."""
|
||||
for a, v in (("target", target), ("brightness", brightness)):
|
||||
with open(self.leds + name + "/" + a, "w") as f:
|
||||
f.write(v)
|
||||
|
||||
def _sync_leds(self):
|
||||
for name in baseline.BUTTON_LEDS:
|
||||
p = self.leds + name + "/target"
|
||||
@@ -88,6 +84,19 @@ class BaselineTests(unittest.TestCase):
|
||||
def bl(self):
|
||||
return baseline.Baseline(self.lines.append)
|
||||
|
||||
def _pos_bytes(self, x, y, z, processed, total):
|
||||
with open(self.sysfs + "cnc/position", "wb") as f:
|
||||
f.write(struct.pack("<3i2I", x, y, z, processed, total))
|
||||
|
||||
def _probe_state(self):
|
||||
# what the kernel shows between the supervisor's motion probe and
|
||||
# the GRBL controller's init writes
|
||||
self._attr("cnc/motor_lock", "0")
|
||||
self._attr("cnc/step_freq", "10000")
|
||||
self._attr("cnc/y_mode", "1")
|
||||
|
||||
|
||||
class BaselineTests(BaselineFixture, unittest.TestCase):
|
||||
def test_clean_machine_has_no_leftovers(self):
|
||||
left = self.bl().enforce("pre", captured=None)
|
||||
self.assertEqual(left, [])
|
||||
@@ -139,6 +148,49 @@ class BaselineTests(unittest.TestCase):
|
||||
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
|
||||
self.assertEqual(items["position"].found, [1000, 0, 0])
|
||||
|
||||
def test_a_lost_counter_frame_never_moves_the_head(self):
|
||||
# The controller zeroes the step counters at every start and at
|
||||
# every home, and rewrites its anchor then. A run that began at
|
||||
# -6400 steps and restarted the controller ends at 0 with the head
|
||||
# where it was: the difference is not a distance, and the hand-back
|
||||
# must neither jog the head nor call it a leftover.
|
||||
anchor = os.path.join(self.tmp, "grblhal.homed")
|
||||
old_path, baseline.ANCHOR_PATH = baseline.ANCHOR_PATH, anchor
|
||||
try:
|
||||
with open(anchor, "w") as f:
|
||||
f.write("0 0 0 4 startup")
|
||||
self._pos(-6400, 0, 0)
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
self.assertIsNotNone(cap["frame"])
|
||||
os.unlink(anchor) # a controller start: a new anchor file
|
||||
with open(anchor + ".new", "w") as f:
|
||||
f.write("0 0 0 4 startup")
|
||||
os.replace(anchor + ".new", anchor)
|
||||
os.utime(anchor, ns=(5, 5)) # a freed inode can come straight back, inside one clock tick
|
||||
self._pos(0, 0, 0)
|
||||
left = b.enforce("post", captured=cap)
|
||||
self.assertEqual([x.item for x in left], [])
|
||||
self.assertTrue(any("re-zeroed its counters during the run" in l for l in self.lines), self.lines)
|
||||
# the same counters with the frame intact are a displaced head, as before
|
||||
self.lines.clear()
|
||||
self._pos(-6400, 0, 0)
|
||||
cap = b.capture()
|
||||
self._pos(0, 0, 0)
|
||||
left = b.enforce("post", captured=cap)
|
||||
self.assertEqual([x.item for x in left], ["position"])
|
||||
# and a test that vouches for the new frame is held to it
|
||||
self._pos(-6400, 0, 0)
|
||||
cap = b.capture()
|
||||
os.utime(anchor, ns=(1, 1)) # re-zeroed, and declared (ctx.counters_rezeroed)
|
||||
cap["position"], cap["rezero_declared"] = [0, 0, 0], True
|
||||
self._pos(0, 0, 0)
|
||||
self.assertEqual(b.enforce("post", captured=cap), [])
|
||||
self._pos(1000, 0, 0)
|
||||
self.assertEqual([x.item for x in b.enforce("post", captured=cap)], ["position"])
|
||||
finally:
|
||||
baseline.ANCHOR_PATH = old_path
|
||||
|
||||
def test_the_counters_are_read_at_the_modes_scale(self):
|
||||
# 6400 steps is 30 mm at x32 (a return within the bound; on the
|
||||
# host it stops at the missing controller) and 120 mm at x8
|
||||
@@ -183,10 +235,6 @@ class BaselineTests(unittest.TestCase):
|
||||
for k in ("GRBL_HOST", "GRBL_PORT"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
def _pos_bytes(self, x, y, z, processed, total):
|
||||
with open(self.sysfs + "cnc/position", "wb") as f:
|
||||
f.write(struct.pack("<3i2I", x, y, z, processed, total))
|
||||
|
||||
def test_ring_residue_is_a_leftover_and_blocks_the_return_jog(self):
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
@@ -265,13 +313,6 @@ class BaselineTests(unittest.TestCase):
|
||||
|
||||
# -- the reference is taken after the controller applied its config -----
|
||||
|
||||
def _probe_state(self):
|
||||
# what the kernel shows between the supervisor's motion probe and
|
||||
# the GRBL controller's init writes
|
||||
self._attr("cnc/motor_lock", "0")
|
||||
self._attr("cnc/step_freq", "10000")
|
||||
self._attr("cnc/y_mode", "1")
|
||||
|
||||
def test_wait_configured_returns_once_the_controller_wrote_its_config(self):
|
||||
self._probe_state()
|
||||
calls = {"n": 0}
|
||||
@@ -391,125 +432,95 @@ class BaselineTests(unittest.TestCase):
|
||||
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}))
|
||||
|
||||
def test_stale_preconfig_reference_is_retaken_on_a_fresh_boot(self):
|
||||
# Both sides of BOOT_MAX_AGE_S, with the uptime held: read from the host, it can cross the limit
|
||||
# between boot_reference()'s reading and the test's (it did, on a CI runner 600 s after its boot).
|
||||
os.environ["FORGETEST_BOOT_ID"] = "test-boot-2"
|
||||
real = baseline.uptime_s
|
||||
try:
|
||||
path = os.path.join(self.tmp, "boot-test-boot-2.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump({"ts": "old", "sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000",
|
||||
"cnc/y_mode": "1"}}, f)
|
||||
ref = baseline.boot_reference(self.lines.append, self.tmp)
|
||||
up = baseline.uptime_s()
|
||||
if up is None or up > baseline.BOOT_MAX_AGE_S:
|
||||
# too old to retake: the stale reference stands, marked
|
||||
self.assertTrue(any("predates the controller's config" in l for l in self.lines))
|
||||
self.assertEqual(ref["ts"], "old")
|
||||
else:
|
||||
self.assertTrue(any("retaking" in l for l in self.lines))
|
||||
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
|
||||
for up, fresh in ((baseline.BOOT_MAX_AGE_S - 60, True), (baseline.BOOT_MAX_AGE_S + 60, False)):
|
||||
self.lines.clear()
|
||||
path = os.path.join(self.tmp, "boot-test-boot-2.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump({"ts": "old", "sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000",
|
||||
"cnc/y_mode": "1"}}, f)
|
||||
baseline.uptime_s = lambda up=up: up
|
||||
ref = baseline.boot_reference(self.lines.append, self.tmp)
|
||||
if fresh:
|
||||
self.assertTrue(any("retaking" in l for l in self.lines), self.lines)
|
||||
self.assertNotEqual(ref["ts"], "old")
|
||||
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
|
||||
else:
|
||||
# too old to retake: the stale reference stands, marked
|
||||
self.assertTrue(any("predates the controller's config" in l for l in self.lines), self.lines)
|
||||
self.assertEqual(ref["ts"], "old")
|
||||
finally:
|
||||
baseline.uptime_s = real
|
||||
os.environ.pop("FORGETEST_BOOT_ID", None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class 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}
|
||||
class ExtensionFreeTests(BaselineFixture, unittest.TestCase):
|
||||
"""A campaign's machine is extension-free: what a test made is removed,
|
||||
what is the operator's is reported and left alone."""
|
||||
|
||||
def setUp(self):
|
||||
self.lines = []
|
||||
super().setUp()
|
||||
self.root = os.path.join(self.tmp, "ext")
|
||||
for d in ("pkg", "keys", "data"):
|
||||
os.makedirs(os.path.join(self.root, d))
|
||||
# a stand-in for the host's command line: remove <id> takes the package away
|
||||
self.forgeext = os.path.join(self.tmp, "forgeext")
|
||||
with open(self.forgeext, "w") as f:
|
||||
f.write('#!/bin/sh\n[ "$1" = remove ] && rm -rf "%s/pkg/$2" && echo \'{"ok": true}\'\n' % self.root)
|
||||
os.chmod(self.forgeext, 0o755)
|
||||
self.pool = {}
|
||||
self._saved = (baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT, baseline.hw.pool_pids, baseline.EXT_STOP_S)
|
||||
baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT = self.root, self.forgeext
|
||||
baseline.hw.pool_pids = lambda: dict(self.pool)
|
||||
baseline.EXT_STOP_S = 0.2
|
||||
|
||||
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 tearDown(self):
|
||||
baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT, baseline.hw.pool_pids, baseline.EXT_STOP_S = self._saved
|
||||
super().tearDown()
|
||||
|
||||
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 _package(self, id_):
|
||||
os.makedirs(os.path.join(self.root, "pkg", id_, "1.0.0"))
|
||||
|
||||
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 _key(self, name):
|
||||
with open(os.path.join(self.root, "keys", name), "w") as f:
|
||||
f.write("x")
|
||||
|
||||
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_an_installed_package_that_does_not_run_is_nobodys_leftover(self):
|
||||
self._package("org.example.notify")
|
||||
self._key("owner.pub")
|
||||
self.assertEqual(self.bl().enforce("pre", captured=None), [])
|
||||
|
||||
def test_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_what_a_test_left_behind_is_removed_and_the_operators_is_not(self):
|
||||
self._package("org.forgetest.reference")
|
||||
self._package("org.example.notify")
|
||||
self._key("forgetest-reference.pub")
|
||||
self._key("owner.pub")
|
||||
left = {x.item: x for x in self.bl().enforce("post", captured=None)}
|
||||
self.assertEqual(sorted(left), ["ext.owner_key", "ext.package"])
|
||||
self.assertEqual((left["ext.package"].found, left["ext.package"].action), ("org.forgetest.reference", "restored"))
|
||||
self.assertEqual((left["ext.owner_key"].found, left["ext.owner_key"].action), ("forgetest-reference.pub", "restored"))
|
||||
self.assertEqual(os.listdir(os.path.join(self.root, "pkg")), ["org.example.notify"])
|
||||
self.assertEqual(os.listdir(os.path.join(self.root, "keys")), ["owner.pub"])
|
||||
|
||||
def test_a_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_a_package_that_cannot_be_removed_says_so(self):
|
||||
self._package("org.forgetest.reference")
|
||||
with open(self.forgeext, "w") as f:
|
||||
f.write('#!/bin/sh\necho \'{"ok": false, "error": "the lock is held"}\'\nexit 1\n')
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
self.assertEqual([x.item for x in left], ["ext.package"])
|
||||
self.assertTrue(left[0].action.startswith("failed:") and "the lock is held" in left[0].action, left[0].action)
|
||||
|
||||
def test_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, [])
|
||||
def test_running_extensions_are_reported_and_left_alone(self):
|
||||
self._package("org.example.notify")
|
||||
self.pool = {4242: 800}
|
||||
left = self.bl().enforce("pre", captured=None)
|
||||
self.assertEqual([(x.item, x.action) for x in left], [("ext.running", "unrestorable")])
|
||||
self.assertIn("org.example.notify", left[0].found)
|
||||
self.assertTrue(os.path.isdir(os.path.join(self.root, "pkg", "org.example.notify")))
|
||||
|
||||
|
||||
class CloudOwnedSysfsTests(unittest.TestCase):
|
||||
@@ -536,52 +547,35 @@ class PositionDeadbandTests(unittest.TestCase):
|
||||
where motion.lid-cancel-home returned the head twice, each within
|
||||
0.04 mm, and failed the hand-back on the four steps left over."""
|
||||
|
||||
def test_a_few_steps_of_xy_are_the_quantization(self):
|
||||
self.assertTrue(baseline.position_quantized([0, 0, 0], [4, 0, 0]))
|
||||
self.assertTrue(baseline.position_quantized([0, 0, 0], [-4, 4, 0]))
|
||||
self.assertTrue(baseline.position_quantized([10, 20, 30], [10, 20, 30]))
|
||||
|
||||
def test_past_the_dead_band_is_a_leftover(self):
|
||||
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
|
||||
|
||||
def test_the_dead_band_scales_with_the_counters(self):
|
||||
# the same step count is a quarter of the distance at x32
|
||||
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
|
||||
x32 = baseline.XY_STEPS_PER_MM_OF[32]
|
||||
self.assertTrue(baseline.position_quantized([0, 0, 0], [over, 0, 0], x32))
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [4 * over, 0, 0], x32))
|
||||
|
||||
def test_z_is_exact_because_the_return_never_moves_it(self):
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
|
||||
|
||||
def test_an_unreadable_reading_is_never_quantization(self):
|
||||
self.assertFalse(baseline.position_quantized(None, [0, 0, 0]))
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], None))
|
||||
|
||||
|
||||
class BaselineModeTests(BaselineTests):
|
||||
class BaselineModeTests(BaselineFixture, unittest.TestCase):
|
||||
"""The baseline against a fake forgectrl: what the mode in force
|
||||
owns. Reuses the fake sysfs tree of BaselineTests; only the new
|
||||
tests run here (the inherited ones are skipped)."""
|
||||
owns, over the same fake sysfs tree."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
import helpers
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
baseline.Baseline._unreachable_until = 0.0
|
||||
# the lamp's settle, short: five polls, so a lamp that never holds
|
||||
# costs the suite no real seconds
|
||||
self._lamp_timing = (baseline.LID_LAMP_SETTLE_S, baseline.LID_LAMP_POLL_S)
|
||||
baseline.LID_LAMP_SETTLE_S, baseline.LID_LAMP_POLL_S = 0.05, 0.01
|
||||
|
||||
def tearDown(self):
|
||||
baseline.LID_LAMP_SETTLE_S, baseline.LID_LAMP_POLL_S = self._lamp_timing
|
||||
self.fc.stop()
|
||||
super().tearDown()
|
||||
|
||||
def run(self, result=None):
|
||||
# only this class's own tests, not the base class's
|
||||
if self._testMethodName not in BaselineModeTests.__dict__:
|
||||
return
|
||||
return super().run(result)
|
||||
|
||||
def cloud(self):
|
||||
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
|
||||
self.fc.state["settings"]["controller_mode"] = "cloud"
|
||||
@@ -728,3 +722,7 @@ class BaselineModeTests(BaselineTests):
|
||||
cap["mode"] = "cloud"
|
||||
b.enforce("post", captured=cap)
|
||||
self.assertEqual([p for p, _ in self.fc.posts if p == "/settings"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -5,13 +5,11 @@
|
||||
|
||||
"""The bench registry against scripts/bench: every python tool is
|
||||
registered (or named as not-a-tool), every registered script exists,
|
||||
every argument spec builds a command line, every script compiles, and
|
||||
the shared helper resolves host and local mode as documented."""
|
||||
a ported tool builds a command line with its defaults, and every
|
||||
script compiles."""
|
||||
import glob
|
||||
import os
|
||||
import py_compile
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import helpers # noqa: F401 (sys.path)
|
||||
@@ -56,78 +54,10 @@ class RegistryTests(unittest.TestCase):
|
||||
self.assertTrue(ok, "%s: %s" % (t["id"], err))
|
||||
self.assertEqual(argv[1], os.path.join(BENCH, t["script"]))
|
||||
|
||||
def test_flagged_args_build_option_pairs(self):
|
||||
tool = next(t for t in bench_mod.TOOLS if t["id"] == "resume-dark-lead")
|
||||
ok, argv, err = self.bench.command(tool, {"run": "live", "feed": 900})
|
||||
self.assertTrue(ok, err)
|
||||
self.assertIn("--run", argv)
|
||||
self.assertEqual(argv[argv.index("--run") + 1], "live")
|
||||
self.assertEqual(argv[argv.index("--feed") + 1], "900.0")
|
||||
# an empty optional with an empty default is left off entirely
|
||||
self.assertNotIn("--auto", argv)
|
||||
# a choice outside the list is refused before anything runs
|
||||
ok, _argv, err = self.bench.command(tool, {"run": "sideways"})
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("run must be one of", err)
|
||||
|
||||
def test_unported_tools_are_host_only(self):
|
||||
# what stays unported is what cannot run on the machine at all
|
||||
for t in bench_mod.TOOLS:
|
||||
if not t["ported"]:
|
||||
self.assertEqual(t["where"], "host", t["id"])
|
||||
self.assertIn("not a bench-page tool", t["desc"], t["id"])
|
||||
|
||||
def test_scripts_compile(self):
|
||||
for s in self.scripts:
|
||||
py_compile.compile(os.path.join(BENCH, s), doraise=True)
|
||||
|
||||
|
||||
class GfbenchTests(unittest.TestCase):
|
||||
"""gfbench.py in a subprocess (module-level host resolution)."""
|
||||
|
||||
def run_snippet(self, code, env_extra):
|
||||
env = dict(os.environ)
|
||||
env.pop("GF_HOST", None)
|
||||
env.pop("FORGETEST_BENCH_DATA", None)
|
||||
env.update(env_extra)
|
||||
r = subprocess.run([sys.executable, "-c", code], cwd=BENCH, env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
|
||||
return r.returncode, r.stdout.strip()
|
||||
|
||||
def test_host_mode(self):
|
||||
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL, gfbench.SSH[0])",
|
||||
{"GF_HOST": "192.0.2.7", "GF_SSH": "fakessh -x"})
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(out, "192.0.2.7 False fakessh")
|
||||
|
||||
def test_local_when_named_local(self):
|
||||
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL); "
|
||||
"print(gfbench.board('echo hi').strip())",
|
||||
{"GF_HOST": "127.0.0.1"})
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(out.splitlines(), ["127.0.0.1 True", "hi"])
|
||||
|
||||
def test_refuses_without_a_machine(self):
|
||||
if os.path.isdir("/sys/glowforge"):
|
||||
self.skipTest("running on the machine")
|
||||
rc, out = self.run_snippet("import gfbench", {})
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("GF_HOST", out)
|
||||
|
||||
def test_degc_and_data_dir(self):
|
||||
import tempfile
|
||||
d = tempfile.mkdtemp(prefix="forgetest-bench-")
|
||||
rc, out = self.run_snippet(
|
||||
"import gfbench, os; print(round(gfbench.degc(700), 2), gfbench.degc('x') != gfbench.degc('x'), "
|
||||
"os.path.dirname(gfbench.data_path('f.json')) == os.environ['FORGETEST_BENCH_DATA'])",
|
||||
{"GF_HOST": "127.0.0.1", "FORGETEST_BENCH_DATA": d})
|
||||
self.assertEqual(rc, 0, out)
|
||||
val, nan, indir = out.split()
|
||||
# a plausible coolant reading (700 counts is room temperature territory)
|
||||
self.assertTrue(15.0 < float(val) < 35.0, val)
|
||||
self.assertEqual(nan, "True")
|
||||
self.assertEqual(indir, "True")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""A test that lets the service move the head hands it back where it found
|
||||
it. Every service motion zeroes the step counters at its start, and a home
|
||||
and every controller start zero them again, so no counter reading says
|
||||
where the test found the head: the client's own record does. Every motion
|
||||
logs the counters it ended on, and the travel is their sum (x8 steps, the
|
||||
one mode a service motion runs at), plus what the counters read since. The
|
||||
log lines are the machine's own, trimmed to the motion records: a homing
|
||||
session whose one motion the cooling engine stopped short, and a whole
|
||||
three-motion one. Runs against scratch logs and fakes for the controller."""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from forgetest.runner import Failed
|
||||
from forgetest.suite import cloud, homeoff
|
||||
|
||||
STOPPED = """\
|
||||
2026-09-25T15:56:19.641824+00:00 gfhome[7387] INFO gfhome:home service action: motion (ready)
|
||||
2026-09-25T15:56:19.644041+00:00 gfhome[7387] INFO machine:_motion start motion
|
||||
2026-09-25T15:56:19.970342+00:00 gfhome[7387] INFO machine:_run_loop starting run
|
||||
2026-09-25T15:56:20.905040+00:00 gfhome[7387] ERR machine:_run_loop run stopped short: 8130 of 21756 bytes played; the job did not finish
|
||||
2026-09-25T15:56:20.918652+00:00 gfhome[7387] INFO machine:_feed_and_run end positions (actual/expected): X (4390/12115), Y (2682/7402), Z (0/0)
|
||||
2026-09-25T15:56:20.937235+00:00 gfhome[7387] INFO machine:_motion_locked end positions (4390, 2682, 0)
|
||||
2026-09-25T15:56:20.938026+00:00 gfhome[7387] INFO machine:_motion end motion
|
||||
2026-09-25T15:56:20.938677+00:00 gfhome[7387] INFO basemachine:_finish_action motion [1588842275]: finished with event ":cancelled"
|
||||
2026-09-25T15:56:21.145580+00:00 gfhome[7387] INFO gfhome:home motion completed
|
||||
"""
|
||||
|
||||
WHOLE = "".join(
|
||||
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion start motion\n"
|
||||
"2026-09-07T20:%s gfhome[28592] INFO machine:_feed_and_run end positions (actual/expected): "
|
||||
"X (%d/%d), Y (%d/%d), Z (0/0)\n"
|
||||
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion_locked end positions (%d, %d, 0)\n"
|
||||
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion end motion\n"
|
||||
"2026-09-07T20:%s gfhome[28592] INFO basemachine:_finish_action motion [%d]: finished with event "
|
||||
"\":completed\"\n" % (t, t, x, x, y, y, t, x, y, t, t, i)
|
||||
for i, (t, x, y) in enumerate((("25:50.1", 4774, 4600), ("25:56.2", -71, -144), ("26:02.9", -13096, -7399))))
|
||||
|
||||
|
||||
class FakeGrbl:
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def command(self, line, timeout=None):
|
||||
self.ctx.jogs.append(line)
|
||||
return ["ok"]
|
||||
|
||||
|
||||
class FakeCtx:
|
||||
def __init__(self):
|
||||
self.forgectrl = object()
|
||||
self.jogs = []
|
||||
self.lines = []
|
||||
self.rezeroed = 0
|
||||
|
||||
def check(self, cond, msg, *args):
|
||||
if not cond:
|
||||
raise Failed(msg % args if args else msg)
|
||||
|
||||
def log(self, msg, *args):
|
||||
self.lines.append(msg % args if args else msg)
|
||||
|
||||
def grbl(self):
|
||||
return FakeGrbl(self)
|
||||
|
||||
def counters_rezeroed(self):
|
||||
self.rezeroed += 1
|
||||
|
||||
|
||||
class HandBackTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
self.log = os.path.join(self.dir, "gfhome.log")
|
||||
self.cloudlog = os.path.join(self.dir, "gfcloud.log")
|
||||
self.ctx = FakeCtx()
|
||||
self.anchor = True
|
||||
self.counters = [0, 0, 0]
|
||||
self.restarts = 0
|
||||
self.spm = 213.333 # the controller's x32 scale
|
||||
saved_logs = (cloud.GFHOME_LOG, cloud.GFCLOUD_LOG)
|
||||
self.addCleanup(lambda: setattr(cloud, "GFHOME_LOG", saved_logs[0]))
|
||||
self.addCleanup(lambda: setattr(cloud, "GFCLOUD_LOG", saved_logs[1]))
|
||||
cloud.GFHOME_LOG, cloud.GFCLOUD_LOG = self.log, self.cloudlog
|
||||
patches = {
|
||||
"counter_frame": lambda: [1, 2] if self.anchor else None,
|
||||
"read_position": lambda: list(self.counters),
|
||||
"counter_steps_per_mm": lambda: self.spm,
|
||||
"_drop_reference": self._restart,
|
||||
"clean_slate": lambda ctx, g: None,
|
||||
"wait_idle": self._jog_ends,
|
||||
"machine_idle": lambda ctx: None,
|
||||
}
|
||||
saved = {k: getattr(homeoff, k) for k in patches}
|
||||
self.addCleanup(lambda: [setattr(homeoff, k, v) for k, v in saved.items()])
|
||||
for k, v in patches.items():
|
||||
setattr(homeoff, k, v)
|
||||
self.addCleanup(shutil.rmtree, self.dir, True)
|
||||
|
||||
def _restart(self, ctx, fc):
|
||||
# a controller start zeroes the counters where the head stands and
|
||||
# removes the anchor
|
||||
self.restarts += 1
|
||||
self.counters = [0, 0, 0]
|
||||
self.anchor = False
|
||||
|
||||
def _jog_ends(self, ctx, g, timeout=30.0):
|
||||
# the jog played: the counters moved by what it asked, at the scale
|
||||
words = dict((w[0], float(w[1:])) for w in self.ctx.jogs[-1].split()[2:4])
|
||||
self.counters = [self.counters[0] + round(words["X"] * self.spm),
|
||||
self.counters[1] + round(words["Y"] * self.spm), 0]
|
||||
return 0.0, ["Jog", "Idle"], {}
|
||||
|
||||
def write(self, text, path=None):
|
||||
with open(path or self.log, "a", encoding="utf-8", newline="\n") as f:
|
||||
f.write(text)
|
||||
|
||||
def expect_jog(self, dx, dy):
|
||||
self.assertEqual(self.ctx.jogs, ["$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy)])
|
||||
|
||||
# -- the stopped-short judge --------------------------------------------
|
||||
def test_a_motion_stopped_short_fails_the_judge(self):
|
||||
self.write(STOPPED)
|
||||
ev = {}
|
||||
with self.assertRaises(Failed):
|
||||
homeoff.judge_whole_motions(self.ctx, ev, 0)
|
||||
self.assertEqual(len(ev["short_motions"]), 2)
|
||||
|
||||
# -- after a camera home -------------------------------------------------
|
||||
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
|
||||
self.write(STOPPED)
|
||||
self.counters = [round(1.0 * self.spm), 0, 0]
|
||||
since = self.counters[0] / self.spm
|
||||
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
|
||||
self.expect_jog(4390 / homeoff.XY_STEPS_PER_MM + since, 2682 / homeoff.XY_STEPS_PER_MM)
|
||||
# dropped before the jog, and once more with the head back
|
||||
self.assertEqual(self.restarts, 2)
|
||||
self.assertEqual(self.counters, [0, 0, 0])
|
||||
self.assertEqual(self.ctx.rezeroed, 1)
|
||||
|
||||
def test_unhomed_the_counters_are_not_counted_twice(self):
|
||||
# a failed session leaves the last motion's end in the counters,
|
||||
# which the travel already counts
|
||||
self.write(STOPPED)
|
||||
self.counters = [4390, 2682, 0]
|
||||
self.anchor = False
|
||||
homeoff.camera_home_return(self.ctx, {"homed": False}, 0)
|
||||
self.expect_jog(4390 / homeoff.XY_STEPS_PER_MM, 2682 / homeoff.XY_STEPS_PER_MM)
|
||||
|
||||
def test_an_unknown_travel_moves_nothing(self):
|
||||
self.write(STOPPED.split("machine:_motion_locked")[0])
|
||||
with self.assertRaises(Failed):
|
||||
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
self.assertEqual(self.ctx.rezeroed, 0)
|
||||
|
||||
def test_a_restart_since_the_home_moves_nothing(self):
|
||||
# the anchor gone: the counters no longer read from the home
|
||||
self.write(STOPPED)
|
||||
self.anchor = False
|
||||
with self.assertRaises(Failed):
|
||||
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
|
||||
def test_a_head_that_never_moved_is_not_jogged(self):
|
||||
homeoff.camera_home_return(self.ctx, {}, 0)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
self.assertEqual((self.restarts, self.ctx.rezeroed), (1, 1))
|
||||
|
||||
# -- after cloud mode ----------------------------------------------------
|
||||
def test_after_cloud_mode_the_head_goes_back_by_the_clients_record(self):
|
||||
# the service's re-hunt, cut short by the switch back: to the middle
|
||||
# of the bed and one correction (the bench reference, 2026-09-25)
|
||||
self.write(WHOLE[:WHOLE.index("2026-09-07T20:26:02.9")].replace("gfhome[", "gfcloud["), self.cloudlog)
|
||||
ev = {}
|
||||
homeoff.cloud_mode_return(self.ctx, ev, 0)
|
||||
self.expect_jog((4774 - 71) / homeoff.XY_STEPS_PER_MM, (4600 - 144) / homeoff.XY_STEPS_PER_MM)
|
||||
self.assertEqual(tuple(ev["cloud_return"]["session_travel_steps"]), (4774 - 71, 4600 - 144))
|
||||
# no drop before the jog (the switch back started the controller), one after
|
||||
self.assertEqual((self.restarts, self.ctx.rezeroed), (1, 1))
|
||||
|
||||
def test_after_cloud_mode_an_unknown_travel_moves_nothing(self):
|
||||
self.write(STOPPED.replace("gfhome[", "gfcloud[").split("machine:_motion_locked")[0], self.cloudlog)
|
||||
with self.assertRaises(Failed):
|
||||
homeoff.cloud_mode_return(self.ctx, {}, 0)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,22 +3,18 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The cloud.* job-behavior tests against a fake forgectrl and a replayed
|
||||
gfcloud log: the real test functions run under the real runner Context,
|
||||
the operator prompts answered - and the standing notices of the machine
|
||||
actions acted on - by a script that also drives the replay (open the
|
||||
lid -> the machine's lid reads open; close it -> the service's re-hunt
|
||||
lines land). The log lines are the machine's own: bench excerpts
|
||||
(the lid-abort, button-wait, and lid-open-hunt runs) and, for the pause,
|
||||
the run loop's lines as its host test emits them on the print skeleton
|
||||
of the lid-abort excerpt.
|
||||
"""The cloud.* tests' false-PASS guards, against a fake forgectrl and a
|
||||
replayed gfcloud log: the real test functions run under the real runner
|
||||
Context, the operator prompts answered - and the standing notices of the
|
||||
machine actions acted on - by a script that also drives the replay. The
|
||||
log lines are the machine's own: the lid-abort bench excerpt, and for the
|
||||
pause, the run loop's lines as its host test emits them on the print
|
||||
skeleton of that excerpt.
|
||||
|
||||
What is proven here: the tests find the right lines in real log noise,
|
||||
reuse an existing cloud session and never switch back, restart the client
|
||||
for a fresh hunt, judge the print's own finish line (not another action's),
|
||||
wait the service's deferred moves out, and fail for the right reasons."""
|
||||
What is proven here: each test fails when the machine did the unsafe
|
||||
thing - a dark print with the latch open at the button wait, a lid stop
|
||||
that is polled rather than edge-driven, and a resume the kernel refused."""
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
@@ -40,22 +36,6 @@ def fixture(name):
|
||||
return f.read().decode().splitlines()
|
||||
|
||||
|
||||
# What the build under test logs around a print, in the machine's own format.
|
||||
WARM_UP_LINE = ("2026-08-17T09:44:02.100000+00:00 gfcloud[1522] INFO "
|
||||
"machine:_dwell warm up: holding 3.0 s")
|
||||
COOL_DOWN_LINE = ("2026-08-17T09:45:31.700000+00:00 gfcloud[1522] INFO "
|
||||
"machine:_dwell cool down: holding 10.0 s")
|
||||
PROGRESS_LINE = ("2026-08-17T09:44:05.200000+00:00 gfcloud[1522] INFO "
|
||||
"machine:__init__ print:progress: reporting against 47848 bytes every 30 s")
|
||||
LIMITS_LINE = ("2026-08-17T09:44:01.900000+00:00 gfcloud[1522] INFO "
|
||||
"machine:_motion job limits from the header: air_assist_min_rpm=116 "
|
||||
"coolant_max_c=33.0 coolant_min_c=5.0")
|
||||
# What the engine logs when the job's limits reach it (forgectrl's log).
|
||||
EFFECTIVE_LINE = ("2026-08-17T09:44:02.050000+00:00 forgectrl[410] INFO cool: effective limits: "
|
||||
"coolant ceiling 33.0 C (local 33.0, header 33.0) resume 31.0 C; floors coolant "
|
||||
"5.0 C, exhaust 0 rpm, intake 0 rpm, air assist 116 rpm (from the header, no gate yet)")
|
||||
|
||||
|
||||
def cut(lines, marker, count=1):
|
||||
"""(before, after) at the count-th line containing marker (the line
|
||||
itself opens `after`)."""
|
||||
@@ -68,6 +48,19 @@ def cut(lines, marker, count=1):
|
||||
raise KeyError(marker)
|
||||
|
||||
|
||||
def dark_print(ctx):
|
||||
"""The dark print as exthost.armed-freeze runs it: the cloud suite's
|
||||
dark_print_body between the offline service's setup and cleanup."""
|
||||
offset = cloud.enter_offline(ctx)
|
||||
job = cloud.offline_job(ctx, "dark.puls", seconds=30)
|
||||
off = cloud.Offline().__enter__()
|
||||
try:
|
||||
cloud.dark_print_body(ctx, ctx.evidence, off, job, offset, ctx.forgectrl)
|
||||
finally:
|
||||
off.__exit__(None, None, None)
|
||||
cloud.offline_cleanup(ctx)
|
||||
|
||||
|
||||
class Script:
|
||||
"""Answers every prompt with its first option; a hook per prompt or
|
||||
notice substring drives the fake machine and the log replay (a
|
||||
@@ -182,10 +175,11 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
cloud.NOHUNT_MARKER = os.path.join(self.tmp, "nohunt-marker")
|
||||
FakeOffline.sent = []
|
||||
FakeOffline.on_send = None
|
||||
self.engine_line = EFFECTIVE_LINE # what the engine logs at the print; None = nothing
|
||||
self.client_limits = True # the client names its header limits
|
||||
cloud.QUIET_S = 0.4
|
||||
cloud.QUIET_TIMEOUT_S = 3
|
||||
# A deadline, not a wait: a quiet machine is seen at once. The
|
||||
# replays land their lines from threads, so under a loaded host a
|
||||
# tighter deadline expires before the last of them is written.
|
||||
cloud.QUIET_TIMEOUT_S = 15
|
||||
cloud.HUNT_TIMEOUT_S = 8
|
||||
self.script = None
|
||||
|
||||
@@ -270,535 +264,7 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.assertIn(needle, str(cm.exception))
|
||||
return cm.exception
|
||||
|
||||
# -- session detection -------------------------------------------------
|
||||
def test_offline_mark_is_not_a_live_session(self):
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[3100] INFO websocket:_on_open RX-EVENT: ready",
|
||||
OFFLINE_LINE])
|
||||
live, detail = cloud.session_live(3100)
|
||||
self.assertFalse(live)
|
||||
self.assertIn("offline", detail)
|
||||
self.assertTrue(cloud.client_offline(3100))
|
||||
|
||||
# -- the emulator ------------------------------------------------------------------
|
||||
EMU_PID = 4100
|
||||
EMU_START = ["2026-08-22T23:50:00.100000+00:00 gfcloud[4100] INFO ffmachine:build_emulator EMULATE: the "
|
||||
"emulator in this machine's identity, frames from /usr/share/gfutilities/emulator, no hardware",
|
||||
"2026-08-22T23:50:02.000000+00:00 gfcloud[4100] INFO authentication:authenticate_machine SUCCESS",
|
||||
"2026-08-22T23:50:03.000000+00:00 gfcloud[4100] INFO websocket:ws_connect ESTABLISHED",
|
||||
"2026-08-22T23:50:05.000000+00:00 gfcloud[4100] INFO basemachine:_finish_action hunt [1578300001]: "
|
||||
"finished with event \":completed\"",
|
||||
"2026-08-22T23:50:06.000000+00:00 gfcloud[4100] INFO websocket:img_upload COMPLETE",
|
||||
"2026-08-22T23:50:06.500000+00:00 gfcloud[4100] INFO basemachine:_finish_action lid_image [1578300002]: "
|
||||
"finished with event \":completed\""]
|
||||
EMU_PRINT = ["2026-08-22T23:51:00.000000+00:00 gfcloud[4100] INFO gfuiservice:run service action request: print (ready)",
|
||||
"2026-08-22T23:51:01.000000+00:00 gfcloud[4100] INFO websocket:fetch_motion pulse data is uncompressed, "
|
||||
"47848 byte body",
|
||||
"2026-08-22T23:51:02.000000+00:00 gfcloud[4100] INFO basemachine:_finish_action print [1578300003]: "
|
||||
"finished with event \":completed\""]
|
||||
REAL_BACK = ["2026-08-22T23:52:01.000000+00:00 gfcloud[4200] INFO ffmachine:inhibit_connect_hunt NO-HUNT: the "
|
||||
"first settings report in the reconnect form; the service keeps its head position, no "
|
||||
"connect-time hunt",
|
||||
"2026-08-22T23:52:02.000000+00:00 gfcloud[4200] INFO authentication:authenticate_machine SUCCESS",
|
||||
"2026-08-22T23:52:03.000000+00:00 gfcloud[4200] INFO websocket:ws_connect ESTABLISHED",
|
||||
"2026-08-22T23:52:03.500000+00:00 gfcloud[4200] INFO websocket:_on_open RX-EVENT: ready"]
|
||||
|
||||
def emulator_setup(self, print_lines=None, info=True):
|
||||
self.in_cloud(pid=2278)
|
||||
self.append(["2026-08-22T23:49:00.500000+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
starts = []
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/controller/stop":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0)
|
||||
elif path == "/controller/start":
|
||||
starts.append((os.path.exists(cloud.EMULATE_MARKER), os.path.exists(cloud.NOHUNT_MARKER)))
|
||||
if len(starts) == 1:
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=self.EMU_PID)
|
||||
self.append(self.EMU_START, delay=0.2)
|
||||
else:
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=4200)
|
||||
self.append(self.REAL_BACK, delay=0.2)
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
|
||||
def at_print():
|
||||
os.makedirs(cloud.EMULATOR_WORK, exist_ok=True)
|
||||
if info:
|
||||
with open(os.path.join(cloud.EMULATOR_WORK, "2026-08-22_235101_print.info"), "w") as f:
|
||||
json.dump({"header_data": {"STfr": 10000, "MCsn": 0, "PDfm": 0}}, f)
|
||||
self.append(print_lines if print_lines is not None else self.EMU_PRINT, delay=0.1)
|
||||
return starts, {"Set up any small job": at_print}
|
||||
|
||||
def test_service_protocol_against_the_emulator(self):
|
||||
starts, hooks = self.emulator_setup()
|
||||
run = self.run_test(cloud.service_protocol, hooks=hooks, test_id="cloud.service-protocol")
|
||||
ev = run.evidence
|
||||
# the emulator under its marker with the hunt; the real client back without the hunt
|
||||
self.assertEqual(starts, [(True, False), (False, True)])
|
||||
self.assertFalse(os.path.exists(cloud.EMULATE_MARKER))
|
||||
self.assertFalse(os.path.exists(cloud.NOHUNT_MARKER))
|
||||
self.assertTrue(any("no hunt" in l for l in run.lines), run.lines[-5:])
|
||||
self.assertEqual([p for p, _ in self.fc.posts],
|
||||
["/controller/stop", "/controller/start", "/controller/stop", "/controller/start"])
|
||||
self.assertIn(":completed", ev["hunt"])
|
||||
self.assertIn(":completed", ev["print"])
|
||||
self.assertEqual(ev["images_uploaded"], 1)
|
||||
self.assertEqual(ev["header_stfr"], 10000)
|
||||
self.assertEqual(self.script.asked, ["Does the app show the print complete?"])
|
||||
self.assertTrue(any("PASS: session, hunt" in l for l in run.lines), run.lines[-5:])
|
||||
self.assertFalse(os.path.exists(cloud.EMULATOR_WORK))
|
||||
|
||||
def test_service_protocol_fails_when_the_print_does_not_complete_and_still_restores_the_client(self):
|
||||
failed = [l.replace(':completed"', ':failed"') for l in self.EMU_PRINT]
|
||||
starts, hooks = self.emulator_setup(print_lines=failed)
|
||||
self.assertFails(cloud.service_protocol, "did not complete", hooks=hooks)
|
||||
self.assertEqual(starts, [(True, False), (False, True)]) # the real client came back regardless
|
||||
|
||||
def test_markers_the_client_consumed_are_not_missed(self):
|
||||
# gfcloud takes a marker it read down itself; the suite's own removal is then a no-op
|
||||
starts, hooks = self.emulator_setup()
|
||||
on_post = self.fc.on_post
|
||||
|
||||
def consuming(path, form):
|
||||
r = on_post(path, form)
|
||||
if path == "/controller/start":
|
||||
for p in (cloud.EMULATE_MARKER, cloud.NOHUNT_MARKER):
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
return r
|
||||
self.fc.on_post = consuming
|
||||
run = self.run_test(cloud.service_protocol, hooks=hooks, test_id="cloud.service-protocol")
|
||||
self.assertEqual(starts, [(True, False), (False, True)])
|
||||
self.assertTrue(any("PASS: session, hunt" in l for l in run.lines), run.lines[-5:])
|
||||
self.assertFalse(any("marker removed" in l for l in run.lines), run.lines)
|
||||
|
||||
def test_the_emulators_session_is_not_the_machines(self):
|
||||
self.append(self.EMU_START + ["2026-08-22T23:50:07.000000+00:00 gfcloud[4100] INFO websocket:_on_open "
|
||||
"RX-EVENT: ready"])
|
||||
live, detail = cloud.session_live(4100)
|
||||
self.assertFalse(live)
|
||||
self.assertIn("emulator", detail)
|
||||
|
||||
# -- the offline entry ----------------------------------------------------------
|
||||
def test_enter_offline_reuses_a_running_offline_client(self):
|
||||
self.in_offline()
|
||||
run = Run("test", "cloud.x", "cloud.x")
|
||||
run.baseline_captured = {"mode": "cloud", "position": [0, 0, 0]}
|
||||
ctx = Context(run, None, helpers.make_test("cloud.x", []))
|
||||
cloud.enter_offline(ctx)
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertFalse(os.path.exists(cloud.OFFLINE_MARKER))
|
||||
|
||||
def test_enter_offline_from_grbl_sets_the_marker_for_one_start_and_declares_the_mode(self):
|
||||
seen = {}
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/mode":
|
||||
seen["marker_at_start"] = os.path.exists(cloud.OFFLINE_MARKER)
|
||||
self.append([OFFLINE_LINE], delay=0.2)
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
run = Run("test", "cloud.x", "cloud.x")
|
||||
run.baseline_captured = {"mode": "grbl", "position": [0, 0, 0]}
|
||||
ctx = Context(run, None, helpers.make_test("cloud.x", []))
|
||||
cloud.enter_offline(ctx)
|
||||
self.assertEqual([p for p, _ in self.fc.posts], ["/mode"])
|
||||
self.assertTrue(seen["marker_at_start"]) # the client started offline
|
||||
self.assertFalse(os.path.exists(cloud.OFFLINE_MARKER)) # and the marker is gone again
|
||||
self.assertEqual(run.baseline_captured["mode"], "cloud")
|
||||
self.assertTrue(any("offline service up" in l for l in run.lines))
|
||||
|
||||
def test_enter_offline_restarts_a_service_client_in_cloud_mode(self):
|
||||
self.in_cloud(pid=2278)
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/controller/stop":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0)
|
||||
elif path == "/controller/start":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=3100)
|
||||
self.append([OFFLINE_LINE], delay=0.2)
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
run = Run("test", "cloud.x", "cloud.x")
|
||||
run.baseline_captured = {"mode": "cloud", "position": [0, 0, 0]}
|
||||
ctx = Context(run, None, helpers.make_test("cloud.x", []))
|
||||
cloud.enter_offline(ctx)
|
||||
self.assertEqual([p for p, _ in self.fc.posts], ["/controller/stop", "/controller/start"])
|
||||
|
||||
def test_enter_cloud_restarts_an_offline_client_with_the_service(self):
|
||||
self.in_offline()
|
||||
with open(cloud.OFFLINE_MARKER, "w") as f:
|
||||
f.write("stale\n")
|
||||
lines = fixture("huntlid")
|
||||
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
|
||||
hunt_part, _close = cut(post, "_switch_event lid closed")
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/controller/stop":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0)
|
||||
elif path == "/controller/start":
|
||||
self.assertFalse(os.path.exists(cloud.OFFLINE_MARKER)) # taken down before the start
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=2278)
|
||||
self.append(pre + hunt_part, delay=0.2)
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
run = Run("test", "cloud.x", "cloud.x")
|
||||
run.baseline_captured = {"mode": "cloud", "position": [0, 0, 0]}
|
||||
ctx = Context(run, None, helpers.make_test("cloud.x", []))
|
||||
cloud.enter_cloud(ctx)
|
||||
self.assertEqual([p for p, _ in self.fc.posts], ["/controller/stop", "/controller/start"])
|
||||
self.assertTrue(any("offline service: restarting it with the service" in l for l in run.lines))
|
||||
|
||||
def test_offline_jobs_are_the_synthesized_square(self):
|
||||
from forgetest import puls
|
||||
run = Run("test", "cloud.x", "cloud.x")
|
||||
ctx = Context(run, None, helpers.make_test("cloud.x", []))
|
||||
path = cloud.offline_job(ctx, "t.puls", seconds=5)
|
||||
tags, payload = puls.parse(open(path, "rb").read())
|
||||
self.assertEqual(tags["MCsn"], 0)
|
||||
self.assertEqual(tags["STfr"], 10000)
|
||||
self.assertFalse(any(b & 0x10 for b in payload if not b & 0x80)) # no LASER bit anywhere
|
||||
self.assertEqual(payload[0], 0x80) # a leading power byte of zero
|
||||
self.assertEqual(run.evidence["jobs"]["t.puls"]["compressed"], False)
|
||||
|
||||
def test_session_live_is_per_client(self):
|
||||
lines = fixture("huntlid")
|
||||
# the old client (1927) closed and left; the new one (2278) is ready
|
||||
self.append(lines)
|
||||
self.assertTrue(cloud.session_live(2278)[0])
|
||||
self.assertFalse(cloud.session_live(1927)[0])
|
||||
# an unknown pid falls back to the newest lines (ready)
|
||||
self.assertTrue(cloud.session_live(9999)[0])
|
||||
# a drop after the ready line: not live until it is ready again
|
||||
self.append(["2026-08-17T10:00:00.000000+00:00 gfcloud[2278] INFO websocket:_on_close RX-EVENT: closed (1006, )",
|
||||
"2026-08-17T10:00:00.100000+00:00 gfcloud[2278] INFO websocket:run RECONNECTING"])
|
||||
live, detail = cloud.session_live(2278)
|
||||
self.assertFalse(live)
|
||||
self.assertIn("RECONNECTING", detail)
|
||||
self.append(["2026-08-17T10:00:06.000000+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
self.assertTrue(cloud.session_live(2278)[0])
|
||||
|
||||
def test_enter_cloud_reuses_a_live_session_and_never_switches(self):
|
||||
self.in_cloud()
|
||||
self.append(fixture("huntlid"))
|
||||
run = self.run_test(lambda ctx: cloud.enter_cloud(ctx))
|
||||
self.assertTrue(any("reusing it" in l for l in run.lines), run.lines)
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertEqual(run.baseline_captured["mode"], "cloud")
|
||||
|
||||
NOHUNT_CLIENT = ["2026-08-22T23:52:01.000000+00:00 gfcloud[4200] INFO ffmachine:inhibit_connect_hunt NO-HUNT: the "
|
||||
"first settings report in the reconnect form; the service keeps its head position, no "
|
||||
"connect-time hunt",
|
||||
"2026-08-22T23:52:02.000000+00:00 gfcloud[4200] INFO authentication:authenticate_machine SUCCESS",
|
||||
"2026-08-22T23:52:03.000000+00:00 gfcloud[4200] INFO websocket:ws_connect ESTABLISHED",
|
||||
"2026-08-22T23:52:03.500000+00:00 gfcloud[4200] INFO websocket:_on_open RX-EVENT: ready"]
|
||||
|
||||
def test_session_hunted_is_the_clients_own_hunt(self):
|
||||
self.append(fixture("huntlid"))
|
||||
self.assertTrue(cloud.session_hunted(2278)) # its connect-time hunt completed
|
||||
self.assertFalse(cloud.session_hunted(9999)) # no lines: unknown is not hunted
|
||||
self.append(self.NOHUNT_CLIENT)
|
||||
self.assertTrue(cloud.session_live(4200)[0])
|
||||
self.assertFalse(cloud.session_hunted(4200)) # live, never hunted
|
||||
self.append(self.EMU_START)
|
||||
self.assertFalse(cloud.session_hunted(4100)) # the emulator's hunt is not the machine's
|
||||
# a later re-hunt (the lid closed) makes a no-hunt client hunted
|
||||
self.append(["2026-08-22T23:55:00.000000+00:00 gfcloud[4200] INFO basemachine:_finish_action hunt [1578300020]: "
|
||||
"finished with event \":completed\""])
|
||||
self.assertTrue(cloud.session_hunted(4200))
|
||||
|
||||
def test_enter_cloud_restarts_a_live_session_that_never_hunted_with_the_hunt(self):
|
||||
# the real print must not ride a head position the service only believes
|
||||
self.in_cloud(pid=4200)
|
||||
self.append(fixture("huntlid") + self.NOHUNT_CLIENT)
|
||||
lines = fixture("huntlid")
|
||||
_, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
|
||||
starts = []
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/controller/stop":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0)
|
||||
elif path == "/controller/start":
|
||||
starts.append(os.path.exists(cloud.NOHUNT_MARKER))
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=2278)
|
||||
self.append(post, delay=0.2)
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
run = self.run_test(lambda ctx: cloud.enter_cloud(ctx))
|
||||
self.assertEqual([p for p, _ in self.fc.posts], ["/controller/stop", "/controller/start"])
|
||||
self.assertEqual(starts, [False]) # with the hunt: no marker
|
||||
self.assertTrue(any("never hunted" in l for l in run.lines), run.lines)
|
||||
self.assertTrue(any("connect-time hunt" in l and ":completed" in l for l in run.lines), run.lines)
|
||||
|
||||
def test_enter_cloud_refuses_a_dead_session(self):
|
||||
self.in_cloud(pid=1927) # the client that shut down in the excerpt
|
||||
self.append(fixture("huntlid"))
|
||||
self.assertFails(cloud.enter_cloud, "no live service session")
|
||||
|
||||
def test_enter_cloud_switches_once_from_grbl_and_declares_it(self):
|
||||
lines = fixture("huntlid")
|
||||
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
|
||||
self.append(pre)
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/mode":
|
||||
self.append(post, delay=0.2) # the new client's session + hunt + moves
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
run = self.run_test(lambda ctx: cloud.enter_cloud(ctx))
|
||||
self.assertEqual([p for p, _ in self.fc.posts], ["/mode"])
|
||||
self.assertEqual(self.fc.state["mode"]["mode"], "cloud")
|
||||
self.assertEqual(run.baseline_captured["mode"], "cloud") # declared: the baseline keeps it
|
||||
self.assertTrue(any("connect-time hunt" in l and ":completed" in l for l in run.lines), run.lines)
|
||||
|
||||
def test_enter_cloud_waits_for_the_service_to_stop_moving(self):
|
||||
self.in_cloud()
|
||||
self.append(fixture("huntlid"))
|
||||
# a motion in flight (never idle within the timeout) fails, and says so
|
||||
self.fc.state["status"]["state"] = "running"
|
||||
self.assertFails(cloud.enter_cloud, "still running service moves")
|
||||
|
||||
# -- the hunt with the lid open ------------------------------------------
|
||||
# -- the mode switch: hunt with the lid open, then $H -----------------------
|
||||
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
|
||||
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
|
||||
|
||||
def mode_switch_setup(self, hunt_lines=None, home_complete=True, lid_late=False):
|
||||
"""The fakes a mode-switch run needs: grbl to answer $H, the lid
|
||||
lamp attr, homing_mode = gfcloud, the service lines landing on
|
||||
the switch to cloud - the client's start at once, its session and
|
||||
hunt once the lid reads open, as on the bench where the lid opens
|
||||
behind the controller's start and ahead of the hunt (the hunt
|
||||
reads as a run to the cooling engine while it lasts) - the re-hunt
|
||||
on the lid close, and gfhome finishing the homing after $H. With
|
||||
lid_late the hunt is requested before the lid opens: the race the
|
||||
test must call."""
|
||||
self.grbl = helpers.FakeGrbl().start()
|
||||
os.makedirs(self.sysfs + "pic", exist_ok=True)
|
||||
self._attr("pic/lid_led", "236")
|
||||
self.fc.state["settings"]["homing_mode"] = "gfcloud"
|
||||
lines = fixture("huntlid")
|
||||
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
|
||||
hunt_part, close_part = cut(post, "_switch_event lid closed")
|
||||
if hunt_lines is not None:
|
||||
hunt_part = hunt_lines(hunt_part)
|
||||
fc = self.fc
|
||||
|
||||
def hunt():
|
||||
self.append(hunt_part, delay=0.0)
|
||||
time.sleep(4.0) # the hunt outlasts the session wait on the bench
|
||||
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/mode" and form.get("controller") == "cloud":
|
||||
fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE)
|
||||
|
||||
def land():
|
||||
self.append(pre, delay=0.0)
|
||||
if lid_late:
|
||||
return
|
||||
t0 = time.time()
|
||||
while fc.state["status"]["switches"]["lid"] and time.time() - t0 < 5:
|
||||
time.sleep(0.02)
|
||||
time.sleep(0.3)
|
||||
hunt()
|
||||
threading.Thread(target=land, daemon=True).start()
|
||||
elif path == "/mode" and form.get("controller") == "grbl":
|
||||
self.grbl.state = "Idle"
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
|
||||
def lid_opened():
|
||||
if lid_late:
|
||||
self.append(hunt_part, delay=0.0)
|
||||
self.lid(False)
|
||||
|
||||
def lid_closed():
|
||||
self.lid(True)
|
||||
self.append(close_part, delay=0.2)
|
||||
|
||||
def homing():
|
||||
self.grbl.state = "Home"
|
||||
time.sleep(0.4)
|
||||
self.grbl.state = "Idle"
|
||||
self.fc.state["status"]["homed"] = True
|
||||
if home_complete:
|
||||
with open(self.homelog, "ab") as f:
|
||||
f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete "
|
||||
b"(service quiet 8s, 3 motion windows)\n")
|
||||
self.home_hook = homing
|
||||
return {"Open the lid.": lid_opened, "Close the lid.": lid_closed}
|
||||
|
||||
def wait_home_command(self):
|
||||
while "$H" not in self.grbl.sent:
|
||||
time.sleep(0.02)
|
||||
self.home_hook()
|
||||
|
||||
def test_mode_switch_round_trip_with_the_hunt_lid_open_and_the_homing(self):
|
||||
hooks = self.mode_switch_setup()
|
||||
threading.Thread(target=self.wait_home_command, daemon=True).start()
|
||||
run = self.run_test(cloud.mode_switch, hooks=hooks, test_id="cloud.mode-switch")
|
||||
ev = run.evidence
|
||||
posts = [(p, f.get("controller")) for p, f in self.fc.posts if p == "/mode"]
|
||||
self.assertEqual(posts, [("/mode", "cloud"), ("/mode", "grbl")])
|
||||
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
|
||||
self.assertIn(":completed", ev["hunt_line"])
|
||||
self.assertEqual(ev["refusals_before_hunt_end"], 0)
|
||||
self.assertTrue(ev["lens_homed"])
|
||||
self.assertEqual(ev["motions_after_lid_close"], 3)
|
||||
self.assertTrue(all(c.get("verdict") != "AIRFLOW" for c in ev["hunt_gates"]))
|
||||
self.assertTrue(ev["homed"])
|
||||
self.assertIn("3 motion windows", ev["gfhome_complete"])
|
||||
self.assertIn("$H", self.grbl.sent)
|
||||
# the lid was a machine action, not a prompt: two notices, no questions
|
||||
self.assertEqual([r["state"] for r in ev["actions"]], ["open", "close"])
|
||||
self.assertEqual(self.script.asked, [])
|
||||
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
|
||||
|
||||
def test_mode_switch_opens_the_lid_behind_the_controller_and_ahead_of_the_hunt(self):
|
||||
# No controller starts with the enclosure open: the switch is made
|
||||
# lid closed, the lid opens once the controller is up, and the
|
||||
# hunt's request lands after that - the order the test records.
|
||||
hooks = self.mode_switch_setup()
|
||||
threading.Thread(target=self.wait_home_command, daemon=True).start()
|
||||
run = self.run_test(cloud.mode_switch, hooks=hooks, test_id="cloud.mode-switch")
|
||||
self.assertFalse(run.evidence["hunt_before_lid_open"])
|
||||
acts = [(a["channel"], a["state"]) for a in run.evidence["actions"]]
|
||||
self.assertEqual(acts, [("lid", "open"), ("lid", "close")])
|
||||
i_post = next(i for i, l in enumerate(run.lines) if "POST /mode controller=cloud" in l)
|
||||
i_open = next(i for i, l in enumerate(run.lines) if "ACT lid open" in l)
|
||||
self.assertLess(i_post, i_open)
|
||||
|
||||
def test_mode_switch_refuses_to_start_with_the_lid_open(self):
|
||||
hooks = self.mode_switch_setup()
|
||||
self.lid(False)
|
||||
self.assertFails(cloud.mode_switch, "close the lid first", hooks=hooks)
|
||||
|
||||
def test_mode_switch_fails_when_the_hunt_was_requested_before_the_lid_opened(self):
|
||||
hooks = self.mode_switch_setup(lid_late=True)
|
||||
self.assertFails(cloud.mode_switch, "before the lid was open", hooks=hooks)
|
||||
|
||||
def test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self):
|
||||
def refused(hunt_part):
|
||||
i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)
|
||||
return hunt_part[:i] + ["2026-08-17T09:45:10.595000+00:00 gfcloud[2278] INFO machine:_safe_to_move "
|
||||
"lid opened, unsafe to move"] + hunt_part[i:]
|
||||
hooks = self.mode_switch_setup(hunt_lines=refused)
|
||||
self.assertFails(cloud.mode_switch, "refused for the lid", hooks=hooks)
|
||||
|
||||
def test_mode_switch_fails_when_the_hunt_skipped_the_lens(self):
|
||||
hooks = self.mode_switch_setup(hunt_lines=lambda part: [l for l in part if "z homing cycle" not in l])
|
||||
self.assertFails(cloud.mode_switch, "did not home the lens", hooks=hooks)
|
||||
|
||||
def test_mode_switch_fails_when_gfhome_never_saw_the_head_move(self):
|
||||
hooks = self.mode_switch_setup(home_complete=False)
|
||||
threading.Thread(target=self.wait_home_command, daemon=True).start()
|
||||
self.assertFails(cloud.mode_switch, "no 'homing complete' line", hooks=hooks)
|
||||
|
||||
def test_mode_switch_needs_no_setting_from_the_operator(self):
|
||||
# The test sets the gfcloud homing itself and restores it: no precheck
|
||||
# names a setting the operator should change first.
|
||||
from forgetest import catalog
|
||||
t = catalog.load_suite()["cloud.mode-switch"]
|
||||
self.assertIsNone(getattr(t, "precheck", None))
|
||||
self.assertNotIn("homing_mode_is_gfcloud", dir(cloud))
|
||||
|
||||
# -- pause / resume ---------------------------------------------------------
|
||||
def replay_print(self, name, at_run, at_end, tail_delay=0.3):
|
||||
"""Split a print excerpt into: up to the run's RUNNING line
|
||||
(lands when the operator says Print is done), the block landing at
|
||||
the mid-run prompt, and the rest."""
|
||||
lines = fixture(name)
|
||||
pre, rest = cut(lines, "waiting for button")
|
||||
run_pre, rest = cut(rest, "current state: MachineState.RUNNING") # the PRINT's run
|
||||
# The excerpt was captured before the machine held for a warm-up and
|
||||
# a rest, and before it reported a print's progress; the replay
|
||||
# carries those lines where it emits them now, rather than editing
|
||||
# what the machine actually said that day.
|
||||
run_pre = run_pre + ([LIMITS_LINE] if self.client_limits else []) + [WARM_UP_LINE, PROGRESS_LINE]
|
||||
pre, rest = pre + run_pre + [rest[0]], rest[1:]
|
||||
mid, tail = cut(rest, at_end)
|
||||
tail = tail + [COOL_DOWN_LINE]
|
||||
|
||||
def at_done():
|
||||
self.append(pre, delay=0.1)
|
||||
if self.engine_line:
|
||||
with open(self.fclog, "ab") as f:
|
||||
f.write((self.engine_line + "\n").encode())
|
||||
return {"Click Done here": at_done,
|
||||
at_run: lambda: (self.append(mid, delay=0.05), self.append(tail, delay=tail_delay))}
|
||||
|
||||
def test_pause_resume_passes_on_the_machines_lines(self):
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
self.append(["2026-08-17T09:41:00.000000+00:00 gfcloud[1522] INFO authentication:authenticate_machine SUCCESS",
|
||||
"2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready",
|
||||
"2026-08-17T09:41:00.900000+00:00 gfcloud[1522] INFO websocket:ws_connect ESTABLISHED"])
|
||||
hooks = self.replay_print("pause", "the press pauses the print", "current state: MachineState.IDLE")
|
||||
self.fc.state["cool"]["armed"] = True
|
||||
run = self.run_test(cloud.pause_resume, hooks=hooks, test_id="cloud.pause-resume")
|
||||
ev = run.evidence
|
||||
self.assertEqual(ev["log"], {"button pressed mid-run; pausing": True, "paused at": True,
|
||||
"button pressed while paused": True, "resuming (laser lead": True})
|
||||
self.assertTrue(ev["armed_after_resume"])
|
||||
self.assertIn(":completed", ev["log_end"]["print finished"])
|
||||
self.assertTrue(ev["log_end"]["return home complete"])
|
||||
self.assertEqual(ev["relock_or_cancel_lines"], 0)
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertTrue(any("PASS: button pause/resume" in l for l in run.lines))
|
||||
# the post-print hunt was waited out
|
||||
self.assertTrue(any("machine is quiet" in l for l in run.lines))
|
||||
# the job's envelope passed through: the client's line and the engine's
|
||||
self.assertEqual(ev["header_limits"],
|
||||
"air_assist_min_rpm=116 coolant_max_c=33.0 coolant_min_c=5.0")
|
||||
self.assertIn("header 33.0", ev["effective_limits"][0]) # the line with the header
|
||||
self.assertTrue(all("effective limits: coolant ceiling 33.0 C" in ln for ln in ev["effective_limits"]))
|
||||
|
||||
def test_pause_resume_fails_when_the_client_names_no_header_limits(self):
|
||||
self.client_limits = False
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
hooks = self.replay_print("pause", "the press pauses the print", "current state: MachineState.IDLE")
|
||||
self.fc.state["cool"]["armed"] = True
|
||||
self.assertFails(cloud.pause_resume, "named no job limits", hooks=hooks)
|
||||
|
||||
def test_pause_resume_fails_when_the_engine_resolves_against_no_header(self):
|
||||
self.engine_line = EFFECTIVE_LINE.replace("header 33.0", "header none 0.0")
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
hooks = self.replay_print("pause", "the press pauses the print", "current state: MachineState.IDLE")
|
||||
self.fc.state["cool"]["armed"] = True
|
||||
self.assertFails(cloud.pause_resume, "never resolved an effective ceiling", hooks=hooks)
|
||||
|
||||
def test_pause_resume_fails_when_the_print_is_cancelled_instead(self):
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
lines = fixture("pause")
|
||||
lines = [l.replace('print [1576550507]: finished with event ":completed"',
|
||||
'print [1576550507]: finished with event ":cancelled"') for l in lines]
|
||||
pre, rest = cut(lines, "current state: MachineState.RUNNING")
|
||||
pre, rest = pre + [rest[0]], rest[1:]
|
||||
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
|
||||
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
|
||||
self.assertFails(cloud.pause_resume, "did not complete after the resume", hooks=hooks)
|
||||
|
||||
def test_pause_resume_fails_without_the_retraced_restart(self):
|
||||
"""The second press was seen but the retraced restart never logged
|
||||
(the app's resume is its own line now): the failure names that."""
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
lines = [l for l in fixture("pause") if "resuming (laser lead" not in l]
|
||||
pre, rest = cut(lines, "current state: MachineState.RUNNING")
|
||||
pre, rest = pre + [rest[0]], rest[1:]
|
||||
with self.fast_wait_log():
|
||||
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
|
||||
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
|
||||
self.assertFails(cloud.pause_resume, "no retraced restart logged", hooks=hooks)
|
||||
|
||||
def test_pause_resume_fails_when_the_kernel_refuses_the_resume(self):
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
@@ -830,19 +296,6 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
cloud.wait_log = saved
|
||||
cloud.PRESS_TIMEOUT_S = press
|
||||
|
||||
def test_pause_resume_fails_without_the_pause_line(self):
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
lines = [l for l in fixture("pause") if "button pressed mid-run" not in l]
|
||||
pre, rest = cut(lines, "current state: MachineState.RUNNING")
|
||||
pre, rest = pre + [rest[0]], rest[1:]
|
||||
with self.fast_wait_log():
|
||||
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
|
||||
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
|
||||
self.assertFails(cloud.pause_resume, "did not pause the run", hooks=hooks)
|
||||
|
||||
# -- the lid/interlock abort and the button-wait tests, on their excerpts ------
|
||||
# -- the merged lid + interlock abort test -------------------------------
|
||||
def abort_parts(self):
|
||||
"""The lid-abort excerpt split for the merged test: the print
|
||||
@@ -879,54 +332,6 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
"on its way back": lambda: (self.lid(False), self.append(ilk_park, delay=0.05)),
|
||||
"Restore the remote-interlock loop": restore}
|
||||
|
||||
def test_lid_interlock_abort_on_the_bench_excerpt(self):
|
||||
self.in_offline()
|
||||
prints = []
|
||||
run = self.run_test(cloud.lid_interlock_abort,
|
||||
hooks=self.abort_hooks(*self.abort_parts(), prints),
|
||||
test_id="cloud.lid-interlock-abort")
|
||||
ev = run.evidence
|
||||
self.assertEqual(prints, [9001, 9002]) # two prints, handed over the socket
|
||||
self.assertTrue(FakeOffline.sent[0]["motion_url"].endswith("abort.puls"))
|
||||
self.assertEqual(ev["jobs"]["abort.puls"]["compressed"], False)
|
||||
# print 1: the lid
|
||||
self.assertLess(ev["edge_to_stop_ms"], 60)
|
||||
self.assertIn(":cancelled", ev["lid_log"]["print finished"])
|
||||
self.assertEqual(ev["lid_counters_after_park"], [0, 0, 3])
|
||||
self.assertTrue(ev["lid_latch_locked"])
|
||||
self.assertFalse(ev["lid_armed_after"])
|
||||
# print 2: the interlock, with the lid opened during the park
|
||||
self.assertFalse(ev["interlock_ok_after_pull"])
|
||||
self.assertIn(":cancelled", ev["interlock_log"]["print finished"])
|
||||
self.assertTrue(ev["interlock_log"]["return home complete"])
|
||||
self.assertEqual(ev["switches_at_return"], {"lid": False, "interlock_ok": False})
|
||||
self.assertEqual(ev["interlock_counters_after_park"], [0, 0, 3])
|
||||
self.assertTrue(ev["interlock_latch_locked"])
|
||||
self.assertEqual(ev["restored"], {"lid": True, "interlock_ok": True})
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertTrue(any("PASS: lid open" in l for l in run.lines), run.lines)
|
||||
|
||||
def test_lid_interlock_abort_refuses_when_the_loop_is_already_open(self):
|
||||
self.in_offline()
|
||||
self.fc.state["status"]["switches"]["interlock_ok"] = False
|
||||
self.assertFails(cloud.lid_interlock_abort, "already reads open")
|
||||
|
||||
def test_lid_interlock_abort_fails_when_the_park_stops_at_the_lid(self):
|
||||
# the regression this guards: a park an open lid can interrupt
|
||||
self.in_offline()
|
||||
pre, lid_tail, ilk_stop, ilk_park = self.abort_parts()
|
||||
ilk_park = [l for l in ilk_park if "return home complete" not in l]
|
||||
saved = cloud.wait_log
|
||||
|
||||
def fast_wait_log(ctx, offset, needles, timeout, poll=0.5):
|
||||
return saved(ctx, offset, needles, min(timeout, 1.5), poll=0.1)
|
||||
cloud.wait_log = fast_wait_log
|
||||
try:
|
||||
self.assertFails(cloud.lid_interlock_abort, "did not run to completion",
|
||||
hooks=self.abort_hooks(pre, lid_tail, ilk_stop, ilk_park, []))
|
||||
finally:
|
||||
cloud.wait_log = saved
|
||||
|
||||
def test_lid_interlock_abort_fails_when_the_lid_stop_is_not_edge_driven(self):
|
||||
# a polled stop (the pre-parity behavior) shows up as a long edge->stop gap
|
||||
self.in_offline()
|
||||
@@ -936,30 +341,7 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.assertFails(cloud.lid_interlock_abort, "not edge-driven",
|
||||
hooks=self.abort_hooks(pre, lid_tail, ilk_stop, ilk_park, []))
|
||||
|
||||
def test_lid_during_button_wait_on_the_bench_excerpt(self):
|
||||
self.in_offline()
|
||||
lines = fixture("buttonwait")
|
||||
pre, rest = cut(lines, "waiting for button")
|
||||
# The bench excerpt is a print that fit the ring; the test now wants
|
||||
# one that did not, so the feeder is alive through the wait.
|
||||
long_job = ("2026-08-17T09:44:14.100000+00:00 gfcloud[1927] INFO machine:_feed_and_run "
|
||||
"job is longer than the ring: 33554432 bytes enqueued, feeding the rest as it plays")
|
||||
pre, rest = pre + [long_job, rest[0]], rest[1:]
|
||||
self.offline_print_hooks(pre)
|
||||
hooks = {"do NOT press it": lambda: (self.lid(False), self.append(rest, delay=0.05)),
|
||||
"Close the lid.": lambda: self.lid(True)}
|
||||
run = self.run_test(cloud.lid_during_button_wait, hooks=hooks, test_id="cloud.lid-during-button-wait")
|
||||
ev = run.evidence
|
||||
self.assertEqual([m["status"] for m in FakeOffline.sent], ["ready"])
|
||||
self.assertEqual(ev["runs_started_after_wait"], 0)
|
||||
self.assertIn(":cancelled", ev["log"]["print finished"])
|
||||
self.assertTrue(ev["latch_locked"])
|
||||
self.assertEqual(ev["program_total_after"], {"first": 0, "later": 0})
|
||||
self.assertEqual(ev["streaming_after"], 0)
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertTrue(any("PASS: lid open at the button prompt" in l for l in run.lines))
|
||||
|
||||
# -- the cooling verdict: locked until the run, a hold that never clears --
|
||||
# -- the dark print exthost.armed-freeze runs: locked at the button --------
|
||||
def dark_parts(self):
|
||||
"""(the print prologue through the button prompt, the run and the
|
||||
print's end with no pause in it)."""
|
||||
@@ -970,164 +352,13 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
run = [l for l in run if "paus" not in l and "resum" not in l]
|
||||
return pre, run
|
||||
|
||||
def test_dark_print_on_the_bench_excerpt(self):
|
||||
self.in_offline()
|
||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
|
||||
pre, run = self.dark_parts()
|
||||
self.offline_print_hooks(pre)
|
||||
|
||||
def press():
|
||||
# the run: the client unlocks the latch right before it, and
|
||||
# relocks when the job ends
|
||||
self._attr("cnc/interlock_circuit", "37")
|
||||
self.append(run[:1], delay=0.05)
|
||||
|
||||
def ended():
|
||||
self._attr("cnc/interlock_circuit", "45")
|
||||
self.append(run[1:], delay=0.0)
|
||||
threading.Timer(2.0, ended).start()
|
||||
hooks = {"press it. The print runs dark": press}
|
||||
run_ = self.run_test(cloud.dark_print, hooks=hooks, test_id="cloud.dark-print")
|
||||
ev = run_.evidence
|
||||
self.assertTrue(ev["latch_locked_at_button"])
|
||||
self.assertFalse(ev["latch_locked_in_run"])
|
||||
self.assertTrue(ev["latch_locked_after"])
|
||||
self.assertIn(":completed", ev["print finished"])
|
||||
self.assertTrue(any("PASS: locked at the button" in l for l in run_.lines))
|
||||
|
||||
def test_dark_print_fails_when_the_latch_is_open_at_the_button(self):
|
||||
self.in_offline()
|
||||
pre, run = self.dark_parts()
|
||||
self._attr("cnc/interlock_circuit", "37") # unlocked before the press
|
||||
self.offline_print_hooks(pre)
|
||||
hooks = {"press it. The print runs dark": lambda: self.append(run, delay=0.05)}
|
||||
self.assertFails(cloud.dark_print, "the latch is unlocked at the button wait", hooks=hooks)
|
||||
|
||||
def test_verdict_refuse_on_the_bench_excerpt(self):
|
||||
self.in_offline()
|
||||
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
||||
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16",
|
||||
"cloud_hold_max_s": ""})
|
||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
|
||||
pre, run = self.dark_parts()
|
||||
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] %s machine:_verdict_wait "
|
||||
wait = [stamp % (100000, "INFO") + "waiting on the cooling engine: WARMUP (WARM-UP: coolant "
|
||||
"21.5 C under the 29.5 C start gate - heater on, hold)"]
|
||||
refuse = [stamp % (200000, "WARNING") + "cooling hold held 60 s - relocking the laser",
|
||||
"2026-08-17T09:44:15.000000+00:00 gfcloud[1927] INFO basemachine:_finish_action print "
|
||||
"[9006]: finished with event \":cancelled\""]
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/settings" and form.get("cool_temp_start") not in (None, "0", "16"):
|
||||
self.fc.state["cool"].update({"verdict": "WARMUP", "hold": True, "fire_ok": False,
|
||||
"armed": True, "phase": "warm-up"})
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
self.offline_print_hooks(pre)
|
||||
|
||||
def press():
|
||||
self.append(wait, delay=0.05)
|
||||
|
||||
def refused():
|
||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True,
|
||||
"armed": False, "phase": "idle"})
|
||||
self.append(refuse, delay=0.0)
|
||||
threading.Timer(2.5, refused).start()
|
||||
hooks = {"press it. Nothing runs": press}
|
||||
run_ = self.run_test(cloud.verdict_refuse, hooks=hooks, test_id="cloud.verdict-refuse")
|
||||
ev = run_.evidence
|
||||
self.assertEqual(ev["gate"], 29.5)
|
||||
self.assertTrue(ev["latch_locked_at_button"])
|
||||
self.assertEqual(ev["latch_samples"]["unlocked"], 0)
|
||||
self.assertGreaterEqual(ev["latch_samples"]["n"], 1)
|
||||
self.assertIn(":cancelled", ev["print finished"])
|
||||
posted = [f for p_, f in self.fc.posts if p_ == "/settings"]
|
||||
self.assertEqual(posted[0]["cool_temp_start"], "29.5")
|
||||
self.assertEqual(posted[0]["cloud_hold_max_s"], "60")
|
||||
self.assertEqual(posted[-1], {"cool_temp_min": "5", "cool_temp_start": "16", "cloud_hold_max_s": ""})
|
||||
self.assertTrue(any("PASS: held" in l for l in run_.lines))
|
||||
|
||||
def test_verdict_refuse_fails_when_the_latch_unlocks_during_the_hold(self):
|
||||
self.in_offline()
|
||||
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
||||
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"})
|
||||
self.fc.state["cool"].update({"verdict": "WARMUP", "hold": True, "fire_ok": False, "armed": True})
|
||||
pre, run = self.dark_parts()
|
||||
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] %s machine:_verdict_wait "
|
||||
wait = [stamp % (100000, "INFO") + "waiting on the cooling engine: WARMUP (no reason given)"]
|
||||
refuse = [stamp % (200000, "WARNING") + "cooling hold held 60 s - relocking the laser",
|
||||
"2026-08-17T09:44:15.000000+00:00 gfcloud[1927] INFO basemachine:_finish_action print "
|
||||
"[9006]: finished with event \":cancelled\""]
|
||||
self.offline_print_hooks(pre)
|
||||
|
||||
def press():
|
||||
self.append(wait, delay=0.05)
|
||||
self._attr("cnc/interlock_circuit", "37") # unlocked under the hold
|
||||
threading.Timer(2.5, lambda: self.append(refuse, delay=0.0)).start()
|
||||
hooks = {"press it. Nothing runs": press}
|
||||
self.assertFails(cloud.verdict_refuse, "the latch unlocked during the hold", hooks=hooks)
|
||||
|
||||
# -- a paused print canceled by the lid, a running one by the app --------
|
||||
def cancel_parts(self):
|
||||
"""(print prologue, the pause lines, the lid stop + park + cancel,
|
||||
the same tail with the app's cancel as the trigger - what
|
||||
cloud.oversize-stream's ending looks like)."""
|
||||
lines = fixture("lidabort")
|
||||
pre, rest = cut(lines, "waiting for button")
|
||||
run_pre, rest = cut(rest, "machine:_run_loop starting run")
|
||||
pre, rest = pre + run_pre + [rest[0]], rest[1:]
|
||||
paused = ["2026-08-17T09:42:25.500000+00:00 gfcloud[1522] INFO machine:_run_loop "
|
||||
"button pressed mid-run; pausing",
|
||||
"2026-08-17T09:42:26.100000+00:00 gfcloud[1522] INFO machine:_run_loop "
|
||||
"paused at Position(x=41.2, y=17.0, z=0.0)"]
|
||||
app_cancel = [l.replace("lid opened mid-run; stopping motion",
|
||||
"action canceled mid-run; stopping motion") for l in rest]
|
||||
return pre, paused, rest, app_cancel
|
||||
|
||||
def test_paused_lid_cancel_on_the_bench_excerpt(self):
|
||||
self.in_offline()
|
||||
pre, paused, lid_tail, _app = self.cancel_parts()
|
||||
self.offline_print_hooks(pre)
|
||||
run = self.run_test(cloud.paused_lid_cancel,
|
||||
hooks={"the press pauses the print": lambda: self.append(paused, delay=0.05),
|
||||
"The print is paused: leave the lid open": lambda: (
|
||||
self.lid(False), self.append(lid_tail, delay=0.05)),
|
||||
"Close the lid.": lambda: self.lid(True)},
|
||||
test_id="cloud.paused-lid-cancel")
|
||||
ev = run.evidence
|
||||
self.assertEqual(len(FakeOffline.sent), 1) # one print, handed over the socket
|
||||
self.assertEqual(ev["paused"], {"button pressed mid-run; pausing": True, "paused at": True})
|
||||
self.assertIn(":cancelled", ev["lid_from_pause"]["print finished"])
|
||||
self.assertTrue(ev["lid_from_pause"]["return home complete"])
|
||||
self.assertEqual(ev["counters_after"], [0, 0, 3])
|
||||
self.assertTrue(ev["latch_locked_after"])
|
||||
self.assertFalse(ev["armed_after"])
|
||||
self.assertFalse(ev["button_dark"])
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertTrue(any("PASS: a paused print canceled by the lid" in l for l in run.lines), run.lines)
|
||||
|
||||
def test_paused_lid_cancel_fails_when_the_paused_print_resumes_instead(self):
|
||||
# a lid that resumed (or was ignored) leaves the print ':completed'
|
||||
self.in_offline()
|
||||
pre, paused, lid_tail, _app = self.cancel_parts()
|
||||
lid_tail = [l.replace(':cancelled"', ':completed"') for l in lid_tail]
|
||||
self.offline_print_hooks(pre)
|
||||
self.assertFails(
|
||||
cloud.paused_lid_cancel, "the print did not end ':cancelled'",
|
||||
hooks={"the press pauses the print": lambda: self.append(paused, delay=0.05),
|
||||
"The print is paused: leave the lid open": lambda: (
|
||||
self.lid(False), self.append(lid_tail, delay=0.05))})
|
||||
|
||||
def test_print_finish_is_the_prints_not_another_actions(self):
|
||||
# a motion that completes before the print must not satisfy the print's finish
|
||||
lines = fixture("lidabort")
|
||||
i = cloud.action_finish_index(lines, "print")
|
||||
j = cloud.action_finish_index(lines, "motion")
|
||||
self.assertIsNotNone(i)
|
||||
self.assertIsNotNone(j)
|
||||
self.assertLess(j, i)
|
||||
self.assertIn(":cancelled", lines[i])
|
||||
self.assertIn(":completed", lines[j])
|
||||
self.assertFails(dark_print, "the latch is unlocked at the button wait", hooks=hooks)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,739 +3,18 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
|
||||
under the real runner Context against a scripted machine: a fake
|
||||
forgectrl (/status fans, /cool/status phase and verdict, /settings with
|
||||
the gates table, /logs/tail), a fake kernel sysfs (the fan duties), and
|
||||
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.
|
||||
"""
|
||||
"""cooling.flow-under-load reads the engine's flow verdict from what the
|
||||
forgectrl log gained since the job began: only the text after the offset,
|
||||
never an older verdict before it, and the whole of a log rotated under
|
||||
the test."""
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest.runner import Context, Failed, Run
|
||||
from forgetest.suite import cooling
|
||||
|
||||
|
||||
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(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.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()
|
||||
|
||||
# -- 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.fc.state["logs_tail"]["text"] += (
|
||||
"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.fc.state["logs_tail"]["text"] += (
|
||||
"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_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(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.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()
|
||||
|
||||
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.fc.state["logs_tail"]["text"] += (
|
||||
"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):
|
||||
"""cooling.flow-under-load finds the engine's verdict in what the log
|
||||
gained since the job began, not in a count over a tail of fixed length:
|
||||
@@ -780,5 +59,6 @@ class FlowVerdictLogTests(unittest.TestCase):
|
||||
self.write(self.VERDICT) # the rotation: a new, shorter file
|
||||
self.assertEqual(cooling._log_since(self.path, off), self.VERDICT)
|
||||
|
||||
def test_a_missing_log_reads_as_nothing(self):
|
||||
self.assertEqual(cooling._log_since(os.path.join(self.dir, "none.log"), 0), "")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+230
-231
@@ -7,19 +7,16 @@
|
||||
|
||||
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
|
||||
tool finds it by name without a resolver on the image, speaks its API
|
||||
under the key, falls back to the operator when it cannot act, moves an
|
||||
operator test it can perform alone into the unattended queue (never a
|
||||
live one, never one that needs a person for anything else), refuses a
|
||||
prompt during such a run rather than hanging on it, releases what the
|
||||
box still holds after every run, and presses the arm button only where
|
||||
the bench opted in.
|
||||
tool speaks its API under the key, falls back to the operator when it
|
||||
cannot act, moves an operator test it can perform alone into the
|
||||
unattended queue (never a live one, never one that needs a person for
|
||||
anything else), refuses a prompt during such a run rather than hanging on
|
||||
it, releases what the box still holds after every run, and presses the
|
||||
arm button only where the bench opted in.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
@@ -30,7 +27,7 @@ import helpers
|
||||
from forgetest import fixture as fx
|
||||
from forgetest import runner as runner_mod
|
||||
from forgetest.log import Log
|
||||
from forgetest.runner import Context, Failed, Run, Runner
|
||||
from forgetest.runner import Context, Run, Runner
|
||||
|
||||
KEY = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
@@ -43,6 +40,8 @@ class FakeFixture:
|
||||
self.button_enabled = button_enabled
|
||||
self.calls = []
|
||||
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
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
@@ -98,6 +97,14 @@ class FakeFixture:
|
||||
threading.Timer(0.05, lambda: srv.state.__setitem__("button", "idle")).start()
|
||||
s = self._state()
|
||||
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)
|
||||
if self.path == "/release":
|
||||
srv.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
|
||||
@@ -112,37 +119,10 @@ class FakeFixture:
|
||||
self.httpd.shutdown()
|
||||
self.httpd.server_close()
|
||||
|
||||
|
||||
class MdnsTests(unittest.TestCase):
|
||||
def test_query_packet(self):
|
||||
q = fx.mdns_query("forgefixture.local")
|
||||
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"), [])
|
||||
def wait_replied(self, n, timeout=5.0):
|
||||
deadline = time.time() + timeout
|
||||
while self.replied < n and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
class ConfigTests(unittest.TestCase):
|
||||
@@ -190,11 +170,11 @@ class ClientTests(unittest.TestCase):
|
||||
self.resolved.append(hostname)
|
||||
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"],
|
||||
"port": self.dev.port}
|
||||
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):
|
||||
f = self.client()
|
||||
@@ -207,6 +187,10 @@ class ClientTests(unittest.TestCase):
|
||||
f.act("interlock", "open")
|
||||
f.act("button", "press")
|
||||
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {}))
|
||||
time.sleep(0.15)
|
||||
# a pulse length asked for goes to the box as asked; the clamp is the box's
|
||||
f.act("button", "press", ms=500)
|
||||
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {"ms": 500}))
|
||||
time.sleep(0.15) # the fake's pulse ends
|
||||
self.assertEqual(sorted(fx.Fixture.energized(f.status())), ["interlock", "lid"])
|
||||
f.release()
|
||||
@@ -227,36 +211,34 @@ class ClientTests(unittest.TestCase):
|
||||
f.act("button", "press")
|
||||
self.assertIn("jumper", str(cm.exception))
|
||||
|
||||
def test_two_presses_are_spaced_so_the_controller_sees_the_release(self):
|
||||
f = self.client()
|
||||
f.status()
|
||||
t0 = time.time()
|
||||
f.act("button", "press")
|
||||
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"
|
||||
def test_a_failure_after_the_request_went_out_says_so(self):
|
||||
# The press made and its reply lost (it comes after the client's
|
||||
# timeout): sent, one press. A refusal, a failed lookup and a
|
||||
# refused connect: not sent, no press. The one retry keeps it.
|
||||
self.dev.reply_delay = 0.6
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
f.act("button", "press")
|
||||
self.assertIn("in progress", str(cm.exception))
|
||||
self.client(timeout=0.2, ip="127.0.0.1").act("button", "press")
|
||||
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):
|
||||
f = self.client(key="wrong")
|
||||
@@ -275,10 +257,9 @@ class ClientTests(unittest.TestCase):
|
||||
self.assertTrue(f.covers("lid"))
|
||||
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 = []
|
||||
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-")
|
||||
try:
|
||||
p = os.path.join(tmp, "fixture.json")
|
||||
@@ -287,7 +268,6 @@ class ClientTests(unittest.TestCase):
|
||||
# port 1 on localhost: nothing listens there
|
||||
f2 = fx.probe(lines.append, path=p, resolver=lambda h: None)
|
||||
self.assertIsNone(f2)
|
||||
self.assertTrue(lines and "running without it" in lines[-1], lines)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
@@ -310,6 +290,24 @@ class CatalogTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
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:
|
||||
"""What the runner needs of a fixture, scripted."""
|
||||
@@ -330,10 +328,10 @@ class StubFixture:
|
||||
def covers(self, channel):
|
||||
return channel in self.channels and (channel != "button" or self.button_enabled)
|
||||
|
||||
def act(self, channel, state):
|
||||
def act(self, channel, state, ms=None):
|
||||
if self.fail:
|
||||
raise fx.FixtureError("the box is off")
|
||||
self.acts.append((channel, state))
|
||||
self.acts.append((channel, state) if ms is None else (channel, state, ms))
|
||||
if state == "open":
|
||||
self.held.append(channel)
|
||||
if self.fc is not None and channel in ("lid", "interlock"):
|
||||
@@ -408,39 +406,6 @@ class RoutingTests(unittest.TestCase):
|
||||
self.assertEqual(av["attended"], ["r.app", "r.live"]) # needs the app; live never moves
|
||||
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):
|
||||
ok, msg, order = self.runner.start_batch(group, ack_live=ack_live)
|
||||
self.assertTrue(ok, msg)
|
||||
@@ -473,20 +438,6 @@ class RoutingTests(unittest.TestCase):
|
||||
asks = self.last_result("r.asks")
|
||||
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):
|
||||
# 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
|
||||
@@ -541,36 +492,6 @@ class RoutingTests(unittest.TestCase):
|
||||
self.assertEqual(rec[0]["by"], "operator")
|
||||
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):
|
||||
"""The bench's standing opt-in is not needed once the operator has
|
||||
proved presence: that press is what the opt-in existed to
|
||||
@@ -591,68 +512,6 @@ class RoutingTests(unittest.TestCase):
|
||||
finally:
|
||||
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):
|
||||
"""Falling back to the operator without a word is how one dropped
|
||||
actuator becomes a press nobody can account for afterwards."""
|
||||
@@ -667,21 +526,6 @@ class RoutingTests(unittest.TestCase):
|
||||
rec = run.evidence["actions"][-1]
|
||||
self.assertTrue(rec.get("fixture_lost"))
|
||||
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):
|
||||
run = Run("test", "r.live", "r.live")
|
||||
@@ -712,6 +556,161 @@ class RoutingTests(unittest.TestCase):
|
||||
finally:
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
+386
-19
@@ -3,27 +3,205 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from forgetest import catalog
|
||||
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):
|
||||
def test_localversion_hash_is_stripped(self):
|
||||
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc-g72a0b1431a9d"), "6.12.20-fslc-fslc")
|
||||
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc+g72a0b1431a9d"), "6.12.20-fslc-fslc")
|
||||
# A layer script run as the image runs it, in private mount, UTS and
|
||||
# network namespaces: /run a fresh tmpfs, the scratch tree's net directory
|
||||
# over /sys/class/net, and /etc read-only with the scratch tree's files
|
||||
# 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):
|
||||
rel = "6.12.20-fslc-fslc-g72a0b1431a9d"
|
||||
self.assertTrue(image.kernel_matches(rel, ["6.12.20-fslc-fslc"]))
|
||||
self.assertTrue(image.kernel_matches(rel, ["6.12.20-fslc-fslc-gf52af5b522f4"]))
|
||||
self.assertTrue(image.kernel_matches(rel, []))
|
||||
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
|
||||
def run_layer_script(script, macs, etc, net=False):
|
||||
"""Run a layer script over a scratch tree: macs {interface: address or
|
||||
None for a directory with no address file}, etc {file: text}. Returns
|
||||
the namespace's output, or None when no namespace can be made here
|
||||
(unprivileged where user namespaces are open, else root via sudo -n)."""
|
||||
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):
|
||||
@@ -32,11 +210,6 @@ class NetworkBootTests(unittest.TestCase):
|
||||
"\twpa-driver nl80211\n"
|
||||
"\tudhcpc_opts -x hostname:$(hostname)\n")
|
||||
|
||||
def test_the_test_is_registered_and_covers_nothing_by_design(self):
|
||||
t = catalog.load_suite()["image.network-boot"]
|
||||
self.assertEqual(t.kind, "auto")
|
||||
self.assertEqual(t.covers, ()) # the interfaces file is layer content
|
||||
|
||||
def test_ifstate_names_the_interfaces_ifup_finished(self):
|
||||
self.assertEqual(image.ifstate_names("lo=lo\nwlan0=wlan0\n"), {"lo", "wlan0"})
|
||||
self.assertEqual(image.ifstate_names("lo=lo\n"), {"lo"})
|
||||
@@ -65,6 +238,12 @@ class NetworkBootTests(unittest.TestCase):
|
||||
text = self.STANZA + "# No DHCPv6 client runs; udhcpc6 is not on the image.\n"
|
||||
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):
|
||||
self.assertEqual(image.parse_stat("371 (udhcpc) S 1 371 371 0 -1 4194624"),
|
||||
(371, "udhcpc", 1))
|
||||
@@ -82,5 +261,193 @@ class NetworkBootTests(unittest.TestCase):
|
||||
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__":
|
||||
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
|
||||
scripted curl in front of them: a transfer that fails resumes and is
|
||||
tried again, the failures that waiting cannot fix end the tries at once,
|
||||
and every step leaves a line in the install log in the log tree's own
|
||||
format."""
|
||||
the reason an install stopped is in the install log, and a log that cannot
|
||||
be written never fails the install."""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
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")
|
||||
MARK = "# The install log, before anything can fail."
|
||||
|
||||
# The log tree's line format (forgetest/suite/logs.py keeps the same one).
|
||||
LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d install\[\d+\] "
|
||||
r"(EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) .+$")
|
||||
|
||||
# A curl that plays a script: each call takes the next "rc http bytes"
|
||||
# line of $CURL_PLAN, appends that many bytes to the --output file the way
|
||||
# a resumed transfer does, prints the HTTP code as -w '%{http_code}'
|
||||
@@ -149,24 +144,11 @@ class InstallerTests(unittest.TestCase):
|
||||
self.assertEqual(len(calls), 3)
|
||||
self.assertEqual(os.path.getsize(self.fw), 5000) # not 9000: the stale part went
|
||||
|
||||
def test_a_tls_failure_names_the_clock(self):
|
||||
out, _, _ = self.download(["60 000 0"] * 5)
|
||||
self.assertRegex(out, r"TLS handshake failed \(a wrong clock does this: \d{4}-\d\d-\d\d")
|
||||
|
||||
def test_every_log_line_is_in_the_tree_format(self):
|
||||
self.download(["7 000 0", "0 200 10"])
|
||||
self.run_sh('log NOTICE "operator declined to continue; no changes made"')
|
||||
lines = self.log_lines()
|
||||
self.assertGreater(len(lines), 5)
|
||||
for line in lines:
|
||||
self.assertRegex(line, LINE_RE)
|
||||
|
||||
def test_die_leaves_the_reason_in_the_log(self):
|
||||
r = self.run_sh('die "archiving slot 1 failed"; echo not-reached')
|
||||
self.assertEqual(r.returncode, 1)
|
||||
self.assertNotIn("not-reached", r.stdout)
|
||||
self.assertTrue(any(" ERR install failed: archiving slot 1 failed" in line
|
||||
for line in self.log_lines()))
|
||||
self.assertTrue(any("archiving slot 1 failed" in line for line in self.log_lines()))
|
||||
|
||||
def test_no_writable_log_never_fails_the_install(self):
|
||||
r = self.run_sh('LOG_OK=""; log INFO "x"; LOG_OK=yes; LOG_FILE=/nonexistent/dir/x.log; '
|
||||
@@ -174,21 +156,5 @@ class InstallerTests(unittest.TestCase):
|
||||
self.assertIn("still-running rc=0", r.stdout)
|
||||
|
||||
|
||||
class InstallerShapeTests(unittest.TestCase):
|
||||
"""What holds on any host, with no shell to run."""
|
||||
|
||||
def test_the_log_lives_in_the_tree_the_export_carries(self):
|
||||
head = functions()
|
||||
self.assertIn('LOG_DIR="/data/log/forgefirm/install"', head)
|
||||
self.assertIn('LOG_FILE="$LOG_DIR/install.log"', head)
|
||||
|
||||
def test_the_download_is_the_retrying_one(self):
|
||||
with open(SCRIPT, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
body = text.partition(MARK)[2]
|
||||
self.assertIn("download_fw", body)
|
||||
self.assertNotRegex(body, r"(?m)^\s*curl ") # no bare curl past the functions
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
the button latch judged from the first emission through every sample
|
||||
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
|
||||
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
|
||||
|
||||
from forgetest.suite import laser
|
||||
@@ -47,5 +53,157 @@ class DwellGapTests(unittest.TestCase):
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
@@ -120,6 +121,20 @@ class CoverageReportTests(unittest.TestCase):
|
||||
self.assertNotIn("forgetest", m.coverage_report(man, [], allow=[]),
|
||||
"dev-only components are outside the report")
|
||||
|
||||
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
|
||||
# **/*.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")
|
||||
rest = helpers.make_test("a.one", [("forgectrl", "src/**"), ("grblhal-glowforge", "**"),
|
||||
("kernel-module-glowforge", "**"), ("linux-fslc", "**")])
|
||||
self.assertEqual(m.coverage_report(man, [rest], host=()), {"forgectrl": ["docs/advisories/privacy.md"]})
|
||||
doc = helpers.make_test("a.two", [("forgectrl", "docs/advisories/privacy.md")])
|
||||
self.assertEqual(m.coverage_report(man, [rest, doc], host=()), {})
|
||||
self.assertNotEqual(m.fingerprint(man, doc.covers),
|
||||
m.fingerprint(helpers.with_file(man, "forgectrl", "docs/advisories/privacy.md", "new"),
|
||||
doc.covers))
|
||||
|
||||
def test_non_behavioral_paths_are_outside_every_fingerprint(self):
|
||||
man = helpers.make_manifest()
|
||||
t = helpers.make_test("a.one", [("forgectrl", "**")])
|
||||
@@ -132,6 +147,17 @@ class CoverageReportTests(unittest.TestCase):
|
||||
t.covers))
|
||||
self.assertTrue(m.non_behavioral("forgectrl", "tools/devserver.py"))
|
||||
self.assertFalse(m.non_behavioral("grblhal-glowforge", "tools/devserver.py"))
|
||||
# forgeext's image content is its binary and init script: the kit, the template, the host tools
|
||||
# and the shared package workflow are not, and the host's sources are
|
||||
for path in ("sdk/js/ffx-bridge.js", "template/manifest.json", "tools/ffx", ".github/actions/kit/action.yml"):
|
||||
self.assertTrue(m.non_behavioral("forgeext", path), path)
|
||||
for path in ("src/api.c", "init/forgeext.init", "CMakeLists.txt"):
|
||||
self.assertFalse(m.non_behavioral("forgeext", path), path)
|
||||
# an advisory is a document forgectrl serves and records consent to by its hash: behavior, though
|
||||
# it is Markdown under docs/
|
||||
self.assertFalse(m.non_behavioral("forgectrl", "docs/advisories/extensions.md"))
|
||||
self.assertTrue(m.non_behavioral("forgectrl", "docs/SERVICES.md"))
|
||||
self.assertTrue(m.non_behavioral("grblhal-glowforge", "docs/advisories/x.md"))
|
||||
|
||||
def test_an_entry_that_selects_nothing_is_reported(self):
|
||||
man = helpers.make_manifest()
|
||||
@@ -145,6 +171,441 @@ class CoverageReportTests(unittest.TestCase):
|
||||
docs = helpers.make_test("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):
|
||||
def test_catalog_hash_is_definition_only(self):
|
||||
@@ -179,9 +640,36 @@ class CatalogTests(unittest.TestCase):
|
||||
for t in catalog.all_tests(reg):
|
||||
self.assertEqual(len(t.source_sha), 64)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
def test_the_shared_judges_exist_and_take_every_package_import_of_the_suite(self):
|
||||
# The list is kept by hand: the Context reaches runner.py, hw.py
|
||||
# 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):
|
||||
@@ -219,7 +707,7 @@ def two(ctx):
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def write(self, text):
|
||||
catalog._PARTS.pop(self.path, None)
|
||||
catalog.forget(self.path)
|
||||
with open(self.path, "w", newline="\n") as f:
|
||||
f.write(text)
|
||||
|
||||
@@ -254,12 +742,12 @@ def two(ctx):
|
||||
sib = os.path.join(self.tmp, "judge.py")
|
||||
with open(sib, "w", newline="\n") as f:
|
||||
f.write("def judge(x):\n return x > 1\n")
|
||||
catalog._PARTS.pop(sib, None)
|
||||
catalog.forget(sib)
|
||||
self.write("from .judge import judge\n" + self.MODULE)
|
||||
a1, b1 = self.shas()
|
||||
with open(sib, "w", newline="\n") as f:
|
||||
f.write("def judge(x):\n return x > 2\n")
|
||||
catalog._PARTS.pop(sib, None)
|
||||
catalog.forget(sib)
|
||||
a2, b2 = self.shas()
|
||||
self.assertNotEqual(a1, a2)
|
||||
self.assertNotEqual(b1, b2)
|
||||
@@ -269,9 +757,97 @@ def two(ctx):
|
||||
f.write("def o():\n return 1\n")
|
||||
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):
|
||||
a1, b1 = self.shas()
|
||||
catalog._PARTS.pop(self.path, None)
|
||||
catalog.forget(self.path)
|
||||
with open(self.path, "wb") as f:
|
||||
f.write(self.MODULE.replace("\n", "\r\n").encode())
|
||||
self.assertEqual((a1, b1), self.shas())
|
||||
@@ -285,3 +861,7 @@ def two(ctx):
|
||||
for t in catalog.all_tests(reg):
|
||||
path = inspect.getsourcefile(t.fn)
|
||||
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.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):
|
||||
cloud(self.fc)
|
||||
b = self.bl()
|
||||
|
||||
@@ -10,10 +10,8 @@ it is needed and a bench actuator can later perform: `act` names a
|
||||
machine action and watches the machine for it (a standing notice for
|
||||
the human, no button to race), `ready` pre-announces a timed step, and
|
||||
a `precheck` refuses a start the machine cannot honor instead of
|
||||
recording a FAIL. Runner-level events go to the journal, never to the
|
||||
page state.
|
||||
recording a FAIL.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -24,16 +22,7 @@ import unittest
|
||||
import helpers
|
||||
from forgetest import catalog
|
||||
from forgetest.log import Log
|
||||
from forgetest.runner import Context, Failed, Run, Runner, journal
|
||||
|
||||
|
||||
class Catch(logging.Handler):
|
||||
def __init__(self):
|
||||
logging.Handler.__init__(self)
|
||||
self.lines = []
|
||||
|
||||
def emit(self, rec):
|
||||
self.lines.append(rec.getMessage())
|
||||
from forgetest.runner import Context, Failed, Run, Runner
|
||||
|
||||
|
||||
class Answerer:
|
||||
@@ -95,11 +84,6 @@ class ActTests(unittest.TestCase):
|
||||
self.assertIsNotNone(rec["took_s"])
|
||||
self.assertTrue(any("ACT lid open: done" in ln for ln in self.run.lines))
|
||||
|
||||
def test_the_tests_context_follows_the_wording(self):
|
||||
self.lid(False)
|
||||
self.ctx.act("lid", "close", text="Leave it closed.", until=lambda: True, timeout=1)
|
||||
self.assertTrue(any("NOTICE: Close the lid. Leave it closed." in ln for ln in self.run.lines))
|
||||
|
||||
def test_already_done_returns_at_once(self):
|
||||
self.lid(False)
|
||||
dt = self.ctx.act("lid", "open", timeout=2)
|
||||
@@ -134,31 +118,6 @@ class ActTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
self.ctx.act("lid", "press")
|
||||
|
||||
def test_a_fixture_covering_the_channel_performs_it(self):
|
||||
class Fixture:
|
||||
done = []
|
||||
|
||||
def covers(self, channel):
|
||||
return channel == "lid"
|
||||
|
||||
def act(self, channel, state):
|
||||
self.done.append((channel, state))
|
||||
self_outer.lid(state != "open")
|
||||
self_outer = self
|
||||
|
||||
class R:
|
||||
fixture = Fixture()
|
||||
ctx = Context(self.run, R(), helpers.make_test("t.f", [], kind="operator"))
|
||||
ctx.act("lid", "open", timeout=3)
|
||||
self.assertEqual(Fixture.done, [("lid", "open")])
|
||||
self.assertEqual(self.ans.notices, [])
|
||||
self.assertEqual(self.run.evidence["actions"][0]["by"], "fixture")
|
||||
# a channel the fixture does not cover falls back to the operator
|
||||
threading.Timer(0.2, self.interlock, args=(False,)).start()
|
||||
ctx.act("interlock", "open", timeout=3)
|
||||
self.assertEqual(self.ans.notices, [ctx.runner and "Open the remote-interlock loop: unplug the "
|
||||
"Pro's interlock plug, or pull the jumper at J8 on a Basic/Plus."])
|
||||
|
||||
def test_ready_is_a_single_button_prompt(self):
|
||||
self.ctx.ready("On Ready the head moves.")
|
||||
self.assertEqual(self.ans.asked, [("On Ready the head moves.", ["Ready", "Cannot"])])
|
||||
@@ -177,13 +136,6 @@ class ActTests(unittest.TestCase):
|
||||
with self.assertRaises(Failed):
|
||||
self.ctx.ready("x")
|
||||
|
||||
def test_notice_is_logged_and_in_the_snapshot(self):
|
||||
self.ctx.notice("Hold still.")
|
||||
self.assertEqual(self.run.snapshot()["notice"]["text"], "Hold still.")
|
||||
self.assertTrue(any("NOTICE: Hold still." in ln for ln in self.run.lines))
|
||||
self.ctx.clear_notice()
|
||||
self.assertIsNone(self.run.snapshot()["notice"])
|
||||
|
||||
|
||||
class PrecheckTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -262,48 +214,6 @@ class PrecheckTests(unittest.TestCase):
|
||||
catalog.REGISTRY.update(saved)
|
||||
|
||||
|
||||
class JournalTests(unittest.TestCase):
|
||||
def test_runner_notes_go_to_the_journal_and_the_run_in_progress(self):
|
||||
tmp = tempfile.mkdtemp(prefix="forgetest-j-")
|
||||
os.environ["FORGETEST_DATA"] = tmp
|
||||
os.environ["FORGETEST_MARKER"] = os.path.join(tmp, "marker")
|
||||
h = Catch()
|
||||
journal.addHandler(h)
|
||||
try:
|
||||
r = Runner(Log(os.path.join(tmp, "r.jsonl")), helpers.make_manifest(),
|
||||
helpers.registry(helpers.make_test("j.a", [])))
|
||||
r._note("queue unattended: 1 test(s) to run")
|
||||
self.assertIn("queue unattended: 1 test(s) to run", h.lines)
|
||||
self.assertNotIn("messages", r.state()[0])
|
||||
run = Run("test", "j.a", "j.a")
|
||||
r.current = run
|
||||
r._note("queue unattended: stop requested")
|
||||
self.assertTrue(any("stop requested" in ln for ln in run.lines))
|
||||
finally:
|
||||
journal.removeHandler(h)
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def test_journal_tail_serves_the_daemon_log(self):
|
||||
from forgetest.server import journal_tail
|
||||
tmp = tempfile.mkdtemp(prefix="forgetest-jt-")
|
||||
try:
|
||||
p = os.path.join(tmp, "daemon.log")
|
||||
self.assertIn(b"no journal yet", journal_tail(p))
|
||||
with open(p, "wb") as f:
|
||||
for i in range(2000):
|
||||
f.write(b"line %d\n" % i)
|
||||
tail = journal_tail(p, max_bytes=200)
|
||||
self.assertTrue(tail.endswith(b"line 1999\n"))
|
||||
self.assertLess(len(tail), 200)
|
||||
self.assertTrue(tail.startswith(b"line ")) # a whole line, not a fragment
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class AccelSamplerTests(unittest.TestCase):
|
||||
"""The head accelerometer sampler over a fake iio tree."""
|
||||
|
||||
@@ -353,3 +263,7 @@ class AccelSamplerTests(unittest.TestCase):
|
||||
with s:
|
||||
pass
|
||||
self.assertEqual(s.p2p(0), (0, 0, 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -28,7 +28,7 @@ import urllib.request
|
||||
import helpers
|
||||
from forgetest import catalog, server
|
||||
from forgetest.log import Log
|
||||
from forgetest.runner import BATCH_GROUPS, Failed, Runner
|
||||
from forgetest.runner import BATCH_GROUPS, Runner
|
||||
|
||||
|
||||
def t_pass(ctx):
|
||||
@@ -53,33 +53,17 @@ class OrderTests(unittest.TestCase):
|
||||
def order(self, tests, selected):
|
||||
return catalog.order_by_requires(tests, selected)
|
||||
|
||||
def test_prerequisite_first(self):
|
||||
a = helpers.make_test("s.a", [], fn=t_pass)
|
||||
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
|
||||
# selection order must not matter; registration order breaks ties
|
||||
self.assertEqual(self.order([b, a], ["s.b", "s.a"]), ["s.a", "s.b"])
|
||||
self.assertEqual(self.order([a, b], ["s.b", "s.a"]), ["s.a", "s.b"])
|
||||
|
||||
def test_chain(self):
|
||||
c = helpers.make_test("s.c", [], requires=("s.b",), fn=t_pass)
|
||||
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
|
||||
a = helpers.make_test("s.a", [], fn=t_pass)
|
||||
self.assertEqual(self.order([c, b, a], ["s.c", "s.b", "s.a"]), ["s.a", "s.b", "s.c"])
|
||||
|
||||
def test_registration_order_otherwise(self):
|
||||
ts = [helpers.make_test("s.%d" % i, [], fn=t_pass) for i in range(4)]
|
||||
self.assertEqual(self.order(ts, [t.id for t in ts]), ["s.0", "s.1", "s.2", "s.3"])
|
||||
|
||||
def test_prerequisite_outside_the_selection_places_nothing(self):
|
||||
a = helpers.make_test("s.a", [], fn=t_pass)
|
||||
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
|
||||
self.assertEqual(self.order([a, b], ["s.b"]), ["s.b"])
|
||||
|
||||
def test_unknown_and_empty(self):
|
||||
a = helpers.make_test("s.a", [], fn=t_pass)
|
||||
self.assertEqual(self.order([a], []), [])
|
||||
self.assertEqual(self.order([a], ["s.nope"]), [])
|
||||
|
||||
def test_a_cycle_does_not_recurse_forever(self):
|
||||
"""validate() rejects cycles; a malformed registry must still not
|
||||
hang the runner."""
|
||||
@@ -89,32 +73,6 @@ class OrderTests(unittest.TestCase):
|
||||
self.assertEqual(sorted(out), ["s.a", "s.b"])
|
||||
|
||||
|
||||
class RunClockTests(unittest.TestCase):
|
||||
"""The page shows the last run until the next one starts, so a
|
||||
finished run's elapsed figure must be the time it took, not the time
|
||||
since. Found on the bench reference, where a failed test went on
|
||||
counting past 5000 s and read as still running."""
|
||||
|
||||
def test_a_finished_runs_clock_stops(self):
|
||||
from forgetest.runner import Run
|
||||
run = Run("test", "t.x", "t.x")
|
||||
run.started = time.time() - 30
|
||||
self.assertGreaterEqual(run.snapshot()["elapsed_s"], 30)
|
||||
run.finished = {"result": "FAIL", "message": "", "duration_s": 31}
|
||||
first = run.snapshot()["elapsed_s"]
|
||||
time.sleep(1.1)
|
||||
self.assertEqual(first, 31)
|
||||
self.assertEqual(run.snapshot()["elapsed_s"], 31)
|
||||
|
||||
def test_a_running_runs_clock_ticks(self):
|
||||
from forgetest.runner import Run
|
||||
run = Run("test", "t.x", "t.x")
|
||||
run.started = time.time() - 5
|
||||
first = run.snapshot()["elapsed_s"]
|
||||
time.sleep(1.1)
|
||||
self.assertGreater(run.snapshot()["elapsed_s"], first)
|
||||
|
||||
|
||||
class QueueTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
@@ -322,13 +280,13 @@ class QueueTests(unittest.TestCase):
|
||||
time.sleep(0.02)
|
||||
ok, msg = r.start_test("s.other")
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(msg, "a queue is running")
|
||||
self.assertTrue(msg)
|
||||
ok, msg = r.start_bench("anything")
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(msg, "a queue is running")
|
||||
self.assertTrue(msg)
|
||||
ok, msg, _ = r.start_batch("unattended")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("already running", msg)
|
||||
self.assertTrue(msg)
|
||||
r.stop_batch()
|
||||
r.abort()
|
||||
deadline = time.time() + 20
|
||||
@@ -366,7 +324,7 @@ class QueueTests(unittest.TestCase):
|
||||
def test_unknown_group(self):
|
||||
st, d = self.call("POST", "/batch", {"group": "everything"})
|
||||
self.assertEqual(st, 409)
|
||||
self.assertEqual(d["message"], "unknown queue")
|
||||
self.assertTrue(d["message"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,33 +3,22 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""What keeps the page answering the operator instead of the timer.
|
||||
"""The caches behind the page's state.
|
||||
|
||||
Two things went wrong on the bench and are pinned here:
|
||||
|
||||
- the state cost. Every poll re-read and re-parsed the whole result log,
|
||||
and recomputed every test's domain fingerprint. A result record carries
|
||||
its run log, so the file reaches megabytes over a campaign and the poll
|
||||
grew with it. Both are now parsed and computed once.
|
||||
- the wasted payload. An idle page polls an unchanged state; it now gets
|
||||
a 304 instead of the whole thing.
|
||||
|
||||
The click-swallowing defect these were found with lives in the page's
|
||||
JavaScript and is not reachable from here: rows, prompt buttons and tool
|
||||
entries are built once and afterwards only updated in place, because a
|
||||
poll that rebuilt them removed the button between the operator's mousedown
|
||||
and mouseup, and no click event was ever raised. `test_page_never_rebuilds`
|
||||
holds the shape of that rule.
|
||||
Every poll used to re-read and re-parse the whole result log and recompute
|
||||
every test's domain fingerprint; a result record carries its run log, so
|
||||
the file reaches megabytes over a campaign. Both are now kept between
|
||||
polls, and a stale cache would misjudge the gate: the log cache picks up
|
||||
what is appended or replaced and never counts a partial line, and the
|
||||
fingerprint cache never serves a changed manifest or one it cannot key.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest import catalog, manifest as manifest_mod, page
|
||||
from forgetest import manifest as manifest_mod
|
||||
from forgetest.log import Log
|
||||
|
||||
|
||||
@@ -53,9 +42,6 @@ class LogCacheTests(unittest.TestCase):
|
||||
for line in lines:
|
||||
f.write(line + "\n")
|
||||
|
||||
def test_missing_file_reads_empty(self):
|
||||
self.assertEqual(self.log.read(), [])
|
||||
|
||||
def test_appends_are_picked_up(self):
|
||||
self.log.append({"t": "campaign", "id": "c1"})
|
||||
self.assertEqual([r["id"] for r in self.log.read()], ["c1"])
|
||||
@@ -65,27 +51,6 @@ class LogCacheTests(unittest.TestCase):
|
||||
self.assertEqual([r["t"] for r in recs], ["campaign", "result", "result"])
|
||||
self.assertEqual(recs[2]["test"], "c.d")
|
||||
|
||||
def test_only_new_bytes_are_parsed(self):
|
||||
for i in range(5):
|
||||
self.log.append({"t": "result", "test": "t%d" % i})
|
||||
self.log.read()
|
||||
# a second read must not touch the file at all
|
||||
real_open = open
|
||||
opened = []
|
||||
|
||||
def counting_open(*a, **kw):
|
||||
opened.append(a[0])
|
||||
return real_open(*a, **kw)
|
||||
|
||||
import builtins
|
||||
builtins.open = counting_open
|
||||
try:
|
||||
recs = self.log.read()
|
||||
finally:
|
||||
builtins.open = real_open
|
||||
self.assertEqual(opened, [], "an unchanged log was re-opened")
|
||||
self.assertEqual(len(recs), 5)
|
||||
|
||||
def test_read_returns_a_fresh_list(self):
|
||||
"""Callers filter and sort the result; the cache must not be theirs."""
|
||||
self.log.append({"t": "result", "test": "a.b"})
|
||||
@@ -106,16 +71,6 @@ class LogCacheTests(unittest.TestCase):
|
||||
self.assertEqual([r["test"] for r in recs], ["a.b", "c.d"])
|
||||
self.assertEqual(self.log.corrupt, 0)
|
||||
|
||||
def test_corrupt_lines_counted_once_not_per_read(self):
|
||||
self.write('{"t":"result","test":"a.b"}', "not json at all", '["not","a","record"]',
|
||||
'{"t":"result","test":"c.d"}')
|
||||
recs = self.log.read()
|
||||
self.assertEqual([r["test"] for r in recs], ["a.b", "c.d"])
|
||||
self.assertEqual(self.log.corrupt, 2)
|
||||
self.log.read()
|
||||
self.log.read()
|
||||
self.assertEqual(self.log.corrupt, 2, "corrupt lines re-counted on every read")
|
||||
|
||||
def test_replaced_file_is_read_again(self):
|
||||
self.write('{"t":"result","test":"a.b"}', '{"t":"result","test":"c.d"}')
|
||||
self.assertEqual(len(self.log.read()), 2)
|
||||
@@ -124,11 +79,6 @@ class LogCacheTests(unittest.TestCase):
|
||||
recs = self.log.read()
|
||||
self.assertEqual([r.get("id") for r in recs], ["c9"])
|
||||
|
||||
def test_blank_lines_are_not_corrupt(self):
|
||||
self.write('{"t":"result","test":"a.b"}', "", " ", '{"t":"result","test":"c.d"}')
|
||||
self.assertEqual(len(self.log.read()), 2)
|
||||
self.assertEqual(self.log.corrupt, 0)
|
||||
|
||||
|
||||
class FingerprintCacheTests(unittest.TestCase):
|
||||
"""Memoized per manifest: the page recomputes every test's fingerprint
|
||||
@@ -138,11 +88,6 @@ class FingerprintCacheTests(unittest.TestCase):
|
||||
self.man = helpers.make_manifest()
|
||||
self.t = helpers.make_test("fake.fp", [("forgectrl", "src/ui.c")], fn=t_noop)
|
||||
|
||||
def test_repeat_calls_agree(self):
|
||||
first = self.t.fingerprint(self.man)
|
||||
self.assertEqual(self.t.fingerprint(self.man), first)
|
||||
self.assertEqual(self.t.fingerprint(self.man), first)
|
||||
|
||||
def test_a_changed_manifest_is_not_served_from_the_cache(self):
|
||||
first = self.t.fingerprint(self.man)
|
||||
man2 = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
|
||||
@@ -150,11 +95,6 @@ class FingerprintCacheTests(unittest.TestCase):
|
||||
# and back again: the cache must not have latched the new one either
|
||||
self.assertEqual(self.t.fingerprint(self.man), first)
|
||||
|
||||
def test_a_file_outside_the_coverage_does_not_move_it(self):
|
||||
first = self.t.fingerprint(self.man)
|
||||
man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
|
||||
self.assertEqual(self.t.fingerprint(man2), first)
|
||||
|
||||
def test_manifest_without_a_content_hash_is_not_cached(self):
|
||||
"""The cache is keyed by the manifest's content hash. A manifest
|
||||
that has none must still fingerprint correctly, not collide."""
|
||||
@@ -165,93 +105,6 @@ class FingerprintCacheTests(unittest.TestCase):
|
||||
self.assertIsNone(a.content_sha)
|
||||
self.assertNotEqual(self.t.fingerprint(a), self.t.fingerprint(b))
|
||||
|
||||
def test_component_file_list_is_stable_and_read_only(self):
|
||||
files = self.man.files("forgectrl")
|
||||
self.assertEqual(files, self.man.files("forgectrl"))
|
||||
self.assertIsNone(self.man.files("no-such-component"))
|
||||
|
||||
|
||||
class PageTests(unittest.TestCase):
|
||||
def test_page_never_rebuilds_what_the_operator_may_be_pressing(self):
|
||||
"""A poll must update rows, prompt buttons and tool entries in
|
||||
place. Assigning innerHTML to their containers on every poll is
|
||||
what swallowed the clicks; only the one-time build may do it."""
|
||||
html = page.render("0" * 32)
|
||||
for container, builder in (("groups", "buildGroups"), ("tools", "buildBench")):
|
||||
self.assertIn("function %s(" % builder, html,
|
||||
"no one-time builder for #%s" % container)
|
||||
assigns = html.count("$('%s').innerHTML=" % container)
|
||||
self.assertEqual(assigns, 1,
|
||||
"#%s is assigned innerHTML %d times; it belongs to the "
|
||||
"builder alone" % (container, assigns))
|
||||
# the per-poll path writes through the guarded setters only
|
||||
self.assertIn("function setHtml(e,h){if(e&&e.__h!==h)", html)
|
||||
self.assertIn("function updateGroups()", html)
|
||||
self.assertIn("function updateBench()", html)
|
||||
# the prompt buttons are rebuilt only when the prompt changes
|
||||
self.assertIn("if(pk!==promptKey)", html)
|
||||
# the queue controls are static markup: a poll relabels them and
|
||||
# flips disabled, it never replaces the node
|
||||
for bid in ("q-unattended", "q-attended", "q-stop"):
|
||||
self.assertIn('id="%s"' % bid, html)
|
||||
self.assertNotIn("id='" + bid + "-", html)
|
||||
self.assertNotIn('id="' + bid + "-", html)
|
||||
self.assertIn("function renderQueue()", html)
|
||||
# the help popovers sit on static markup only: a rebuilt row or
|
||||
# tool entry would orphan one
|
||||
self.assertNotIn("data-help", html[html.index("function buildGroups("):])
|
||||
|
||||
def test_every_group_is_built_on_the_same_grid(self):
|
||||
"""The subsystems are separate tables. Left to size themselves
|
||||
from their own content no two line up, which is what made the
|
||||
page look busy, so they share one colgroup and a fixed layout."""
|
||||
html = page.render("0" * 32)
|
||||
self.assertRegex(html, r"#groups table\s*\{\s*table-layout:\s*fixed")
|
||||
# one colgroup definition, emitted into every group's table
|
||||
self.assertEqual(html.count('var COLS="<colgroup>'), 1)
|
||||
self.assertEqual(html.count('<table>"+COLS+"'), 1,
|
||||
"a group table built without the shared colgroup")
|
||||
colgroup = re.search(r'var COLS="(.*?)";', html).group(1)
|
||||
heads = ["Test", "Kind", "Status", "Last result"]
|
||||
for h in heads:
|
||||
self.assertIn("<th>%s</th>" % h, html)
|
||||
self.assertEqual(len(re.findall(r"<col(?:>| )", colgroup)), len(heads) + 1,
|
||||
"a column per header, the action column included")
|
||||
# every fixed column carries a width, or the grid is only a wish
|
||||
for cls in re.findall(r"<col class='(\w)'", colgroup):
|
||||
self.assertRegex(html, r"#groups col\.%s\s*\{\s*width:\s*\d+px" % cls)
|
||||
|
||||
def test_details_and_the_requires_note_stay_out_of_the_columns(self):
|
||||
"""Both are long enough to stretch a cell. In a column they would
|
||||
pull one group's grid out of step with the rest: the details get a
|
||||
full-width row, and the note sits under Status, which is sized for
|
||||
it."""
|
||||
html = page.render("0" * 32)
|
||||
self.assertIn("<tr class='detrow'><td colspan='5'>", html)
|
||||
# the status cell carries the status, the unmet note and the row
|
||||
# message; the action cell carries the button and nothing else
|
||||
self.assertIn("<td><div id='st-\"+d+\"'></div><div id='unmet-\"+d+"
|
||||
"\"'></div><div id='note-\"+d+\"'></div></td>", html)
|
||||
self.assertIn("<td><button class='btn btn-sm btn-primary' id='btn-\"+d+\"' "
|
||||
"onclick='startTest(\\\"\"+d+\"\\\")'>Start</button></td>", html)
|
||||
|
||||
def test_page_is_self_contained_ascii(self):
|
||||
html = page.render("0" * 32)
|
||||
self.assertNotIn("__TOKEN__", html)
|
||||
# our own sources stay ASCII (no stray typography in an embedded
|
||||
# page); the vendored Bootstrap carries its own glyphs
|
||||
for name in ("index.html",) + page.CSS_FILES + page.JS_FILES:
|
||||
if name.startswith("vendor/"):
|
||||
continue
|
||||
page.read_ui(name).encode("ascii")
|
||||
stray = sorted(set(hex(ord(c)) for c in html if ord(c) < 32 and c != "\n"))
|
||||
self.assertEqual(stray, [], "control characters in the page source")
|
||||
# nothing fetched from anywhere: the documentation links open a
|
||||
# site, they are not assets
|
||||
for remote in ("<link ", "<script src=", "@import", "url(http", "url(//",
|
||||
'src="http', "src='http", "//cdn"):
|
||||
self.assertNotIn(remote, html)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -8,7 +8,6 @@ fake bench tool. No hardware, no forgectrl."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
@@ -18,7 +17,7 @@ import urllib.request
|
||||
|
||||
import helpers
|
||||
from forgetest import bench as bench_mod
|
||||
from forgetest import catalog, server
|
||||
from forgetest import server
|
||||
from forgetest.log import Log
|
||||
from forgetest.runner import Failed, Runner
|
||||
|
||||
@@ -157,13 +156,6 @@ class ServerTests(unittest.TestCase):
|
||||
st, d = self.call("GET", "/", token=False)
|
||||
self.assertEqual(st, 200)
|
||||
self.assertIn(self.token.encode(), d)
|
||||
page = d.decode("utf-8")
|
||||
# one self-contained response: nothing linked, the token once,
|
||||
# the theme attribute the head script sets
|
||||
self.assertEqual(page.count(self.token), 1)
|
||||
self.assertNotIn("<link ", page)
|
||||
self.assertNotIn("<script src=", page)
|
||||
self.assertIn("data-bs-theme", page)
|
||||
st, d = self.call("GET", "/state", headers={"Host": "evil.example.net"})
|
||||
self.assertEqual(st, 403)
|
||||
st, d = self.call("GET", "/state", headers={"Origin": "http://evil.example.net"})
|
||||
@@ -179,23 +171,6 @@ class ServerTests(unittest.TestCase):
|
||||
st, d = self.call("GET", "/nope")
|
||||
self.assertEqual(st, 404)
|
||||
|
||||
def test_01c_connection_is_kept_alive(self):
|
||||
"""A poll per second over a fresh TCP connection each time is
|
||||
waste the board does not need to pay."""
|
||||
import http.client
|
||||
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||
try:
|
||||
for _ in range(3):
|
||||
c.request("GET", "/state", headers={"Host": "127.0.0.1:%d" % self.port})
|
||||
r = c.getresponse()
|
||||
r.read()
|
||||
self.assertEqual(r.status, 200)
|
||||
self.assertEqual(r.version, 11)
|
||||
self.assertNotEqual((r.getheader("Connection") or "").lower(), "close")
|
||||
self.assertIsNotNone(r.getheader("Content-Length"))
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
def test_01d_refused_post_does_not_poison_the_connection(self):
|
||||
"""A POST refused before its body is read leaves that body in the
|
||||
socket. On a kept-alive connection the next read would take it for
|
||||
@@ -364,22 +339,6 @@ class ServerTests(unittest.TestCase):
|
||||
st, d = self.call("POST", "/abort")
|
||||
state = self.wait_idle()
|
||||
self.assertEqual(state["last_run"]["finished"]["result"], "ABORTED")
|
||||
# a takeover tool runs inside the takeover wrapper (init.d is absent on the host: rc 127)
|
||||
st, d = self.call("POST", "/bench/start", {"tool": "tk"})
|
||||
self.assertEqual(st, 200, d)
|
||||
state = self.wait_idle(timeout=40) # two unreachable-forgectrl settle waits
|
||||
log = "\n".join(state["last_run"]["log"])
|
||||
self.assertIn("takeover: pulse device free", log)
|
||||
self.assertIn("takeover: forgectrl start", log)
|
||||
self.assertIn("baseline: forgectrl unreachable for 10 s", log)
|
||||
self.assertFalse(os.path.exists(os.environ["FORGETEST_MARKER"]))
|
||||
# a scope tool is a takeover too
|
||||
st, d = self.call("POST", "/bench/start", {"tool": "sc"})
|
||||
self.assertEqual(st, 200, d)
|
||||
state = self.wait_idle(timeout=40)
|
||||
log = "\n".join(state["last_run"]["log"])
|
||||
self.assertIn("takeover: pulse device free", log)
|
||||
self.assertIn("takeover: forgectrl start", log)
|
||||
# bench runs never touched the acceptance log
|
||||
recs = self.log.read()
|
||||
self.assertFalse(any(r.get("t") == "result" and r.get("test") == "echo" for r in recs))
|
||||
@@ -406,31 +365,6 @@ class ServerTests(unittest.TestCase):
|
||||
self.assertNotIn("messages", r.state()[0])
|
||||
self.assertFalse(os.path.exists(marker))
|
||||
|
||||
def test_10_state_is_conditional(self):
|
||||
"""The page polls; an unchanged state must cost a 304, and the
|
||||
ETag must move as soon as the state does.
|
||||
|
||||
Runs last because it invalidates: timestamps are whole seconds, and
|
||||
a PASS stamped in the same second as an invalidate is deliberately
|
||||
not inheritable, so an invalidate early in this class would decide
|
||||
the inheritance the earlier tests are checking.
|
||||
"""
|
||||
st, d, hdrs = self.call_h("GET", "/state")
|
||||
self.assertEqual(st, 200)
|
||||
etag = hdrs.get("ETag")
|
||||
self.assertTrue(etag and etag.startswith('"'), "no ETag on /state")
|
||||
st, _, hdrs2 = self.call_h("GET", "/state", headers={"If-None-Match": etag})
|
||||
self.assertEqual(st, 304)
|
||||
self.assertEqual(hdrs2.get("ETag"), etag)
|
||||
# a stale validator must not be honored
|
||||
st, _, _ = self.call_h("GET", "/state", headers={"If-None-Match": '"stale"'})
|
||||
self.assertEqual(st, 200)
|
||||
# and the validator moves with the state
|
||||
self.call("POST", "/invalidate", {"reason": "etag check"})
|
||||
st, _, hdrs3 = self.call_h("GET", "/state", headers={"If-None-Match": etag})
|
||||
self.assertEqual(st, 200)
|
||||
self.assertNotEqual(hdrs3.get("ETag"), etag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The setup's focus burn in the catalog: a live test on the button with
|
||||
a scrap at hand, its covers naming the live runner, the renderer, the
|
||||
sender, the page, and the driver's laser module; and the burn against a
|
||||
scripted daemon: the preview and the program read, the start, the
|
||||
prompts answered in order (the gantry stops, the head at home, the scrap,
|
||||
its thickness in the machine's units, the arm, the machine's own press
|
||||
prompt, the pick), the witnesses judged (the burn's own, and the two the
|
||||
test reads from sysfs at every poll), the record read at version 1, the
|
||||
setting the burn wrote put back as found, and the controller started
|
||||
again on the edge and the window put back."""
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import helpers # noqa: E402
|
||||
from forgetest import baseline, catalog # noqa: E402
|
||||
from forgetest.runner import Aborted, Context, Failed, Run # noqa: E402
|
||||
from forgetest.suite import setup_burns, setup_dark # noqa: E402
|
||||
|
||||
LIVE = ["laser.focus"]
|
||||
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "beam_sensor_seen": True,
|
||||
"lit_s": 30.0}
|
||||
SPM = 2.922 # the lens screw, half-steps per mm ($102)
|
||||
EDGE_DEFAULT = 3.35 # the edge's height while the setting is unset (lens.h)
|
||||
|
||||
|
||||
def lens_block(s):
|
||||
"""The /status lens block as the daemon builds it from the settings: a
|
||||
stop count from 1 to 40 stands, anything else is the fallback."""
|
||||
def count(key, fallback):
|
||||
try:
|
||||
n = float(s.get(key, ""))
|
||||
except ValueError:
|
||||
return fallback, False
|
||||
return (int(n), True) if 1 <= n <= 40 else (fallback, False)
|
||||
try:
|
||||
edge = float(s.get("lens_hall_edge_z_mm", ""))
|
||||
except ValueError:
|
||||
edge = EDGE_DEFAULT
|
||||
below, a = count("lens_stop_below_steps", 10)
|
||||
above, b = count("lens_stop_above_steps", 12)
|
||||
return {"edge_z": round(edge, 2), "below": below, "above": above, "stops_found": a and b,
|
||||
"reach_min": round(edge - below / SPM, 2), "reach_max": round(edge + above / SPM, 2)}
|
||||
|
||||
|
||||
def ladder_z(edge, below, above):
|
||||
"""The focus ladder the daemon builds: twelve whole half-steps from
|
||||
the top of the window to the bottom."""
|
||||
return [round(edge + round(above - (above + below) * i / 11) / SPM, 2) for i in range(12)]
|
||||
|
||||
|
||||
# The burn as the daemon runs it: its prompts in order (id, kind,
|
||||
# options) and the result it ends with. The press prompt is the
|
||||
# machine's own: nothing answers it, the discharge closes it. The focus
|
||||
# burn's window and ladder are the ones the settings held at its start.
|
||||
SETUP = [("gantry-stops", "confirm", ["Yes", "No"]),
|
||||
("home", "choice", ["The head is home", "Release the motors"]),
|
||||
("scrap", "continue", ["Continue"]), ("thickness", "number", ["Set"])]
|
||||
PRESS = ("press", "wait", [])
|
||||
SCRIPT = {
|
||||
"laser.focus": (SETUP + [("focus-arm", "continue", ["Continue"]), PRESS,
|
||||
("focus-pick", "multichoice", [str(i) for i in range(1, 13)])],
|
||||
{"z_passes": [100, 101], "emission": EMISSION, "pick": 11, "picked_lines": [11, 11],
|
||||
"pick_half_steps": -8, "edge_z_mm": 6.96, "steps_per_mm": SPM, "max_height_mm": 13.81,
|
||||
"focus_range_mm": {"min": 2.17, "max": 13.81}, "summary": "Line 11."}),
|
||||
}
|
||||
# What the burn writes when it ends.
|
||||
WRITES = {"laser.focus": {"lens_hall_edge_z_mm": "6.96"}}
|
||||
# The machine as the burn finds it: its own edge, the window the Lens
|
||||
# travel check wrote, its floor.
|
||||
FOUND = {"lens_hall_edge_z_mm": "4.10", "lens_stop_below_steps": "14", "lens_stop_above_steps": "20",
|
||||
"laser_floor_density": "12", "ui_units": "metric"}
|
||||
# What grblHAL reads at its start: the edge and the window.
|
||||
LENS_KEYS = ("lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps")
|
||||
HOME = "The head is home"
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.reg = catalog.load_suite()
|
||||
|
||||
def test_a_live_test_on_the_button_with_a_scrap_at_hand(self):
|
||||
t = self.reg["setup.focus"]
|
||||
self.assertEqual(t.kind, "live")
|
||||
self.assertEqual(t.mode, "grbl")
|
||||
self.assertEqual(t.hardware, "takeover")
|
||||
self.assertEqual(list(t.actions), ["button"])
|
||||
self.assertEqual(list(t.hands), ["scrap"])
|
||||
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
|
||||
self.assertIsNone(getattr(t, "precheck", None))
|
||||
self.assertEqual(list(t.requires),
|
||||
["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "setup.check-lens"])
|
||||
|
||||
def test_the_sheet_is_gone(self):
|
||||
self.assertFalse([i for i in self.reg if i.startswith("setup.sheet")])
|
||||
|
||||
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
|
||||
for tid in ("setup.focus",):
|
||||
covers = set(self.reg[tid].covers)
|
||||
for want in (("forgectrl", "src/wizlive.*"), ("forgectrl", "src/sheet.*"),
|
||||
("forgectrl", "src/jobstream.*"), ("forgectrl", "src/curverec.*"),
|
||||
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/lens.*"),
|
||||
("forgectrl", "src/lenshome.*"), # the lens back on its reference at the end
|
||||
("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/wizrun.h"),
|
||||
("grblhal-glowforge", "src/glowforge_laser.c")):
|
||||
self.assertIn(want, covers, "%s lacks %s" % (tid, want))
|
||||
|
||||
def test_the_steps_ask_for_a_scrap_in_the_corner_and_one_press(self):
|
||||
for tid, size in (("setup.focus", "78 x 68 mm"),):
|
||||
text = " ".join(self.reg[tid].steps)
|
||||
self.assertIn(size, text, tid)
|
||||
self.assertIn("back-left corner", text, tid)
|
||||
self.assertIn("gantry stops", text, tid)
|
||||
self.assertIn("press the machine's button once", text, tid)
|
||||
|
||||
|
||||
class ScriptedDaemon:
|
||||
"""The wizard runner as the suite sees it: /wiz/<id>/start opens the
|
||||
first prompt, each answer opens the next (a thickness that is not a
|
||||
plain number or out of range in the machine's units opens it again),
|
||||
the press prompt
|
||||
closes on the poll after the one that served it, the last answer ends
|
||||
the burn with its result and its writes, /wiz/dark reports it, GET /wiz
|
||||
carries the versions (the record carries Focus already, as the bench's
|
||||
does), the preview and the program are served per burn, the program
|
||||
ending in the wizard's tail, and the supervisor's stop and start levers
|
||||
work (a start records the settings the new controller reads)."""
|
||||
|
||||
TAIL = ["G0 X0 Y0", "G92.1", "M2"] # after the lens's return to the edge
|
||||
|
||||
def __init__(self, fake):
|
||||
self.fake = fake
|
||||
fake.state["settings"].update(FOUND)
|
||||
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
|
||||
"log": [], "prompt": None, "result": None, "error": "",
|
||||
"shots": {"lid": False, "head": False}}
|
||||
self.versions = {"laser.focus": 1}
|
||||
self.starts = [] # the lens settings each controller start read
|
||||
self.tail = list(self.TAIL)
|
||||
self.answers = {} # wid -> [values]
|
||||
self.order = [] # the burns as started
|
||||
self.aborted = []
|
||||
self.settings_at_start = {}
|
||||
self.previews = []
|
||||
self.queue = []
|
||||
self.seq = 0
|
||||
self.press_seen = False
|
||||
self.thickness = None
|
||||
self.lens_at_start = None
|
||||
self.units = None # the units the number prompts read; None = ui_units
|
||||
self.start_writes = {} # wid -> settings the burn writes as it starts
|
||||
self.patch = {} # wid -> result fields that differ from the script
|
||||
self.stray_z = None # a Z the served program commands beyond the reach, when set
|
||||
fake.on_get = self.on_get
|
||||
fake.on_post = self.on_post
|
||||
|
||||
def open_next(self):
|
||||
if not self.queue:
|
||||
return False
|
||||
pid, kind, options = self.queue[0]
|
||||
self.seq += 1
|
||||
self.dark["prompt"] = {"seq": self.seq, "id": pid, "kind": kind, "text": pid, "options": options}
|
||||
return True
|
||||
|
||||
def finish(self, wid):
|
||||
s = self.fake.state["settings"]
|
||||
r = dict(SCRIPT[wid][1], thickness_mm=round(self.thickness, 3))
|
||||
if wid == "laser.focus":
|
||||
lens = self.lens_at_start
|
||||
r["window"] = {k: lens[k] for k in ("below", "above", "reach_min", "reach_max")}
|
||||
r["ladder_z"] = ladder_z(lens["edge_z"], lens["below"], lens["above"])
|
||||
r.update(self.patch.get(wid, {}))
|
||||
s.update(WRITES[wid])
|
||||
self.dark.update({"running": False, "prompt": None, "progress": 100, "result": r})
|
||||
self.versions[wid] = 1
|
||||
|
||||
def on_get(self, path, q):
|
||||
s = self.fake.state["settings"]
|
||||
if path == "/status":
|
||||
# the lens block follows the settings, as the daemon's does
|
||||
return 200, dict(self.fake.state["status"], lens=lens_block(s))
|
||||
if path == "/wiz/dark":
|
||||
p = self.dark.get("prompt")
|
||||
if p and p["id"] == "press":
|
||||
if self.press_seen:
|
||||
self.press_seen = False
|
||||
self.queue.pop(0)
|
||||
if not self.open_next():
|
||||
self.finish(self.dark["id"])
|
||||
else:
|
||||
self.press_seen = True
|
||||
return 200, dict(self.dark)
|
||||
if path == "/wiz":
|
||||
return 200, {"versions": dict(self.versions), "recommended": [], "completed": True}
|
||||
if path == "/wiz/sheet.svg":
|
||||
self.previews.append(q.get("card"))
|
||||
if q.get("card") not in LIVE:
|
||||
return 404, {"error": "no such card"}
|
||||
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
|
||||
if path == "/wiz/sheet.gcode":
|
||||
# the text is M4 at the mark dose, as the daemon serves it; the
|
||||
# head's Z is the edge, the tail goes back to it, and the focus
|
||||
# ladder spans the window the settings hold now
|
||||
lens = lens_block(s)
|
||||
lines = ["; mock", "G0 Z%.2f" % lens["edge_z"], "M4 S400", "G0 X12 Y12", "G1 X40 Y12 F3000"]
|
||||
if q.get("card") == "laser.focus":
|
||||
lines += ["G0 Z%.2f" % z for z in ladder_z(lens["edge_z"], lens["below"], lens["above"])]
|
||||
if self.stray_z is not None:
|
||||
lines.append("G0 Z%.2f" % self.stray_z)
|
||||
lines += ["M5", "G0 Z%.2f" % lens["edge_z"]] + self.tail
|
||||
return 200, ("\n".join(lines) + "\n").encode(), "text/plain"
|
||||
return None
|
||||
|
||||
def on_post(self, path, form):
|
||||
s = self.fake.state["settings"]
|
||||
if path.startswith("/wiz/") and path.endswith("/start"):
|
||||
wid = path[len("/wiz/"):-len("/start")]
|
||||
if wid not in SCRIPT:
|
||||
return 404, {"error": "no such wizard"}
|
||||
self.order.append(wid)
|
||||
self.settings_at_start[wid] = dict(s)
|
||||
s.update(self.start_writes.get(wid, {}))
|
||||
self.lens_at_start = lens_block(s)
|
||||
self.queue = list(SCRIPT[wid][0])
|
||||
self.press_seen = False
|
||||
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
|
||||
self.open_next()
|
||||
return 200, {"started": True, "id": wid}
|
||||
if path.startswith("/wiz/") and path.endswith("/answer"):
|
||||
wid = path[len("/wiz/"):-len("/answer")]
|
||||
p = self.dark.get("prompt")
|
||||
if not p or form.get("seq") != str(p["seq"]):
|
||||
return 409, {"error": "no such prompt is open"}
|
||||
value = form.get("value")
|
||||
self.answers.setdefault(wid, []).append(value)
|
||||
if p["id"] == "thickness":
|
||||
imperial = (self.units or s.get("ui_units")) == "imperial"
|
||||
try:
|
||||
mm = float(value.strip()) * (25.4 if imperial else 1.0)
|
||||
except ValueError:
|
||||
mm = -1 # "1/8" or "3 mm": not a plain number
|
||||
if not 0 < mm <= 30:
|
||||
self.open_next() # the same prompt again, a new sequence number
|
||||
return 200, {"ok": True}
|
||||
self.thickness = mm
|
||||
self.queue.pop(0)
|
||||
if not self.open_next():
|
||||
self.finish(wid)
|
||||
return 200, {"ok": True}
|
||||
if path.startswith("/wiz/") and path.endswith("/abort"):
|
||||
self.aborted.append(path[len("/wiz/"):-len("/abort")])
|
||||
self.dark.update({"running": False, "prompt": None, "error": "aborted"})
|
||||
return 200, {"ok": True}
|
||||
if path == "/settings":
|
||||
for k, v in form.items():
|
||||
if v == "":
|
||||
s.pop(k, None)
|
||||
else:
|
||||
s[k] = v
|
||||
return 200, dict(s)
|
||||
if path == "/controller/stop":
|
||||
self.fake.state["mode"] = dict(self.fake.state["mode"], controller="standby")
|
||||
return 200, {"stopped": True}
|
||||
if path == "/controller/start":
|
||||
m = self.fake.state["mode"]
|
||||
self.fake.state["mode"] = dict(m, controller="running", motion="verified", pid=m["pid"] + 1)
|
||||
self.starts.append({k: s.get(k) for k in LENS_KEYS})
|
||||
return 200, {"started": True}
|
||||
return None
|
||||
|
||||
def lever_posts(self):
|
||||
return [p for p, _ in self.fake.posts if p.startswith("/controller/")]
|
||||
|
||||
|
||||
class BurnRunTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# the two witnesses as sysfs serves them through a lit burn (the
|
||||
# shape of a bench record: the LASER_ON count and the tube current)
|
||||
self.sysfs = tempfile.mkdtemp(prefix="forgetest-burn-")
|
||||
self.plant("cnc/laser_on_sampled", "118")
|
||||
self.plant("pic/hv_current", "431")
|
||||
self.saved_root = os.environ.get("GF_SYSFS_ROOT")
|
||||
os.environ["GF_SYSFS_ROOT"] = self.sysfs + "/"
|
||||
self.fake = helpers.FakeForgectrl().start()
|
||||
# the head at its home against the gantry stops, X and Y referenced
|
||||
# by a manual home with no offset (the fake's pos is X0 Y0 Z0), its
|
||||
# anchor declaring the stops at X0 Y0
|
||||
self.fake.state["status"].update({"homed": True, "homed_axes": 7, "home_source": "manual"})
|
||||
self.saved_anchor = baseline.ANCHOR_PATH
|
||||
baseline.ANCHOR_PATH = os.path.join(self.sysfs, "grblhal.homed")
|
||||
self.anchor("0.000 0.000 0.000 7 manual")
|
||||
self.daemon = ScriptedDaemon(self.fake)
|
||||
self.poll = setup_dark.POLL_S
|
||||
setup_dark.POLL_S = 0.01
|
||||
|
||||
def tearDown(self):
|
||||
setup_dark.POLL_S = self.poll
|
||||
baseline.ANCHOR_PATH = self.saved_anchor
|
||||
self.fake.stop()
|
||||
if self.saved_root is None:
|
||||
os.environ.pop("GF_SYSFS_ROOT", None)
|
||||
else:
|
||||
os.environ["GF_SYSFS_ROOT"] = self.saved_root
|
||||
shutil.rmtree(self.sysfs, ignore_errors=True)
|
||||
|
||||
def plant(self, attr, value):
|
||||
path = os.path.join(self.sysfs, *attr.split("/"))
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write(value + "\n")
|
||||
|
||||
def anchor(self, line):
|
||||
"""The controller's home anchor as a home writes it: X Y Z, the
|
||||
axes it references, and what set it."""
|
||||
with open(baseline.ANCHOR_PATH, "w") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
def run_test(self, tid):
|
||||
t = catalog.load_suite()[tid]
|
||||
run = self.run = Run("test", t.id, t.title) # kept for a run that fails
|
||||
run.unattended = True # the ready gate passes; the arm cue is a notice
|
||||
t.fn(Context(run, None, t))
|
||||
return run
|
||||
|
||||
def assert_found(self, keys=None):
|
||||
s = self.fake.state["settings"]
|
||||
for k, v in FOUND.items():
|
||||
if keys is None or k in keys:
|
||||
self.assertEqual(s.get(k), v, k)
|
||||
|
||||
def test_the_focus_burn_in_the_lens_travel_window(self):
|
||||
run = self.run_test("setup.focus")
|
||||
d = self.daemon
|
||||
self.assertEqual(d.order, ["laser.focus"])
|
||||
self.assertEqual(d.answers["laser.focus"], ["Yes", HOME, "Continue", "3.2", "Continue", "11"])
|
||||
self.assertEqual(d.previews, ["laser.focus", "laser.focus"]) # before the burn, and after it
|
||||
# a fresh head's focus: the edge cleared, the window the Lens travel check's
|
||||
start = d.settings_at_start["laser.focus"]
|
||||
self.assertNotIn("lens_hall_edge_z_mm", start)
|
||||
self.assertEqual((start["lens_stop_below_steps"], start["lens_stop_above_steps"]), ("14", "20"))
|
||||
self.assertEqual(run.evidence["result"]["emission"]["hv_max"], 610)
|
||||
# the head's place on record before the start, judged at the stops: X0 Y0 with no offset
|
||||
self.assertEqual(run.evidence["head_at_start"],
|
||||
{"pos": {"x": 0.0, "y": 0.0, "z": 0.0}, "homed_axes": 7, "home_source": "manual",
|
||||
"judged": True, "stops": {"x": 0.0, "y": 0.0}})
|
||||
# the witnesses read from here at every poll: each at its peak
|
||||
seen = run.evidence["emission_seen"]
|
||||
self.assertEqual((seen["laser_on_max"], seen["hv_max"]), (118, 431))
|
||||
self.assertGreater(seen["reads"], 0)
|
||||
fw = run.evidence["focus_window"]
|
||||
self.assertEqual(fw["window"]["below"], 14)
|
||||
self.assertEqual(fw["window"]["above"], 20)
|
||||
self.assertEqual(fw["edge"], "6.96")
|
||||
self.assertEqual(fw["program_z_before"]["reach"], [-1.44, 10.19]) # the default edge
|
||||
self.assertEqual(fw["program_z_after"]["reach"], [2.17, 13.8]) # the edge the burn wrote
|
||||
self.assertEqual(len(fw["program_z_after"]["z"]), 14) # head, twelve lines, tail
|
||||
# the program served after the burn ends in the tail at the edge the burn wrote
|
||||
self.assertEqual(run.evidence["program_tail"]["laser.focus"], ["G0 Z6.96", "G0 X0 Y0", "G92.1", "M2"])
|
||||
self.assertEqual(run.evidence["settings_before"],
|
||||
{k: FOUND[k] for k in ("lens_hall_edge_z_mm", "lens_stop_below_steps",
|
||||
"lens_stop_above_steps")})
|
||||
self.assert_found() # restored
|
||||
self.assert_restarted_on_found()
|
||||
|
||||
def assert_restarted_on_found(self, found=None):
|
||||
"""grblHAL reads the edge and the window only at its start: the
|
||||
hand-back is one stop and one start after the restore, the start
|
||||
reading what was found."""
|
||||
d = self.daemon
|
||||
self.assertEqual(d.lever_posts(), ["/controller/stop", "/controller/start"])
|
||||
self.assertEqual(d.starts, [found or {k: FOUND[k] for k in LENS_KEYS}])
|
||||
self.assertEqual(self.fake.state["mode"]["controller"], "running")
|
||||
self.assertEqual(self.fake.state["mode"]["pid"], 101)
|
||||
|
||||
def test_the_focus_burn_in_the_fallback_window(self):
|
||||
# A head the Lens travel check has not measured: the settings hold
|
||||
# no window, so the ladder runs in the fallback and the burn leaves
|
||||
# the keys unset.
|
||||
s = self.fake.state["settings"]
|
||||
s.pop("lens_stop_below_steps")
|
||||
s.pop("lens_stop_above_steps")
|
||||
run = self.run_test("setup.focus")
|
||||
fw = run.evidence["focus_window"]
|
||||
self.assertEqual((fw["window"]["below"], fw["window"]["above"]), setup_dark.FALLBACK_WINDOW)
|
||||
self.assertNotIn("lens_stop_below_steps", s)
|
||||
self.assertNotIn("lens_stop_above_steps", s)
|
||||
self.assertEqual(s["lens_hall_edge_z_mm"], "4.10")
|
||||
self.assert_restarted_on_found({"lens_hall_edge_z_mm": "4.10", "lens_stop_below_steps": None,
|
||||
"lens_stop_above_steps": None})
|
||||
|
||||
def test_a_program_that_keeps_its_g92_origin_fails_before_the_burn(self):
|
||||
# The tail clears the G92 origin, which the controller would
|
||||
# otherwise carry to its next start under a sender's own offsets.
|
||||
self.daemon.tail = ["G0 X0 Y0", "M2"]
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("not the tail", str(cm.exception))
|
||||
self.assertIn("G92.1", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, [])
|
||||
self.assert_found()
|
||||
|
||||
def test_imperial_units_answer_the_thickness_in_inches(self):
|
||||
# The number prompts read ui_units: on an imperial machine the
|
||||
# thickness is 0.125 in and the record carries 3.175 mm.
|
||||
self.fake.state["settings"]["ui_units"] = "imperial"
|
||||
run = self.run_test("setup.focus")
|
||||
self.assertEqual(self.daemon.answers["laser.focus"][3], "0.125")
|
||||
self.assertEqual(run.evidence["thickness_answer"], "0.125")
|
||||
self.assertEqual(run.evidence["result"]["thickness_mm"], 3.175)
|
||||
|
||||
def test_a_thickness_the_burn_refuses_ends_it_before_anything_fires(self):
|
||||
# 3.2 read as inches is over the 30 mm cap, and the burn asks again:
|
||||
# the test ends the burn rather than answer the same thing forever.
|
||||
self.daemon.units = "imperial"
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("asked thickness again", str(cm.exception))
|
||||
self.assertEqual(self.daemon.answers["laser.focus"], ["Yes", HOME, "Continue", "3.2"])
|
||||
self.assertEqual(self.daemon.aborted, ["laser.focus"])
|
||||
self.assert_found()
|
||||
|
||||
def test_a_prompt_the_test_does_not_answer_ends_the_burn(self):
|
||||
# The motors found released: the burn asks for the head pushed home
|
||||
# by hand, which the bench never needs.
|
||||
SCRIPT["laser.focus"][0].insert(1, ("home-push", "continue", ["Continue"]))
|
||||
try:
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
finally:
|
||||
SCRIPT["laser.focus"][0].pop(1)
|
||||
self.assertIn("asked home-push, which the test does not answer", str(cm.exception))
|
||||
self.assertEqual(self.daemon.aborted, ["laser.focus"])
|
||||
self.assert_found()
|
||||
|
||||
def test_a_dark_burn_fails_and_still_restores(self):
|
||||
self.daemon.patch["laser.focus"] = {"emission": {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0}}
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("the tube current never rose", str(cm.exception))
|
||||
# what the test read itself is on record beside the daemon's word
|
||||
seen = self.run.evidence["emission_seen"]
|
||||
self.assertEqual((seen["laser_on_max"], seen["hv_max"]), (118, 431))
|
||||
self.assert_found()
|
||||
|
||||
def test_a_head_off_its_home_fails_before_any_start(self):
|
||||
# X and Y referenced by a manual home, and the head 12.5 mm from
|
||||
# the stops: nothing starts, no controller lever is touched.
|
||||
self.fake.state["status"]["pos"] = {"x": 12.5, "y": 0.0, "z": 0.0}
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("not at its home", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, [])
|
||||
self.assertEqual(self.daemon.lever_posts(), [])
|
||||
self.assert_found()
|
||||
|
||||
def test_a_manual_home_offset_puts_the_stops_off_x0_y0(self):
|
||||
# A manual home made with manual_home_x 5 and manual_home_y 3
|
||||
# declares the stops X-5 Y-3 (its anchor) and leaves the head at
|
||||
# X0 Y0, in front of them, where the wizard does not draw from. X0
|
||||
# Y0 fails before any start, with no controller lever touched; the
|
||||
# head at the stops is home.
|
||||
self.fake.state["settings"].update({"manual_home_x": "5", "manual_home_y": "3"})
|
||||
self.anchor("-5.000 -3.000 0.000 7 manual")
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("not at its home", str(cm.exception))
|
||||
self.assertEqual(self.run.evidence["head_at_start"]["stops"], {"x": -5.0, "y": -3.0})
|
||||
self.assertEqual(self.daemon.order, [])
|
||||
self.assertEqual(self.daemon.lever_posts(), [])
|
||||
self.assert_found()
|
||||
self.fake.state["status"]["pos"] = {"x": -5.0, "y": -3.0, "z": 0.0}
|
||||
run = self.run_test("setup.focus")
|
||||
self.assertTrue(run.evidence["head_at_start"]["judged"])
|
||||
self.assertEqual(self.daemon.order, ["laser.focus"])
|
||||
# The offsets cleared after that home leave its reference as it is:
|
||||
# the stops are still X-5 Y-3, and the head at X0 Y0 still fails
|
||||
# before any start.
|
||||
for k in ("manual_home_x", "manual_home_y"):
|
||||
self.fake.state["settings"].pop(k)
|
||||
self.fake.state["status"]["pos"] = {"x": 0.0, "y": 0.0, "z": 0.0}
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("not at its home", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, ["laser.focus"])
|
||||
# The offsets set after a home made with none: the head at X0 Y0 is
|
||||
# at the stops that home declared, and the burn runs.
|
||||
self.fake.state["settings"].update({"manual_home_x": "5", "manual_home_y": "3"})
|
||||
self.anchor("0.000 0.000 0.000 7 manual")
|
||||
run = self.run_test("setup.focus")
|
||||
self.assertEqual(run.evidence["head_at_start"]["stops"], {"x": 0.0, "y": 0.0})
|
||||
self.assertEqual(self.daemon.order, ["laser.focus", "laser.focus"])
|
||||
# An anchor that is gone, or that is not the manual home /status
|
||||
# reports, places no stops: nothing starts.
|
||||
os.unlink(baseline.ANCHOR_PATH)
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("home anchor", str(cm.exception))
|
||||
self.anchor("0.000 0.000 0.000 4 startup")
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("home anchor", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, ["laser.focus", "laser.focus"])
|
||||
|
||||
def test_an_unreferenced_head_runs_on_the_operators_word(self):
|
||||
# A fresh controller's counters-only position places nothing: the
|
||||
# read is recorded, not judged, and the burn runs.
|
||||
self.fake.state["status"].update({"homed": False, "homed_axes": 4, "home_source": "startup",
|
||||
"pos": {"x": 12.5, "y": 0.0, "z": 0.0}})
|
||||
run = self.run_test("setup.focus")
|
||||
self.assertEqual(run.evidence["head_at_start"]["judged"], False)
|
||||
self.assertEqual(run.evidence["head_at_start"]["home_source"], "startup")
|
||||
self.assertEqual(self.daemon.order, ["laser.focus"])
|
||||
|
||||
def test_a_burn_the_test_itself_never_saw_fails_and_still_restores(self):
|
||||
# The burn's result reports a lit burn, and the test's own reads
|
||||
# of the two witnesses never leave zero: the burn is judged on
|
||||
# what was read from here, not on the daemon's word alone.
|
||||
self.plant("cnc/laser_on_sampled", "0")
|
||||
self.plant("pic/hv_current", "0")
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("LASER_ON count never read nonzero", str(cm.exception))
|
||||
self.assert_found()
|
||||
self.assert_restarted_on_found()
|
||||
|
||||
def test_a_burn_whose_tube_current_the_test_never_saw_rise_fails(self):
|
||||
# The LASER_ON count read nonzero from here, and the tube current
|
||||
# never read over HV_ON: the second witness is judged on its own
|
||||
# reads, against the daemon's own threshold for a lit burn.
|
||||
self.plant("pic/hv_current", str(setup_burns.HV_ON))
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("tube current never read over %d from here" % setup_burns.HV_ON, str(cm.exception))
|
||||
self.assert_found()
|
||||
|
||||
def test_a_burn_with_a_silent_beam_sensor_passes(self):
|
||||
# A head whose beam sensor gives no output: the tube current and
|
||||
# LASER_ON saw the beam, so the burn counts.
|
||||
self.daemon.patch["laser.focus"] = {"emission": dict(EMISSION, thermopile_delta=0,
|
||||
beam_sensor_seen=False)}
|
||||
self.run_test("setup.focus")
|
||||
self.assert_found()
|
||||
|
||||
def test_a_burn_that_does_not_record_the_beam_sensor_fails(self):
|
||||
self.daemon.patch["laser.focus"] = {"emission": {"hv_max": 610, "laser_on_samples": 900}}
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("the result does not record the beam sensor", str(cm.exception))
|
||||
self.assert_found()
|
||||
|
||||
def test_a_focus_burn_that_writes_the_window_fails(self):
|
||||
# The focus burn writes the edge alone: the window is the Lens
|
||||
# travel check's. A burn that moves it fails, and it goes back.
|
||||
self.daemon.start_writes["laser.focus"] = {"lens_stop_below_steps": "16", "lens_stop_above_steps": "22"}
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("the focus burn wrote the lens window", str(cm.exception))
|
||||
self.assert_found()
|
||||
self.assert_restarted_on_found() # a failed run hands back the controller too
|
||||
|
||||
def test_an_aborted_focus_burn_still_restarts_the_controller_on_the_settings_as_found(self):
|
||||
# The run aborted at the arm press: the burn is ended, the settings
|
||||
# go back, and the controller starts again on them.
|
||||
t = catalog.load_suite()["setup.focus"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True
|
||||
real = self.daemon.on_get
|
||||
|
||||
def on_get(path, q):
|
||||
p = self.daemon.dark.get("prompt")
|
||||
if path == "/wiz/dark" and p and p["id"] == "press":
|
||||
run.aborted.set()
|
||||
return real(path, q)
|
||||
self.fake.on_get = on_get
|
||||
with self.assertRaises(Aborted):
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertEqual(self.daemon.aborted, ["laser.focus"])
|
||||
self.assertIn("emission_seen", run.evidence) # the test's own reads, on record all the same
|
||||
self.assert_found()
|
||||
self.assert_restarted_on_found()
|
||||
|
||||
def test_a_focus_ladder_outside_the_window_held_fails(self):
|
||||
self.daemon.patch["laser.focus"] = {"window": {"below": 10, "above": 12, "reach_min": -0.07,
|
||||
"reach_max": 7.46}}
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("the ladder ran in 10/12", str(cm.exception))
|
||||
self.assert_found()
|
||||
|
||||
def test_a_focus_burn_that_reports_a_stop_search_fails(self):
|
||||
self.daemon.patch["laser.focus"] = {"stops": {"found": True, "below": 14, "above": 20}}
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("searched for the stops", str(cm.exception))
|
||||
self.assert_found()
|
||||
|
||||
def test_a_program_z_beyond_the_reach_fails_before_the_burn(self):
|
||||
# A served program that commands a Z the lens does not reach is
|
||||
# refused before the burn starts: nothing burns.
|
||||
self.daemon.stray_z = 12.6
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("outside the lens window", str(cm.exception))
|
||||
self.assertIn("12.6", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, [])
|
||||
self.assert_found()
|
||||
|
||||
def test_the_result_keys_the_tests_require(self):
|
||||
# every key the test reads is one the scripted burn reports
|
||||
self.assertTrue(set(setup_burns.FOCUS_KEYS)
|
||||
<= set(SCRIPT["laser.focus"][1]) | {"thickness_mm", "window", "ladder_z"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,11 +3,13 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The setup's checks in the catalog: registered as automatic tests with
|
||||
no precheck, the takeover ones marked so, every covers map naming the
|
||||
check runner; and the check driver itself against the fake daemon: a
|
||||
start, the run polled to its end with no question asked, the result
|
||||
taken, the record read at version 1, and the settings read as before."""
|
||||
"""The setup's checks in the catalog: registered as automatic tests, the
|
||||
takeover ones marked so, every covers map naming the check runner, and
|
||||
the one that puts the intake fan count back as found refused unless it
|
||||
is set; and the check driver itself against the fake daemon: a start, the
|
||||
run polled to its end with no question asked, the result taken, the
|
||||
record read at version 1, the settings read as before or put back as
|
||||
found, and the controller started again on a lens window put back."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
@@ -17,8 +19,10 @@ import helpers # noqa: E402
|
||||
from forgetest import catalog # noqa: E402
|
||||
from forgetest.runner import Context, Run # noqa: E402
|
||||
|
||||
IDS = ["setup.check-switches", "setup.check-sensors", "setup.check-airflow",
|
||||
"setup.check-cameras", "setup.check-motion", "setup.check-flow-verify"]
|
||||
IDS = ["setup.check-switches", "setup.check-airflow", "setup.check-motion", "setup.check-lens"]
|
||||
# The check that puts the intake fan count back as found: it starts only
|
||||
# with it set, so a put-back never leaves the gate closed behind it.
|
||||
COUNTED = ("setup.check-airflow",)
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
@@ -30,21 +34,43 @@ class RegistrationTests(unittest.TestCase):
|
||||
# bench fixture performs; the rest need no hands at all.
|
||||
for tid in IDS:
|
||||
self.assertIn(tid, self.reg)
|
||||
for tid in ("setup.check-switches", "setup.check-cameras"):
|
||||
self.assertEqual(self.reg[tid].kind, "operator", tid)
|
||||
self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid)
|
||||
for tid in ("setup.check-sensors", "setup.check-airflow", "setup.check-motion",
|
||||
"setup.check-flow-verify"):
|
||||
self.assertEqual(self.reg["setup.check-switches"].kind, "operator")
|
||||
self.assertTrue(self.reg["setup.check-switches"].fixture_runnable(("button", "lid", "interlock")))
|
||||
for tid in ("setup.check-airflow", "setup.check-motion", "setup.check-lens"):
|
||||
self.assertEqual(self.reg[tid].kind, "auto", tid)
|
||||
|
||||
def test_no_check_asks_the_operator_to_prepare_anything(self):
|
||||
for tid in IDS:
|
||||
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
|
||||
if tid not in COUNTED:
|
||||
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
|
||||
|
||||
def test_the_intake_count_must_be_set_before_a_check_puts_it_back(self):
|
||||
# The refusal says why the test cannot start; it never names a
|
||||
# setting for the operator to change.
|
||||
fake = helpers.FakeForgectrl().start()
|
||||
try:
|
||||
for tid in COUNTED:
|
||||
fake.state["settings"].pop("cool_intake_fans", None)
|
||||
why = self.reg[tid].cannot_start()
|
||||
self.assertIn("the intake fans are not counted", why, tid)
|
||||
self.assertIn("the setup gate holds every controller", why, tid)
|
||||
self.assertNotIn("cool_intake_fans", why, tid)
|
||||
self.assertNotIn("Intake fans step", why, tid)
|
||||
fake.state["settings"]["cool_intake_fans"] = "2"
|
||||
self.assertIsNone(self.reg[tid].cannot_start(), tid)
|
||||
finally:
|
||||
fake.stop()
|
||||
|
||||
def test_the_checks_that_take_the_machine_say_so(self):
|
||||
for tid in ("setup.check-airflow", "setup.check-motion", "setup.check-flow-verify"):
|
||||
for tid in ("setup.check-airflow", "setup.check-motion", "setup.check-lens"):
|
||||
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||
|
||||
def test_the_lens_check_covers_the_finder_and_the_frame(self):
|
||||
covers = set(self.reg["setup.check-lens"].covers)
|
||||
for want in (("forgectrl", "src/lensfind.*"), ("forgectrl", "src/lens.*")):
|
||||
self.assertIn(want, covers)
|
||||
self.assertIn("setup.check-motion", self.reg["setup.check-lens"].requires)
|
||||
|
||||
def test_every_check_covers_the_runner_and_the_page(self):
|
||||
for tid in IDS:
|
||||
covers = set(self.reg[tid].covers)
|
||||
@@ -52,32 +78,69 @@ class RegistrationTests(unittest.TestCase):
|
||||
self.assertIn(("forgectrl", "src/ui/wizard.*"), covers, tid)
|
||||
|
||||
|
||||
class SensorsCheckTests(unittest.TestCase):
|
||||
"""The check driver against a scripted daemon."""
|
||||
class AirflowCheckTests(unittest.TestCase):
|
||||
"""setup.check-airflow's judge against a scripted daemon: the check
|
||||
writes its floors, and the intake fan count is the machine's. Intake
|
||||
2 turning proves two fans and the check writes the count as 2; intake
|
||||
2 silent on a machine counted with one is reported not fitted and not
|
||||
turning, and the count is not written; intake 2 silent on a machine
|
||||
counted with two fails the check. The bench reference has two, both
|
||||
turning."""
|
||||
|
||||
RESULT = {"coolant_down_c": 22.4, "coolant_up_c": 21.9, "chassis_c": 28.0, "soc_c": 45.2,
|
||||
"lid_ir_max": [110, 120, 105, 118], "accel_events": 0, "laser_pgood": 1,
|
||||
"hv_current_max": 0, "exhaust_rpm_idle": 3400, "intake_rpm_idle": 2200}
|
||||
FLOORS = {"cool_tach_exhaust_min_rpm": "6400", "cool_tach_intake_min_rpm": "2290",
|
||||
"cool_tach_air_assist_min_rpm": "6050", "cool_purge_min_current": "345",
|
||||
"cool_fan_grace_s": "12"}
|
||||
FAN = {"steady_rpm": 4150, "spinup_s": 6.8, "ok": True}
|
||||
|
||||
def result(self, silent2):
|
||||
fans = {"exhaust": dict(self.FAN), "intake 1": dict(self.FAN), "intake 2": dict(self.FAN),
|
||||
"air assist": dict(self.FAN)}
|
||||
if silent2:
|
||||
fans["intake 2"] = {"fitted": False, "turning": False}
|
||||
return {"fans": fans, "intake_fans": 1 if silent2 else 2, "purge_current_on": 628,
|
||||
"purge_current_off": 74, "floors": dict(self.FLOORS)}
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.sysfs = tempfile.mkdtemp(prefix="forgetest-airflow-")
|
||||
os.makedirs(os.path.join(self.sysfs, "head"))
|
||||
with open(os.path.join(self.sysfs, "head", "purge_air"), "w") as f:
|
||||
f.write("1\n")
|
||||
self.saved_root = os.environ.get("GF_SYSFS_ROOT")
|
||||
os.environ["GF_SYSFS_ROOT"] = self.sysfs + "/"
|
||||
self.fake = helpers.FakeForgectrl().start()
|
||||
self.fake.state["settings"].update({"cool_temp_offset_c": "0.5"})
|
||||
# the bench reference: two intake fans, counted
|
||||
self.fake.state["settings"].update({"cool_tach_intake_min_rpm": "2300", "cool_intake_fans": "2"})
|
||||
self.fake.state["cool"] = {"phase": "idle", "fan_gates": {"purge": {"reading": 628, "floor": 345}}}
|
||||
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
|
||||
"log": [], "prompt": None, "result": None, "error": "",
|
||||
"shots": {"lid": False, "head": False}}
|
||||
self.versions = {}
|
||||
self.answers = []
|
||||
self.polls = 0
|
||||
self.silent2 = False # intake 2 gives no tach pulse
|
||||
self.wizard_result = None # the result the check ends with; None = the daemon's own
|
||||
self.wizard_writes = None # the count the check writes; None = the daemon's own rule
|
||||
|
||||
def on_get(path, q):
|
||||
if path == "/wiz/dark":
|
||||
# One poll sees the sampling; the next sees the result.
|
||||
if self.dark["running"]:
|
||||
self.polls += 1
|
||||
if self.polls >= 2:
|
||||
self.dark.update({"running": False, "prompt": None, "progress": 100,
|
||||
"result": dict(self.RESULT)})
|
||||
self.versions["sensors"] = 1
|
||||
s = self.fake.state["settings"]
|
||||
if self.silent2 and s.get("cool_intake_fans") == "2":
|
||||
# a silent intake 2 on a machine counted with two
|
||||
self.dark.update({"running": False, "progress": 100, "result": None,
|
||||
"error": "a fan did not come up to speed at the run "
|
||||
"profile: see the log"})
|
||||
return 200, dict(self.dark)
|
||||
r = self.wizard_result or self.result(self.silent2)
|
||||
s.update(r["floors"])
|
||||
if self.wizard_writes:
|
||||
s["cool_intake_fans"] = self.wizard_writes
|
||||
elif not self.silent2:
|
||||
s["cool_intake_fans"] = "2" # intake 2 turned: two fans
|
||||
self.dark.update({"running": False, "progress": 100, "result": r})
|
||||
self.versions["airflow"] = 1
|
||||
return 200, dict(self.dark)
|
||||
if path == "/wiz":
|
||||
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
|
||||
@@ -85,13 +148,9 @@ class SensorsCheckTests(unittest.TestCase):
|
||||
|
||||
def on_post(path, form):
|
||||
s = self.fake.state["settings"]
|
||||
if path == "/wiz/sensors/start":
|
||||
self.dark.update({"id": "sensors", "running": True, "phase": "sampling",
|
||||
"prompt": None})
|
||||
return 200, {"started": True, "id": "sensors"}
|
||||
if path == "/wiz/sensors/answer":
|
||||
self.answers.append(dict(form))
|
||||
return 409, {"error": "no such prompt is open"}
|
||||
if path == "/wiz/airflow/start":
|
||||
self.dark.update({"id": "airflow", "running": True, "phase": "fans to the run profile"})
|
||||
return 200, {"started": True, "id": "airflow"}
|
||||
if path == "/settings":
|
||||
for k, v in form.items():
|
||||
if v == "":
|
||||
@@ -104,37 +163,291 @@ class SensorsCheckTests(unittest.TestCase):
|
||||
self.fake.on_post = on_post
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
self.fake.stop()
|
||||
if self.saved_root is None:
|
||||
os.environ.pop("GF_SYSFS_ROOT", None)
|
||||
else:
|
||||
os.environ["GF_SYSFS_ROOT"] = self.saved_root
|
||||
shutil.rmtree(self.sysfs, ignore_errors=True)
|
||||
|
||||
def test_start_no_question_result_and_settings_untouched(self):
|
||||
t = catalog.load_suite()["setup.check-sensors"]
|
||||
def run_check(self):
|
||||
t = catalog.load_suite()["setup.check-airflow"]
|
||||
run = Run("test", t.id, t.title)
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertIn(("/wiz/sensors/start", {}), [(p, f) for p, f in self.fake.posts])
|
||||
self.assertEqual(self.answers, [])
|
||||
self.assertEqual(run.evidence["result"]["laser_pgood"], 1)
|
||||
self.assertEqual(run.evidence["settings_before"].get("cool_temp_offset_c"), "0.5")
|
||||
self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5")
|
||||
self.assertNotIn("/settings", [p for p, _ in self.fake.posts])
|
||||
return run
|
||||
|
||||
def test_a_question_fails_the_check(self):
|
||||
t = catalog.load_suite()["setup.check-sensors"]
|
||||
run = Run("test", t.id, t.title)
|
||||
real_get = self.fake.on_get
|
||||
def count(self, n):
|
||||
self.fake.state["settings"]["cool_intake_fans"] = n
|
||||
|
||||
def assert_put_back(self, count="2"):
|
||||
s = self.fake.state["settings"]
|
||||
self.assertEqual(s.get("cool_tach_intake_min_rpm"), "2300")
|
||||
self.assertEqual(s.get("cool_intake_fans"), count)
|
||||
self.assertNotIn("cool_tach_exhaust_min_rpm", s)
|
||||
|
||||
def test_two_intake_fans_pass_and_are_put_back(self):
|
||||
# the bench path: both intakes turn, the count is written as 2
|
||||
run = self.run_check()
|
||||
self.assertEqual(run.evidence["intakes"], {"counted": "2", "found": 2, "intake_2_silent": False})
|
||||
self.assert_put_back()
|
||||
|
||||
def test_a_machine_counted_with_one_whose_intake_2_turns_is_counted_two_and_put_back(self):
|
||||
# intake 2 turning proves two fans: the check writes 2, and the
|
||||
# count goes back as found
|
||||
self.count("1")
|
||||
self.run_check()
|
||||
self.assert_put_back("1")
|
||||
self.assertIn(("/settings", {"cool_intake_fans": "1"}), self.fake.posts)
|
||||
|
||||
def test_one_intake_fan_counted_one_passes_and_the_count_is_not_written(self):
|
||||
self.count("1")
|
||||
self.silent2 = True
|
||||
run = self.run_check()
|
||||
self.assertEqual(run.evidence["intakes"], {"counted": "1", "found": 1, "intake_2_silent": True})
|
||||
self.assert_put_back("1")
|
||||
|
||||
def test_a_silent_intake_2_on_a_machine_counted_with_two_fails_the_check(self):
|
||||
self.silent2 = True
|
||||
with self.assertRaises(Exception) as cm:
|
||||
self.run_check()
|
||||
self.assertIn("a fan did not come up to speed at the run profile", str(cm.exception))
|
||||
self.assert_put_back()
|
||||
|
||||
def test_a_silent_intake_2_whose_count_was_written_fails(self):
|
||||
# a silent intake 2 never writes the count
|
||||
self.count("1")
|
||||
self.silent2 = True
|
||||
self.wizard_writes = "2"
|
||||
with self.assertRaises(Exception) as cm:
|
||||
self.run_check()
|
||||
self.assertIn("cool_intake_fans reads '2', expected '1' (intake 2 silent", str(cm.exception))
|
||||
self.assert_put_back("1")
|
||||
|
||||
def test_a_turning_intake_2_counted_one_fails(self):
|
||||
r = self.result(False)
|
||||
r["intake_fans"] = 1
|
||||
self.wizard_result = r
|
||||
with self.assertRaises(Exception) as cm:
|
||||
self.run_check()
|
||||
self.assertIn("intake 2 turned, and the check found 1 intake fans", str(cm.exception))
|
||||
self.assert_put_back()
|
||||
|
||||
def test_a_silent_intake_2_reported_fitted_fails(self):
|
||||
self.count("1")
|
||||
self.silent2 = True
|
||||
r = self.result(True)
|
||||
r["fans"]["intake 2"] = {"fitted": True, "turning": False}
|
||||
self.wizard_result = r
|
||||
with self.assertRaises(Exception) as cm:
|
||||
self.run_check()
|
||||
self.assertIn("intake 2 with one intake fan", str(cm.exception))
|
||||
self.assert_put_back("1")
|
||||
|
||||
def test_a_result_without_the_count_fails(self):
|
||||
r = self.result(False)
|
||||
del r["intake_fans"]
|
||||
self.wizard_result = r
|
||||
with self.assertRaises(Exception) as cm:
|
||||
self.run_check()
|
||||
self.assertIn("no intake fan count", str(cm.exception))
|
||||
self.assert_put_back()
|
||||
|
||||
|
||||
|
||||
class ScriptedCheck(unittest.TestCase):
|
||||
"""A dark check against the fake daemon: the start, one poll that sees
|
||||
the run, the next that sees its end (`finish` writes the settings and
|
||||
returns the result), the record at version 1, /status's lens block
|
||||
read from the settings as the daemon reads them, and the supervisor's
|
||||
stop and start levers (a start records the settings the new controller
|
||||
reads)."""
|
||||
|
||||
WID = ""
|
||||
LENS_KEYS = ("lens_stop_below_steps", "lens_stop_above_steps", "lens_hall_edge_z_mm")
|
||||
|
||||
def setUp(self):
|
||||
self.fake = helpers.FakeForgectrl().start()
|
||||
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
|
||||
"log": [], "prompt": None, "result": None, "error": "",
|
||||
"shots": {"lid": False, "head": False}}
|
||||
self.versions = {}
|
||||
self.polls = 0
|
||||
self.prompt = None # a prompt the run opens on its first poll, when set
|
||||
self.answers = []
|
||||
self.starts = [] # the lens settings each controller start read
|
||||
|
||||
def on_get(path, q):
|
||||
if path == "/wiz/dark" and self.dark["running"]:
|
||||
d = dict(self.dark)
|
||||
d["prompt"] = {"seq": 3, "id": "room-temp", "kind": "number", "text": "?",
|
||||
"options": ["Set", "Skip"]}
|
||||
return 200, d
|
||||
return real_get(path, q)
|
||||
s = self.fake.state["settings"]
|
||||
if path == "/wiz/dark":
|
||||
if self.dark["running"]:
|
||||
self.polls += 1
|
||||
if self.polls == 1 and self.prompt:
|
||||
self.dark["prompt"] = dict(self.prompt, seq=1)
|
||||
elif self.polls >= 2 and not self.dark["prompt"]:
|
||||
self.dark.update({"running": False, "progress": 100, "result": self.finish(s)})
|
||||
self.versions[self.WID] = 1
|
||||
return 200, dict(self.dark)
|
||||
if path == "/wiz":
|
||||
return 200, {"versions": dict(self.versions), "recommended": [], "completed": True}
|
||||
if path == "/status":
|
||||
def count(key, fallback):
|
||||
v = s.get(key)
|
||||
return int(v) if v and v.isdigit() and 1 <= int(v) <= 40 else fallback
|
||||
lens = {"below": count("lens_stop_below_steps", 10), "above": count("lens_stop_above_steps", 12)}
|
||||
return 200, dict(self.fake.state["status"], lens=lens)
|
||||
return None
|
||||
|
||||
def on_post(path, form):
|
||||
s = self.fake.state["settings"]
|
||||
if path == "/wiz/%s/start" % self.WID:
|
||||
self.dark.update({"id": self.WID, "running": True, "phase": "running", "prompt": None})
|
||||
return 200, {"started": True, "id": self.WID}
|
||||
if path == "/wiz/%s/answer" % self.WID:
|
||||
self.answers.append(dict(form))
|
||||
self.dark["prompt"] = None
|
||||
return 200, {"ok": True}
|
||||
if path == "/wiz/%s/abort" % self.WID:
|
||||
self.dark.update({"running": False, "prompt": None, "error": "aborted"})
|
||||
return 200, {"ok": True}
|
||||
if path == "/settings":
|
||||
for k, v in form.items():
|
||||
if v == "":
|
||||
s.pop(k, None)
|
||||
else:
|
||||
s[k] = v
|
||||
return 200, dict(s)
|
||||
if path == "/controller/stop":
|
||||
self.fake.state["mode"] = dict(self.fake.state["mode"], controller="standby")
|
||||
return 200, {"stopped": True}
|
||||
if path == "/controller/start":
|
||||
m = self.fake.state["mode"]
|
||||
self.fake.state["mode"] = dict(m, controller="running", motion="verified", pid=m["pid"] + 1)
|
||||
self.starts.append({k: s.get(k) for k in self.LENS_KEYS})
|
||||
return 200, {"started": True}
|
||||
return None
|
||||
self.fake.on_get = on_get
|
||||
self.fake.on_post = on_post
|
||||
|
||||
def tearDown(self):
|
||||
self.fake.stop()
|
||||
|
||||
def lever_posts(self):
|
||||
return [p for p, _ in self.fake.posts if p.startswith("/controller/")]
|
||||
|
||||
def run_check(self, tid):
|
||||
t = catalog.load_suite()[tid]
|
||||
run = Run("test", t.id, t.title)
|
||||
t.fn(Context(run, None, t))
|
||||
return run
|
||||
|
||||
|
||||
class LensCheckTests(ScriptedCheck):
|
||||
"""setup.check-lens's judge: the window is the stops found or the
|
||||
fallback, the settings and /status hold it, and both keys go back as
|
||||
found."""
|
||||
|
||||
WID = "lens.travel"
|
||||
FOUND = {"lens_stop_below_steps": "12", "lens_stop_above_steps": "18"}
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.fake.state["settings"].update(self.FOUND)
|
||||
self.stops = {"found": True, "below": 14, "above": 20, "contact_below": 16, "contact_above": 22, "why": ""}
|
||||
self.writes = None # what the check writes; None = the window of its result
|
||||
|
||||
def finish(self, s):
|
||||
st = self.stops
|
||||
below, above = (st["below"], st["above"]) if st["found"] else (10, 12)
|
||||
w = self.writes or (below, above)
|
||||
s.update({"lens_stop_below_steps": str(w[0]), "lens_stop_above_steps": str(w[1])})
|
||||
return {"z_passes": [100, 101], "stops": dict(st),
|
||||
"window": {"below": below, "above": above, "found": st["found"]}, "summary": "x"}
|
||||
|
||||
def assert_put_back(self):
|
||||
"""The window as found, in the settings and in the controller:
|
||||
grblHAL reads it only at its start, so the hand-back is one stop
|
||||
and one start after the put-back, the start reading the window
|
||||
as found."""
|
||||
s = self.fake.state["settings"]
|
||||
for k, v in self.FOUND.items():
|
||||
self.assertEqual(s.get(k), v, k)
|
||||
self.assertEqual(self.lever_posts(), ["/controller/stop", "/controller/start"])
|
||||
self.assertEqual([{k: st[k] for k in self.FOUND} for st in self.starts], [self.FOUND])
|
||||
self.assertEqual(self.fake.state["mode"]["controller"], "running")
|
||||
self.assertEqual(self.fake.state["mode"]["pid"], 101)
|
||||
|
||||
def test_the_stops_found_are_the_window(self):
|
||||
run = self.run_check("setup.check-lens")
|
||||
self.assertEqual(run.evidence["window"]["settings"], ("14", "20"))
|
||||
self.assertEqual(run.evidence["window"]["status"]["below"], 14)
|
||||
self.assertEqual(run.evidence["result"]["stops"]["contact_above"], 22)
|
||||
self.assertEqual(run.evidence["restart"], {"mode": "grbl", "pid": 100, "stop": 200, "start": 200})
|
||||
self.assert_put_back()
|
||||
|
||||
def test_stops_not_found_write_the_fallback(self):
|
||||
self.stops = {"found": False, "below": 0, "above": 0, "contact_below": 0, "contact_above": 0,
|
||||
"why": "the lens steps do not ring clearly enough on this head"}
|
||||
run = self.run_check("setup.check-lens")
|
||||
self.assertEqual(run.evidence["window"]["settings"], ("10", "12"))
|
||||
self.assert_put_back()
|
||||
|
||||
def test_a_window_the_settings_do_not_hold_fails_and_still_restores(self):
|
||||
self.writes = (12, 18)
|
||||
with self.assertRaises(Exception) as cm:
|
||||
t.fn(Context(run, None, t))
|
||||
self.run_check("setup.check-lens")
|
||||
self.assertIn("the settings hold", str(cm.exception))
|
||||
self.assert_put_back()
|
||||
|
||||
def test_a_question_fails_the_check(self):
|
||||
self.prompt = {"id": "lens-stop", "kind": "continue", "text": "?", "options": ["Continue"]}
|
||||
with self.assertRaises(Exception) as cm:
|
||||
self.run_check("setup.check-lens")
|
||||
self.assertIn("asked a question", str(cm.exception))
|
||||
self.assertEqual(self.answers, [])
|
||||
self.assertIn(("/wiz/sensors/abort", {}), [(p, f) for p, f in self.fake.posts])
|
||||
self.assertIn(("/wiz/lens.travel/abort", {}), self.fake.posts)
|
||||
self.assert_put_back()
|
||||
|
||||
def test_a_check_that_does_not_end_in_time_is_aborted_before_the_test_fails(self):
|
||||
# The run never ends: the test stops following it at its timeout,
|
||||
# aborts it, and fails, so no check runs on behind the FAIL.
|
||||
from forgetest.runner import Failed
|
||||
from forgetest.suite import setup_dark
|
||||
real = self.fake.on_get
|
||||
|
||||
def on_get(path, q):
|
||||
if path == "/wiz/dark":
|
||||
return 200, dict(self.dark) # running until aborted
|
||||
return real(path, q)
|
||||
self.fake.on_get = on_get
|
||||
t = catalog.load_suite()["setup.check-lens"]
|
||||
ctx = Context(Run("test", t.id, t.title), None, t)
|
||||
with self.assertRaises(Failed) as cm:
|
||||
setup_dark.run_check(ctx, self.WID, lambda p: None, 0.2)
|
||||
self.assertIn("did not end within", str(cm.exception))
|
||||
self.assertIn(("/wiz/lens.travel/abort", {}), self.fake.posts)
|
||||
self.assertFalse(self.dark["running"])
|
||||
|
||||
def test_a_controller_that_does_not_restart_fails_the_hand_back(self):
|
||||
# The start refused: the controller stays stopped on the stale
|
||||
# window's heels, and the run says so rather than pass.
|
||||
real = self.fake.on_post
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/controller/start":
|
||||
return 500, {"error": "no"}
|
||||
return real(path, form)
|
||||
self.fake.on_post = on_post
|
||||
with self.assertRaises(Exception) as cm:
|
||||
self.run_check("setup.check-lens")
|
||||
self.assertIn("the controller was not restarted on the window as found", str(cm.exception))
|
||||
|
||||
def test_cloud_mode_restarts_nothing(self):
|
||||
# The cloud client reads no lens window: nothing to restart.
|
||||
self.fake.state["mode"] = dict(self.fake.state["mode"], mode="cloud")
|
||||
run = self.run_check("setup.check-lens")
|
||||
self.assertEqual(self.lever_posts(), [])
|
||||
self.assertEqual(run.evidence["restart"], {"mode": "cloud", "pid": 100})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The setup's sheet in the catalog: one live test on the button with a
|
||||
piece of wood at hand, its covers map naming the live runner, the
|
||||
renderer, the sender, the page, and the driver's laser module; and the
|
||||
whole run against a scripted daemon: the placement's jogs and origin,
|
||||
then each card's preview and program read, its start, the arm prompt and
|
||||
the pick answered, the witnesses judged, the record read at version 1,
|
||||
every card's evidence filed under its own key, and the settings the cards
|
||||
wrote put back as found at the end."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import helpers # noqa: E402
|
||||
from forgetest import catalog # noqa: E402
|
||||
from forgetest.runner import Context, Run # noqa: E402
|
||||
from forgetest.suite import setup_sheet # noqa: E402
|
||||
|
||||
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
|
||||
# The focus card's program is read again after it ran: the served ladder
|
||||
# must then lie in the window the card wrote.
|
||||
PREVIEWS = ["sheet.frame", "laser.focus", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner",
|
||||
"cooling.flow-load"]
|
||||
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
|
||||
SPM = 2.922 # the lens screw, half-steps per mm ($102)
|
||||
EDGE_DEFAULT = 3.35
|
||||
|
||||
|
||||
def lens_block(s):
|
||||
"""The /status lens block as the daemon builds it from the settings: a
|
||||
stop count from 1 to 40 stands, anything else is the fallback."""
|
||||
def count(key, fallback):
|
||||
try:
|
||||
n = float(s.get(key, ""))
|
||||
except ValueError:
|
||||
return fallback, False
|
||||
return (int(n), True) if 1 <= n <= 40 else (fallback, False)
|
||||
try:
|
||||
edge = float(s.get("lens_hall_edge_z_mm", ""))
|
||||
except ValueError:
|
||||
edge = EDGE_DEFAULT
|
||||
below, a = count("lens_stop_below_steps", 10)
|
||||
above, b = count("lens_stop_above_steps", 12)
|
||||
return {"edge_z": round(edge, 2), "below": below, "above": above, "stops_found": a and b,
|
||||
"reach_min": round(edge - below / SPM, 2), "reach_max": round(edge + above / SPM, 2)}
|
||||
|
||||
|
||||
def ladder_z(edge, below, above):
|
||||
"""The focus ladder the daemon builds: twelve whole half-steps from
|
||||
the top of the window to the bottom."""
|
||||
return [round(edge + round(above - (above + below) * i / 11) / SPM, 2) for i in range(12)]
|
||||
|
||||
|
||||
# The window the focus card finds and writes before its controller starts.
|
||||
STOPS = {"lens_stop_below_steps": "14", "lens_stop_above_steps": "20"}
|
||||
FOCUS_WINDOW = {"below": 14, "above": 20, "reach_min": round(EDGE_DEFAULT - 14 / SPM, 2),
|
||||
"reach_max": round(EDGE_DEFAULT + 20 / SPM, 2)}
|
||||
|
||||
# Each wizard as the daemon runs it: its prompts in order (id, kind,
|
||||
# options) and the result it ends with. A "jog" prompt repeats until the
|
||||
# origin is set.
|
||||
SCRIPT = {
|
||||
"sheet.place": ([("place", "jog", ["X-10", "X+10", "Set origin"]),
|
||||
("sheet-kind", "choice", ["Full sheet", "One card"]),
|
||||
("thickness", "number", ["Set", "Skip"])],
|
||||
{"origin_x": 100, "origin_y": 200, "origin_z": 0, "steps_per_mm": {"x": 40.0, "y": 40.0},
|
||||
"z_referenced": True, "thickness_mm": 3.2, "alone": False}),
|
||||
"sheet.frame": ([("frame-arm", "continue", ["Continue"]), ("frame-ok", "choice", ["Yes", "No", "Again"])],
|
||||
{"mark_s": 400, "mark_feed": 3000, "emission": EMISSION}),
|
||||
"laser.focus": ([("focus-arm", "continue", ["Continue"]),
|
||||
("focus-pick", "multichoice", [str(i) for i in range(1, 13)]),
|
||||
("thickness", "number", ["Keep", "Set"])],
|
||||
{"pick": 11, "thickness_mm": 3.2, "pick_half_steps": -11, "edge_z_mm": 6.96,
|
||||
"steps_per_mm": 2.922, "max_height_mm": 13.81,
|
||||
"focus_range_mm": {"min": 2.17, "max": 13.81},
|
||||
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
|
||||
"contact_above": 22, "why": ""},
|
||||
"window": dict(FOCUS_WINDOW), "ladder_z": ladder_z(EDGE_DEFAULT, 14, 20),
|
||||
"emission": EMISSION}),
|
||||
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
|
||||
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
|
||||
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
|
||||
"laser.dose-curve": ([("dose-arm", "continue", ["Continue"])],
|
||||
{"points": [[10, 0.44], [100, 100]], "curve": "10:0.44,100:100", "emission": EMISSION}),
|
||||
"laser.corner": ([("corner-arm", "continue", ["Continue"]),
|
||||
("corner-pick", "choice", ["1.00", "1.25", "1.50", "1.75", "2.00"])],
|
||||
{"gamma": 1.5, "emission": EMISSION}),
|
||||
"cooling.flow-load": ([("load-arm", "continue", ["Continue"])],
|
||||
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
|
||||
"k_cw": 2.78e-5, "emission": EMISSION}),
|
||||
}
|
||||
# What a card writes when it starts (the focus card's window, before its
|
||||
# controller starts) and when it ends.
|
||||
START_WRITES = {"laser.focus": dict(STOPS)}
|
||||
WRITES = {
|
||||
"laser.focus": {"lens_hall_edge_z_mm": "6.96"},
|
||||
"laser.floor": {"laser_floor_density": "10"},
|
||||
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
|
||||
"laser.corner": {"laser_corner_gamma": "1.5"},
|
||||
"cooling.flow-load": {"cool_laser_heat_density": "2.14e-5", "cool_laser_heat_cw": "2.78e-5"},
|
||||
}
|
||||
FOUND = {"lens_hall_edge_z_mm": "3.35", "lens_stop_below_steps": "10", "lens_stop_above_steps": "12",
|
||||
"laser_floor_density": "12",
|
||||
"laser_dose_curve": "off", "laser_corner_gamma": "2", "cool_laser_heat_density": "1e-5",
|
||||
"cool_laser_heat_cw": "1e-5"}
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.t = catalog.load_suite()["setup.sheet"]
|
||||
|
||||
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
|
||||
t = self.t
|
||||
self.assertEqual(t.kind, "live")
|
||||
self.assertEqual(t.mode, "grbl")
|
||||
self.assertEqual(t.hardware, "takeover")
|
||||
self.assertIn("button", t.actions)
|
||||
self.assertTrue(t.hands)
|
||||
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
|
||||
self.assertIsNone(getattr(t, "precheck", None))
|
||||
for want in ("setup.check-motion", "laser.emission-witness", "cooling.flow-verify"):
|
||||
self.assertIn(want, t.requires)
|
||||
|
||||
def test_no_other_sheet_test_remains(self):
|
||||
ids = [i for i in catalog.load_suite() if i.startswith("setup.sheet")]
|
||||
self.assertEqual(ids, ["setup.sheet"])
|
||||
|
||||
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
|
||||
covers = set(self.t.covers)
|
||||
for want in (("forgectrl", "src/wizlive.*"), ("forgectrl", "src/sheet.*"), ("forgectrl", "src/jobstream.*"),
|
||||
("forgectrl", "src/curverec.*"), ("forgectrl", "src/font_hershey.*"),
|
||||
("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/wizrun.h"),
|
||||
("grblhal-glowforge", "src/glowforge_laser.c")):
|
||||
self.assertIn(want, covers, "lacks %s" % (want,))
|
||||
|
||||
def test_the_steps_ask_for_one_press_and_no_page(self):
|
||||
text = " ".join(self.t.steps).lower()
|
||||
self.assertIn("press the machine's button once", text)
|
||||
self.assertNotIn("when the page", text)
|
||||
self.assertEqual(len(setup_sheet.CARDS), 6)
|
||||
|
||||
|
||||
class ScriptedDaemon:
|
||||
"""The wizard runner as the suite sees it: /wiz/<id>/start opens the
|
||||
first prompt, each answer opens the next or ends the wizard with its
|
||||
result and its writes, /wiz/dark reports it, GET /wiz carries the
|
||||
versions, the sheet preview and program are served per card."""
|
||||
|
||||
def __init__(self, fake):
|
||||
self.fake = fake
|
||||
fake.state["settings"].update(FOUND)
|
||||
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
|
||||
"log": [], "prompt": None, "result": None, "error": "",
|
||||
"shots": {"lid": False, "head": False}}
|
||||
self.versions = {}
|
||||
self.answers = {} # wid -> [values]
|
||||
self.order = [] # the wizards as started
|
||||
self.settings_at_start = {} # wid -> the settings as the wizard found them
|
||||
self.previews = []
|
||||
self.queue = []
|
||||
self.seq = 0
|
||||
self.stray_z = None # a Z the served program commands beyond the reach, when set
|
||||
fake.on_get = self.on_get
|
||||
fake.on_post = self.on_post
|
||||
|
||||
def open_next(self):
|
||||
if not self.queue:
|
||||
return False
|
||||
pid, kind, options = self.queue[0]
|
||||
self.seq += 1
|
||||
self.dark["prompt"] = {"seq": self.seq, "id": pid, "kind": kind, "text": pid, "options": options}
|
||||
return True
|
||||
|
||||
def on_get(self, path, q):
|
||||
s = self.fake.state["settings"]
|
||||
if path == "/status":
|
||||
# the lens block follows the settings, as the daemon's does
|
||||
return 200, dict(self.fake.state["status"], lens=lens_block(s))
|
||||
if path == "/wiz/dark":
|
||||
return 200, dict(self.dark)
|
||||
if path == "/wiz":
|
||||
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
|
||||
if path == "/wiz/sheet.svg":
|
||||
self.previews.append(q.get("card"))
|
||||
if q.get("card") not in LIVE:
|
||||
return 404, {"error": "no such card"}
|
||||
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
|
||||
if path == "/wiz/sheet.gcode":
|
||||
# the frame and the text are M4 at the mark dose, as the daemon
|
||||
# serves them; the head's Z is the edge, the tail goes back to
|
||||
# it, and the focus card's ladder spans the window the settings
|
||||
# hold now
|
||||
lens = lens_block(s)
|
||||
lines = ["; mock", "G0 Z%.2f" % lens["edge_z"], "M4 S400", "G0 X60 Y42", "G1 X120 Y42 F3000"]
|
||||
if q.get("card") == "laser.focus":
|
||||
lines += ["G0 Z%.2f" % z for z in ladder_z(lens["edge_z"], lens["below"], lens["above"])]
|
||||
if self.stray_z is not None:
|
||||
lines.append("G0 Z%.2f" % self.stray_z)
|
||||
lines += ["M5", "G0 Z%.2f" % lens["edge_z"], "G0 X0 Y0", "M2"]
|
||||
return 200, ("\n".join(lines) + "\n").encode(), "text/plain"
|
||||
return None
|
||||
|
||||
def on_post(self, path, form):
|
||||
s = self.fake.state["settings"]
|
||||
if path.startswith("/wiz/") and path.endswith("/start"):
|
||||
wid = path[len("/wiz/"):-len("/start")]
|
||||
if wid not in SCRIPT:
|
||||
return 404, {"error": "no such wizard"}
|
||||
self.order.append(wid)
|
||||
self.settings_at_start[wid] = dict(s)
|
||||
s.update(START_WRITES.get(wid, {}))
|
||||
self.queue = list(SCRIPT[wid][0])
|
||||
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
|
||||
self.open_next()
|
||||
return 200, {"started": True, "id": wid}
|
||||
if path.startswith("/wiz/") and path.endswith("/answer"):
|
||||
wid = path[len("/wiz/"):-len("/answer")]
|
||||
p = self.dark.get("prompt")
|
||||
if not p or form.get("seq") != str(p["seq"]):
|
||||
return 409, {"error": "no such prompt is open"}
|
||||
self.answers.setdefault(wid, []).append(form.get("value"))
|
||||
if not (p["kind"] == "jog" and form.get("value") != "Set origin"):
|
||||
self.queue.pop(0)
|
||||
if not self.open_next():
|
||||
s.update(WRITES.get(wid, {}))
|
||||
self.dark.update({"running": False, "prompt": None, "progress": 100,
|
||||
"result": dict(SCRIPT[wid][1])})
|
||||
self.versions[wid] = 1
|
||||
return 200, {"ok": True}
|
||||
if path == "/settings":
|
||||
for k, v in form.items():
|
||||
if v == "":
|
||||
s.pop(k, None)
|
||||
else:
|
||||
s[k] = v
|
||||
return 200, dict(s)
|
||||
return None
|
||||
|
||||
|
||||
class SheetRunTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fake = helpers.FakeForgectrl().start()
|
||||
self.daemon = ScriptedDaemon(self.fake)
|
||||
|
||||
def tearDown(self):
|
||||
self.fake.stop()
|
||||
|
||||
def test_the_whole_sheet_on_one_piece(self):
|
||||
t = catalog.load_suite()["setup.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True # the ready gate passes; the arm cue is a notice
|
||||
t.fn(Context(run, None, t))
|
||||
d = self.daemon
|
||||
self.assertEqual(d.order, ["sheet.place"] + LIVE)
|
||||
self.assertEqual(d.answers["sheet.place"], ["X+10", "X-10", "Set origin", "Full sheet", "3.2"])
|
||||
self.assertEqual(d.answers["sheet.frame"], ["Continue", "Yes"])
|
||||
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
|
||||
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
|
||||
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
|
||||
self.assertEqual(d.previews, PREVIEWS)
|
||||
cards = run.evidence["cards"]
|
||||
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
|
||||
for wid in LIVE:
|
||||
self.assertEqual(cards[wid]["emission"]["hv_max"], 610, wid)
|
||||
self.assertEqual(cards[wid]["result"], SCRIPT[wid][1], wid)
|
||||
self.assertNotIn("emission", run.evidence)
|
||||
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
|
||||
self.assertEqual(run.evidence["settings_before"], FOUND)
|
||||
# A fresh machine's run: the lens settings were gone before the
|
||||
# frame, the frame's program ran in the fallback window, the focus
|
||||
# card's window was in the settings when the floor card started,
|
||||
# and the window check filed what it compared.
|
||||
for k in setup_sheet.LENS_SETTINGS:
|
||||
self.assertNotIn(k, d.settings_at_start["sheet.frame"], k)
|
||||
self.assertEqual(run.evidence["lens_fresh"]["stops_found"], False)
|
||||
self.assertEqual(run.evidence["program_z"]["sheet.frame"]["reach"], [-0.07, 7.46])
|
||||
floor_start = d.settings_at_start["laser.floor"]
|
||||
self.assertEqual({k: floor_start[k] for k in setup_sheet.LENS_SETTINGS},
|
||||
{"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14", "lens_stop_above_steps": "20"})
|
||||
fw = run.evidence["focus_window"]
|
||||
self.assertEqual(fw["window"], FOCUS_WINDOW)
|
||||
self.assertEqual(fw["settings"]["lens_stop_below_steps"], "14")
|
||||
self.assertEqual(len(fw["ladder_z"]), 12)
|
||||
self.assertEqual(fw["program_z_before"]["reach"], [-0.07, 7.46]) # the fallback ladder
|
||||
self.assertEqual(fw["program_z_after"]["reach"], [2.17, 13.8]) # the written window at the new edge
|
||||
self.assertEqual(len(fw["program_z_after"]["z"]), 14) # head, twelve lines, tail
|
||||
for k, v in FOUND.items():
|
||||
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
|
||||
|
||||
def test_imperial_units_answer_the_thickness_in_inches(self):
|
||||
# The number prompts read ui_units: on an imperial machine the
|
||||
# placement's thickness is 0.125 in and the record carries 3.175 mm.
|
||||
self.fake.state["settings"]["ui_units"] = "imperial"
|
||||
place_prompts, place_result = SCRIPT["sheet.place"]
|
||||
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=3.175))
|
||||
try:
|
||||
t = catalog.load_suite()["setup.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertEqual(self.daemon.answers["sheet.place"][-1], "0.125")
|
||||
self.assertEqual(run.evidence["thickness_answer"], "0.125")
|
||||
finally:
|
||||
SCRIPT["sheet.place"] = (place_prompts, place_result)
|
||||
|
||||
def test_a_metric_answer_on_an_imperial_machine_fails_the_placement(self):
|
||||
# The defect the bench found: 3.2 read as inches is over the 30 mm
|
||||
# cap and the wizard records 0; the test must not read that as a pass.
|
||||
from forgetest.runner import Failed
|
||||
place_prompts, place_result = SCRIPT["sheet.place"]
|
||||
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=0.0))
|
||||
try:
|
||||
t = catalog.load_suite()["setup.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True
|
||||
with self.assertRaises(Failed) as cm:
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertIn("thickness 0.0 mm", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, ["sheet.place"]) # nothing burned
|
||||
finally:
|
||||
SCRIPT["sheet.place"] = (place_prompts, place_result)
|
||||
|
||||
def test_a_dark_burn_fails_the_run_and_still_restores(self):
|
||||
from forgetest.runner import Failed
|
||||
SCRIPT_DARK = dict(SCRIPT["laser.floor"][1])
|
||||
SCRIPT_DARK["emission"] = {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0}
|
||||
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], SCRIPT_DARK)
|
||||
try:
|
||||
t = catalog.load_suite()["setup.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True
|
||||
with self.assertRaises(Failed):
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus", "laser.floor"])
|
||||
for k, v in FOUND.items():
|
||||
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||
finally:
|
||||
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
|
||||
|
||||
def test_a_focus_window_the_settings_do_not_hold_fails_the_run(self):
|
||||
# The defect the bench found: the focus card burned its ladder over
|
||||
# the stops it found while the controller's Z limit stood on the
|
||||
# settings, which did not hold them. A daemon whose focus result
|
||||
# names a window the settings do not hold fails the run before the
|
||||
# floor card, and the settings still go back.
|
||||
from forgetest.runner import Failed
|
||||
saved = dict(START_WRITES["laser.focus"])
|
||||
START_WRITES["laser.focus"] = {}
|
||||
try:
|
||||
t = catalog.load_suite()["setup.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True
|
||||
with self.assertRaises(Failed) as cm:
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertIn("the settings hold", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus"])
|
||||
for k, v in FOUND.items():
|
||||
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||
finally:
|
||||
START_WRITES["laser.focus"] = saved
|
||||
|
||||
def test_a_program_z_beyond_the_reach_fails_before_the_burn(self):
|
||||
# A served program that commands a Z the lens does not reach is
|
||||
# refused before the card starts: nothing burns.
|
||||
from forgetest.runner import Failed
|
||||
self.daemon.stray_z = 10.6
|
||||
t = catalog.load_suite()["setup.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True
|
||||
with self.assertRaises(Failed) as cm:
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertIn("outside the lens window", str(cm.exception))
|
||||
self.assertIn("10.6", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, ["sheet.place"])
|
||||
for k, v in FOUND.items():
|
||||
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,123 +3,53 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The setup.* suite on the host: the registration (ids, kinds, the
|
||||
takeover tests, the operator tests' hands), the record builders, the
|
||||
machine's name, the cookie parsing, the LED cue, the settle rule for a
|
||||
gated supervisor, and the cloud-off surface test driven end to end
|
||||
against the fake daemon."""
|
||||
"""The setup.* suite on the host: the registration (the operator tests'
|
||||
hands, the intake tests' precheck), the record builders, the cookie
|
||||
parsing, and the settle rule for a gated supervisor."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest import baseline, catalog
|
||||
from forgetest.runner import Context, Run
|
||||
from forgetest.suite import setup
|
||||
|
||||
IDS = ("setup.gate-blocks-controllers", "setup.override-until-reboot",
|
||||
"setup.advisories-rehash", "setup.account-login", "setup.https-only-writes",
|
||||
"setup.ssh-until-reboot", "setup.cloud-disabled-surface",
|
||||
"setup.factory-return", "setup.machine-name", "setup.first-run-flow",
|
||||
"setup.first-run-page", "setup.what-changed", "setup.record-export",
|
||||
"setup.mirror")
|
||||
OPERATOR = ("setup.first-run-flow", "setup.first-run-page")
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import helpers # noqa: E402
|
||||
from forgetest import baseline, catalog # noqa: E402
|
||||
from forgetest.suite import setup # noqa: E402
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.reg = catalog.load_suite()
|
||||
|
||||
def test_every_test_is_registered_with_its_kind(self):
|
||||
kinds = {tid: self.reg[tid].kind for tid in IDS if tid in self.reg}
|
||||
self.assertEqual(sorted(kinds), sorted(IDS))
|
||||
for tid in OPERATOR:
|
||||
self.assertEqual(kinds[tid], "operator", tid)
|
||||
for tid in IDS:
|
||||
if tid not in OPERATOR:
|
||||
self.assertEqual(kinds[tid], "auto", tid)
|
||||
self.assertNotIn("setup.root-ssh-refused", self.reg) # folded into ssh-until-reboot
|
||||
self.assertNotIn("setup.wizard-first-run", self.reg) # split into the flow and the page
|
||||
def test_recommended_reads_the_status_document(self):
|
||||
w = {"recommended": [{"id": "laser.floor", "reason": "the tube was replaced"},
|
||||
{"id": "laser.focus", "reason": "the tray was replaced"},
|
||||
{"id": "airflow", "reason": "not run yet"}, "stray"]}
|
||||
self.assertEqual(setup.recommended(w), {"laser.floor": "the tube was replaced",
|
||||
"laser.focus": "the tray was replaced",
|
||||
"airflow": "not run yet"})
|
||||
|
||||
def test_the_record_swapping_tests_are_takeovers(self):
|
||||
for tid in ("setup.gate-blocks-controllers", "setup.advisories-rehash",
|
||||
"setup.first-run-flow", "setup.first-run-page",
|
||||
"setup.what-changed", "setup.mirror"):
|
||||
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||
self.assertEqual(self.reg["setup.record-export"].hardware, "api")
|
||||
def test_the_form_wizards_and_the_required_steps(self):
|
||||
self.assertEqual(setup.FORM_WIZARDS, ("advisories", "account", "preferences", "machine", "cloud"))
|
||||
# in the order the gate's reason names them
|
||||
self.assertEqual(setup.REQUIRED_FORM_STEPS, ("preferences", "machine", "cloud"))
|
||||
self.assertEqual(setup.REQUIRED_STEPS, ("preferences", "machine", "cloud", "cooling.flow"))
|
||||
self.assertIn("cooling.flow", setup.REQUIRED_STEPS)
|
||||
|
||||
def test_the_lifecycle_tests_cover_their_files(self):
|
||||
changed = set(self.reg["setup.what-changed"].covers)
|
||||
export = set(self.reg["setup.record-export"].covers)
|
||||
mirror = set(self.reg["setup.mirror"].covers)
|
||||
self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
|
||||
self.assertIn(("forgectrl", "src/recordhtml.*"), export)
|
||||
self.assertIn(("forgectrl", "src/logs.*"), export)
|
||||
self.assertIn(("forgectrl", "src/wizdark.*"), mirror)
|
||||
self.assertIn("setup.check-sensors", self.reg["setup.mirror"].requires)
|
||||
def test_the_intake_tests_start_from_a_count(self):
|
||||
reg = self.reg
|
||||
from forgetest.suite import setup_dark
|
||||
self.assertIs(reg["setup.check-airflow"].precheck, setup_dark.intakes_counted)
|
||||
|
||||
def test_flag_of_reads_the_status_document(self):
|
||||
w = {"required": ["airflow", {"id": "laser.floor", "reason": "the tube was replaced"}],
|
||||
"recommended": [{"id": "laser.focus", "reason": "the tray was replaced"}]}
|
||||
self.assertEqual(setup.flag_of(w, "required", "laser.floor"), "the tube was replaced")
|
||||
self.assertEqual(setup.flag_of(w, "recommended", "laser.focus"), "the tray was replaced")
|
||||
self.assertIsNone(setup.flag_of(w, "required", "airflow")) # a plain id is the table, not a flag
|
||||
self.assertIsNone(setup.flag_of(w, "required", "laser.focus"))
|
||||
self.assertIsNone(setup.flag_of({}, "recommended", "x"))
|
||||
|
||||
def test_the_first_run_flow_runs_unattended_with_the_fixture_and_the_page_walk_does_not(self):
|
||||
flow = self.reg["setup.first-run-flow"]
|
||||
self.assertEqual(list(flow.actions), ["button"])
|
||||
self.assertFalse(flow.hands)
|
||||
self.assertTrue(flow.fixture_runnable(("button", "lid", "interlock")))
|
||||
page = self.reg["setup.first-run-page"]
|
||||
self.assertTrue(page.hands)
|
||||
self.assertFalse(page.fixture_runnable(("button", "lid", "interlock")))
|
||||
self.assertIn("setup.first-run-flow", page.requires)
|
||||
|
||||
def test_the_first_run_split_keeps_the_backend_and_the_page_apart(self):
|
||||
flow = set(self.reg["setup.first-run-flow"].covers)
|
||||
page = set(self.reg["setup.first-run-page"].covers)
|
||||
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
|
||||
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
|
||||
self.assertIn(want, flow, want)
|
||||
self.assertNotIn(want, page, want)
|
||||
for want in (("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/md.js"), ("forgectrl", "src/tls.*")):
|
||||
self.assertIn(want, page, want)
|
||||
|
||||
def test_the_only_attended_tests_need_a_workstation_or_the_sheet(self):
|
||||
# With the bench actuator up, three setup tests keep a
|
||||
# person: the page walk, the print from the Glowforge app, and
|
||||
# the sheet. Everything else runs from the queue.
|
||||
def test_the_only_attended_tests_need_a_workstation_or_a_scrap(self):
|
||||
# With the bench actuator up, two setup tests keep a person: the
|
||||
# print from the Glowforge app, and the focus burn on a fresh
|
||||
# scrap. Everything else runs from the queue.
|
||||
attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("setup.")
|
||||
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
|
||||
self.assertEqual(attended, ["setup.cloud-header-capture", "setup.first-run-page",
|
||||
"setup.sheet"])
|
||||
|
||||
def test_the_factory_return_never_runs_the_return(self):
|
||||
import inspect
|
||||
t = self.reg["setup.factory-return"]
|
||||
src = inspect.getsource(t.fn)
|
||||
self.assertNotIn('"confirm": "1"', src) # the only argument that starts the return
|
||||
self.assertIn('"confirm": "0"', src)
|
||||
self.assertEqual(t.kind, "auto") # the return itself is a bench drill, not a test
|
||||
self.assertFalse(t.hands)
|
||||
|
||||
def test_the_machine_name_covers_nothing_by_design(self):
|
||||
self.assertEqual(self.reg["setup.machine-name"].covers, ())
|
||||
|
||||
def test_the_login_test_makes_its_own_account(self):
|
||||
# No bench credentials, no precheck: the test installs a temporary
|
||||
# account under a takeover and restores the real one.
|
||||
t = self.reg["setup.account-login"]
|
||||
self.assertIsNone(getattr(t, "precheck", None))
|
||||
self.assertEqual(t.hardware, "takeover")
|
||||
with open(setup.__file__) as f:
|
||||
src = f.read()
|
||||
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
|
||||
self.assertNotIn(k, src)
|
||||
self.assertEqual(attended, ["setup.cloud-header-capture", "setup.focus"])
|
||||
|
||||
|
||||
class RecordTests(unittest.TestCase):
|
||||
@@ -152,13 +82,44 @@ class RecordTests(unittest.TestCase):
|
||||
|
||||
def test_without_wizards_keeps_consent_and_account(self):
|
||||
rec = setup.complete_record(self.STATUS)
|
||||
rec["flags"] = {"machine": {"level": "required", "reason": "x"}}
|
||||
rec["flags"] = {"machine": {"reason": "x"}}
|
||||
out = setup.record_without_wizards(rec)
|
||||
self.assertEqual(out["wizards"], {})
|
||||
self.assertEqual(out["flags"], {})
|
||||
self.assertEqual(out["advisories"], rec["advisories"])
|
||||
self.assertEqual(out["account"], rec["account"])
|
||||
self.assertEqual(rec["flags"], {"machine": {"level": "required", "reason": "x"}}) # a copy
|
||||
self.assertEqual(rec["flags"], {"machine": {"reason": "x"}}) # a copy
|
||||
|
||||
def test_with_required_steps_keeps_them_and_nothing_else(self):
|
||||
rec = setup.complete_record(self.STATUS)
|
||||
rec["wizards"]["machine"]["result"] = {"model": "basic"}
|
||||
rec["wizards"]["switches"] = {"version": 1}
|
||||
rec["flags"] = {"machine": {"reason": "the head was replaced"}}
|
||||
out = setup.record_with_required_steps(rec)
|
||||
self.assertEqual(sorted(out["wizards"]), ["cloud", "cooling.flow", "machine", "preferences"])
|
||||
self.assertEqual(out["wizards"]["machine"]["result"], {"model": "basic"}) # kept from the record
|
||||
self.assertEqual(out["wizards"]["preferences"]["version"], 1) # written at version 1
|
||||
self.assertEqual(out["wizards"]["cooling.flow"]["version"], 1)
|
||||
self.assertEqual(out["flags"], {})
|
||||
self.assertEqual(out["account"], rec["account"])
|
||||
self.assertIn("switches", rec["wizards"]) # a copy
|
||||
|
||||
def test_without_consent_keeps_the_account_and_the_wizards(self):
|
||||
rec = setup.complete_record(self.STATUS)
|
||||
out = setup.record_without_consent(rec)
|
||||
self.assertEqual(out["advisories"], {})
|
||||
self.assertNotIn("acceptance", out)
|
||||
self.assertEqual(out["account"], rec["account"])
|
||||
self.assertEqual(out["wizards"], rec["wizards"])
|
||||
self.assertIn("pressed_at", rec["acceptance"]) # a copy
|
||||
|
||||
def test_without_account_keeps_the_consent(self):
|
||||
rec = setup.complete_record(self.STATUS)
|
||||
out = setup.record_without_account(rec)
|
||||
self.assertNotIn("account", out)
|
||||
self.assertEqual(out["advisories"], rec["advisories"])
|
||||
self.assertEqual(out["acceptance"], rec["acceptance"])
|
||||
self.assertEqual(rec["account"]["name"], "bench") # a copy
|
||||
|
||||
def test_write_file_and_remove(self):
|
||||
tmp = tempfile.mkdtemp(prefix="forgetest-comm-")
|
||||
@@ -180,54 +141,12 @@ class RecordTests(unittest.TestCase):
|
||||
try:
|
||||
self.assertEqual(setup.record_path(), "/tmp/ffdata/setup.json")
|
||||
self.assertEqual(setup.users_path(), "/tmp/ffdata/users")
|
||||
self.assertEqual(setup.override_path(), "/tmp/ffrun/setup-override")
|
||||
self.assertEqual(setup.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
|
||||
finally:
|
||||
os.environ.pop("FORGECTRL_DATA_DIR", None)
|
||||
os.environ.pop("GF_RUN_DIR", None)
|
||||
|
||||
|
||||
class MachineNameTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||
|
||||
def _net(self, **devs):
|
||||
"""A /sys/class/net tree; setup.mac_suffix reads it through
|
||||
setup.read_file, which takes an absolute path."""
|
||||
for dev, mac in devs.items():
|
||||
d = os.path.join(self.root, dev)
|
||||
os.makedirs(d)
|
||||
with open(os.path.join(d, "address"), "w") as f:
|
||||
f.write(mac + "\n")
|
||||
real = setup.read_file
|
||||
|
||||
def read(path):
|
||||
head = "/sys/class/net/"
|
||||
if path.startswith(head):
|
||||
return real(os.path.join(self.root, path[len(head):]))
|
||||
return real(path)
|
||||
|
||||
setup.read_file = read
|
||||
self.addCleanup(setattr, setup, "read_file", real)
|
||||
|
||||
def test_the_wifi_address_names_the_machine(self):
|
||||
self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
|
||||
self.assertEqual(setup.mac_suffix(), "b00a")
|
||||
|
||||
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
|
||||
self._net(eth0="00:11:22:33:44:55")
|
||||
self.assertEqual(setup.mac_suffix(), "4455")
|
||||
|
||||
def test_an_unread_address_is_no_address(self):
|
||||
self._net(wlan0="00:00:00:00:00:00")
|
||||
self.assertEqual(setup.mac_suffix(), "")
|
||||
|
||||
def test_no_interface_is_no_address(self):
|
||||
self._net()
|
||||
self.assertEqual(setup.mac_suffix(), "")
|
||||
|
||||
|
||||
class SmallHelpersTests(unittest.TestCase):
|
||||
def test_cookie_parsing(self):
|
||||
sid = "ab" * 32
|
||||
@@ -237,16 +156,6 @@ class SmallHelpersTests(unittest.TestCase):
|
||||
self.assertIsNone(setup.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
|
||||
self.assertIsNone(setup.session_from_cookie(""))
|
||||
|
||||
def test_the_teal_breathe_cue(self):
|
||||
self.assertTrue(setup.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
|
||||
"pulse_off": [0, 1400, 1400]}))
|
||||
self.assertFalse(setup.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0],
|
||||
"pulse_off": [0, 0, 0]})) # solid green
|
||||
self.assertFalse(setup.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800],
|
||||
"pulse_off": [1800] * 3})) # white
|
||||
self.assertFalse(setup.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
|
||||
self.assertFalse(setup.breathes_teal({}))
|
||||
|
||||
def test_decode(self):
|
||||
self.assertEqual(setup.decode(b'{"a": 1}'), {"a": 1})
|
||||
self.assertEqual(setup.decode(b"cloud mode is not enabled"), "cloud mode is not enabled")
|
||||
@@ -266,163 +175,12 @@ class GatedSettleTests(unittest.TestCase):
|
||||
|
||||
def test_gated_returns_at_once(self):
|
||||
self.fake.state["mode"] = {"mode": "grbl", "controller": "gated", "pid": 0, "motion": "unverified",
|
||||
"gated": True, "why": "setup required: machine"}
|
||||
"gated": True, "why": "the intake fans are not counted"}
|
||||
lines = []
|
||||
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
|
||||
self.assertEqual(body["controller"], "gated")
|
||||
self.assertTrue(any("controller=gated" in ln for ln in lines))
|
||||
|
||||
|
||||
class FirstRunSeedTests(unittest.TestCase):
|
||||
"""The record the first-run tests install: a first run behind the
|
||||
checks and the sheet."""
|
||||
|
||||
STATUS = {"wizards": [{"id": "advisories", "version": 1, "class": "form"},
|
||||
{"id": "account", "version": 1, "class": "form"},
|
||||
{"id": "cloud", "version": 1, "class": "form"},
|
||||
{"id": "switches", "version": 1, "class": "dark"},
|
||||
{"id": "motion", "version": 2, "class": "dark"},
|
||||
{"id": "sheet.frame", "version": 1, "class": "live"}]}
|
||||
|
||||
def test_no_consent_no_account_not_complete_and_every_check_done(self):
|
||||
base = {"created": "2026-09-01T00:00:00Z", "completed": "2026-09-02T00:00:00Z",
|
||||
"advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"},
|
||||
"account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True},
|
||||
"wizards": {"advisories": {"version": 1}, "switches": {"version": 1, "result": {"lid": True}}}}
|
||||
rec = setup.first_run_record(self.STATUS, base)
|
||||
self.assertNotIn("completed", rec)
|
||||
self.assertNotIn("acceptance", rec)
|
||||
self.assertNotIn("account", rec)
|
||||
self.assertEqual(rec["advisories"], {})
|
||||
self.assertEqual(rec["created"], "2026-09-01T00:00:00Z")
|
||||
self.assertEqual(rec["flags"], {"flow_thin": True})
|
||||
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
|
||||
self.assertEqual(rec["wizards"]["switches"]["result"], {"lid": True}) # kept from the base
|
||||
self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version
|
||||
|
||||
def test_without_a_base_record(self):
|
||||
rec = setup.first_run_record(self.STATUS, None)
|
||||
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
|
||||
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
|
||||
|
||||
|
||||
class SshdPolicyTests(unittest.TestCase):
|
||||
def test_the_three_keys_out_of_sshd_t(self):
|
||||
from forgetest import hw
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, timeout=60):
|
||||
calls.append(cmd)
|
||||
return 0, "port 22\npermitrootlogin no\npasswordauthentication yes\npermitemptypasswords no\nx11forwarding no\n"
|
||||
real = hw.run
|
||||
hw.run = fake_run
|
||||
try:
|
||||
self.assertEqual(setup.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
|
||||
"permitemptypasswords": "no"})
|
||||
finally:
|
||||
hw.run = real
|
||||
self.assertEqual(calls, [["/usr/sbin/sshd", "-T"]])
|
||||
|
||||
def test_a_failing_sshd_reports_the_error(self):
|
||||
from forgetest import hw
|
||||
real = hw.run
|
||||
hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available")
|
||||
try:
|
||||
self.assertEqual(setup.sshd_policy(), {"error": "sshd: no hostkeys available"})
|
||||
finally:
|
||||
hw.run = real
|
||||
|
||||
|
||||
class CloudDisabledSurfaceTests(unittest.TestCase):
|
||||
"""The cloud-off surface test against the fake daemon: cloud_enabled
|
||||
is turned off with one write that sweeps the cloud choices, the three
|
||||
cloud-pointing writes and the phrase-less cloud_enabled=1 are refused,
|
||||
the settings are left alone, and the prior values come back."""
|
||||
|
||||
def setUp(self):
|
||||
self.fake = helpers.FakeForgectrl().start()
|
||||
self.fake.state["settings"].update({"controller_mode": "grbl", "homing_mode": "none",
|
||||
"cloud_enabled": "1", "ui_units": "metric"})
|
||||
|
||||
def on_post(path, form):
|
||||
s = self.fake.state["settings"]
|
||||
if path == "/settings":
|
||||
# as the daemon rules it: on from off takes the typed phrase
|
||||
if form.get("cloud_enabled") == "1" and s.get("cloud_enabled") != "1" \
|
||||
and form.get("phrase") != "I UNDERSTAND":
|
||||
return 400, "type I UNDERSTAND to turn cloud mode on"
|
||||
# the request's own cloud choices, as main.c checks them
|
||||
enabled = form.get("cloud_enabled", s.get("cloud_enabled")) == "1"
|
||||
if not enabled and form.get("controller_mode") == "cloud":
|
||||
return 409, "cloud mode is not enabled on this machine"
|
||||
if not enabled and form.get("homing_mode") == "gfcloud":
|
||||
return 409, "cloud homing needs cloud mode enabled"
|
||||
for k, v in form.items():
|
||||
if k == "phrase":
|
||||
continue
|
||||
if v == "":
|
||||
s.pop(k, None)
|
||||
else:
|
||||
s[k] = v
|
||||
# off sweeps what pointed at the cloud, as the cloud step does
|
||||
if form.get("cloud_enabled") == "0":
|
||||
if "homing_mode" not in form and s.get("homing_mode") == "gfcloud":
|
||||
s["homing_mode"] = "none"
|
||||
if "controller_mode" not in form and s.get("controller_mode") == "cloud":
|
||||
s["controller_mode"] = "grbl"
|
||||
return 200, dict(s)
|
||||
if path == "/mode" and form.get("controller") == "cloud" and s.get("cloud_enabled") != "1":
|
||||
return 409, "cloud mode is not enabled on this machine"
|
||||
return None
|
||||
self.fake.on_post = on_post
|
||||
|
||||
def tearDown(self):
|
||||
self.fake.stop()
|
||||
|
||||
def run_test(self):
|
||||
t = catalog.load_suite()["setup.cloud-disabled-surface"]
|
||||
run = Run("test", t.id, t.title)
|
||||
t.fn(Context(run, None, t))
|
||||
return run
|
||||
|
||||
def test_refusals_and_restore(self):
|
||||
run = self.run_test()
|
||||
s = self.fake.state["settings"]
|
||||
self.assertEqual(s["cloud_enabled"], "1") # restored, with the phrase
|
||||
self.assertEqual(s["controller_mode"], "grbl")
|
||||
self.assertEqual(s["homing_mode"], "none")
|
||||
self.assertNotIn("phrase", s)
|
||||
posts = [(p, f) for p, f in self.fake.posts]
|
||||
self.assertIn(("/settings", {"cloud_enabled": "0"}), posts)
|
||||
self.assertIn(("/settings", {"cloud_enabled": "1"}), posts) # refused: no phrase
|
||||
self.assertIn(("/settings", {"cloud_enabled": "1", "phrase": "I UNDERSTAND"}), posts)
|
||||
self.assertIn(("/mode", {"controller": "cloud"}), posts)
|
||||
self.assertEqual(run.evidence["mode_cloud"]["status"], 409)
|
||||
self.assertEqual(run.evidence["settings cloud_enabled=1 no phrase"], 400)
|
||||
self.assertEqual(run.evidence["found"]["cloud_enabled"], "1")
|
||||
|
||||
def test_cloud_homing_and_boot_mode_are_swept_by_the_one_write_and_go_back(self):
|
||||
# As the daemon rules it: cloud_enabled=0 takes the gfcloud homing
|
||||
# and the cloud boot mode down with it, so the test writes the one
|
||||
# key, checks the sweep, and puts the three back, cloud mode first.
|
||||
self.fake.state["settings"].update({"controller_mode": "cloud", "homing_mode": "gfcloud"})
|
||||
run = self.run_test()
|
||||
s = self.fake.state["settings"]
|
||||
self.assertEqual((s["cloud_enabled"], s["homing_mode"], s["controller_mode"]), ("1", "gfcloud", "cloud"))
|
||||
posts = [(p, f) for p, f in self.fake.posts if p == "/settings"]
|
||||
order = [f for _, f in posts]
|
||||
self.assertEqual(order[:1], [{"cloud_enabled": "0"}])
|
||||
self.assertEqual(order[-3:], [{"cloud_enabled": "1", "phrase": "I UNDERSTAND"},
|
||||
{"homing_mode": "gfcloud"}, {"controller_mode": "cloud"}])
|
||||
self.assertEqual(run.evidence["found"], {"cloud_enabled": "1", "homing_mode": "gfcloud",
|
||||
"controller_mode": "cloud"})
|
||||
|
||||
def test_an_unset_value_is_cleared_back(self):
|
||||
self.fake.state["settings"].pop("cloud_enabled")
|
||||
self.run_test()
|
||||
self.assertNotIn("cloud_enabled", self.fake.state["settings"])
|
||||
self.assertIn(("/settings", {"cloud_enabled": ""}), self.fake.posts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""A takeover hands back the cloud client it found. In cloud mode
|
||||
forgectrl's start at the end of a takeover starts a cloud client, and what
|
||||
that client is comes only from gfcloud's one-start markers. Started bare it
|
||||
is the online client with the service's connect-time hunt, and the moves
|
||||
the service sends after the hunt outlast the run. The start is made under
|
||||
the no-hunt marker, and under the offline marker when the takeover found
|
||||
the offline service, which it then proves came back. Runs against the
|
||||
fake forgectrl, a fake /proc, and a fake init script that plays the
|
||||
client's start."""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from forgetest import baseline, hw, runner
|
||||
|
||||
from helpers import FakeForgectrl
|
||||
|
||||
|
||||
class FakeRun:
|
||||
def __init__(self, cap):
|
||||
self.baseline_captured = cap
|
||||
self.lines = []
|
||||
|
||||
def log(self, s):
|
||||
self.lines.append(s)
|
||||
|
||||
|
||||
class TakeoverClientTests(unittest.TestCase):
|
||||
OLD_PID, NEW_PID = 200, 201
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-tkc-")
|
||||
self.proc = os.path.join(self.tmp, "proc")
|
||||
os.makedirs(os.path.join(self.proc, "net"))
|
||||
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
|
||||
for group in ("cnc", "pic", "head"):
|
||||
os.makedirs(self.sysfs + group)
|
||||
os.environ["GF_SYSFS_ROOT"] = self.sysfs
|
||||
os.environ["FORGETEST_PROC_ROOT"] = self.proc
|
||||
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "forgetest.active")
|
||||
self.saved = (baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER, baseline.OFFLINE_SOCKET,
|
||||
hw.initd, runner.Takeover.OFFLINE_BACK_S)
|
||||
baseline.NOHUNT_MARKER = os.path.join(self.tmp, "gfcloud-nohunt")
|
||||
baseline.OFFLINE_MARKER = os.path.join(self.tmp, "gfcloud-offline")
|
||||
baseline.OFFLINE_SOCKET = "/run/gfcloud-offline.sock"
|
||||
runner.Takeover.OFFLINE_BACK_S = 0
|
||||
baseline.Baseline._unreachable_until = 0.0
|
||||
self.unix = [] # (pid, inode, path, listening)
|
||||
self.fc = FakeForgectrl().start()
|
||||
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": self.OLD_PID,
|
||||
"motion": "verified"}
|
||||
self.calls = []
|
||||
self.at_start = None # the markers present when forgectrl started
|
||||
self.comes_up = "running" # the controller forgectrl's start settles on
|
||||
hw.initd = self._initd
|
||||
|
||||
def tearDown(self):
|
||||
(baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER, baseline.OFFLINE_SOCKET,
|
||||
hw.initd, runner.Takeover.OFFLINE_BACK_S) = self.saved
|
||||
self.fc.stop()
|
||||
for k in ("GF_SYSFS_ROOT", "FORGETEST_PROC_ROOT", "FORGETEST_MARKER"):
|
||||
os.environ.pop(k, None)
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
# -- the fakes ------------------------------------------------------
|
||||
def _socket(self, pid, inode, path, listening=True):
|
||||
self.unix.append((pid, inode, path, listening))
|
||||
fd_dir = os.path.join(self.proc, str(pid), "fd")
|
||||
os.makedirs(fd_dir, exist_ok=True)
|
||||
os.symlink("socket:[%d]" % inode, os.path.join(fd_dir, str(len(os.listdir(fd_dir)) + 3)))
|
||||
with open(os.path.join(self.proc, "net", "unix"), "w") as f:
|
||||
f.write("Num RefCount Protocol Flags Type St Inode Path\n")
|
||||
for _pid, ino, p, lis in self.unix:
|
||||
f.write("00000000: 00000002 00000000 %08X 0001 %s %d %s\n"
|
||||
% (0x10000 if lis else 0, "01" if lis else "03", ino, p))
|
||||
|
||||
def _offline_client(self, pid):
|
||||
self._socket(pid, 9000 + pid, baseline.OFFLINE_SOCKET)
|
||||
|
||||
def _initd(self, service, action, timeout=60):
|
||||
"""forgectrl stop/start; the start plays gfcloud's: it reads and
|
||||
takes down the markers, and it is the offline service when the
|
||||
offline marker was there."""
|
||||
self.calls.append((service, action))
|
||||
if action != "start":
|
||||
return 0, ""
|
||||
self.at_start = sorted(os.path.basename(p) for p in (baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER)
|
||||
if os.path.exists(p))
|
||||
mode = self.fc.state["mode"]
|
||||
if self.comes_up != "running":
|
||||
self.fc.state["mode"] = dict(mode, controller=self.comes_up, pid=None, motion="unverified")
|
||||
return 0, ""
|
||||
offline = os.path.exists(baseline.OFFLINE_MARKER)
|
||||
for p in (baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER):
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
self.fc.state["mode"] = dict(mode, controller="running", pid=self.NEW_PID, motion="verified")
|
||||
if offline and mode.get("mode") == "cloud":
|
||||
self._offline_client(self.NEW_PID)
|
||||
return 0, ""
|
||||
|
||||
def takeover(self):
|
||||
cap = {"position": None, "mode": self.fc.state["mode"]["mode"]}
|
||||
run = FakeRun(cap)
|
||||
with runner.Takeover(run.log, "test.id", run=run):
|
||||
pass
|
||||
return cap, run
|
||||
|
||||
def markers_left(self):
|
||||
return [p for p in (baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER) if os.path.exists(p)]
|
||||
|
||||
# -- the socket judge -------------------------------------------------
|
||||
def test_the_listener_is_the_process_own_descriptor(self):
|
||||
self._offline_client(self.OLD_PID)
|
||||
self.assertTrue(hw.listens_on(self.OLD_PID, baseline.OFFLINE_SOCKET))
|
||||
# the path alone is not enough: another process's socket, a
|
||||
# connected (not listening) socket, another path, no pid
|
||||
self.assertFalse(hw.listens_on(self.NEW_PID, baseline.OFFLINE_SOCKET))
|
||||
self._socket(self.NEW_PID, 7001, baseline.OFFLINE_SOCKET, listening=False)
|
||||
self.assertFalse(hw.listens_on(self.NEW_PID, baseline.OFFLINE_SOCKET))
|
||||
self.assertFalse(hw.listens_on(self.OLD_PID, "/run/forgefirm/grbl.ctl"))
|
||||
self.assertFalse(hw.listens_on(None, baseline.OFFLINE_SOCKET))
|
||||
|
||||
# -- the takeover ------------------------------------------------------
|
||||
def test_the_offline_service_comes_back_offline(self):
|
||||
# exthost.armed-freeze's case: the setup record put back under a
|
||||
# takeover while the offline service ran
|
||||
self._offline_client(self.OLD_PID)
|
||||
cap, run = self.takeover()
|
||||
self.assertEqual(self.calls, [("forgectrl", "stop"), ("forgectrl", "start")])
|
||||
self.assertEqual(self.at_start, ["gfcloud-nohunt", "gfcloud-offline"])
|
||||
self.assertTrue(hw.listens_on(self.NEW_PID, baseline.OFFLINE_SOCKET))
|
||||
self.assertNotIn("restart_clients", cap)
|
||||
self.assertEqual(self.markers_left(), [])
|
||||
self.assertTrue(any("back as the offline service (pid %d)" % self.NEW_PID in l for l in run.lines),
|
||||
run.lines)
|
||||
|
||||
def test_an_online_client_comes_back_without_the_hunt(self):
|
||||
cap, run = self.takeover()
|
||||
self.assertEqual(self.at_start, ["gfcloud-nohunt"])
|
||||
self.assertFalse(hw.listens_on(self.NEW_PID, baseline.OFFLINE_SOCKET))
|
||||
self.assertNotIn("restart_clients", cap)
|
||||
self.assertEqual(self.markers_left(), [])
|
||||
|
||||
def test_grbl_mode_starts_under_no_marker(self):
|
||||
self.fc.state["mode"] = {"mode": "grbl", "controller": "running", "pid": self.OLD_PID,
|
||||
"motion": "verified"}
|
||||
cap, run = self.takeover()
|
||||
self.assertEqual(self.at_start, [])
|
||||
self.assertNotIn("restart_clients", cap)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,190 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""A takeover judges the head where the baseline cannot. forgectrl's start
|
||||
at the end of a takeover is a controller start, which re-zeroes the step
|
||||
counters wherever the head stands, so after it a head left out reads as
|
||||
home. The takeover reads the counters as the controller goes away (against
|
||||
where the run expects the head) and before it comes back (against where the
|
||||
takeover found it); a miss is a leftover that fails the run, and the head is
|
||||
never moved on it. Runs against a fake sysfs tree and a fake anchor file."""
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from forgetest import baseline, hw, runner
|
||||
|
||||
|
||||
class FakeRun:
|
||||
def __init__(self, cap):
|
||||
self.baseline_captured = cap
|
||||
self.lines = []
|
||||
|
||||
def log(self, s):
|
||||
self.lines.append(s)
|
||||
|
||||
|
||||
class TakeoverPositionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# the clean machine test_baseline.py builds, so the post pass
|
||||
# finds nothing but what a test here leaves
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-tko-")
|
||||
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
|
||||
self.leds = os.path.join(self.tmp, "leds") + os.sep
|
||||
for group in ("cnc", "pic", "head", "thermal"):
|
||||
os.makedirs(self.sysfs + group)
|
||||
for name in baseline.BUTTON_LEDS + ("lid_led",):
|
||||
os.makedirs(self.leds + name)
|
||||
with open(self.leds + name + "/brightness", "w") as f:
|
||||
f.write("0")
|
||||
for attr, val in baseline.fixed_sysfs() + baseline.IDLE_READBACKS:
|
||||
self._attr(attr, val)
|
||||
self._attr("cnc/interlock_circuit", "45")
|
||||
self._attr("pic/lid_led", "0")
|
||||
self._pos(0, 0, 0)
|
||||
os.environ["GF_SYSFS_ROOT"] = self.sysfs
|
||||
os.environ["GF_LEDS_ROOT"] = self.leds
|
||||
os.environ["FORGECTRL_URL"] = "http://127.0.0.1:1" # nothing listens
|
||||
baseline.Baseline._unreachable_until = 0.0
|
||||
self.anchor = os.path.join(self.tmp, "grblhal.homed")
|
||||
self._new_anchor(1)
|
||||
self.old_anchor, baseline.ANCHOR_PATH = baseline.ANCHOR_PATH, self.anchor
|
||||
self.spm = baseline.counter_steps_per_mm()
|
||||
|
||||
def tearDown(self):
|
||||
baseline.ANCHOR_PATH = self.old_anchor
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
for k in ("GF_SYSFS_ROOT", "GF_LEDS_ROOT", "FORGECTRL_URL"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
def _attr(self, attr, val):
|
||||
with open(self.sysfs + attr, "w") as f:
|
||||
f.write(str(val))
|
||||
|
||||
def _pos(self, x, y, z):
|
||||
with open(self.sysfs + "cnc/position", "wb") as f:
|
||||
f.write(struct.pack("<5i", x, y, z, 0, 0))
|
||||
|
||||
def _new_anchor(self, ns):
|
||||
"""A controller start or a home: a new anchor file."""
|
||||
with open(self.anchor + ".new", "w") as f:
|
||||
f.write("0 0 0 4 startup")
|
||||
os.replace(self.anchor + ".new", self.anchor)
|
||||
os.utime(self.anchor, ns=(ns, ns))
|
||||
|
||||
def cap(self):
|
||||
return {"position": baseline.read_position(), "frame": baseline.counter_frame()}
|
||||
|
||||
def takeover(self, cap):
|
||||
run = FakeRun(cap)
|
||||
return runner.Takeover(run.log, "test.id", run=run), run
|
||||
|
||||
def mm(self, mm):
|
||||
return round(mm * self.spm)
|
||||
|
||||
def start(self, t):
|
||||
"""Takeover.__enter__'s order: the frame is read while the
|
||||
controller runs, then its exit takes the anchor with it (forgectrl
|
||||
unlinks it) and leaves the counters, which are then read."""
|
||||
t.frame = baseline.counter_frame()
|
||||
if os.path.exists(self.anchor):
|
||||
os.unlink(self.anchor)
|
||||
t.position_at_start()
|
||||
|
||||
def test_a_head_where_the_run_expects_it_is_not_recorded(self):
|
||||
cap = self.cap()
|
||||
t, run = self.takeover(cap)
|
||||
self.start(t)
|
||||
t.position_at_end()
|
||||
self.assertNotIn("restart_positions", cap)
|
||||
|
||||
def test_a_head_left_out_at_the_start_is_recorded(self):
|
||||
cap = self.cap()
|
||||
self._pos(self.mm(10), self.mm(10), 0)
|
||||
t, run = self.takeover(cap)
|
||||
self.start(t)
|
||||
self.assertEqual(cap["restart_positions"],
|
||||
[{"where": "takeover start", "found": [self.mm(10), self.mm(10), 0],
|
||||
"expected": [0, 0, 0]}])
|
||||
|
||||
def test_the_frame_is_read_before_the_controller_goes(self):
|
||||
# found on the bench: forgectrl unlinks the anchor when the
|
||||
# controller exits, so a frame read after the stop never matched
|
||||
# and the check judged nothing. Read before the stop, it does.
|
||||
cap = self.cap()
|
||||
self._pos(self.mm(10), self.mm(10), 0)
|
||||
late, run = self.takeover(cap)
|
||||
os.unlink(self.anchor)
|
||||
late.frame = baseline.counter_frame()
|
||||
late.position_at_start()
|
||||
self.assertNotIn("restart_positions", cap)
|
||||
self._new_anchor(1)
|
||||
cap = self.cap() # a run that began at the origin in this frame
|
||||
cap["position"] = [0, 0, 0]
|
||||
on_time, run = self.takeover(cap)
|
||||
self.start(on_time)
|
||||
self.assertEqual([r["where"] for r in cap["restart_positions"]], ["takeover start"])
|
||||
|
||||
def test_the_dead_band_is_not_a_leftover(self):
|
||||
cap = self.cap()
|
||||
self._pos(self.mm(0.05), 0, 0)
|
||||
t, run = self.takeover(cap)
|
||||
self.start(t)
|
||||
self.assertNotIn("restart_positions", cap)
|
||||
|
||||
def test_the_post_pass_fails_the_run_and_moves_nothing(self):
|
||||
# a check's case: a declared camera home, the head
|
||||
# left out, the record put back under a takeover, whose restart
|
||||
# zeroes the counters where the head stands
|
||||
cap = self.cap()
|
||||
self._new_anchor(3) # the camera home
|
||||
cap["position"], cap["rezero_declared"], cap["frame"] = [0, 0, 0], True, baseline.counter_frame()
|
||||
self._pos(self.mm(10), self.mm(10), 0)
|
||||
t, run = self.takeover(cap)
|
||||
self.start(t)
|
||||
t.position_at_end()
|
||||
self._new_anchor(4) # forgectrl's start: a controller start
|
||||
self._pos(0, 0, 0)
|
||||
lines = []
|
||||
left = baseline.Baseline(lines.append).enforce("post", captured=cap)
|
||||
self.assertEqual([x.item for x in left], ["position at the takeover start"])
|
||||
self.assertTrue(left[0].action.startswith("unrestorable"), left[0].action)
|
||||
self.assertIn("not moved", left[0].action)
|
||||
self.assertEqual(baseline.read_position(), [0, 0, 0])
|
||||
# without the takeover's record the same run reads clean: the gap
|
||||
del cap["restart_positions"]
|
||||
self.assertEqual(baseline.Baseline(lines.append).enforce("post", captured=cap), [])
|
||||
|
||||
def test_a_failed_reading_never_keeps_forgectrl_down(self):
|
||||
# the exit path restores the attributes, relocks the latch and
|
||||
# starts forgectrl whatever the position reading does
|
||||
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
|
||||
calls = []
|
||||
real = (hw.initd, baseline.read_position, runner.Takeover.wait_settled)
|
||||
hw.initd = lambda service, action, timeout=60: (calls.append((service, action)) or (0, ""))
|
||||
|
||||
def broken():
|
||||
raise OSError("unreadable")
|
||||
baseline.read_position = broken
|
||||
runner.Takeover.wait_settled = lambda self: None
|
||||
try:
|
||||
cap = {"position": [0, 0, 0], "frame": baseline.counter_frame()}
|
||||
t, run = self.takeover(cap)
|
||||
t.found = ([0, 0, 0], hw.sysfs_read("cnc/x_mode"))
|
||||
t.saved = {"cnc/x_mode": hw.sysfs_read("cnc/x_mode")}
|
||||
t.__exit__(None, None, None)
|
||||
finally:
|
||||
hw.initd, baseline.read_position, runner.Takeover.wait_settled = real
|
||||
os.environ.pop("FORGETEST_MARKER", None)
|
||||
self.assertEqual(calls, [("forgectrl", "start")])
|
||||
with open(self.sysfs + "cnc/laser_latch") as f:
|
||||
self.assertEqual(f.read().strip(), "1")
|
||||
self.assertTrue(any("could not be judged" in l for l in run.lines), run.lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,4 +11,4 @@ overrides:
|
||||
meta-freescale-distro:
|
||||
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
||||
meta-openglow:
|
||||
commit: 71fad52e8ff5695bce3b9c6bc8530a1468426819
|
||||
commit: c542ce6ec5799df9cf0f689eb883c790c71a414b
|
||||
|
||||
@@ -38,6 +38,8 @@ hostname:pn-base-files = "forgefirm"
|
||||
# code without poky's CVE patches
|
||||
# no tpm, fips, dane, and no certificate compression (brotli, zlib,
|
||||
# zstd), none of which the panel uses
|
||||
# Kernel TLS is built in and turned on by the gnutls bbappend
|
||||
# (recipes-support/gnutls), which also installs /etc/gnutls/config.
|
||||
PACKAGECONFIG:pn-gnutls = "libtasn1"
|
||||
|
||||
# opengl stays: forgectrl's camera demosaic runs as GLES2 fragment
|
||||
@@ -61,6 +63,12 @@ BAD_RECOMMENDATIONS += "eudev-hwdb"
|
||||
VIRTUAL-RUNTIME_base-utils-syslog = "rsyslog"
|
||||
PACKAGECONFIG:pn-rsyslog = "rsyslogd rsyslogrt klog inet regexp"
|
||||
|
||||
# nftables: the extension sandbox's rule loader (forgefirm-sandbox). The nft
|
||||
# binary and its library, with JSON output so a program can read back what
|
||||
# the kernel holds; no interactive shell (readline) and no Python binding.
|
||||
# gmp and jansson are on the image already.
|
||||
PACKAGECONFIG:pn-nftables = "json"
|
||||
|
||||
# Local (file://) source checksums leave out a workstation's Python bytecode
|
||||
# caches: the forgetest recipe fetches its package directory whole, and a
|
||||
# host test run must not move a task hash without a source change.
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||
# layer content hash because the component entry already identifies the
|
||||
# pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "0dae758cb93e1461034aaa58f4b9fc22edc68367"
|
||||
PV = "0.1.27"
|
||||
SRCREV = "b235be3e8ac5fcd88080b00c9d441dbda9c0014c"
|
||||
PV = "0.1.34"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# forgeext pin. Bump deliberately after pushing forgeext changes; keep
|
||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||
# layer content hash because the component entry already identifies the
|
||||
# pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "ebd959256d3da9f648d6975ba740bee86ad69c7b"
|
||||
PV = "0.1.5"
|
||||
@@ -0,0 +1,35 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
DESCRIPTION = "Extension host for ForgeFIRM powered Glowforge"
|
||||
HOMEPAGE = "https://github.com/openglow-org/forgeext"
|
||||
|
||||
LICENSE = "MIT"
|
||||
LIC_FILES_CHKSUM = "file://LICENSE;md5=785c97b59b518e7ad943cd9e8b15fc97"
|
||||
|
||||
SRC_URI = "git://github.com/openglow-org/forgeext.git;protocol=https;branch=main"
|
||||
# SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass).
|
||||
require forgeext-pin.inc
|
||||
|
||||
S = "${WORKDIR}/git"
|
||||
|
||||
inherit cmake pkgconfig update-rc.d forgefirm-manifest
|
||||
|
||||
# jansson (the manifest, state.json, every answer), libarchive (the .ffx and
|
||||
# its payload, read streaming), libsodium (the archive's signature and the
|
||||
# blake2b-256 of the payload and of every installed file).
|
||||
DEPENDS += "jansson libarchive libsodium"
|
||||
# fwup reads an archive's metadata and task list the way the firmware
|
||||
# paths would; the keyring holds the keys that sign firmware, which an
|
||||
# extension is never signed with; forgefirm-sandbox is the account pool,
|
||||
# the cgroup tree, and the deny rules a package's service runs inside.
|
||||
RDEPENDS:${PN} = "fwup forgefirm-keys forgefirm-sandbox"
|
||||
|
||||
# After forgectrl (90), whose read-only routes the host takes the machine's
|
||||
# state from, and down before it.
|
||||
INITSCRIPT_NAME = "forgeext"
|
||||
INITSCRIPT_PARAMS = "start 91 2 3 4 5 . stop 9 0 1 6 ."
|
||||
|
||||
do_install:append() {
|
||||
install -d ${D}${sysconfdir}/init.d
|
||||
install -m 0755 ${S}/init/forgeext.init ${D}${sysconfdir}/init.d/forgeext
|
||||
}
|
||||
@@ -6,9 +6,9 @@
|
||||
# because the component entry already identifies the pinned source
|
||||
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
||||
# meta-glowforge-bsp pins the same repository; move both together.
|
||||
SRCREV = "6cc4f45d311ab451c7d72dee009103e55a749675"
|
||||
SRCREV = "6fb146a13c51765eec492bf758946e573ae25297"
|
||||
|
||||
# Bump PV with every SRCREV move: the hash-derived package version is not
|
||||
# monotonic on its own and buildhistory QA fails the build when it sorts
|
||||
# backwards.
|
||||
PV = "0.1.30+git"
|
||||
PV = "0.1.33+git"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
rTTFnjdFASq6WR/yswjwmUenMe9OKuYZC6S5K3Ay7uU=
|
||||
@@ -1,10 +1,11 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
SUMMARY = "Firmware verification public keys"
|
||||
DESCRIPTION = "Trust anchors for firmware archive verification: the \
|
||||
ForgeFIRM release-signing public key (verifies release downloads and \
|
||||
uploads) and the Glowforge factory keyring (verifies factory .fw \
|
||||
archives for cloud restore). Public keys only."
|
||||
SUMMARY = "Firmware and extension verification public keys"
|
||||
DESCRIPTION = "Trust anchors for archive verification: the ForgeFIRM \
|
||||
release-signing public key (verifies release downloads and uploads), the \
|
||||
Glowforge factory keyring (verifies factory .fw archives for cloud \
|
||||
restore), and the OpenGlow extension-signing public key (the official \
|
||||
tier of extension packages). Public keys only."
|
||||
# LICENSE covers this recipe, not the key material. The Glowforge factory
|
||||
# keyring in files/gf/ is Glowforge, Inc.'s: bare Ed25519 public keys, in which
|
||||
# no copyright subsists and over which OpenGlow claims nothing and grants
|
||||
@@ -12,9 +13,13 @@ archives for cloud restore). Public keys only."
|
||||
LICENSE = "MIT"
|
||||
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
|
||||
|
||||
# The extension key is a different key from the release key on purpose: it
|
||||
# signs more often, and its loss must not sign firmware. forgeext refuses an
|
||||
# extension archive whose only valid signature is one of the two above.
|
||||
SRC_URI = " \
|
||||
file://forgefirm-release.pub \
|
||||
file://gf \
|
||||
file://ext/forgefirm-ext.pub \
|
||||
"
|
||||
|
||||
S = "${WORKDIR}"
|
||||
@@ -24,6 +29,9 @@ do_install() {
|
||||
install -m 0644 ${WORKDIR}/forgefirm-release.pub \
|
||||
${D}${sysconfdir}/forgefirm/keys/forgefirm-release.pub
|
||||
install -m 0644 ${WORKDIR}/gf/*.pub ${D}${sysconfdir}/forgefirm/keys/gf/
|
||||
install -d ${D}${sysconfdir}/forgefirm/keys/ext
|
||||
install -m 0644 ${WORKDIR}/ext/forgefirm-ext.pub \
|
||||
${D}${sysconfdir}/forgefirm/keys/ext/forgefirm-ext.pub
|
||||
}
|
||||
|
||||
FILES:${PN} = "${sysconfdir}/forgefirm/keys"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/sbin/nft -f
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# The extension sandbox's network rules (forgefirm-sandbox loads them from
|
||||
# rcS, before the network starts). Every packet sent from a socket that an
|
||||
# extension account owns (ffx0 to ffx31, uid 800 to 831) is refused: on
|
||||
# loopback, on the machine's own LAN address, IPv4 and IPv6 alike. That is
|
||||
# what keeps a package off the Grbl port, off forgectrl's listeners, and off
|
||||
# the controller's report route, whatever else fails.
|
||||
#
|
||||
# The rules sit on the output hook and match the sending socket's uid, so
|
||||
# they need no connection tracking, and no packet of the firmware's own pays
|
||||
# for more than one comparison. A refused TCP connect gets a reset, so it
|
||||
# fails at once instead of timing out; anything else is dropped, which the
|
||||
# sender sees as EPERM.
|
||||
#
|
||||
# The one way through is the allow map: uid -> a chain holding that package's
|
||||
# declared destinations. Whatever starts a package adds the element and the
|
||||
# chain, and removes both when the package stops; a chain that accepts
|
||||
# nothing returns here and the packet is refused. Loading this file again
|
||||
# replaces the whole table, allowlists included: it fails closed.
|
||||
#
|
||||
# The machine itself is not a destination, whatever the allow map says.
|
||||
# Everything a host sends to one of its own addresses, the LAN one included,
|
||||
# leaves through lo, so that is refused before the map is looked at. An
|
||||
# allowlist names addresses, and the machine's own address can change under
|
||||
# it (a new DHCP lease); this rule does not depend on knowing it.
|
||||
|
||||
table inet ffx
|
||||
delete table inet ffx
|
||||
|
||||
table inet ffx {
|
||||
map allow {
|
||||
typeof meta skuid : verdict
|
||||
}
|
||||
|
||||
chain output {
|
||||
type filter hook output priority filter; policy accept;
|
||||
meta skuid 800-831 jump pool
|
||||
}
|
||||
|
||||
chain pool {
|
||||
oifname "lo" jump refuse
|
||||
meta skuid vmap @allow
|
||||
jump refuse
|
||||
}
|
||||
|
||||
chain refuse {
|
||||
meta l4proto tcp counter reject with tcp reset
|
||||
counter drop
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
### BEGIN INIT INFO
|
||||
# Provides: forgefirm-sandbox
|
||||
# Required-Start: mountkernfs
|
||||
# Required-Stop:
|
||||
# Default-Start: S
|
||||
# Default-Stop:
|
||||
# Short-Description: ForgeFIRM extension sandbox: the cgroup tree and the deny rules
|
||||
### END INIT INFO
|
||||
|
||||
# Runs at S30 in rcS, before the network starts (rc5). Two jobs, and each
|
||||
# is told apart in what it prints and in its exit status, because whatever
|
||||
# starts extension services checks both in the kernel and starts nothing
|
||||
# when either is missing:
|
||||
#
|
||||
# the cgroup v2 tree mounted at /sys/fs/cgroup, the cpu, memory, and pids
|
||||
# controllers handed down to /sys/fs/cgroup/ffx and
|
||||
# from there to the group each package gets. The ffx
|
||||
# group is idle-class against everything outside it:
|
||||
# a runnable task in the root group always goes first.
|
||||
# the deny rules table inet ffx from /etc/forgefirm/ffx.nft: a pool
|
||||
# uid sends nothing, loopback included.
|
||||
#
|
||||
# The firmware's own processes stay in the root group, which has no limit
|
||||
# and no controller file that could give it one.
|
||||
|
||||
CG=/sys/fs/cgroup
|
||||
RULES=/etc/forgefirm/ffx.nft
|
||||
CONTROLLERS="cpu memory pids"
|
||||
|
||||
cgroup_mounted () {
|
||||
awk -v m="$CG" '$2 == m && $3 == "cgroup2" { f = 1 } END { exit !f }' /proc/mounts
|
||||
}
|
||||
|
||||
# hand_down <group dir>: every controller into the group's subtree_control.
|
||||
hand_down () {
|
||||
for c in $CONTROLLERS; do
|
||||
echo "+$c" > "$1/cgroup.subtree_control" 2>/dev/null || return 1
|
||||
done
|
||||
}
|
||||
|
||||
start_cgroups () {
|
||||
cgroup_mounted || mount -t cgroup2 -o nosuid,nodev,noexec cgroup2 "$CG" || return 1
|
||||
hand_down "$CG" || return 1
|
||||
mkdir -p "$CG/ffx" || return 1
|
||||
hand_down "$CG/ffx" || return 1
|
||||
# An idle group's weight is the scheduler's own lowest; the kernel refuses
|
||||
# a cpu.weight written on top of it.
|
||||
echo 1 > "$CG/ffx/cpu.idle" || return 1
|
||||
}
|
||||
|
||||
start_rules () {
|
||||
/usr/sbin/nft -f "$RULES"
|
||||
}
|
||||
|
||||
status () {
|
||||
rc=0
|
||||
if cgroup_mounted && [ -d "$CG/ffx" ]; then
|
||||
echo "cgroups: $CG/ffx controls: $(cat "$CG/ffx/cgroup.subtree_control")"
|
||||
else
|
||||
echo "cgroups: not set up"; rc=1
|
||||
fi
|
||||
if /usr/sbin/nft list table inet ffx >/dev/null 2>&1; then
|
||||
echo "deny rules: loaded"
|
||||
else
|
||||
echo "deny rules: NOT loaded"; rc=1
|
||||
fi
|
||||
return $rc
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start|restart|reload|force-reload)
|
||||
rc=0
|
||||
start_cgroups || { echo "forgefirm-sandbox: the cgroup tree could not be set up"; rc=1; }
|
||||
start_rules || { echo "forgefirm-sandbox: the deny rules did not load"; rc=1; }
|
||||
exit $rc
|
||||
;;
|
||||
stop)
|
||||
;;
|
||||
status)
|
||||
status
|
||||
exit $?
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|restart|status}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,46 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
SUMMARY = "ForgeFIRM extension sandbox: the account pool, the cgroup tree, and the deny rules"
|
||||
DESCRIPTION = "What the image holds ready before any extension package runs: \
|
||||
a static pool of system accounts (ffx0 to ffx31, uid and gid 800 to 831), \
|
||||
the cgroup v2 tree with the cpu, memory, and pids controllers handed down to \
|
||||
/sys/fs/cgroup/ffx, and the nftables table that refuses every packet a pool \
|
||||
uid sends, loopback included, until an allowlist names its destination. The \
|
||||
rules load from rcS, before the network starts."
|
||||
LICENSE = "MIT"
|
||||
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
|
||||
|
||||
SRC_URI = " \
|
||||
file://forgefirm-sandbox.init \
|
||||
file://ffx.nft \
|
||||
"
|
||||
|
||||
S = "${WORKDIR}"
|
||||
|
||||
inherit update-rc.d useradd
|
||||
|
||||
INITSCRIPT_NAME = "forgefirm-sandbox"
|
||||
# 30 in rcS: /sys is mounted (sysfs.sh, S02) and nothing the script needs
|
||||
# lives on /data. The network starts in rc5 (S01), so no pool uid ever has
|
||||
# an interface to send on before the rules are in the kernel.
|
||||
INITSCRIPT_PARAMS = "start 30 S ."
|
||||
|
||||
# The pool. Static ids below 1000, in a block no other account uses (the
|
||||
# image's dynamic system ids count down from 999): the forgefirm-users render
|
||||
# replaces only the accounts from 1000 up, so an account reset leaves these
|
||||
# alone, and the read-only rootfs never needs a useradd at run time. One
|
||||
# group per account, so no two slots share a group. No home, no shell, and
|
||||
# the password field useradd leaves locked. The rules in ffx.nft and the
|
||||
# pool size here name the same range.
|
||||
FFX_POOL_SIZE = "32"
|
||||
FFX_POOL_BASE = "800"
|
||||
USERADD_PACKAGES = "${PN}"
|
||||
GROUPADD_PARAM:${PN} = "${@'; '.join('--system -g %d ffx%d' % (int(d.getVar('FFX_POOL_BASE')) + n, n) for n in range(int(d.getVar('FFX_POOL_SIZE'))))}"
|
||||
USERADD_PARAM:${PN} = "${@'; '.join('--system -u %d -g ffx%d -M -d /nonexistent -s /bin/false ffx%d' % (int(d.getVar('FFX_POOL_BASE')) + n, n, n) for n in range(int(d.getVar('FFX_POOL_SIZE'))))}"
|
||||
|
||||
do_install() {
|
||||
install -Dm 0755 ${WORKDIR}/forgefirm-sandbox.init ${D}${sysconfdir}/init.d/forgefirm-sandbox
|
||||
install -Dm 0644 ${WORKDIR}/ffx.nft ${D}${sysconfdir}/forgefirm/ffx.nft
|
||||
}
|
||||
|
||||
RDEPENDS:${PN} = "nftables"
|
||||
@@ -44,8 +44,9 @@
|
||||
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
||||
umask 077
|
||||
|
||||
RECORD=/data/forgefirm/users
|
||||
HOMES=/data/forgefirm/home
|
||||
DATA_DIR=/data/forgefirm
|
||||
RECORD=$DATA_DIR/users
|
||||
HOMES=$DATA_DIR/home
|
||||
STATE=/run/forgefirm/accounts
|
||||
ACCOUNT_FILES="passwd shadow group gshadow"
|
||||
LOGIN_SHELL=/bin/sh
|
||||
@@ -140,6 +141,12 @@ replay () {
|
||||
# 0755: a login traverses it to reach its home (the umask above is for
|
||||
# the tmpfs state).
|
||||
[ -d "$HOMES" ] || { mkdir -p "$HOMES" && chmod 0755 "$HOMES"; }
|
||||
# And it walks through the data directory to get there. A data directory
|
||||
# that was made under a strict umask is 0700, and the account cannot
|
||||
# reach its own home. The search bit is put on it whoever made it:
|
||||
# nothing is taken away and nothing in it becomes listable, and what is
|
||||
# private in it is closed file by file.
|
||||
chmod go+x "$DATA_DIR" || log "cannot open $DATA_DIR for search: no account reaches its home"
|
||||
ACCOUNTS=""
|
||||
KEEP=""
|
||||
while IFS=: read -r name hash uid rest; do
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# changes; keep only SRCREV and PV here - the image manifest leaves
|
||||
# *-pin.inc out of the layer content hash because the component entry
|
||||
# already identifies the pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "f93aca89821ae3bf8eee39d5948e53f1edbc20db"
|
||||
PV = "0.1.18"
|
||||
SRCREV = "e15972005c6e3e3b5bcb4d7408df0eaf83b5cc43"
|
||||
PV = "0.1.22"
|
||||
|
||||
@@ -36,6 +36,9 @@ IMAGE_FEATURES += " \
|
||||
tools-debug \
|
||||
debug-tweaks \
|
||||
"
|
||||
# The rootfs lint holds a dev image to the dev policy: root and an empty
|
||||
# password over SSH are what debug-tweaks turns on.
|
||||
FORGEFIRM_IMAGE_KIND = "dev"
|
||||
|
||||
# Dev images boot from SD, never from a 200 MiB eMMC slot: lift the slot
|
||||
# ceiling and give the filesystem generous working space instead.
|
||||
|
||||
@@ -18,12 +18,19 @@ DESCRIPTION = "OpenGlow/ForgeFIRM image for Glowforge"
|
||||
# removal spec is expanded when applied, so the variable, not the removal,
|
||||
# is what a requiring recipe can override). python3 is the meta-package:
|
||||
# it installs every standard-library module (tkinter, idle, 2to3, pydoc,
|
||||
# ensurepip, venv, the debugger, asyncio, multiprocessing, xmlrpc), which
|
||||
# nothing here imports. Each Python recipe declares the module packages it
|
||||
# ensurepip, venv, the debugger, multiprocessing, xmlrpc), which nothing
|
||||
# here imports. Each Python recipe declares the module packages it
|
||||
# imports (python3-core plus the few it uses), so the meta-package goes.
|
||||
FORGEFIRM_RELEASE_TRIM ?= "nano"
|
||||
IMAGE_INSTALL:remove = "python3 ${FORGEFIRM_RELEASE_TRIM}"
|
||||
|
||||
# The Python an extension can count on. An extension runs on the image's
|
||||
# interpreter and brings no standard-library module of its own, so the set
|
||||
# it may import is the set installed here: what ForgeFIRM's own Python
|
||||
# recipes pull in, plus python3-asyncio, which no ForgeFIRM program imports
|
||||
# and which an extension's event-stream and socket clients are written on.
|
||||
IMAGE_INSTALL:append = " python3-asyncio"
|
||||
|
||||
# grblhal-glowforge: the grblHAL motion controller (Grbl over TCP:23).
|
||||
# forgectrl: the ForgeFIRM machine-services daemon (HTTP :80, HTTPS :443
|
||||
# with a self-signed certificate): controller supervisor, pulse-device
|
||||
@@ -57,6 +64,15 @@ IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome gfcloud v4l-utils fw
|
||||
# forgefirm-persist: the boot timestamp and the random seed on /data.
|
||||
IMAGE_INSTALL:append = " forgefirm-users forgefirm-hostname forgefirm-banner forgefirm-persist"
|
||||
|
||||
# forgefirm-sandbox: what an extension package is held by, in place before
|
||||
# any package exists: the ffx account pool, the cgroup v2 tree with the cpu,
|
||||
# memory, and pids controllers, and the nftables rules (nft comes with it)
|
||||
# that refuse everything a pool uid sends, loaded from rcS before the
|
||||
# network starts. forgeext: the extension host that verifies, installs,
|
||||
# and runs packages inside it. It starts after forgectrl and runs nothing
|
||||
# while ext_enabled is 0, which is the default.
|
||||
IMAGE_INSTALL:append = " forgefirm-sandbox forgeext"
|
||||
|
||||
# The rootfs mounts read-only on both images; /data (p3) is the writable
|
||||
# partition. read-only-rootfs is poky's feature for it: the root line of
|
||||
# /etc/fstab (the BSP's, already ro) and ROOTFS_READ_ONLY in
|
||||
@@ -200,18 +216,34 @@ ROOTFS_POSTPROCESS_COMMAND += "write_forgefirm_version "
|
||||
# control panel serves the bundle and its manifest (GET /system/licenses,
|
||||
# GET /system/licenses/manifest). The license class runs first
|
||||
# (license_create_manifest is prepended to this list); this step is
|
||||
# appended, so it runs after.
|
||||
# appended, so it runs after. pack-licenses.sh, next to this recipe, is
|
||||
# the step: it refuses a tree with no manifest, and a manifest that does
|
||||
# not name forgectrl, grblhal-glowforge and kernel-module-glowforge.
|
||||
COPY_LIC_MANIFEST = "1"
|
||||
COPY_LIC_DIRS = "1"
|
||||
|
||||
PACK_LICENSES := "${THISDIR}/pack-licenses.sh"
|
||||
do_rootfs[file-checksums] += "${PACK_LICENSES}:True"
|
||||
|
||||
pack_licenses() {
|
||||
d="${IMAGE_ROOTFS}${datadir}/common-licenses"
|
||||
[ -d "$d" ] || bbfatal "pack_licenses: $d is missing (COPY_LIC_DIRS off?)"
|
||||
[ -f "$d/license.manifest" ] || bbfatal "pack_licenses: no license.manifest in $d"
|
||||
install -d "${IMAGE_ROOTFS}${datadir}/forgefirm"
|
||||
tar -C "${IMAGE_ROOTFS}${datadir}" --sort=name --mtime=@0 --owner=0 --group=0 \
|
||||
--numeric-owner -cf - common-licenses | gzip -9 -n \
|
||||
> "${IMAGE_ROOTFS}${datadir}/forgefirm/licenses.tar.gz"
|
||||
rm -rf "$d"
|
||||
sh "${PACK_LICENSES}" "${IMAGE_ROOTFS}${datadir}/common-licenses" \
|
||||
"${IMAGE_ROOTFS}${datadir}/forgefirm/licenses.tar.gz" \
|
||||
|| bbfatal "pack_licenses: pack-licenses.sh refused the license tree (its message is above)"
|
||||
}
|
||||
ROOTFS_POSTPROCESS_COMMAND += "pack_licenses "
|
||||
|
||||
# The rootfs lint: the image content fixed at build time, checked on the
|
||||
# tree as it is imaged, so a drift fails the build of the release image
|
||||
# too, which no campaign runs. rootfs-lint.sh, next to this recipe, holds
|
||||
# the rules. It runs after every other rootfs step (an append is applied
|
||||
# after every +=, the dev recipe's and debug-tweaks' included), and
|
||||
# FORGEFIRM_IMAGE_KIND tells it which image's policy applies.
|
||||
ROOTFS_LINT := "${THISDIR}/rootfs-lint.sh"
|
||||
do_rootfs[file-checksums] += "${ROOTFS_LINT}:True"
|
||||
FORGEFIRM_IMAGE_KIND ?= "release"
|
||||
|
||||
rootfs_lint() {
|
||||
sh "${ROOTFS_LINT}" "${IMAGE_ROOTFS}" "${FORGEFIRM_IMAGE_KIND}" \
|
||||
|| bbfatal "rootfs_lint: rootfs-lint.sh refused the rootfs (its messages are above)"
|
||||
}
|
||||
ROOTFS_POSTPROCESS_COMMAND:append = " rootfs_lint"
|
||||
|
||||
@@ -17,4 +17,4 @@
|
||||
# it to equal the rootfs stamp, the .fw meta-version and the release tag,
|
||||
# and the acceptance test image.health compares the stamp on the running
|
||||
# machine with the manifest's.
|
||||
FORGEFIRM_RELEASE ?= "0.0.6"
|
||||
FORGEFIRM_RELEASE ?= "0.0.9"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# pack-licenses.sh <common-licenses directory> <tar.gz>
|
||||
#
|
||||
# Packs the image's license tree into one reproducible tar.gz (sorted
|
||||
# names, no timestamps, no owners), its members under the tree's own
|
||||
# name, and removes the tree. It refuses, before it writes the tar or
|
||||
# removes anything, a tree that is missing, a tree with no
|
||||
# license.manifest, and a manifest that does not name the project's own
|
||||
# recipes: forgectrl, grblhal-glowforge and kernel-module-glowforge.
|
||||
# forgefirm-image.bb's pack_licenses runs it; forgetest's test_image.py
|
||||
# runs it on a fixture.
|
||||
|
||||
dir=${1:?common-licenses directory}
|
||||
out=${2:?output tar.gz}
|
||||
fail() { echo "pack-licenses: $*" >&2; exit 1; }
|
||||
|
||||
[ -d "$dir" ] || fail "$dir is missing (COPY_LIC_DIRS off?)"
|
||||
manifest=$dir/license.manifest
|
||||
[ -f "$manifest" ] || fail "no license.manifest in $dir"
|
||||
# A line counts with its newline: an unterminated last line is not read.
|
||||
lines=$(wc -l < "$manifest")
|
||||
for name in forgectrl grblhal-glowforge kernel-module-glowforge; do
|
||||
head -n "$lines" "$manifest" | grep -Fxq "RECIPE NAME: $name" \
|
||||
|| fail "$manifest does not name the recipe $name"
|
||||
done
|
||||
|
||||
base=$(basename "$dir")
|
||||
mkdir -p "$(dirname "$out")" || fail "cannot make the directory of $out"
|
||||
# tar runs the compressor itself, so its exit status covers both: a
|
||||
# member it cannot read fails the pack, and a failed pack leaves no bundle.
|
||||
tar -C "$(dirname "$dir")" --sort=name --mtime=@0 --owner=0 --group=0 \
|
||||
--numeric-owner -I 'gzip -9 -n' -cf "$out" "$base" \
|
||||
|| { rm -f "$out"; fail "cannot pack $dir into $out"; }
|
||||
# the tree goes only once the bundle holds its manifest
|
||||
tar -tzf "$out" "$base/license.manifest" > /dev/null 2>&1 \
|
||||
|| fail "$out holds no $base/license.manifest"
|
||||
rm -rf "$dir"
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# rootfs-lint.sh <rootfs> <release|dev>
|
||||
#
|
||||
# The image content that is fixed at build time, checked on the tree as it
|
||||
# is imaged: a failure here fails the build, on the release image too,
|
||||
# which no campaign runs. forgefirm-image.bb's rootfs_lint runs it last of
|
||||
# the rootfs steps; forgetest's test_image.py runs it on fixture trees.
|
||||
# Every rule is checked and every failure printed, one line each, before
|
||||
# it exits 1. The rules:
|
||||
#
|
||||
# syslog rsyslog is the only logger: /usr/sbin/rsyslogd is on the
|
||||
# image, the syslog init script starts it and runlevel 5
|
||||
# starts that script; no syslogd or klogd is linked in a bin
|
||||
# directory, and no init script runs one.
|
||||
# sshd every sshd configuration the image carries (sshd_config,
|
||||
# and sshd_config_readonly, which the read-only rootfs runs)
|
||||
# takes passwords (PasswordAuthentication yes) and sends no
|
||||
# pre-authentication banner; on a release image it refuses
|
||||
# root (PermitRootLogin no) and an empty password
|
||||
# (PermitEmptyPasswords no). Nothing in sshd_config.d sets
|
||||
# these, since an included value comes first.
|
||||
# dhcp-name the wlan0 DHCP stanza sends the machine's name:
|
||||
# udhcpc_opts carries -x hostname:$(hostname).
|
||||
# mdns no mDNS responder: no /etc/avahi, no avahi-daemon, no
|
||||
# libnss_mdns, and the hosts line of nsswitch.conf names no
|
||||
# mdns source.
|
||||
# init-order runlevel 5 starts forgectrl before forgeext, and runlevel 6
|
||||
# stops grblhal before forgectrl and forgeext before
|
||||
# forgectrl: the links sort in that order.
|
||||
# logging a ForgeFIRM logging init script is on the image
|
||||
# (forgefirm-logging or forgefirm-logrotate).
|
||||
# watchdog no userspace watchdog init script (/etc/init.d/watchdog):
|
||||
# the kernel feeds the hardware watchdog.
|
||||
# read-only /etc/default/rcS sets ROOTFS_READ_ONLY=yes.
|
||||
# dhcpv6 no DHCPv6 client (udhcpc6, dhcp6c, odhcp6c) in a bin
|
||||
# directory, and no /usr/share/udhcpc/default6.script.
|
||||
# factory on a release image, no /etc/fstab line mounts anything
|
||||
# under /factory/ (the dev image mounts the factory slots).
|
||||
|
||||
root=${1:?rootfs}
|
||||
kind=${2:?release or dev}
|
||||
case "$kind" in
|
||||
release|dev) ;;
|
||||
*) echo "rootfs-lint: the image kind is release or dev, not $kind" >&2; exit 2 ;;
|
||||
esac
|
||||
[ -d "$root" ] || { echo "rootfs-lint: $root is not a directory" >&2; exit 2; }
|
||||
|
||||
failed=0
|
||||
fail() { echo "rootfs-lint: $1: $2" >&2; failed=1; }
|
||||
|
||||
# The active lines of a file: no comment, no blank line.
|
||||
active() { grep -v -E '^[[:space:]]*(#|$)' "$1" 2>/dev/null; }
|
||||
|
||||
# ---------------------------------------------------------------- syslog
|
||||
[ -x "$root/usr/sbin/rsyslogd" ] || fail syslog "/usr/sbin/rsyslogd is not on the image"
|
||||
if [ -f "$root/etc/init.d/syslog" ]; then
|
||||
active "$root/etc/init.d/syslog" | grep -q '/usr/sbin/rsyslogd' \
|
||||
|| fail syslog "/etc/init.d/syslog does not start /usr/sbin/rsyslogd"
|
||||
else
|
||||
fail syslog "there is no /etc/init.d/syslog"
|
||||
fi
|
||||
started=
|
||||
for l in "$root"/etc/rc5.d/S*; do
|
||||
[ -L "$l" ] && [ "$(readlink "$l")" = "../init.d/syslog" ] && started=$l
|
||||
done
|
||||
[ -n "$started" ] || fail syslog "runlevel 5 does not start /etc/init.d/syslog"
|
||||
for d in bin sbin usr/bin usr/sbin; do
|
||||
for p in syslogd klogd; do
|
||||
if [ -e "$root/$d/$p" ] || [ -L "$root/$d/$p" ]; then
|
||||
fail syslog "/$d/$p is on the image beside rsyslog"
|
||||
fi
|
||||
done
|
||||
done
|
||||
for s in "$root"/etc/init.d/*; do
|
||||
[ -f "$s" ] || continue
|
||||
# syslogd or klogd as a word of its own: rsyslogd is not one
|
||||
if active "$s" | grep -q -E '(^|[^[:alnum:]_.-])(syslogd|klogd)([^[:alnum:]_.-]|$)'; then
|
||||
fail syslog "/etc/init.d/${s##*/} runs syslogd or klogd"
|
||||
fi
|
||||
done
|
||||
|
||||
# ------------------------------------------------------------------ sshd
|
||||
# The first value a configuration sets for a keyword before any Match
|
||||
# block: sshd takes the first one it reads. Keywords are case-insensitive,
|
||||
# and a value follows spaces or an equals sign.
|
||||
sshd_value() {
|
||||
awk -v k="$2" '
|
||||
{ line = $0; sub(/^[ \t]+/, "", line) }
|
||||
line == "" || line ~ /^#/ { next }
|
||||
{ n = split(line, f, /[ \t=]+/); key = tolower(f[1]) }
|
||||
key == "match" { exit }
|
||||
key == tolower(k) { print (n > 1 ? f[2] : ""); exit }' "$1"
|
||||
}
|
||||
configs=
|
||||
for c in sshd_config sshd_config_readonly; do
|
||||
[ -f "$root/etc/ssh/$c" ] && configs="$configs $c"
|
||||
done
|
||||
[ -n "$configs" ] || fail sshd "the image carries no /etc/ssh/sshd_config"
|
||||
for c in $configs; do
|
||||
f=$root/etc/ssh/$c
|
||||
v=$(sshd_value "$f" PasswordAuthentication)
|
||||
[ "$v" = yes ] || fail sshd "$c: PasswordAuthentication is '${v:-unset}', not yes"
|
||||
v=$(sshd_value "$f" Banner)
|
||||
[ -z "$v" ] || [ "$v" = none ] || fail sshd "$c: Banner $v sends a pre-authentication banner"
|
||||
if [ "$kind" = release ]; then
|
||||
v=$(sshd_value "$f" PermitRootLogin)
|
||||
[ "$v" = no ] || fail sshd "$c: PermitRootLogin is '${v:-unset}', not no"
|
||||
v=$(sshd_value "$f" PermitEmptyPasswords)
|
||||
[ "$v" = no ] || fail sshd "$c: PermitEmptyPasswords is '${v:-unset}', not no"
|
||||
fi
|
||||
done
|
||||
for f in "$root"/etc/ssh/sshd_config.d/*.conf; do
|
||||
[ -f "$f" ] || continue
|
||||
for k in PasswordAuthentication Banner PermitRootLogin PermitEmptyPasswords; do
|
||||
v=$(sshd_value "$f" "$k")
|
||||
[ -z "$v" ] || fail sshd "sshd_config.d/${f##*/} sets $k $v ahead of sshd_config"
|
||||
done
|
||||
done
|
||||
|
||||
# ------------------------------------------------------------- dhcp-name
|
||||
ifaces=$root/etc/network/interfaces
|
||||
if [ -f "$ifaces" ]; then
|
||||
# the udhcpc_opts lines of the wlan0 dhcp stanza, which runs to the
|
||||
# next stanza
|
||||
opts=$(awk '
|
||||
/^[ \t]*#/ { next }
|
||||
/^(iface|auto|allow-|mapping|source)/ { in_w = ($1 == "iface" && $2 == "wlan0" && $4 == "dhcp"); next }
|
||||
in_w && $1 == "udhcpc_opts" { sub(/^[ \t]*udhcpc_opts[ \t]+/, ""); print }' "$ifaces")
|
||||
case " $opts " in
|
||||
*' -x hostname:$(hostname) '*) ;;
|
||||
*) fail dhcp-name "the wlan0 dhcp stanza does not send -x hostname:\$(hostname) (udhcpc_opts: '${opts}')" ;;
|
||||
esac
|
||||
else
|
||||
fail dhcp-name "there is no /etc/network/interfaces"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ mdns
|
||||
[ -e "$root/etc/avahi" ] && fail mdns "/etc/avahi is on the image"
|
||||
for d in sbin usr/sbin; do
|
||||
[ -e "$root/$d/avahi-daemon" ] && fail mdns "/$d/avahi-daemon is on the image"
|
||||
done
|
||||
for d in lib usr/lib; do
|
||||
for l in "$root/$d"/libnss_mdns*; do
|
||||
[ -e "$l" ] && fail mdns "/$d/${l##*/} is on the image"
|
||||
done
|
||||
done
|
||||
if active "$root/etc/nsswitch.conf" | grep -E '^[[:space:]]*hosts:' | grep -q 'mdns'; then
|
||||
fail mdns "nsswitch.conf resolves hosts through mdns"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------ init-order
|
||||
# link <runlevel dir> <S|K> <name>: the first link of that kind whose name
|
||||
# ends in name, or nothing
|
||||
link() {
|
||||
for l in "$root/etc/$1"/"$2"*"$3"; do
|
||||
[ -L "$l" ] || [ -e "$l" ] || continue
|
||||
echo "${l##*/}"
|
||||
return
|
||||
done
|
||||
}
|
||||
# before <what> <a> <b>: a and b are both there and a sorts first
|
||||
before() {
|
||||
if [ -z "$2" ] || [ -z "$3" ]; then
|
||||
fail init-order "$1: a link is missing (${2:-none}, ${3:-none})"
|
||||
elif [ "$(printf '%s\n%s\n' "$2" "$3" | LC_ALL=C sort | head -n 1)" != "$2" ] || [ "$2" = "$3" ]; then
|
||||
fail init-order "$1: $2 does not sort before $3"
|
||||
fi
|
||||
}
|
||||
before "rc5.d starts forgectrl before forgeext" "$(link rc5.d S forgectrl)" "$(link rc5.d S forgeext)"
|
||||
before "rc6.d stops grblhal before forgectrl" "$(link rc6.d K grblhal)" "$(link rc6.d K forgectrl)"
|
||||
before "rc6.d stops forgeext before forgectrl" "$(link rc6.d K forgeext)" "$(link rc6.d K forgectrl)"
|
||||
|
||||
# --------------------------------------------------------------- logging
|
||||
[ -e "$root/etc/init.d/forgefirm-logging" ] || [ -e "$root/etc/init.d/forgefirm-logrotate" ] \
|
||||
|| fail logging "no ForgeFIRM logging init script (forgefirm-logging, forgefirm-logrotate)"
|
||||
|
||||
# -------------------------------------------------------------- watchdog
|
||||
[ -e "$root/etc/init.d/watchdog" ] && fail watchdog "/etc/init.d/watchdog is on the image"
|
||||
|
||||
# ------------------------------------------------------------- read-only
|
||||
grep -qx 'ROOTFS_READ_ONLY=yes' "$root/etc/default/rcS" 2>/dev/null \
|
||||
|| fail read-only "/etc/default/rcS does not set ROOTFS_READ_ONLY=yes"
|
||||
|
||||
# ---------------------------------------------------------------- dhcpv6
|
||||
for d in bin sbin usr/bin usr/sbin; do
|
||||
for p in udhcpc6 dhcp6c odhcp6c; do
|
||||
if [ -e "$root/$d/$p" ] || [ -L "$root/$d/$p" ]; then
|
||||
fail dhcpv6 "/$d/$p is on the image"
|
||||
fi
|
||||
done
|
||||
done
|
||||
[ -e "$root/usr/share/udhcpc/default6.script" ] && fail dhcpv6 "/usr/share/udhcpc/default6.script is on the image"
|
||||
|
||||
# --------------------------------------------------------------- factory
|
||||
if [ "$kind" = release ] && active "$root/etc/fstab" | awk '$2 ~ /^\/factory\// { f = 1 } END { exit !f }'; then
|
||||
fail factory "/etc/fstab mounts a factory slot under /factory/ on a release image"
|
||||
fi
|
||||
|
||||
exit $failed
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
From dc016daf1af41d0285c61d3c65332b43d0ca577a Mon Sep 17 00:00:00 2001
|
||||
From: Alexander Sosedkin <asosedkin@redhat.com>
|
||||
Date: Tue, 3 Mar 2026 19:47:25 +0100
|
||||
Subject: [PATCH] ktls: fix ChaCha20-Poly1305 IV passing for TLS 1.2
|
||||
|
||||
Upstream-Status: Backport [https://gitlab.com/gnutls/gnutls/-/commit/dc016daf1af41d0285c61d3c65332b43d0ca577a]
|
||||
|
||||
Signed-off-by: Alexander Sosedkin <asosedkin@redhat.com>
|
||||
---
|
||||
lib/system/ktls.c | 6 ++----
|
||||
1 file changed, 2 insertions(+), 4 deletions(-)
|
||||
|
||||
diff --git a/lib/system/ktls.c b/lib/system/ktls.c
|
||||
index a4c2a60df0..d531cfb85a 100644
|
||||
--- a/lib/system/ktls.c
|
||||
+++ b/lib/system/ktls.c
|
||||
@@ -679,10 +679,9 @@ int _gnutls_ktls_set_keys(gnutls_session_t session,
|
||||
assert(cipher_key.size ==
|
||||
TLS_CIPHER_CHACHA20_POLY1305_KEY_SIZE);
|
||||
|
||||
- /* for TLS 1.2 IV is generated in kernel */
|
||||
if (version == GNUTLS_TLS1_2) {
|
||||
crypto_info.info.version = TLS_1_2_VERSION;
|
||||
- memcpy(crypto_info.iv, seq_number,
|
||||
+ memcpy(crypto_info.iv, iv.data,
|
||||
TLS_CIPHER_CHACHA20_POLY1305_IV_SIZE);
|
||||
} else {
|
||||
crypto_info.info.version = TLS_1_3_VERSION;
|
||||
@@ -854,10 +853,9 @@ int _gnutls_ktls_set_keys(gnutls_session_t session,
|
||||
assert(cipher_key.size ==
|
||||
TLS_CIPHER_CHACHA20_POLY1305_KEY_SIZE);
|
||||
|
||||
- /* for TLS 1.2 IV is generated in kernel */
|
||||
if (version == GNUTLS_TLS1_2) {
|
||||
crypto_info.info.version = TLS_1_2_VERSION;
|
||||
- memcpy(crypto_info.iv, seq_number,
|
||||
+ memcpy(crypto_info.iv, iv.data,
|
||||
TLS_CIPHER_CHACHA20_POLY1305_IV_SIZE);
|
||||
} else {
|
||||
crypto_info.info.version = TLS_1_3_VERSION;
|
||||
--
|
||||
GitLab
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# GnuTLS's system-wide configuration. Kernel TLS on: after the handshake
|
||||
# the kernel seals and opens the records. A connection on a cipher that
|
||||
# kernel TLS does not take stays in user space.
|
||||
[global]
|
||||
ktls = true
|
||||
@@ -0,0 +1,31 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# Kernel TLS. GnuTLS runs the handshake, then gives the connection's keys
|
||||
# to the socket, and the kernel seals and opens every record after that:
|
||||
# ChaCha20-Poly1305 in NEON, AES-GCM with its AES on the CAAM crypto
|
||||
# engine and its GHASH in NEON (the kernel options are in the BSP's
|
||||
# kernel fragment). --enable-ktls builds the support in; /etc/gnutls/config
|
||||
# turns it on, because the library leaves it off without that line. A
|
||||
# connection on a cipher that kernel TLS does not take (AES-256-CCM, the
|
||||
# CBC suites) stays in GnuTLS. forgectrl is the only program on the image
|
||||
# that links GnuTLS (curl and Python use OpenSSL). The other build options
|
||||
# are in conf/distro/forgefirm.conf.
|
||||
#
|
||||
# The backport: 3.8.4 hands the kernel the record sequence number where the
|
||||
# connection's IV belongs for ChaCha20-Poly1305 over TLS 1.2, so every such
|
||||
# connection fails the kernel's first decryption. Fixed upstream after 3.8.4.
|
||||
FILESEXTRAPATHS:prepend := "${THISDIR}/files:"
|
||||
|
||||
SRC_URI:append:class-target = " file://config \
|
||||
file://0001-ktls-fix-ChaCha20-Poly1305-IV-passing-for-TLS-1.2.patch"
|
||||
|
||||
EXTRA_OECONF:append:class-target = " --enable-ktls"
|
||||
|
||||
do_install:append:class-target() {
|
||||
install -d ${D}${sysconfdir}/gnutls
|
||||
install -m 0644 ${WORKDIR}/config ${D}${sysconfdir}/gnutls/config
|
||||
}
|
||||
|
||||
FILES:${PN}:append:class-target = " ${sysconfdir}/gnutls/config"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
# ForgeFIRM acceptance - 20260928194539 (dev)
|
||||
|
||||
- Image: `20260928194539 (dev)` (forgefirm-image-dev)
|
||||
- Manifest identity: `e7ca694f20116a46222907e7ba9222021efd2c007f9394f8b3bb9f3ff14875ba`
|
||||
- Catalog: `32019673226a83358d90ee62a4a22b9ac46f730c9185343e0019ab401a6e463f`
|
||||
- Campaign: `c-20260929000044-e1ea` opened 2026-09-29T00:00:44Z
|
||||
- Exported: 2026-09-29T00:07:08Z
|
||||
- **Release authorized: YES**
|
||||
- Tests: 127 total, 127 satisfied (120 inherited), 0 required
|
||||
|
||||
| Test | Kind | Result | Run at | Campaign | Inherited |
|
||||
|---|---|---|---|---|---|
|
||||
| `image.health` | auto, core | PASS | 2026-09-29T00:00:45Z | `c-20260929000044-e1ea` | no |
|
||||
| `image.license-bundle` | auto | PASS | 2026-09-28T16:23:38Z | `c-20260928162331-3969` | yes |
|
||||
| `image.network-boot` | auto | PASS | 2026-09-28T16:23:38Z | `c-20260928162331-3969` | yes |
|
||||
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-29T00:00:46Z | `c-20260929000044-e1ea` | no |
|
||||
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-29T00:01:07Z | `c-20260929000044-e1ea` | no |
|
||||
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-29T00:01:17Z | `c-20260929000044-e1ea` | no |
|
||||
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-29T00:01:35Z | `c-20260929000044-e1ea` | no |
|
||||
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-29T00:02:18Z | `c-20260929000044-e1ea` | no |
|
||||
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-28T16:25:37Z | `c-20260928162331-3969` | yes |
|
||||
| `kernel.pic-soc-load` | auto | PASS | 2026-09-28T16:25:41Z | `c-20260928162331-3969` | yes |
|
||||
| `forgectrl.auth` | auto | PASS | 2026-09-28T16:25:42Z | `c-20260928162331-3969` | yes |
|
||||
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-28T16:25:43Z | `c-20260928162331-3969` | yes |
|
||||
| `forgectrl.panel-serves` | auto | PASS | 2026-09-28T16:23:34Z | `c-20260928162331-3969` | yes |
|
||||
| `events.stream` | auto | PASS | 2026-09-28T16:25:54Z | `c-20260928162331-3969` | yes |
|
||||
| `forgectrl.lease` | auto | PASS | 2026-09-28T16:25:58Z | `c-20260928162331-3969` | yes |
|
||||
| `forgectrl.tokens` | auto | PASS | 2026-09-28T16:26:02Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-28T16:26:20Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.override-until-reboot` | auto | PASS | 2026-09-28T16:26:21Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.advisories-rehash` | auto, takeover | PASS | 2026-09-28T16:26:30Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.account-login` | auto, takeover | PASS | 2026-09-28T16:27:19Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.https-only-writes` | auto | PASS | 2026-09-28T16:27:21Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.ssh-until-reboot` | auto | PASS | 2026-09-28T16:27:22Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.cloud-disabled-surface` | auto | PASS | 2026-09-28T16:27:23Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.extensions-consent` | auto, takeover | PASS | 2026-09-28T16:27:32Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.factory-return` | auto | PASS | 2026-09-28T16:27:35Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.machine-name` | auto | PASS | 2026-09-28T16:27:36Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.first-run-flow` | operator, takeover | PASS | 2026-09-28T16:27:57Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.first-run-page` | operator, takeover | PASS | 2026-09-28T18:52:23Z | `c-20260928183105-64ee` | yes |
|
||||
| `setup.cert-page` | auto | PASS | 2026-09-28T16:27:57Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.what-changed` | auto, takeover | PASS | 2026-09-28T16:28:07Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.record-export` | auto | PASS | 2026-09-28T16:29:50Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.mirror` | auto, takeover | PASS | 2026-09-28T16:30:23Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.check-switches` | operator | PASS | 2026-09-28T16:30:27Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.check-sensors` | auto | PASS | 2026-09-28T16:30:02Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.check-airflow` | auto, takeover | PASS | 2026-09-28T16:31:59Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.check-cameras` | operator | PASS | 2026-09-28T16:32:30Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.check-motion` | auto, takeover | PASS | 2026-09-28T16:33:06Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.check-flow-verify` | auto, takeover | PASS | 2026-09-28T16:40:24Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.cloud-header-capture` | operator, takeover | PASS | 2026-09-28T18:53:27Z | `c-20260928183105-64ee` | yes |
|
||||
| `setup.sheet` | live, takeover | PASS | 2026-09-28T19:06:20Z | `c-20260928183105-64ee` | yes |
|
||||
| `logs.tree-tail-export` | auto | PASS | 2026-09-28T16:28:59Z | `c-20260928162331-3969` | yes |
|
||||
| `logs.routing` | auto | PASS | 2026-09-28T16:40:28Z | `c-20260928162331-3969` | yes |
|
||||
| `logs.level-settings` | auto | PASS | 2026-09-28T16:40:29Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.pacing` | auto | PASS | 2026-09-28T16:32:45Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.jog-roundtrip` | auto | PASS | 2026-09-28T16:40:44Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.microstep-modes` | auto | PASS | 2026-09-28T16:41:09Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.liveness-probe` | auto | PASS | 2026-09-28T16:41:18Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-28T16:41:34Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.cancel-abort` | auto | PASS | 2026-09-28T16:41:41Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.deadman` | auto | PASS | 2026-09-28T16:42:27Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.respawn-gate` | operator | PASS | 2026-09-28T16:42:35Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.soft-limits` | auto | PASS | 2026-09-28T16:53:34Z | `c-20260928165212-b92e` | yes |
|
||||
| `motion.button-hold-resume` | operator | PASS | 2026-09-28T17:12:49Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.lid-cancel-home` | operator | PASS | 2026-09-28T17:13:04Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-28T17:13:12Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.lid-policy-hold` | operator | PASS | 2026-09-28T17:13:26Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.step-timing-under-load` | auto | PASS | 2026-09-28T17:13:56Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.release` | auto, takeover | PASS | 2026-09-28T17:14:23Z | `c-20260928171100-3ff5` | yes |
|
||||
| `homing.manual` | auto | PASS | 2026-09-28T17:14:35Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.port-jog` | auto | PASS | 2026-09-28T17:14:55Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.job` | auto | PASS | 2026-09-28T17:15:05Z | `c-20260928171100-3ff5` | yes |
|
||||
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-28T16:34:38Z | `c-20260928162331-3969` | yes |
|
||||
| `cooling.flow-verify` | auto | PASS | 2026-09-28T16:37:19Z | `c-20260928162331-3969` | yes |
|
||||
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-28T17:16:01Z | `c-20260928171100-3ff5` | yes |
|
||||
| `cooling.gate-off` | auto | PASS | 2026-09-28T16:30:35Z | `c-20260928162331-3969` | yes |
|
||||
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-28T17:17:23Z | `c-20260928171100-3ff5` | yes |
|
||||
| `cooling.tec-drive` | auto | PASS | 2026-09-28T17:17:33Z | `c-20260928171100-3ff5` | yes |
|
||||
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-28T17:17:48Z | `c-20260928171100-3ff5` | yes |
|
||||
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-28T17:17:55Z | `c-20260928171100-3ff5` | yes |
|
||||
| `cooling.critical-tier` | auto | PASS | 2026-09-28T17:18:04Z | `c-20260928171100-3ff5` | yes |
|
||||
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-28T16:31:18Z | `c-20260928162331-3969` | yes |
|
||||
| `cooling.flow-under-load` | live | PASS | 2026-09-28T19:09:14Z | `c-20260928183105-64ee` | yes |
|
||||
| `cooling.fan-duty-readback` | auto | PASS | 2026-09-28T17:18:21Z | `c-20260928171100-3ff5` | yes |
|
||||
| `cooling.fail-tier-stop` | operator | PASS | 2026-09-28T17:18:41Z | `c-20260928171100-3ff5` | yes |
|
||||
| `cooling.report-channel` | auto | PASS | 2026-09-28T17:18:51Z | `c-20260928171100-3ff5` | yes |
|
||||
| `laser.power-floor` | auto | PASS | 2026-09-28T17:18:52Z | `c-20260928171100-3ff5` | yes |
|
||||
| `laser.emission-witness` | live, core | PASS | 2026-09-29T00:03:33Z | `c-20260929000044-e1ea` | no |
|
||||
| `laser.m5-rapid-dark` | live | PASS | 2026-09-28T19:10:52Z | `c-20260928183105-64ee` | yes |
|
||||
| `laser.disarm-in-hold` | live | PASS | 2026-09-28T19:12:23Z | `c-20260928183105-64ee` | yes |
|
||||
| `laser.armed-kill` | live | PASS | 2026-09-28T19:13:11Z | `c-20260928183105-64ee` | yes |
|
||||
| `laser.verdict-cut` | live | PASS | 2026-09-28T19:13:49Z | `c-20260928183105-64ee` | yes |
|
||||
| `laser.arm-wait-lid` | operator | PASS | 2026-09-28T17:18:55Z | `c-20260928171100-3ff5` | yes |
|
||||
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-28T19:14:46Z | `c-20260928183105-64ee` | yes |
|
||||
| `laser.port-dark` | live | PASS | 2026-09-28T19:15:23Z | `c-20260928183105-64ee` | yes |
|
||||
| `laser.recorder-dark` | auto | PASS | 2026-09-28T17:19:15Z | `c-20260928171100-3ff5` | yes |
|
||||
| `camera.snapshot` | auto | PASS | 2026-09-28T16:32:16Z | `c-20260928162331-3969` | yes |
|
||||
| `camera.sensor-profile` | auto | PASS | 2026-09-28T17:19:28Z | `c-20260928171100-3ff5` | yes |
|
||||
| `camera.h264-stream` | auto | PASS | 2026-09-28T17:19:42Z | `c-20260928171100-3ff5` | yes |
|
||||
| `camera.frame-health` | auto | PASS | 2026-09-28T17:20:00Z | `c-20260928171100-3ff5` | yes |
|
||||
| `camera.lid-privacy` | operator | PASS | 2026-09-28T17:20:16Z | `c-20260928171100-3ff5` | yes |
|
||||
| `camera.key-read` | auto | PASS | 2026-09-28T17:20:17Z | `c-20260928171100-3ff5` | yes |
|
||||
| `update.slots-and-signature` | auto | PASS | 2026-09-28T16:27:34Z | `c-20260928162331-3969` | yes |
|
||||
| `update.product-gate` | auto | PASS | 2026-09-28T17:20:19Z | `c-20260928171100-3ff5` | yes |
|
||||
| `update.release-check` | auto | PASS | 2026-09-28T17:20:20Z | `c-20260928171100-3ff5` | yes |
|
||||
| `cloud.mode-switch` | operator | PASS | 2026-09-28T16:45:24Z | `c-20260928162331-3969` | yes |
|
||||
| `cloud.service-protocol` | operator | PASS | 2026-09-28T19:17:20Z | `c-20260928183105-64ee` | yes |
|
||||
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-28T19:19:36Z | `c-20260928183105-64ee` | yes |
|
||||
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-28T17:21:44Z | `c-20260928171100-3ff5` | yes |
|
||||
| `cloud.dark-print` | operator | PASS | 2026-09-28T17:22:40Z | `c-20260928171100-3ff5` | yes |
|
||||
| `cloud.verdict-refuse` | operator | PASS | 2026-09-28T17:24:02Z | `c-20260928171100-3ff5` | yes |
|
||||
| `cloud.pause-resume` | live | PASS | 2026-09-28T19:22:10Z | `c-20260928183105-64ee` | yes |
|
||||
| `cloud.oversize-stream` | live | PASS | 2026-09-28T19:25:42Z | `c-20260928183105-64ee` | yes |
|
||||
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-28T19:26:35Z | `c-20260928183105-64ee` | yes |
|
||||
| `exthost.platform` | auto | PASS | 2026-09-28T18:05:37Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.service` | auto, takeover | PASS | 2026-09-28T18:06:28Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.armed-freeze` | operator, takeover | PASS | 2026-09-28T18:08:17Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.hold-pause-tier` | auto, takeover | PASS | 2026-09-28T18:09:57Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.events` | auto, takeover | PASS | 2026-09-28T18:10:19Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.motion-jog` | auto, takeover | PASS | 2026-09-28T18:10:39Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.ui-delivery` | auto | PASS | 2026-09-28T18:10:48Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.motion-job` | auto, takeover | PASS | 2026-09-28T18:11:06Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.package-routes` | auto | PASS | 2026-09-28T18:11:09Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.panel-install` | operator | PASS | 2026-09-28T18:11:12Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.core-range` | auto | PASS | 2026-09-28T18:11:13Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.page-call` | auto | PASS | 2026-09-28T18:11:30Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.operator-destinations` | auto | PASS | 2026-09-28T18:11:51Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.lifecycle` | auto | PASS | 2026-09-28T18:12:06Z | `c-20260928180313-75fb` | yes |
|
||||
| `events.button-telemetry` | auto | PASS | 2026-09-28T18:12:19Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.catalog` | auto | PASS | 2026-09-28T18:12:20Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.mcode` | auto | PASS | 2026-09-28T18:12:39Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.wizard` | auto | PASS | 2026-09-28T18:13:09Z | `c-20260928180313-75fb` | yes |
|
||||
| `update.job-locks` | auto | PASS | 2026-09-28T18:13:11Z | `c-20260928180313-75fb` | yes |
|
||||
| `homing.cloud-offsets` | auto | PASS | 2026-09-28T18:14:18Z | `c-20260928180313-75fb` | yes |
|
||||
| `setup.check-envelope` | auto | PASS | 2026-09-28T18:16:06Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.sender-keep-out` | auto | PASS | 2026-09-28T18:16:39Z | `c-20260928180313-75fb` | yes |
|
||||
| `motion.tray` | auto | PASS | 2026-09-28T18:16:42Z | `c-20260928180313-75fb` | yes |
|
||||
| `forgectrl.tls-records` | auto | PASS | 2026-09-28T18:33:03Z | `c-20260928183105-64ee` | yes |
|
||||
|
||||
Artifact sha256: `550c90e31d327d69f16407ad7539c09cd34c08711fdb62dd479321db2d5c07da`
|
||||
@@ -0,0 +1,15 @@
|
||||
ForgeFIRM v0.0.7 is a beta release.
|
||||
|
||||
ForgeFIRM is now extensible. New capabilities and features can be created as extensions without requiring major changes to the core firmware. This is intended to promote 3rd-party feature development while allowing the core firmware to remain light and stable. Each extension runs in a sandbox with only the permissions the user grants it. Packages can be uploaded or installed from a new catalog.
|
||||
|
||||
Also new:
|
||||
|
||||
- Manual homing: Allows you to home the machine by moving the gantry and head to their home positions against gantry stops. Cheap, repeatable and effective.
|
||||
- Crumb Tray Removal: Toggling whether or not the crumb tray is installed will adjust the Z height for accurate, calculation-free focus.
|
||||
- A new API interface lets your own programs read the status, follow the machine's events, see a camera, jog the head with the laser off, or run jobs (operator button press still required).
|
||||
- Improved the video pipeline and enabled hardware-accelerated TLS encryption to reduce CPU overhead.
|
||||
- New Extension: Adjust the laser start point using the head camera for precision alignment.
|
||||
|
||||
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
|
||||
|
||||
Every release below 0.1.0 is a beta release. Installation, usage, and safety are documented at https://docs.forgefirm.org/.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
# ForgeFIRM acceptance - 20261010224118 (dev)
|
||||
|
||||
- Image: `20261010224118 (dev)` (forgefirm-image-dev)
|
||||
- Manifest identity: `c25844db9b022c976fbc02f5db9dc46bc2cde776cd6a9c90e7d789214ebe257e`
|
||||
- Catalog: `ee8534fed1b8e932ad5a9367f1b38844c9bdce1abe625ba9346bc042a5bd7b1d`
|
||||
- Campaign: `c-20261010224522-439d` opened 2026-10-10T22:45:22Z
|
||||
- Exported: 2026-10-10T23:40:44Z
|
||||
- **Release authorized: YES**
|
||||
- Tests: 128 total, 128 satisfied (80 inherited), 0 required
|
||||
|
||||
| Test | Kind | Result | Run at | Campaign | Inherited |
|
||||
|---|---|---|---|---|---|
|
||||
| `image.health` | auto, core | PASS | 2026-10-10T22:45:22Z | `c-20261010224522-439d` | no |
|
||||
| `image.license-bundle` | auto | PASS | 2026-10-10T21:42:55Z | `c-20261010214232-4790` | yes |
|
||||
| `image.network-boot` | auto | PASS | 2026-09-28T16:23:38Z | `c-20260928162331-3969` | yes |
|
||||
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-10-10T22:45:23Z | `c-20261010224522-439d` | no |
|
||||
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-10-10T22:45:45Z | `c-20261010224522-439d` | no |
|
||||
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-10-10T22:45:55Z | `c-20261010224522-439d` | no |
|
||||
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-10-10T22:46:15Z | `c-20261010224522-439d` | no |
|
||||
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-10-10T22:46:59Z | `c-20261010224522-439d` | no |
|
||||
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-28T16:25:37Z | `c-20260928162331-3969` | yes |
|
||||
| `kernel.pic-soc-load` | auto | PASS | 2026-09-28T16:25:41Z | `c-20260928162331-3969` | yes |
|
||||
| `forgectrl.auth` | auto | PASS | 2026-10-10T21:45:06Z | `c-20261010214232-4790` | yes |
|
||||
| `forgectrl.settings-bounds` | auto | PASS | 2026-10-10T21:45:13Z | `c-20261010214232-4790` | yes |
|
||||
| `forgectrl.panel-serves` | auto | PASS | 2026-10-10T21:42:46Z | `c-20261010214232-4790` | yes |
|
||||
| `events.stream` | auto | PASS | 2026-10-10T21:45:29Z | `c-20261010214232-4790` | yes |
|
||||
| `forgectrl.lease` | auto | PASS | 2026-10-10T21:45:39Z | `c-20261010214232-4790` | yes |
|
||||
| `forgectrl.tokens` | auto | PASS | 2026-10-10T21:45:48Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.gate-blocks-controllers` | auto, takeover | PASS | 2026-10-10T21:46:11Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.override-until-reboot` | auto | PASS | 2026-10-10T21:46:18Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.advisories-rehash` | auto, takeover | PASS | 2026-10-10T21:46:33Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.account-login` | auto, takeover | PASS | 2026-10-10T21:47:28Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.https-only-writes` | auto | PASS | 2026-10-10T21:47:35Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.ssh-until-reboot` | auto | PASS | 2026-10-10T21:47:42Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.cloud-disabled-surface` | auto | PASS | 2026-10-10T21:47:49Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.extensions-consent` | auto, takeover | PASS | 2026-10-10T21:48:04Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.factory-return` | auto | PASS | 2026-10-10T21:48:11Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.machine-name` | auto | PASS | 2026-09-28T16:27:36Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.first-run-flow` | operator, takeover | PASS | 2026-10-10T22:47:21Z | `c-20261010224522-439d` | no |
|
||||
| `setup.first-run-page` | operator, takeover | PASS | 2026-09-28T18:52:23Z | `c-20260928183105-64ee` | yes |
|
||||
| `setup.cert-page` | auto | PASS | 2026-10-10T21:48:17Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.what-changed` | auto, takeover | PASS | 2026-10-10T21:48:33Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.record-export` | auto | PASS | 2026-10-10T21:50:38Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.mirror` | auto, takeover | PASS | 2026-10-10T21:51:21Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.check-switches` | operator | PASS | 2026-10-10T22:47:26Z | `c-20261010224522-439d` | no |
|
||||
| `setup.check-sensors` | auto | PASS | 2026-10-10T21:50:55Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.check-airflow` | auto, takeover | PASS | 2026-10-10T21:53:11Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.check-cameras` | operator | PASS | 2026-10-10T22:47:39Z | `c-20261010224522-439d` | no |
|
||||
| `setup.check-motion` | auto, takeover | PASS | 2026-10-10T21:53:38Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.check-flow-verify` | auto, takeover | PASS | 2026-10-10T22:01:19Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.cloud-header-capture` | operator, takeover | PASS | 2026-10-10T23:04:58Z | `c-20261010224522-439d` | no |
|
||||
| `setup.sheet` | live, takeover | PASS | 2026-10-10T23:18:06Z | `c-20261010224522-439d` | no |
|
||||
| `logs.tree-tail-export` | auto | PASS | 2026-10-10T21:49:36Z | `c-20261010214232-4790` | yes |
|
||||
| `logs.routing` | auto | PASS | 2026-09-28T16:40:28Z | `c-20260928162331-3969` | yes |
|
||||
| `logs.level-settings` | auto | PASS | 2026-10-10T22:01:26Z | `c-20261010214232-4790` | yes |
|
||||
| `motion.pacing` | auto | PASS | 2026-09-28T16:32:45Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.jog-roundtrip` | auto | PASS | 2026-09-28T16:40:44Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.microstep-modes` | auto | PASS | 2026-10-10T22:01:57Z | `c-20261010214232-4790` | yes |
|
||||
| `motion.liveness-probe` | auto | PASS | 2026-09-28T16:41:18Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.gate-waits-for-lid` | operator | PASS | 2026-10-10T22:47:56Z | `c-20261010224522-439d` | no |
|
||||
| `motion.cancel-abort` | auto | PASS | 2026-09-28T16:41:41Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.deadman` | auto | PASS | 2026-10-10T22:02:49Z | `c-20261010214232-4790` | yes |
|
||||
| `motion.respawn-gate` | operator | PASS | 2026-09-28T16:42:35Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.soft-limits` | auto | PASS | 2026-09-28T16:53:34Z | `c-20260928165212-b92e` | yes |
|
||||
| `motion.button-hold-resume` | operator | PASS | 2026-09-28T17:12:49Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.lid-cancel-home` | operator | PASS | 2026-09-28T17:13:04Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-28T17:13:12Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.lid-policy-hold` | operator | PASS | 2026-09-28T17:13:26Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.step-timing-under-load` | auto | PASS | 2026-09-28T17:13:56Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.release` | auto, takeover | PASS | 2026-10-10T22:03:22Z | `c-20261010214232-4790` | yes |
|
||||
| `homing.manual` | auto | PASS | 2026-10-10T22:03:39Z | `c-20261010214232-4790` | yes |
|
||||
| `motion.port-jog` | auto | PASS | 2026-10-10T22:04:05Z | `c-20261010214232-4790` | yes |
|
||||
| `motion.job` | auto | PASS | 2026-10-10T22:04:21Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-10-10T21:55:16Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.flow-verify` | auto | PASS | 2026-10-10T21:58:05Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-10-10T22:05:23Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.gate-off` | auto | PASS | 2026-10-10T21:51:35Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.floor-and-warm-up` | auto | PASS | 2026-10-10T22:06:55Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.tec-drive` | auto | PASS | 2026-10-10T22:07:10Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.fire-watch-tiers` | auto | PASS | 2026-10-10T22:07:31Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-10-10T22:07:44Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.critical-tier` | auto | PASS | 2026-10-10T22:07:58Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.fan-gate-trips` | auto | PASS | 2026-10-10T21:52:25Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.flow-under-load` | live | PASS | 2026-10-10T23:20:48Z | `c-20261010224522-439d` | no |
|
||||
| `cooling.fan-duty-readback` | auto | PASS | 2026-10-10T22:08:19Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.fail-tier-stop` | operator | PASS | 2026-10-10T22:48:15Z | `c-20261010224522-439d` | no |
|
||||
| `cooling.report-channel` | auto | PASS | 2026-10-10T22:08:36Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.intake-one-fan` | auto | PASS | 2026-10-10T22:09:20Z | `c-20261010214232-4790` | yes |
|
||||
| `laser.power-floor` | auto | PASS | 2026-10-10T22:09:27Z | `c-20261010214232-4790` | yes |
|
||||
| `laser.emission-witness` | live, core | PASS | 2026-10-10T23:05:49Z | `c-20261010224522-439d` | no |
|
||||
| `laser.m5-rapid-dark` | live | PASS | 2026-10-10T23:21:48Z | `c-20261010224522-439d` | no |
|
||||
| `laser.disarm-in-hold` | live | PASS | 2026-10-10T23:23:20Z | `c-20261010224522-439d` | no |
|
||||
| `laser.armed-kill` | live | PASS | 2026-10-10T23:24:03Z | `c-20261010224522-439d` | no |
|
||||
| `laser.verdict-cut` | live | PASS | 2026-10-10T23:25:09Z | `c-20261010224522-439d` | no |
|
||||
| `laser.arm-wait-lid` | operator | PASS | 2026-10-10T22:48:33Z | `c-20261010224522-439d` | no |
|
||||
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-10-10T23:26:26Z | `c-20261010224522-439d` | no |
|
||||
| `laser.port-dark` | live | PASS | 2026-10-10T23:27:10Z | `c-20261010224522-439d` | no |
|
||||
| `laser.recorder-dark` | auto | PASS | 2026-10-10T22:09:54Z | `c-20261010214232-4790` | yes |
|
||||
| `camera.snapshot` | auto | PASS | 2026-10-10T22:10:16Z | `c-20261010214232-4790` | yes |
|
||||
| `camera.sensor-profile` | auto | PASS | 2026-10-10T22:10:36Z | `c-20261010214232-4790` | yes |
|
||||
| `camera.h264-stream` | auto | PASS | 2026-10-10T22:10:55Z | `c-20261010214232-4790` | yes |
|
||||
| `camera.frame-health` | auto | PASS | 2026-10-10T22:11:19Z | `c-20261010214232-4790` | yes |
|
||||
| `camera.lid-privacy` | operator | PASS | 2026-10-10T22:48:49Z | `c-20261010224522-439d` | no |
|
||||
| `camera.key-read` | auto | PASS | 2026-10-10T22:11:26Z | `c-20261010214232-4790` | yes |
|
||||
| `update.slots-and-signature` | auto | PASS | 2026-09-28T16:27:34Z | `c-20260928162331-3969` | yes |
|
||||
| `update.product-gate` | auto | PASS | 2026-09-28T17:20:19Z | `c-20260928171100-3ff5` | yes |
|
||||
| `update.release-check` | auto | PASS | 2026-10-10T22:11:33Z | `c-20261010214232-4790` | yes |
|
||||
| `cloud.mode-switch` | operator | PASS | 2026-10-10T22:51:05Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.service-protocol` | operator | PASS | 2026-10-10T23:28:24Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.lid-interlock-abort` | live | PASS | 2026-10-10T23:30:40Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.lid-during-button-wait` | operator | PASS | 2026-10-10T22:52:29Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.dark-print` | operator | PASS | 2026-10-10T22:53:25Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.verdict-refuse` | operator | PASS | 2026-10-10T22:54:47Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.pause-resume` | live | PASS | 2026-10-10T23:34:32Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.oversize-stream` | live | PASS | 2026-10-10T23:38:02Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.paused-lid-cancel` | live | PASS | 2026-10-10T23:38:55Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.platform` | auto | PASS | 2026-09-28T18:05:37Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.service` | auto, takeover | PASS | 2026-10-10T22:12:20Z | `c-20261010214232-4790` | yes |
|
||||
| `exthost.armed-freeze` | operator, takeover | PASS | 2026-10-10T22:56:02Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.hold-pause-tier` | auto, takeover | PASS | 2026-10-10T22:14:00Z | `c-20261010214232-4790` | yes |
|
||||
| `exthost.events` | operator, takeover | PASS | 2026-10-10T22:56:23Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.motion-jog` | auto, takeover | PASS | 2026-10-10T22:56:43Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.ui-delivery` | auto | PASS | 2026-10-10T22:56:52Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.motion-job` | auto, takeover | PASS | 2026-09-28T18:11:06Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.package-routes` | auto | PASS | 2026-10-10T22:56:54Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.panel-install` | operator | PASS | 2026-10-10T22:56:58Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.core-range` | auto | PASS | 2026-09-28T18:11:13Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.page-call` | auto | PASS | 2026-10-10T22:57:14Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.operator-destinations` | auto | PASS | 2026-10-10T22:57:36Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.lifecycle` | auto | PASS | 2026-09-28T18:12:06Z | `c-20260928180313-75fb` | yes |
|
||||
| `events.button-telemetry` | operator | PASS | 2026-10-10T22:57:48Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.catalog` | auto | PASS | 2026-10-10T22:57:50Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.mcode` | auto | PASS | 2026-09-28T18:12:39Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.wizard` | auto | PASS | 2026-10-10T22:58:18Z | `c-20261010224522-439d` | no |
|
||||
| `update.job-locks` | auto | PASS | 2026-09-28T18:13:11Z | `c-20260928180313-75fb` | yes |
|
||||
| `homing.cloud-offsets` | auto | PASS | 2026-10-10T22:59:11Z | `c-20261010224522-439d` | no |
|
||||
| `setup.check-envelope` | auto | PASS | 2026-10-10T23:00:57Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.sender-keep-out` | auto | PASS | 2026-10-10T23:01:30Z | `c-20261010224522-439d` | no |
|
||||
| `motion.tray` | auto | PASS | 2026-10-10T23:01:33Z | `c-20261010224522-439d` | no |
|
||||
| `forgectrl.tls-records` | auto | PASS | 2026-10-10T23:01:37Z | `c-20261010224522-439d` | no |
|
||||
|
||||
Artifact sha256: `c8b5e4c24a59e92337e0ec9607343b64b0d8193919efca7cd0b3d18b3c4d31f7`
|
||||
@@ -0,0 +1,7 @@
|
||||
ForgeFIRM v0.0.8 is a beta release.
|
||||
|
||||
Machines with one intake fan: some Glowforge machines are built with a single intake fan instead of two. On those machines, the Airflow step of setup stopped with an error because the second fan never turned. Setup now detects a single intake fan, tells you so, and finishes. The second intake is then not checked while the machine runs. Machines with two intake fans work as before.
|
||||
|
||||
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
|
||||
|
||||
Every release below 0.1.0 is a beta release. Installation, usage, and safety are documented at https://docs.forgefirm.org/.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
# ForgeFIRM acceptance - 20261018222431 (dev)
|
||||
|
||||
- Image: `20261018222431 (dev)` (forgefirm-image-dev)
|
||||
- Manifest identity: `70ecd7867c2698d17f2f3c38db772868eda3364f541242c93fb5db4e1d593bcd`
|
||||
- Catalog: `b38332a30994e16a499a9ded530062291bacabf873138461dd54421e2174c29b`
|
||||
- Campaign: `c-20261018224638-4ca0` opened 2026-10-18T22:46:38Z
|
||||
- Exported: 2026-10-18T23:33:36Z
|
||||
- **Release authorized: YES**
|
||||
- Tests: 43 total, 43 satisfied (0 inherited), 0 required
|
||||
|
||||
| Test | Kind | Result | Run at | Campaign | Inherited |
|
||||
|---|---|---|---|---|---|
|
||||
| `image.health` | auto, core | PASS | 2026-10-18T22:46:39Z | `c-20261018224638-4ca0` | no |
|
||||
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-10-18T22:46:39Z | `c-20261018224638-4ca0` | no |
|
||||
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-10-18T22:47:02Z | `c-20261018224638-4ca0` | no |
|
||||
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-10-18T22:47:12Z | `c-20261018224638-4ca0` | no |
|
||||
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-10-18T22:47:30Z | `c-20261018224638-4ca0` | no |
|
||||
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-10-18T22:48:14Z | `c-20261018224638-4ca0` | no |
|
||||
| `kernel.resume-lead` | auto, takeover | PASS | 2026-10-18T22:48:37Z | `c-20261018224638-4ca0` | no |
|
||||
| `kernel.pic-soc-load` | auto | PASS | 2026-10-18T22:48:41Z | `c-20261018224638-4ca0` | no |
|
||||
| `forgectrl.auth` | auto | PASS | 2026-10-18T22:48:42Z | `c-20261018224638-4ca0` | no |
|
||||
| `setup.check-switches` | operator | PASS | 2026-10-18T22:48:47Z | `c-20261018224638-4ca0` | no |
|
||||
| `setup.check-airflow` | auto, takeover | PASS | 2026-10-18T22:49:27Z | `c-20261018224638-4ca0` | no |
|
||||
| `setup.check-motion` | auto, takeover | PASS | 2026-10-18T22:50:03Z | `c-20261018224638-4ca0` | no |
|
||||
| `setup.check-lens` | auto, takeover | PASS | 2026-10-18T22:50:24Z | `c-20261018224638-4ca0` | no |
|
||||
| `setup.cloud-header-capture` | operator, takeover | PASS | 2026-10-18T23:08:40Z | `c-20261018224638-4ca0` | no |
|
||||
| `setup.focus` | live, takeover | PASS | 2026-10-18T23:11:20Z | `c-20261018224638-4ca0` | no |
|
||||
| `motion.pacing` | auto | PASS | 2026-10-18T22:49:43Z | `c-20261018224638-4ca0` | no |
|
||||
| `motion.jog-roundtrip` | auto | PASS | 2026-10-18T22:50:42Z | `c-20261018224638-4ca0` | no |
|
||||
| `motion.microstep-modes` | auto | PASS | 2026-10-18T22:51:07Z | `c-20261018224638-4ca0` | no |
|
||||
| `motion.liveness-probe` | auto | PASS | 2026-10-18T22:51:16Z | `c-20261018224638-4ca0` | no |
|
||||
| `motion.deadman` | auto | PASS | 2026-10-18T22:52:02Z | `c-20261018224638-4ca0` | no |
|
||||
| `motion.step-timing-under-load` | auto | PASS | 2026-10-18T22:52:33Z | `c-20261018224638-4ca0` | no |
|
||||
| `motion.release` | auto, takeover | PASS | 2026-10-18T22:52:59Z | `c-20261018224638-4ca0` | no |
|
||||
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-10-18T22:54:32Z | `c-20261018224638-4ca0` | no |
|
||||
| `cooling.flow-verify` | auto | PASS | 2026-10-18T22:57:10Z | `c-20261018224638-4ca0` | no |
|
||||
| `cooling.flow-under-load` | live | PASS | 2026-10-18T23:12:56Z | `c-20261018224638-4ca0` | no |
|
||||
| `cooling.fail-tier-stop` | operator | PASS | 2026-10-18T22:57:30Z | `c-20261018224638-4ca0` | no |
|
||||
| `laser.emission-witness` | live, core | PASS | 2026-10-18T23:09:51Z | `c-20261018224638-4ca0` | no |
|
||||
| `laser.armed-kill` | live | PASS | 2026-10-18T23:13:40Z | `c-20261018224638-4ca0` | no |
|
||||
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-10-18T23:14:18Z | `c-20261018224638-4ca0` | no |
|
||||
| `camera.snapshot` | auto | PASS | 2026-10-18T22:57:47Z | `c-20261018224638-4ca0` | no |
|
||||
| `camera.sensor-profile` | auto | PASS | 2026-10-18T22:58:00Z | `c-20261018224638-4ca0` | no |
|
||||
| `camera.h264-stream` | auto | PASS | 2026-10-18T22:58:14Z | `c-20261018224638-4ca0` | no |
|
||||
| `camera.frame-health` | auto | PASS | 2026-10-18T22:58:32Z | `c-20261018224638-4ca0` | no |
|
||||
| `camera.lid-privacy` | operator | PASS | 2026-10-18T22:58:48Z | `c-20261018224638-4ca0` | no |
|
||||
| `update.slots-and-signature` | auto | PASS | 2026-10-18T22:58:50Z | `c-20261018224638-4ca0` | no |
|
||||
| `cloud.mode-switch` | operator | PASS | 2026-10-18T23:00:59Z | `c-20261018224638-4ca0` | no |
|
||||
| `cloud.service-protocol` | operator | PASS | 2026-10-18T23:15:30Z | `c-20261018224638-4ca0` | no |
|
||||
| `cloud.lid-interlock-abort` | live | PASS | 2026-10-18T23:17:46Z | `c-20261018224638-4ca0` | no |
|
||||
| `cloud.pause-resume` | live | PASS | 2026-10-18T23:21:27Z | `c-20261018224638-4ca0` | no |
|
||||
| `cloud.oversize-stream` | live | PASS | 2026-10-18T23:24:58Z | `c-20261018224638-4ca0` | no |
|
||||
| `exthost.platform` | auto | PASS | 2026-10-18T23:01:21Z | `c-20261018224638-4ca0` | no |
|
||||
| `exthost.armed-freeze` | operator, takeover | PASS | 2026-10-18T23:03:09Z | `c-20261018224638-4ca0` | no |
|
||||
| `forgectrl.tls-records` | auto | PASS | 2026-10-18T23:03:15Z | `c-20261018224638-4ca0` | no |
|
||||
|
||||
Artifact sha256: `97018f73354c1b768b893f1538c43d8c506e06e8bd788ab4e194968910c4e5f1`
|
||||
@@ -0,0 +1,25 @@
|
||||
ForgeFIRM v0.0.9 is a beta release.
|
||||
|
||||
The setup is simpler, and it is safe to run again. On some machines, running First fire a second time, or after a power cycle, drove the head into the side of the machine. The setup sheet and its steps (Place the sheet, First fire, Dose curve, Corner rolloff, Flow under load) are gone. Focus and the optional Laser floor now each burn on their own small scrap in the back-left corner, starting from home against the gantry stops. A new Lens travel check measures the lens with the laser off. Only the first steps, through Intake fans and Coolant flow, are required. The rest are recommended, and Finish later takes you to the control panel.
|
||||
|
||||
Also fixed:
|
||||
|
||||
- Stopping mid-move (a reset from your Grbl sender, Abort in the control panel, or a cooling fault) now slows the head to a stop and keeps its position, so the machine stays homed.
|
||||
- A rare stream fault that could replay leftover motion, with the laser off, at the start of the next job now stops with an alarm.
|
||||
- The setup's burns no longer need the head's beam sensor, which reads nothing on some heads.
|
||||
- The setup no longer shows the head as "not detected".
|
||||
- The TEC check is gone. It could never pass, and it turned the TEC off on Pro machines. Your answer in the Your machine step now sets whether a TEC is fitted.
|
||||
- A setup question or wait that times out now ends its step with the reason and a way to run it again.
|
||||
- The coolant flow calibration no longer leaves its trial settings behind after an abort.
|
||||
- The logs now record which firmware runs at every start and every update, so a log export shows how a machine was updated.
|
||||
- Extensions: a rollback or an interrupted install no longer leaves an extension broken.
|
||||
|
||||
After you update:
|
||||
|
||||
- If your machine has not done the Intake fans or Coolant flow step, the setup asks for it, and the machine waits for it before it runs a job.
|
||||
- Pro owners who ran the setup on an earlier release: make sure Cooling protection > Coolant loop > TEC (Pro chiller) is on. The old TEC check turned it off.
|
||||
- If you ran the setup sheet on an earlier release and your jobs now land offset from where you placed them, send G92.1 once from your Grbl sender.
|
||||
|
||||
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
|
||||
|
||||
Every release below 0.1.0 is a beta release. Installation, usage, and safety are documented at https://docs.forgefirm.org/.
|
||||
@@ -31,9 +31,13 @@ page's takeover does that; from a host, stop them first.
|
||||
| `pwm_stream_test.py` | LASER_PWM stream-path scope test (runs on the board, controller and forgectrl stopped): streams power bytes only (no step bytes, no FIRE bits, `motor_lock=15`, latch locked) through `/dev/glowforge` so the scope verifies the real power path, including the run-start duty reset and the consecutive-power-byte drop; position counters compared before/after. Exit 0 = counters unmoved, idle at the end, no FIRE/emission read back. |
|
||||
| `gate_a_kernel_drills.py` | Kernel laser-safety drills (run on the board with forgectrl stopped so the pulse device is free): `K1` controlled-stop deceleration floor, `K2` resume waypoint honors the locked latch, `K3` a mid-ramp latch unlock never re-arms the FIRE drive. Software witnesses (`cnc/state`, `laser_enable`, `laser_on`, `laser_on_sampled`, interlock bit 3) plus the PSU-connector LASER_ON scope point; K3 refuses to run if HV reports good. |
|
||||
| `laser_stream_test.py` | Host-side laser pulse-stream emission harness: runs the native null-sink controller with `GFSINK_DUMP`, drives small laser jobs over TCP, and checks the dumped bytes against the kernel feeder contract (leading power byte, no back-to-back power bytes, FIRE only inside cutting moves, every stream ends FIRE-clear, no FIRE on a stepless gap, no FIRE leak across cycle churn, the rapids after an M5 executed at idle ship dark, and the next job in the same process fires at the level the previous one ended at). Runs in the grblHAL repo's CI. |
|
||||
| `laser_lifecycle_test.py` | Host-side operator-armed-window lifecycle harness (null-sink controller): arm once per job with M5/M3 persistence, the M2 close, sender-change re-consent, the disarm grace counting down in Hold, and arm refusal under a blocking cooling verdict. Runs in the grblHAL repo's CI. |
|
||||
| `laser_lifecycle_test.py` | Host-side lifecycle harness of the operator-armed window (null-sink controller) ([Test](https://docs.forgefirm.org/developers/testing/)). Runs in the grblHAL repo's CI. |
|
||||
| `z_envelope_test.py` | Host-side Z envelope harness (null-sink controller): the Z soft limit belongs to the driver, not to `$20`, so the driver re-applies `sys.work_envelope`, `sys.homed` and `sys.soft_limits` for Z from the settings-changed chain. Checks that an unreferenced Z is collapsed to where the lens stands and blocks a move each way, that X and Y stay free, and that neither a `$20` write (the core clears the soft-limit mask in the setter) nor a `$132` write (which un-homes the axis as well) frees Z. Runs in the grblHAL repo's CI. |
|
||||
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the density dose curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m4feeds [S] [F1] [F2]` (the density time base across feeds: one out-and-back line pair per feed at the same S under M4 density, one armed run; the operator reads within-line evenness and reversal darkness at both feeds - M4's velocity scaling is what holds dose per mm through the accel), `m4corner [S] [F]` (M4 velocity-scaled power into corners: a corner-heavy vector pattern at 30 % under M4 density, one armed run; the operator confirms every commanded segment marks - the floor makes a dropout unreachable - and the drill asserts the arm report, one discharge window and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <secs> [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
|
||||
| `ctlport_test.py` | Host-side controller port and line-multiplexer harness (null-sink controller): a scripted sender that counts every `ok` and `error` runs beside the port's one client on `grbl.ctl`. A port jog's status goes to the port and never to the sender, a port error does not reach the sender's parser, a sender line cancels a port jog and gets its own `ok` (a line queued right behind the port's included), plus the refusals, the single client, a CR LF sender, a soft reset, and the client as the dead-man. `python3 ctlport_test.py <grblHAL_glowforge>`. |
|
||||
| `cool_report_test.py` | Host-side cooling report harness (null-sink controller, a stand-in for forgectrl's listener on `FORGECTRL_PORT`): every `POST /cool/state` carries the secret the supervisor hands the controller at its spawn (`GF_REPORT_SECRET`) as `X-ForgeFIRM-Report`; with no secret the header is absent; a value that is not 32 hex digits, a CR LF with a header behind it included, never reaches the wire; and the homing runner the controller starts is handed the secret (it reports in the controller's place during a gfcloud `$H`), and none when the controller has none or a malformed one. `python3 cool_report_test.py <grblHAL_glowforge>`. |
|
||||
| `manual_home_test.py` | Host-side manual homing and motor release harness (null-sink controller, `GFSINK_DUMP` and `GFSINK_ATTR_LOG`): `$H` under `homing_mode = manual` ships nothing and declares X0 Y0, and with a manual home offset declares minus it and jogs dark to the origin before it answers, a jog cancel ending the move short; while the motors are released (`$MD`) every motion source is refused and ships nothing, `$X` and a soft reset do not unlock it, and only `$ME` and a manual `$H` write the energize. Imports its sender and port client from `ctlport_test.py`. `python3 manual_home_test.py <grblHAL_glowforge>`. |
|
||||
| `tray_test.py` | Host-side crumb tray harness (null-sink controller, `GFSINK_DUMP`): `M103 P1` and `P0` move Z and its envelope by `tray_offset_mm` on the lens step grid, tell the sender, write and remove the `tray.out` marker beside the shared config, and ship no step; the errors of `M103`; the switch waits for the move before it; the port's `tray` op and its refusals; the mode across a soft reset and a controller restart; the offset's range; an unreferenced lens; a camera home in both modes. Imports its sender and port client from `ctlport_test.py`. `python3 tray_test.py <grblHAL_glowforge>`. |
|
||||
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the density dose curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m4feeds [S] [F1] [F2]` (the density time base across feeds: one out-and-back line pair per feed at the same S under M4 density, one armed run; the operator reads within-line evenness and reversal darkness at both feeds - M4's velocity scaling is what holds dose per mm through the accel), `m4corner [S] [F]` (M4 velocity-scaled power into corners: a corner-heavy vector pattern at 30 % under M4 density, one armed run; the operator confirms every commanded segment marks - the floor makes a dropout unreachable - and the drill asserts the arm report, one discharge window and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.emission-witness` carries its check), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <secs> [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
|
||||
| `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. |
|
||||
| `planner_blocks_test.py` | Host-side planner buffer depth harness (null-sink controller): `$398` written to 400 and to 1000, the controller restarted on the same settings store each time, and it must answer on the port, report the depth in its status report and run a move. A byte-wide ring index once made 255 blocks or more spin the start. Runs in the grblHAL repo's CI. |
|
||||
| `xy_mode_test.py` | Host-side XY microstep mode harness (null-sink controller): `xy_microsteps` in the shared config sets `$100`/`$101` and the machine tick, a typed `$100` is overwritten on the spot, a value that is not a mode falls back to x32 with a warning, and `$110`/`$111` are held under a tick lowered with `GFSINK_RATE`. Runs in the grblHAL repo's CI. |
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Host harness: the controller's cooling reports carry the supervisor's secret.
|
||||
|
||||
POST /cool/state is the running controller's channel alone: forgectrl hands
|
||||
each controller it spawns a secret in its environment (GF_REPORT_SECRET), and
|
||||
the route asks for it. This harness stands in for forgectrl's listener, runs
|
||||
the null-sink controller against it, and reads every report as it arrives.
|
||||
|
||||
secret every report carries X-ForgeFIRM-Report with the secret it was
|
||||
started with
|
||||
none started with no secret, the reports carry no such header
|
||||
malformed a value that is not 32 hex digits (a letter out of range, or a
|
||||
CR LF and a header of its own behind it, 32 characters long) is
|
||||
never sent, and nothing of it reaches the wire
|
||||
runner the homing runner the controller starts is handed the secret (it
|
||||
reports in the controller's place while a gfcloud $H holds the
|
||||
machine, and the controller is quiet then), and the reports
|
||||
still carry it afterwards; a controller with no secret hands the
|
||||
runner none
|
||||
|
||||
Usage: cool_report_test.py <path-to-grblHAL_glowforge>
|
||||
"""
|
||||
import http.server
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
BIN = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else "build/grblHAL_glowforge"
|
||||
SECRET = "0123456789abcdef0123456789abcdef"
|
||||
GRBL_PORT = 23960
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print("FAIL:", msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class Listener:
|
||||
"""forgectrl's HTTP listener, as far as the reports need it: every
|
||||
request is kept whole (the raw header block included) and answered 200."""
|
||||
|
||||
def __init__(self):
|
||||
self.seen = []
|
||||
outer = self
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
outer.seen.append({"path": self.path, "headers": {k.lower(): v for k, v in self.headers.items()},
|
||||
"raw": bytes(self.headers)})
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", "2")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"{}")
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
self.srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
|
||||
self.port = self.srv.server_address[1]
|
||||
threading.Thread(target=self.srv.serve_forever, daemon=True).start()
|
||||
|
||||
def wait(self, n, timeout=8.0):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end and len(self.seen) < n:
|
||||
time.sleep(0.05)
|
||||
return list(self.seen)
|
||||
|
||||
def close(self):
|
||||
self.srv.shutdown()
|
||||
|
||||
|
||||
def publish_verdicts(path, stop):
|
||||
while not stop.is_set():
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write('{"ts_mono":%.3f,"fire_ok":true,"verdict":"OK","hold":false,'
|
||||
'"resume_ok":true,"armed":false,"reason":""}' % time.clock_gettime(time.CLOCK_MONOTONIC))
|
||||
os.replace(tmp, path)
|
||||
stop.wait(0.5)
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, listener, secret, runner_cmd=None):
|
||||
self.workdir = tempfile.mkdtemp(prefix="cool-report-")
|
||||
conf = os.path.join(self.workdir, "forgefirm.conf")
|
||||
with open(conf, "w") as f:
|
||||
f.write("cool_fan_grace_s = 0\nhoming_mode = %s\n" % ("gfcloud" if runner_cmd else "none"))
|
||||
if runner_cmd:
|
||||
f.write("gfcloud_home_cmd = %s\n" % runner_cmd)
|
||||
verdict = os.path.join(self.workdir, "cooling.state")
|
||||
self.stop = threading.Event()
|
||||
threading.Thread(target=publish_verdicts, args=(verdict, self.stop), daemon=True).start()
|
||||
env = dict(os.environ, GFHOME_CONF=conf, GF_STATE_DIR=self.workdir, GF_VERDICT_FILE=verdict,
|
||||
GFSINK_DUMP=os.path.join(self.workdir, "stream.bin"), FFLOG_STDERR="1",
|
||||
FORGECTRL_PORT=str(listener.port))
|
||||
for k in ("GFSINK", "GF_SWITCH_FILE", "GF_REPORT_SECRET"):
|
||||
env.pop(k, None)
|
||||
if secret is not None:
|
||||
env["GF_REPORT_SECRET"] = secret
|
||||
self.proc = subprocess.Popen([BIN, "-p", str(GRBL_PORT)], cwd=self.workdir, env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
def sender(self):
|
||||
for _ in range(50):
|
||||
try:
|
||||
s = socket.create_connection(("127.0.0.1", GRBL_PORT), timeout=2)
|
||||
s.settimeout(6)
|
||||
return s
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
fail("the controller's Grbl socket never opened")
|
||||
|
||||
def close(self):
|
||||
self.stop.set()
|
||||
self.proc.terminate()
|
||||
try:
|
||||
self.proc.wait(5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
shutil.rmtree(self.workdir, ignore_errors=True)
|
||||
|
||||
|
||||
def run(name, secret, check, runner=False, handed=None):
|
||||
lis = Listener()
|
||||
ctl = Controller(lis, secret, runner_cmd=("env > %s" % "RUNNER_ENV") if runner else None)
|
||||
try:
|
||||
if runner:
|
||||
env_file = os.path.join(ctl.workdir, "RUNNER_ENV")
|
||||
s = ctl.sender()
|
||||
time.sleep(0.5)
|
||||
s.sendall(b"$H\n")
|
||||
end = time.time() + 10
|
||||
while time.time() < end and not os.path.exists(env_file):
|
||||
time.sleep(0.1)
|
||||
if not os.path.exists(env_file):
|
||||
fail("[%s] the stand-in homing runner never ran" % name)
|
||||
time.sleep(0.3)
|
||||
with open(env_file) as f:
|
||||
runner_env = f.read()
|
||||
got = [ln.split("=", 1)[1] for ln in runner_env.splitlines() if ln.startswith("GF_REPORT_SECRET=")]
|
||||
if got != ([handed] if handed else []):
|
||||
fail("[%s] the homing runner was handed %r, not %r" % (name, got, handed))
|
||||
if not handed and SECRET in runner_env:
|
||||
fail("[%s] the secret reached the homing runner another way" % name)
|
||||
if "GF_STATE_DIR" not in runner_env:
|
||||
fail("[%s] the runner's environment was not read: %r" % (name, runner_env[:80]))
|
||||
s.close()
|
||||
lis.seen.clear() # what matters is what is reported after it
|
||||
reports = lis.wait(3)
|
||||
if len(reports) < 3:
|
||||
fail("[%s] %d reports in 8 s: the level-triggered report did not arrive" % (name, len(reports)))
|
||||
for r in reports:
|
||||
if not r["path"].startswith("/cool/state?mode="):
|
||||
fail("[%s] a request that is no report: %s" % (name, r["path"]))
|
||||
check(name, r)
|
||||
print("ok: %s (%d reports)" % (name, len(reports)))
|
||||
finally:
|
||||
ctl.close()
|
||||
lis.close()
|
||||
|
||||
|
||||
def with_secret(name, r):
|
||||
if r["headers"].get("x-forgefirm-report") != SECRET:
|
||||
fail("[%s] a report without the secret: %s" % (name, r["headers"]))
|
||||
|
||||
|
||||
def without(name, r):
|
||||
if "x-forgefirm-report" in r["headers"] or "x-evil" in r["headers"] or b"Evil" in r["raw"]:
|
||||
fail("[%s] a header that must not be sent: %s" % (name, r["headers"]))
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(BIN):
|
||||
fail("no controller binary at %s" % BIN)
|
||||
run("secret", SECRET, with_secret)
|
||||
run("none", None, without)
|
||||
for i, bad in enumerate((SECRET[:-1] + "g",
|
||||
SECRET[:16] + "\r\nX-Evil: 1\r\nX:1"), 1): # the second is 32 long
|
||||
run("malformed-%d" % i, bad, without)
|
||||
run("runner", SECRET, with_secret, runner=True, handed=SECRET)
|
||||
run("runner-none", None, without, runner=True)
|
||||
print("cool_report_test: all passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,731 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Host-side verification of the controller port and the stream's line
|
||||
multiplexer.
|
||||
|
||||
Runs the native grblHAL_glowforge binary in null-sink mode and drives two
|
||||
clients at once: a scripted sender on the Grbl TCP socket that counts every
|
||||
response it gets, and the port's one client on the Unix socket grbl.ctl in
|
||||
the state directory. The sender's count is the point: a sender synchronizes
|
||||
on ok and error, so one response too many or too few desynchronizes every
|
||||
job after it.
|
||||
|
||||
1. a port jog's status goes to the port and never to the sender, which
|
||||
sees a [MSG:] and nothing else
|
||||
2. a port jog that errors does not leave its error with the sender: with
|
||||
COMPATIBILITY_LEVEL 0 the core holds a parser error against the next
|
||||
g-code line, and the next line here is the sender's
|
||||
3. the sender always wins: a sender line that arrives during a port jog
|
||||
cancels the jog, waits, and gets its own ok, never error:9
|
||||
4. a port jog is refused while the sender is active, while a program
|
||||
runs, and when its words carry anything a jog does not need; a motion
|
||||
word the core's jog grammar rejects comes back as the core's error
|
||||
5. the port has one client: a second connection is refused and the first
|
||||
keeps working (it never displaces, unlike the Grbl socket); a client
|
||||
that closes and reconnects at once is served
|
||||
6. the status hook survives a soft reset (the core puts its own report
|
||||
handlers back at every reset)
|
||||
7. the port's client is the dead-man: closing it cancels its jog
|
||||
8. every port operation ships dark. Under an open armed window, with M3
|
||||
modal and S above zero, a port jog, a canceled port jog, the refused
|
||||
jogs, and the panel operations (the tray's mode among them) add steps
|
||||
to the shipped stream and not one FIRE tick. A jog block carries the modal spindle state, so the
|
||||
stream's mask on jogging is the only thing that keeps a jog dark, and
|
||||
this is what holds the port to it
|
||||
9. "sender out" keeps senders from the network out, and only on an idle
|
||||
machine with a quiet sender: it is refused while a sender line runs
|
||||
(a dwell), within 2 s of the sender's last line, and while a program
|
||||
moves; granted, it drops the sender after a message line, outlives
|
||||
the port's client, and "sender in" ends it. A sender from the network
|
||||
is one that connects to this host's own address rather than the
|
||||
loopback; one turned away while senders are kept out is serial_test.c's
|
||||
(grblHAL-glowforge)
|
||||
|
||||
Usage: ctlport_test.py [path/to/grblHAL_glowforge]
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build-native/grblHAL_glowforge")
|
||||
PORT = 2397
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print("FAIL: %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class Sender:
|
||||
"""The scripted sender: every line it receives is classified, and the
|
||||
responses (ok, error:N) are counted apart from everything else."""
|
||||
|
||||
def __init__(self, port, eol="\n", host="127.0.0.1"):
|
||||
self.eol = eol
|
||||
self.responses = [] # "ok" / "error:N", in order
|
||||
self.other = [] # messages, reports, the banner
|
||||
self.lock = threading.Lock()
|
||||
self.closed = False # the controller closed the socket
|
||||
for _ in range(50):
|
||||
try:
|
||||
self.sock = socket.create_connection((host, port), timeout=1)
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
fail("cannot connect to the controller")
|
||||
self.sock.settimeout(0.2)
|
||||
self.alive = True
|
||||
self.thread = threading.Thread(target=self._read, daemon=True)
|
||||
self.thread.start()
|
||||
time.sleep(0.5) # the banner
|
||||
|
||||
def _read(self):
|
||||
buf = b""
|
||||
while self.alive:
|
||||
try:
|
||||
data = self.sock.recv(4096)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
self.closed = True
|
||||
return
|
||||
if not data:
|
||||
self.closed = True
|
||||
return
|
||||
buf += data
|
||||
while b"\n" in buf:
|
||||
raw, buf = buf.split(b"\n", 1)
|
||||
line = raw.decode(errors="replace").strip()
|
||||
if not line:
|
||||
continue
|
||||
with self.lock:
|
||||
if line == "ok" or line.startswith("error:"):
|
||||
self.responses.append(line)
|
||||
else:
|
||||
self.other.append(line)
|
||||
|
||||
def count(self):
|
||||
with self.lock:
|
||||
return len(self.responses)
|
||||
|
||||
def send(self, line, timeout=5.0):
|
||||
"""Send one line and return its response: the one response that
|
||||
arrives for it."""
|
||||
n = self.count()
|
||||
self.sock.sendall((line + self.eol).encode())
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if self.count() > n:
|
||||
with self.lock:
|
||||
return self.responses[n]
|
||||
time.sleep(0.01)
|
||||
fail("no response to the sender's %r" % line)
|
||||
|
||||
def realtime(self, byte):
|
||||
self.sock.sendall(byte)
|
||||
|
||||
def state(self):
|
||||
with self.lock:
|
||||
n = len(self.other)
|
||||
self.sock.sendall(b"?")
|
||||
end = time.time() + 2.0
|
||||
while time.time() < end:
|
||||
with self.lock:
|
||||
for line in self.other[n:]:
|
||||
m = re.match(r"<([A-Za-z]+)", line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
time.sleep(0.01)
|
||||
fail("no status report")
|
||||
|
||||
def wait_state(self, want, timeout=20.0):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if self.state() == want:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
fail("the controller never reached %s (now %s)" % (want, self.state()))
|
||||
|
||||
def saw(self, needle):
|
||||
with self.lock:
|
||||
return sum(needle in line for line in self.other)
|
||||
|
||||
def close(self):
|
||||
self.alive = False
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class PortClient:
|
||||
def __init__(self, path):
|
||||
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self.sock.settimeout(5.0)
|
||||
self.sock.connect(path)
|
||||
self.buf = b""
|
||||
|
||||
def request(self, line):
|
||||
self.sock.sendall((line + "\n").encode())
|
||||
return self.reply()
|
||||
|
||||
def reply(self):
|
||||
while b"\n" not in self.buf:
|
||||
data = self.sock.recv(4096)
|
||||
if not data:
|
||||
return None
|
||||
self.buf += data
|
||||
raw, self.buf = self.buf.split(b"\n", 1)
|
||||
return raw.decode()
|
||||
|
||||
def state(self):
|
||||
return json.loads(self.request("state"))
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def publish_verdicts(path, stop):
|
||||
"""Stand in for the cooling engine: a clean verdict that acknowledges the
|
||||
armed window, refreshed twice a second. A laser line arms against it."""
|
||||
while not stop.is_set():
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write('{"ts_mono":%.3f,"fire_ok":true,"verdict":"OK","hold":false,'
|
||||
'"resume_ok":true,"armed":true,"reason":""}'
|
||||
% time.clock_gettime(time.CLOCK_MONOTONIC))
|
||||
os.replace(tmp, path)
|
||||
stop.wait(0.5)
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, eol="\n", laser=False):
|
||||
self.workdir = tempfile.mkdtemp(prefix="ctlport-")
|
||||
conf = os.path.join(self.workdir, "forgefirm.conf")
|
||||
with open(conf, "w") as f:
|
||||
f.write("cool_fan_grace_s = 0\nlaser_disarm_s = 30\nhoming_mode = manual\n")
|
||||
env = dict(os.environ, GFHOME_CONF=conf, GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
|
||||
env.pop("GFSINK", None)
|
||||
env.pop("GF_SWITCH_FILE", None)
|
||||
self.dump = os.path.join(self.workdir, "stream.bin")
|
||||
self.stop = threading.Event()
|
||||
self.pub = None
|
||||
if laser:
|
||||
verdict = os.path.join(self.workdir, "cooling.state")
|
||||
env.update(GFSINK_DUMP=self.dump, GF_VERDICT_FILE=verdict)
|
||||
self.pub = threading.Thread(target=publish_verdicts, args=(verdict, self.stop),
|
||||
daemon=True)
|
||||
self.pub.start()
|
||||
self.proc = subprocess.Popen([BIN, "-p", str(PORT)], cwd=self.workdir, env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
self.sender = Sender(PORT, eol)
|
||||
self.path = os.path.join(self.workdir, "grbl.ctl")
|
||||
for _ in range(50):
|
||||
if os.path.exists(self.path):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
fail("the controller did not create %s" % self.path)
|
||||
self.port = PortClient(self.path)
|
||||
|
||||
def quiet(self):
|
||||
"""Past the port's sender-quiet interval."""
|
||||
time.sleep(0.45)
|
||||
|
||||
def ticks(self):
|
||||
"""(step ticks, FIRE ticks) shipped so far. A byte with bit 7 set is a
|
||||
power byte; in a tick byte 0x25 are the X, Y and Z steps and 0x10 is FIRE."""
|
||||
with open(self.dump, "rb") as f:
|
||||
data = f.read()
|
||||
return (sum(1 for b in data if not b & 0x80 and b & 0x25),
|
||||
sum(1 for b in data if not b & 0x80 and b & 0x10))
|
||||
|
||||
def close(self):
|
||||
self.port.close()
|
||||
self.sender.close()
|
||||
self.proc.send_signal(signal.SIGINT)
|
||||
try:
|
||||
self.proc.wait(5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
self.stop.set()
|
||||
if self.pub is not None:
|
||||
self.pub.join(2)
|
||||
shutil.rmtree(self.workdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_socket_mode(s):
|
||||
mode = os.stat(s.path).st_mode & 0o777
|
||||
if mode != 0o600:
|
||||
fail("[mode] grbl.ctl is %o, not 600" % mode)
|
||||
print("PASS [mode]: grbl.ctl is a socket with mode 600")
|
||||
|
||||
|
||||
def test_status_routing(s):
|
||||
if s.sender.send("G91") != "ok" or s.sender.send("G0 X1") != "ok":
|
||||
fail("[routing] the sender's own lines did not get ok")
|
||||
s.sender.wait_state("Idle")
|
||||
s.quiet()
|
||||
before = s.sender.count()
|
||||
x0 = s.port.state()["mpos"][0]
|
||||
r = s.port.request("jog G91 X2 F600")
|
||||
if r != "ok":
|
||||
fail("[routing] the port jog got %r, not ok" % r)
|
||||
s.sender.wait_state("Idle")
|
||||
# The sender polled its status all the way through the jog. A realtime
|
||||
# character is not a line: it cancels nothing, and the jog ran whole.
|
||||
moved = s.port.state()["mpos"][0] - x0
|
||||
if abs(moved - 2.0) > 0.01:
|
||||
fail("[routing] the sender's status polls cut the port jog short: moved %.3f of 2" % moved)
|
||||
if s.sender.count() != before:
|
||||
fail("[routing] the sender got a response for the port's line: %r"
|
||||
% s.sender.responses[before:])
|
||||
if not s.sender.saw("[MSG:Panel jog]"):
|
||||
fail("[routing] the sender got no [MSG:] for the port jog")
|
||||
if s.sender.send("G0 X1") != "ok":
|
||||
fail("[routing] the sender's next line did not get ok")
|
||||
print("PASS [routing]: the port jog's ok went to the port and the jog ran whole under the "
|
||||
"sender's status polls; the sender saw a [MSG:] and its count is intact")
|
||||
|
||||
|
||||
def test_error_not_inherited(s):
|
||||
s.sender.wait_state("Idle")
|
||||
s.quiet()
|
||||
before = s.sender.count()
|
||||
r = s.port.request("jog G91 X1") # no feed rate: the core refuses it
|
||||
if not (r or "").startswith("error:"):
|
||||
fail("[error] a jog with no feed rate got %r, not an error" % r)
|
||||
if s.sender.count() != before:
|
||||
fail("[error] the sender got the port's error: %r" % s.sender.responses[before:])
|
||||
r2 = s.sender.send("G0 X1")
|
||||
if r2 != "ok":
|
||||
fail("[error] the sender's next g-code line got %r: it inherited the port's %s" % (r2, r))
|
||||
print("PASS [error]: the port got %s; the sender's next line ran and got ok" % r)
|
||||
|
||||
|
||||
def test_sender_wins(s):
|
||||
s.sender.wait_state("Idle")
|
||||
s.quiet()
|
||||
x0 = s.port.state()["mpos"][0]
|
||||
# Fast, so the cancel is a real deceleration: the core stays in the jog
|
||||
# state for the length of it, and a sender line read in that window is
|
||||
# the error:9 the hold exists to prevent. A slow jog stops at once and
|
||||
# would pass with no hold at all.
|
||||
if s.port.request("jog G91 X300 F6000") != "ok": # 3 s if nothing stops it
|
||||
fail("[sender-wins] the long port jog was refused")
|
||||
time.sleep(0.7)
|
||||
st = s.port.state()
|
||||
if st["state"] != "Jog" or not st["port_jog"]:
|
||||
fail("[sender-wins] not jogging for the port: %r" % st)
|
||||
t0 = time.time()
|
||||
r = s.sender.send("G0 X1", timeout=10.0)
|
||||
dt = time.time() - t0
|
||||
if r != "ok":
|
||||
fail("[sender-wins] the sender's line during a port jog got %r, not ok" % r)
|
||||
s.sender.wait_state("Idle")
|
||||
st = s.port.state()
|
||||
if st["port_jog"]:
|
||||
fail("[sender-wins] the port still claims the jog: %r" % st)
|
||||
moved = st["mpos"][0] - x0
|
||||
if moved > 200:
|
||||
fail("[sender-wins] the port jog was not canceled (X moved %.1f mm)" % moved)
|
||||
print("PASS [sender-wins]: the sender's line canceled the port jog, waited %.2f s, "
|
||||
"and got ok" % dt)
|
||||
|
||||
|
||||
def test_sender_wins_queued(s):
|
||||
"""The window the read gate exists for. The core reads a line's bytes
|
||||
in one pass and polls the stream only at the end of line, so a sender
|
||||
line that sits in the ring right behind the port's jog line is read
|
||||
while the jog it follows is still running, with no poll in between to
|
||||
cancel it. A dwell opens the window on purpose: the port's line is
|
||||
queued during it, and the sender streams its next line behind that."""
|
||||
s.sender.wait_state("Idle")
|
||||
s.sender.send("G91")
|
||||
n = s.sender.count()
|
||||
s.sender.sock.sendall(("G4 P1.5" + s.sender.eol).encode()) # no ok until the dwell ends
|
||||
s.quiet()
|
||||
s.port.sock.sendall(b"jog G91 X300 F6000\n") # queued behind the dwell
|
||||
time.sleep(0.2)
|
||||
s.sender.sock.sendall(("G0 X1" + s.sender.eol).encode()) # streamed behind the port's line
|
||||
end = time.time() + 10.0
|
||||
while time.time() < end and s.sender.count() < n + 2:
|
||||
time.sleep(0.02)
|
||||
got = s.sender.responses[n:n + 2]
|
||||
port_reply = s.port.reply()
|
||||
if port_reply != "ok":
|
||||
fail("[sender-wins-queued] the queued port jog got %r, not ok" % port_reply)
|
||||
if got != ["ok", "ok"]:
|
||||
fail("[sender-wins-queued] the sender's dwell and the line behind the port jog got %r, "
|
||||
"not two ok" % got)
|
||||
s.sender.wait_state("Idle")
|
||||
print("PASS [sender-wins-queued]: a sender line queued right behind the port's jog line "
|
||||
"waited for the cancel and got ok")
|
||||
|
||||
|
||||
class NewlinePoller(threading.Thread):
|
||||
"""A sender that polls '?' with an end of line behind it, on a timer,
|
||||
as some Grbl senders do. The '?' is a realtime character; the end of line
|
||||
is an empty line, which the core answers ok."""
|
||||
|
||||
def __init__(self, sender, period=0.25):
|
||||
super().__init__(daemon=True)
|
||||
self.sender, self.period = sender, period
|
||||
self.polls = 0
|
||||
self.halt = threading.Event()
|
||||
|
||||
def run(self):
|
||||
while not self.halt.is_set():
|
||||
self.sender.sock.sendall(b"?" + self.sender.eol.encode())
|
||||
self.polls += 1
|
||||
self.halt.wait(self.period)
|
||||
|
||||
def stop(self):
|
||||
self.halt.set()
|
||||
self.join()
|
||||
|
||||
|
||||
def poll_case(tag, eol):
|
||||
"""An empty line is not the sender speaking. With a sender polling
|
||||
'?'<eol> four times a second: a port jog is never refused for it, a
|
||||
long port jog runs whole under it, and every poll still draws its one
|
||||
ok, during the jog too. A real line still goes first."""
|
||||
s = Session(eol=eol)
|
||||
try:
|
||||
s.sender.wait_state("Idle")
|
||||
s.quiet()
|
||||
before = s.sender.count()
|
||||
poller = NewlinePoller(s.sender)
|
||||
poller.start()
|
||||
time.sleep(0.6)
|
||||
refused = []
|
||||
for i in range(8): # spread across the poll period
|
||||
r = s.port.request("jog G91 X0.5 F3000")
|
||||
if r != "ok":
|
||||
refused.append(r)
|
||||
time.sleep(0.11)
|
||||
if refused:
|
||||
fail("[%s] %d of 8 port jogs were refused under a status poll: %s" % (tag, len(refused), refused))
|
||||
while s.port.state()["state"] != "Idle":
|
||||
time.sleep(0.05)
|
||||
x0 = s.port.state()["mpos"][0]
|
||||
if s.port.request("jog G91 X60 F3000") != "ok": # 1.2 s and more: several polls long
|
||||
fail("[%s] the long port jog was refused under a status poll" % tag)
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 10:
|
||||
st = s.port.state()
|
||||
if st["state"] == "Idle" and not st["port_jog"]:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
moved = s.port.state()["mpos"][0] - x0
|
||||
if abs(moved - 60.0) > 0.02:
|
||||
fail("[%s] a status poll cut the port jog short: moved %.3f of 60" % (tag, moved))
|
||||
polls_mid = poller.polls
|
||||
# A real line still goes first: it cancels a port jog and draws its ok.
|
||||
x1 = s.port.state()["mpos"][0]
|
||||
if s.port.request("jog G91 X-300 F6000") != "ok":
|
||||
fail("[%s] the fast port jog was refused" % tag)
|
||||
time.sleep(0.7)
|
||||
poller.stop() # the real line's response is then the only one outstanding
|
||||
time.sleep(0.3)
|
||||
n = s.sender.count()
|
||||
s.sender.sock.sendall(("G4 P0" + eol).encode())
|
||||
end = time.time() + 10
|
||||
while time.time() < end and s.sender.count() <= n:
|
||||
time.sleep(0.01)
|
||||
if s.sender.count() != n + 1 or s.sender.responses[n] != "ok":
|
||||
fail("[%s] the sender's real line under a port jog got %r" % (tag, s.sender.responses[n:]))
|
||||
s.sender.wait_state("Idle")
|
||||
back = x1 - s.port.state()["mpos"][0]
|
||||
if back > 250:
|
||||
fail("[%s] the sender's real line did not cancel the port jog: it ran %.1f of 300" % (tag, back))
|
||||
time.sleep(0.5)
|
||||
got = s.sender.count() - before
|
||||
want = poller.polls + 1 # one ok per poll's empty line, and the G4
|
||||
if got != want:
|
||||
fail("[%s] %d polls and one line drew %d responses, not %d" % (tag, poller.polls, got, want))
|
||||
print("PASS [%s]: under a '?'+EOL poll 8 of 8 port jogs were accepted, a 60 mm port jog ran whole "
|
||||
"(%d polls into it), every poll drew its ok (%d), and a real line still canceled a port jog at "
|
||||
"%.1f of 300" % (tag, polls_mid, poller.polls, back))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_poll_crlf():
|
||||
poll_case("poll-crlf", "\r\n")
|
||||
|
||||
|
||||
def test_refusals(s):
|
||||
s.sender.wait_state("Idle")
|
||||
s.sender.send("G90")
|
||||
r = s.port.request("jog G91 X1 F600") # inside the quiet interval
|
||||
if r != "busy:sender":
|
||||
fail("[refusals] a jog right after a sender line got %r, not busy:sender" % r)
|
||||
s.quiet()
|
||||
for words in ("G91 X1 F600 $H", "g91 x1 f600", "G91 X1 F600;M3", ""):
|
||||
r = s.port.request("jog " + words)
|
||||
if r != "error:invalid":
|
||||
fail("[refusals] jog %r got %r, not error:invalid" % (words, r))
|
||||
x0 = s.port.state()["mpos"][0]
|
||||
r = s.port.request("jog G1 X5 F600") # a motion mode is not a jog word
|
||||
if not (r or "").startswith("error:"):
|
||||
fail("[refusals] jog G1 got %r, not the core's error" % r)
|
||||
r = s.port.request("jog G91 X1 F600 S500 M3")
|
||||
if not (r or "").startswith("error:"):
|
||||
fail("[refusals] a jog with S and M3 got %r, not the core's error" % r)
|
||||
time.sleep(0.3)
|
||||
if abs(s.port.state()["mpos"][0] - x0) > 0.001:
|
||||
fail("[refusals] a refused jog moved the machine")
|
||||
s.sender.send("G91")
|
||||
s.sender.send("G1 X40 F300") # a program: 8 s of Run
|
||||
s.quiet()
|
||||
st = s.port.state()
|
||||
r = s.port.request("jog G91 Y1 F600")
|
||||
if r != "busy:state":
|
||||
fail("[refusals] a jog during a program (%s) got %r, not busy:state" % (st["state"], r))
|
||||
s.sender.realtime(b"\x18") # end the program
|
||||
time.sleep(1.0)
|
||||
s.sender.send("$X")
|
||||
print("PASS [refusals]: busy:sender inside the quiet interval, error:invalid for stray "
|
||||
"characters, the core's error for G1 and for S/M3, busy:state during a program")
|
||||
|
||||
|
||||
def test_single_client(s):
|
||||
second = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
second.settimeout(3.0)
|
||||
second.connect(s.path)
|
||||
try:
|
||||
second.sendall(b"state\n")
|
||||
data = second.recv(100)
|
||||
except OSError:
|
||||
data = b""
|
||||
second.close()
|
||||
if data:
|
||||
fail("[single-client] a second port client was served: %r" % data)
|
||||
if s.port.state().get("state") is None:
|
||||
fail("[single-client] the first client stopped working")
|
||||
# A client that closes and reconnects at once is not a second client: the
|
||||
# port has to notice the hang-up before it judges the new connection.
|
||||
for i in range(5):
|
||||
s.port.close()
|
||||
s.port = PortClient(s.path)
|
||||
try:
|
||||
ok = s.port.state().get("state") is not None
|
||||
except (OSError, TypeError, ValueError):
|
||||
ok = False
|
||||
if not ok:
|
||||
fail("[single-client] reconnect %d right after a close was refused" % (i + 1))
|
||||
print("PASS [single-client]: the second connection was refused, the first kept working, and "
|
||||
"five reconnects right after a close were each served")
|
||||
|
||||
|
||||
def test_port_dark():
|
||||
s = Session(laser=True)
|
||||
try:
|
||||
s.sender.send("G91")
|
||||
s.sender.send("M3 S500")
|
||||
s.sender.send("G1 X10 F1500")
|
||||
s.sender.wait_state("Idle")
|
||||
time.sleep(0.5)
|
||||
if not s.sender.saw("laser armed"):
|
||||
fail("[dark] the laser line did not arm: the case would prove nothing")
|
||||
steps0, fire0 = s.ticks()
|
||||
if fire0 == 0:
|
||||
fail("[dark] the armed G1 shipped no FIRE tick: the dump cannot see emission")
|
||||
s.quiet()
|
||||
|
||||
replies = {}
|
||||
replies["state"] = s.port.request("state")
|
||||
replies["jog"] = s.port.request("jog G91 X8 F1200")
|
||||
s.sender.wait_state("Idle")
|
||||
replies["jog-long"] = s.port.request("jog G91 X200 F6000")
|
||||
time.sleep(0.4)
|
||||
replies["cancel"] = s.port.request("cancel")
|
||||
s.sender.wait_state("Idle")
|
||||
for words in ("G1 X5 F600", "G91 X5 F600 S900 M3", "G91 X5 F600 M4", "G91 G2 X5 I2 F600"):
|
||||
replies["jog " + words] = s.port.request("jog " + words)
|
||||
replies["release"] = s.port.request("release") # refused: a laser job is armed
|
||||
replies["energize"] = s.port.request("energize")
|
||||
replies["home"] = s.port.request("home") # manual: declares X0 Y0, moves nothing
|
||||
replies["tray out"] = s.port.request("tray out") # the Z frame moves, the head does not
|
||||
replies["tray in"] = s.port.request("tray in")
|
||||
replies["jog-after-home"] = s.port.request("jog G91 X6 F1200")
|
||||
s.sender.wait_state("Idle")
|
||||
time.sleep(0.5)
|
||||
|
||||
steps1, fire1 = s.ticks()
|
||||
if replies["jog"] != "ok" or replies["jog-long"] != "ok" or replies["jog-after-home"] != "ok":
|
||||
fail("[dark] a port jog under the open window was refused: %r" % replies)
|
||||
if replies["tray out"] != "ok" or replies["tray in"] != "ok":
|
||||
fail("[dark] the tray op under the open window was refused: %r" % replies)
|
||||
if steps1 <= steps0:
|
||||
fail("[dark] the port jogs shipped no step: the case would prove nothing")
|
||||
if s.sender.saw("laser disarmed"):
|
||||
fail("[dark] the armed window closed during the case: %r" % replies)
|
||||
if fire1 != fire0:
|
||||
fail("[dark] port operations under an open window with M3 S500 shipped %d FIRE ticks: %r"
|
||||
% (fire1 - fire0, replies))
|
||||
print("PASS [dark]: under an open armed window with M3 S500, %d port operations shipped "
|
||||
"%d steps and no FIRE tick" % (len(replies), steps1 - steps0))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_reset_keeps_hook(s):
|
||||
s.sender.realtime(b"\x18")
|
||||
time.sleep(1.0)
|
||||
s.sender.send("$X")
|
||||
s.sender.wait_state("Idle")
|
||||
s.quiet()
|
||||
before = s.sender.count()
|
||||
if s.port.request("jog G91 X1 F600") != "ok":
|
||||
fail("[reset] the port jog after a soft reset was refused")
|
||||
s.sender.wait_state("Idle")
|
||||
if s.sender.count() != before:
|
||||
fail("[reset] after a soft reset the port's ok went to the sender")
|
||||
print("PASS [reset]: the status hook survives a soft reset")
|
||||
|
||||
|
||||
def test_dead_man(s):
|
||||
s.sender.wait_state("Idle")
|
||||
s.quiet()
|
||||
if s.port.request("jog G91 X60 F200") != "ok":
|
||||
fail("[dead-man] the long port jog was refused")
|
||||
time.sleep(0.5)
|
||||
s.port.close()
|
||||
t0 = time.time()
|
||||
s.sender.wait_state("Idle", timeout=6.0)
|
||||
print("PASS [dead-man]: closing the port's client canceled its jog (idle after %.2f s)"
|
||||
% (time.time() - t0))
|
||||
s.port = PortClient(s.path)
|
||||
if s.port.state()["port_jog"]:
|
||||
fail("[dead-man] a new client inherited the old jog")
|
||||
|
||||
|
||||
def own_address():
|
||||
"""This host's own address on the network, which a connection to it
|
||||
comes from: a sender from the network, as the controller sees it."""
|
||||
u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
u.connect(("192.0.2.1", 9)) # TEST-NET: routes, sends nothing
|
||||
addr = u.getsockname()[0]
|
||||
except OSError:
|
||||
addr = ""
|
||||
finally:
|
||||
u.close()
|
||||
if not addr or addr.startswith("127."):
|
||||
fail("[sender-out] this host has no address but the loopback; the test needs one")
|
||||
return addr
|
||||
|
||||
|
||||
def wait_closed(sender, what, timeout=3.0):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end and not sender.closed:
|
||||
time.sleep(0.05)
|
||||
if not sender.closed:
|
||||
fail("[sender-out] %s was not closed" % what)
|
||||
|
||||
|
||||
def test_sender_out():
|
||||
s = Session()
|
||||
try:
|
||||
own = own_address()
|
||||
if s.port.state().get("sender_out") is not False:
|
||||
fail("[sender-out] the state does not say the sender is let in")
|
||||
net = Sender(PORT, host=own) # displaces the session's own sender
|
||||
net.wait_state("Idle")
|
||||
|
||||
# A line that runs: a dwell is Idle to the core, and still the sender's.
|
||||
net.sock.sendall(b"G4 P3" + net.eol.encode())
|
||||
time.sleep(1.0)
|
||||
got = s.port.request("sender out")
|
||||
if got != "busy:sender":
|
||||
fail("[sender-out] during the sender's dwell: %r, wanted busy:sender" % got)
|
||||
end = time.time() + 5
|
||||
while net.count() < 1 and time.time() < end:
|
||||
time.sleep(0.05)
|
||||
# A sender that just finished a line is not done with the machine.
|
||||
if net.send("G21") != "ok":
|
||||
fail("[sender-out] G21 was not answered ok")
|
||||
got = s.port.request("sender out")
|
||||
if got != "busy:sender":
|
||||
fail("[sender-out] within 2 s of the sender's last line: %r, wanted busy:sender" % got)
|
||||
# A program that moves.
|
||||
net.sock.sendall(b"G91 G1 X20 F300" + net.eol.encode())
|
||||
time.sleep(0.8)
|
||||
got = s.port.request("sender out")
|
||||
if got != "busy:state":
|
||||
fail("[sender-out] while a program moves: %r, wanted busy:state" % got)
|
||||
net.wait_state("Idle")
|
||||
print("PASS [sender-out]: refused during a dwell, within 2 s of a line, and while a program moves")
|
||||
|
||||
time.sleep(2.3)
|
||||
got = s.port.request("sender out")
|
||||
if got != "ok":
|
||||
fail("[sender-out] an idle machine and a quiet sender: %r, wanted ok" % got)
|
||||
wait_closed(net, "the connected sender")
|
||||
if not net.saw("[MSG:The machine is in use"):
|
||||
fail("[sender-out] the dropped sender was not told why: %r" % net.other[-3:])
|
||||
st = s.port.state()
|
||||
if st.get("sender_out") is not True or st.get("sender") is not False:
|
||||
fail("[sender-out] the state after it: %r" % st)
|
||||
print("PASS [sender-out]: granted, it dropped the sender after a message line")
|
||||
|
||||
s.port.close()
|
||||
s.port = PortClient(s.path)
|
||||
if s.port.state().get("sender_out") is not True:
|
||||
fail("[sender-out] it did not outlive the port's client")
|
||||
if s.port.request("sender out") != "ok":
|
||||
fail("[sender-out] asked again, it was not ok")
|
||||
if s.port.request("sender in") != "ok":
|
||||
fail("[sender-out] sender in was not ok")
|
||||
if s.port.state().get("sender_out") is not False:
|
||||
fail("[sender-out] sender in did not end it")
|
||||
again = Sender(PORT, host=own)
|
||||
if again.closed or again.send("G21") != "ok":
|
||||
fail("[sender-out] after sender in, a sender from the network was not let in")
|
||||
again.close()
|
||||
print("PASS [sender-out]: it outlives the port's client, and sender in lets the network back in")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(BIN):
|
||||
fail("no controller binary at %s" % BIN)
|
||||
s = Session()
|
||||
try:
|
||||
test_socket_mode(s)
|
||||
test_status_routing(s)
|
||||
test_error_not_inherited(s)
|
||||
test_sender_wins(s)
|
||||
test_sender_wins_queued(s)
|
||||
test_refusals(s)
|
||||
test_single_client(s)
|
||||
test_reset_keeps_hook(s)
|
||||
test_dead_man(s)
|
||||
finally:
|
||||
s.close()
|
||||
test_poll_crlf()
|
||||
test_port_dark()
|
||||
test_sender_out()
|
||||
print("ALL PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Host-side verification of the measured work envelope and the bed check's port op.
|
||||
|
||||
Runs the native grblHAL_glowforge binary in null-sink mode, with the
|
||||
scripted sender and the port client of ctlport_test.py, homing manually
|
||||
(nothing moves; X and Y become homed and the envelope is set):
|
||||
|
||||
1. envelope_x_mm and _y set the far edges at a home, and a jog past one
|
||||
is the core's error:15 (an unset key being the travel, and a key held
|
||||
to GFHOME_ENVELOPE_MIN_MM and to the travel plus
|
||||
GFHOME_ENVELOPE_EXTRA_MM, are lens_home_test.c's in grblHAL-glowforge)
|
||||
2. the port's envelope open: refused before a home (error:homed); after
|
||||
one, the port's jogs reach the travel plus 30 mm for X and Y, and the
|
||||
port's state says the envelope is open; envelope apply puts the keys'
|
||||
edges back without a home
|
||||
3. an open envelope is the port's jogs' alone: a status poll and an
|
||||
empty line leave it open, and the sender's first line closes it before
|
||||
the core reads the line (the line then answered ok, and the keys'
|
||||
edges in force), a port jog it meets canceled
|
||||
4. a soft reset and the port client going away each close it
|
||||
5. envelope open is refused under an open armed window (busy:state)
|
||||
|
||||
Usage: envelope_test.py [path/to/grblHAL_glowforge]
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ctlport_test as ct # noqa: E402
|
||||
|
||||
fail = ct.fail
|
||||
TRAVEL_X, TRAVEL_Y, EXTRA = 495.0, 279.0, 30.0
|
||||
|
||||
|
||||
def conf(s, **keys):
|
||||
with open(os.path.join(s.workdir, "forgefirm.conf"), "w") as f:
|
||||
f.write("cool_fan_grace_s = 0\nlaser_disarm_s = 30\nhoming_mode = manual\n")
|
||||
for k, v in keys.items():
|
||||
f.write("%s = %s\n" % (k, v))
|
||||
|
||||
|
||||
def home(s):
|
||||
if s.sender.send("$H", timeout=10) != "ok":
|
||||
fail("manual $H refused")
|
||||
s.sender.wait_state("Idle")
|
||||
|
||||
|
||||
def jog_to(s, axis, mm):
|
||||
"""The sender's absolute jog; its response (ok or error:N), and back to 10 mm when it ran."""
|
||||
r = s.sender.send("$J=G90 G21 %s%.3f F6000" % (axis, mm))
|
||||
s.sender.wait_state("Idle")
|
||||
if r == "ok":
|
||||
s.sender.send("$J=G90 G21 %s10 F6000" % axis)
|
||||
s.sender.wait_state("Idle")
|
||||
return r
|
||||
|
||||
|
||||
def edge(s, axis, inside, outside, what):
|
||||
a, b = jog_to(s, axis, inside), jog_to(s, axis, outside)
|
||||
if a != "ok" or b != "error:15":
|
||||
fail("[%s] %s: a jog to %.2f -> %s, to %.2f -> %s" % (what, axis, inside, a, outside, b))
|
||||
|
||||
|
||||
def port_idle(s, timeout=20.0):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
st = s.port.state()
|
||||
if st["state"] == "Idle" and not st["port_jog"]:
|
||||
return st
|
||||
time.sleep(0.05)
|
||||
fail("the port never saw Idle: %r" % s.port.state())
|
||||
|
||||
|
||||
def port_jog_to(s, axis, mm):
|
||||
"""The port's absolute jog, which leaves an open envelope open."""
|
||||
r = s.port.request("jog G90 G21 %s%.3f F6000" % (axis, mm))
|
||||
port_idle(s)
|
||||
if r == "ok":
|
||||
s.port.request("jog G90 G21 %s10 F6000" % axis)
|
||||
port_idle(s)
|
||||
return r
|
||||
|
||||
|
||||
def port_edge(s, axis, inside, outside, what):
|
||||
a, b = port_jog_to(s, axis, inside), port_jog_to(s, axis, outside)
|
||||
if a != "ok" or b != "error:15":
|
||||
fail("[%s] %s: a port jog to %.2f -> %s, to %.2f -> %s" % (what, axis, inside, a, outside, b))
|
||||
|
||||
|
||||
def envelope_open(s):
|
||||
return s.port.state()["envelope_open"]
|
||||
|
||||
|
||||
def open_envelope(s, what):
|
||||
s.quiet()
|
||||
r = s.port.request("envelope open")
|
||||
if r != "ok" or not envelope_open(s):
|
||||
fail("[%s] envelope open after a home -> %r, state %r" % (what, r, s.port.state()))
|
||||
|
||||
|
||||
def saw_within(s, needle, seconds=2.0):
|
||||
end = time.time() + seconds
|
||||
while not s.sender.saw(needle) and time.time() < end:
|
||||
time.sleep(0.05)
|
||||
return s.sender.saw(needle)
|
||||
|
||||
|
||||
def test_measured(s):
|
||||
conf(s, envelope_x_mm=480, envelope_y_mm=284.5)
|
||||
home(s)
|
||||
edge(s, "X", 479, 481, "measured")
|
||||
edge(s, "Y", 284, 285, "measured")
|
||||
print("PASS [measured]: envelope_x_mm 480 and envelope_y_mm 284.5 (past the travel) are the far edges at a home")
|
||||
|
||||
|
||||
def test_open_apply(s):
|
||||
conf(s, envelope_x_mm=480, envelope_y_mm=250)
|
||||
home(s)
|
||||
before = s.sender.saw("Envelope open for the bed check")
|
||||
open_envelope(s, "open")
|
||||
if saw_within(s, "Envelope open for the bed check") <= before:
|
||||
fail("[open] the sender got no [MSG:] for the open envelope: %r" % s.sender.other[-4:])
|
||||
port_edge(s, "X", TRAVEL_X + EXTRA - 1, TRAVEL_X + EXTRA + 1, "open")
|
||||
port_edge(s, "Y", TRAVEL_Y + EXTRA - 1, TRAVEL_Y + EXTRA + 1, "open")
|
||||
if not envelope_open(s):
|
||||
fail("[open] the port's own jogs closed the envelope")
|
||||
r = s.port.request("envelope apply")
|
||||
if r != "ok" or envelope_open(s):
|
||||
fail("[apply] envelope apply -> %r, state %r" % (r, s.port.state()))
|
||||
edge(s, "X", 479, 481, "apply")
|
||||
edge(s, "Y", 249, 251, "apply")
|
||||
print("PASS [open-apply]: open, the port's jogs reach the travel plus 30 mm; apply, the keys' edges again, "
|
||||
"no home")
|
||||
|
||||
|
||||
def test_sender_closes(s):
|
||||
conf(s, envelope_x_mm=480, envelope_y_mm=250)
|
||||
home(s)
|
||||
open_envelope(s, "sender")
|
||||
s.sender.state() # '?' is realtime
|
||||
s.sender.realtime(b"\n") # and an empty line no claim on the machine
|
||||
time.sleep(0.5)
|
||||
if not envelope_open(s):
|
||||
fail("[sender] a status poll or an empty line closed the envelope")
|
||||
before = s.sender.saw("Bed check envelope closed")
|
||||
r = s.sender.send("$J=G90 G21 X%.3f F6000" % (TRAVEL_X + EXTRA - 1))
|
||||
s.sender.wait_state("Idle")
|
||||
if r != "error:15":
|
||||
fail("[sender] the sender's jog into the open envelope's margin -> %r: the core read it before the "
|
||||
"envelope closed" % r)
|
||||
if envelope_open(s) or saw_within(s, "Bed check envelope closed") <= before:
|
||||
fail("[sender] the sender's line did not close the envelope: %r" % s.port.state())
|
||||
edge(s, "X", 479, 481, "sender")
|
||||
# A port jog running when the sender's line arrives is canceled first.
|
||||
open_envelope(s, "sender-jog")
|
||||
x0 = s.port.state()["mpos"][0]
|
||||
if s.port.request("jog G91 G21 X300 F6000") != "ok":
|
||||
fail("[sender-jog] the long port jog was refused")
|
||||
time.sleep(0.7)
|
||||
# A jog line: the core holds a G-code line's parser error for the next
|
||||
# one, and the edge above ended in error:15.
|
||||
r = s.sender.send("$J=G90 G21 X20 F6000", timeout=10.0)
|
||||
s.sender.wait_state("Idle")
|
||||
st = s.port.state()
|
||||
if r != "ok" or st["envelope_open"] or st["port_jog"] or abs(st["mpos"][0] - 20) > 0.01 or x0 > 20:
|
||||
fail("[sender-jog] the sender's line during a port jog -> %r, state %r" % (r, st))
|
||||
print("PASS [sender]: a status poll and an empty line leave the envelope open; the sender's first line "
|
||||
"closes it before the core reads it, a running port jog canceled first")
|
||||
|
||||
|
||||
def test_reset_closes(s):
|
||||
conf(s, envelope_x_mm=480, envelope_y_mm=250)
|
||||
home(s)
|
||||
open_envelope(s, "reset")
|
||||
s.sender.realtime(b"\x18")
|
||||
time.sleep(1.0)
|
||||
if envelope_open(s):
|
||||
fail("[reset] a soft reset left the envelope open")
|
||||
if s.sender.state() == "Alarm":
|
||||
s.sender.send("$X")
|
||||
s.sender.wait_state("Idle")
|
||||
edge(s, "X", 479, 481, "reset")
|
||||
open_envelope(s, "client")
|
||||
s.port.close()
|
||||
s.port = ct.PortClient(s.path)
|
||||
if envelope_open(s):
|
||||
fail("[client] the port client went away and the envelope stayed open")
|
||||
print("PASS [reset]: a soft reset and the port client going away each closed the open envelope")
|
||||
|
||||
|
||||
def test_not_homed():
|
||||
s = ct.Session()
|
||||
try:
|
||||
conf(s)
|
||||
s.quiet()
|
||||
r = s.port.request("envelope open")
|
||||
if r != "error:homed":
|
||||
fail("[not-homed] envelope open before a home -> %r" % r)
|
||||
print("PASS [not-homed]: envelope open before a home -> error:homed")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_armed():
|
||||
s = ct.Session(laser=True)
|
||||
try:
|
||||
conf(s)
|
||||
home(s)
|
||||
s.sender.send("G91")
|
||||
s.sender.send("M3 S500")
|
||||
s.sender.send("G1 X5 F1500")
|
||||
s.sender.wait_state("Idle")
|
||||
time.sleep(0.5)
|
||||
if not s.sender.saw("laser armed"):
|
||||
fail("[armed] the laser line did not arm: the case would prove nothing")
|
||||
s.quiet()
|
||||
r = s.port.request("envelope open")
|
||||
if r != "busy:state":
|
||||
fail("[armed] envelope open under an open armed window -> %r" % r)
|
||||
print("PASS [armed]: envelope open under an open armed window -> busy:state")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(ct.BIN):
|
||||
fail("no controller binary at %s" % ct.BIN)
|
||||
s = ct.Session()
|
||||
try:
|
||||
test_measured(s)
|
||||
test_open_apply(s)
|
||||
test_sender_closes(s)
|
||||
test_reset_closes(s)
|
||||
finally:
|
||||
s.close()
|
||||
test_not_homed()
|
||||
test_armed()
|
||||
print("ALL PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -10,23 +10,30 @@ hardware, no root; arming is automatic without the physical button)
|
||||
and drives jobs over TCP, asserting the window's state machine on the
|
||||
reported messages:
|
||||
|
||||
1. the first laser-on of a job arms exactly once, and the window
|
||||
persists across M5/M3 toggles within the job (no re-prompt)
|
||||
2. program end (M2) closes the window: the disarm lands promptly,
|
||||
and the next job arms afresh
|
||||
1. the first laser-on of a job arms exactly once; the window's
|
||||
persistence across S changes and M5/M3 toggles inside the job is
|
||||
laser_arm_test.c case I's
|
||||
2. program end (M2) closing the window, and the next job arming
|
||||
afresh: laser_stream_test session K
|
||||
3. a sender change closes the window: a reconnected client must
|
||||
re-arm - the displaced session's consent does not carry over
|
||||
re-arm - the displaced session's consent does not carry over; the
|
||||
disarm and the relock at the change are laser_arm_test.c case H,
|
||||
and a new sender's soft reset of the held job is the core's reset
|
||||
from a completed hold, session hold-reset-restart under rule 10
|
||||
4. the spindle-off grace counts down in HOLD, not only in Idle - a
|
||||
job abandoned in feed hold disarms after laser_disarm_s
|
||||
5. arming is refused while the cooling verdict blocks fire, and no
|
||||
armed message appears
|
||||
5. arming refused while the cooling verdict blocks fire, or before
|
||||
the engine acknowledges the session: laser_arm_test.c cases C, A2
|
||||
and A3, and cooling_test.c's arm-time gate; the wait for the fans
|
||||
is laser_arm_test.c case A4
|
||||
6. with a switch source present (GF_SWITCH_FILE), the arm blocks on the
|
||||
button: a press with the lid closed arms, and the lid or the
|
||||
interlock loop opening during the wait cancels the job (a clean
|
||||
soft reset, no alarm; latch relocked, never armed)
|
||||
7. outside the arm wait the button is the pause/resume toggle: a press
|
||||
while running feed-holds, a press while held resumes; the arming
|
||||
press itself is not a pause press
|
||||
press itself is not a pause press; a travel job (no laser, no arm
|
||||
wait) takes the same toggle, and its resumed move lands on its target
|
||||
8. the lid or the interlock loop opening mid-job cancels the job (the
|
||||
default lid_policy): reason reported, armed window closed, a soft
|
||||
reset with the position kept (no alarm), the head returns to the
|
||||
@@ -43,20 +50,34 @@ reported messages:
|
||||
not to where it was paused (the core restarts a held cycle through
|
||||
Idle); a job abandoned in a hold and reset ends there, so the next
|
||||
job's start is captured afresh where it begins
|
||||
11. the cooling verdict's pause tier (OVERTEMP) holds the job under the
|
||||
open window without writing the latch (a lock sets the hardware
|
||||
button latch, which only a press clears); the clean verdict then
|
||||
resumes it with no new button press: no prompt, no second arm
|
||||
12. the verdict's fail tier (AIRFLOW) ends the job: disarmed, reset with
|
||||
ALARM:3, and a ~ under the clean verdict that follows resumes nothing
|
||||
11. the cooling verdict's pause tier holding the job under the open
|
||||
window without a latch write, and the clean verdict resuming it with
|
||||
no press and no second arm: laser_stream_test rule 24 and
|
||||
laser_arm_test.c case V1
|
||||
12. the verdict's fail tier ending the job (disarmed, ALARM:3, nothing
|
||||
resumed): laser_stream_test rule 25 and laser_arm_test.c case V2
|
||||
13. a sender change during a re-arm cancels it: the press that follows
|
||||
resumes nothing, the job stays held, and the new sender's own ~
|
||||
prompts afresh
|
||||
14. a jog does not hold the armed window open: with the spindle off the
|
||||
grace counts down through jogs, and the window closes on time
|
||||
14. a jog not holding the armed window open: laser_arm_test.c case S10
|
||||
15. a press counts only after the button has been seen up: a press that
|
||||
began before the wait is not consent (the harness's own presses
|
||||
follow a fresh prompt for the same reason)
|
||||
16. a job paused on the button is not resumable past a lid open: the lid
|
||||
cancels it from the hold - reason reported, the reset banner, no
|
||||
alarm, the return to the job start - and a later press resumes
|
||||
nothing
|
||||
17. a jog cancel (0x85) stops a jog short of its target, Idle; a ^X
|
||||
mid-move asks the kernel for its ramp (cnc/stop in GFSINK_ATTR_LOG)
|
||||
and lands in Alarm with the position kept: MPos is the X that the
|
||||
shipped stream (GFSINK_DUMP) moved, an unreferenced lens is still
|
||||
held after $X, and the head does not return to the job start; $X
|
||||
recovers to Idle and the next jog ships steps
|
||||
18. a ^X during the web-service homing session keeps the lens hold: the
|
||||
session's command is ended, the stream owes no stop, the reset lands
|
||||
in Alarm, and after $X a Z jog is still refused (error 15) with MPos
|
||||
Z unmoved, while a program Z move hits the pinned lens's soft limit
|
||||
(ALARM:2)
|
||||
|
||||
The disarm grace is shortened via a temp config (GFHOME_CONF), the
|
||||
cooling verdict is published hermetically (GF_VERDICT_FILE), the same
|
||||
@@ -207,9 +228,13 @@ class Session:
|
||||
"""One controller process with the lifecycle overrides applied."""
|
||||
|
||||
def __init__(self, name, fire_ok=True, disarm_s=2, switches=None, conf_extra="",
|
||||
armed_ack=True, ack_after_s=0.0):
|
||||
armed_ack=True, ack_after_s=0.0, dump=False, attr_log=False):
|
||||
self.name = name
|
||||
self.workdir = tempfile.mkdtemp(prefix="laser-lifecycle-")
|
||||
# The stream the controller ships, when the case reads it.
|
||||
self.dump = os.path.join(self.workdir, "stream") if dump else None
|
||||
# The sysfs writes the hardware would get, when the case reads them.
|
||||
self.attr_log = os.path.join(self.workdir, "attr.log") if attr_log else None
|
||||
conf = os.path.join(self.workdir, "forgefirm.conf")
|
||||
with open(conf, "w") as f:
|
||||
# The fan grace at 0 keeps the arm's acknowledgment budget at its
|
||||
@@ -221,6 +246,12 @@ class Session:
|
||||
GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
|
||||
env.pop("GFSINK", None)
|
||||
env.pop("GF_SWITCH_FILE", None)
|
||||
env.pop("GFSINK_DUMP", None)
|
||||
env.pop("GFSINK_ATTR_LOG", None)
|
||||
if self.dump:
|
||||
env["GFSINK_DUMP"] = self.dump
|
||||
if self.attr_log:
|
||||
env["GFSINK_ATTR_LOG"] = self.attr_log
|
||||
self.switch_file = None
|
||||
if switches is not None:
|
||||
self.switch_file = os.path.join(self.workdir, "switches")
|
||||
@@ -311,6 +342,39 @@ class Session:
|
||||
m = re.findall(r"MPos:(-?[\d.]+)", "".join(self.log[-4:]))
|
||||
return float(m[-1]) if m else None
|
||||
|
||||
def mpos_z(self):
|
||||
self.sock.sendall(b"?")
|
||||
read_avail(self.sock, self.log, 0.3)
|
||||
m = re.findall(r"MPos:-?[\d.]+,-?[\d.]+,(-?[\d.]+)", "".join(self.log[-4:]))
|
||||
return float(m[-1]) if m else None
|
||||
|
||||
def setting(self, n):
|
||||
"""A $-setting's value, from a $$ dump."""
|
||||
mark = len(self.log)
|
||||
send_line(self.sock, "$$", self.log)
|
||||
m = re.search(r"\$%d=(-?[\d.]+)" % n, "".join(self.log[mark:]))
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
def attr_count(self, write):
|
||||
"""How many times the hardware would have been given `write`
|
||||
("<attr> <value>") so far."""
|
||||
try:
|
||||
with open(self.attr_log) as f:
|
||||
return sum(1 for line in f if line.strip() == write)
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
def shipped_x_steps(self):
|
||||
"""The X steps the shipped stream moved, net: a tick byte (bit 7
|
||||
clear) with the X step bit (0x01), the X direction bit (0x02) set
|
||||
for -X."""
|
||||
try:
|
||||
with open(self.dump, "rb") as f:
|
||||
data = f.read()
|
||||
except OSError:
|
||||
return 0
|
||||
return sum((-1 if b & 0x02 else 1) for b in data if not b & 0x80 and b & 0x01)
|
||||
|
||||
def armed_count(self):
|
||||
return "".join(self.log).count(ARMED)
|
||||
|
||||
@@ -428,88 +492,10 @@ def test_laser_keys_reload():
|
||||
s.close()
|
||||
|
||||
|
||||
def test_job_window():
|
||||
"""Rules 1 + 2: arm once per job, persist across M5/M3, close at M2,
|
||||
fresh consent for the next job. The grace is set far beyond the test
|
||||
horizon so only the program-end close can produce the disarm."""
|
||||
s = Session("job-window", disarm_s=60)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X1 F600", s.log)
|
||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||
fail("[job-window] first laser-on did not arm")
|
||||
send_line(s.sock, "M5", s.log)
|
||||
send_line(s.sock, "M3 S200", s.log)
|
||||
send_line(s.sock, "G1 X0 F600", s.log)
|
||||
wait_idle(s.sock, s.log)
|
||||
if s.armed_count() != 1:
|
||||
fail("[job-window] M5/M3 inside one job re-prompted the arm "
|
||||
"(armed messages: %d)" % s.armed_count())
|
||||
send_line(s.sock, "M2", s.log)
|
||||
if not wait_for(s.log, DISARMED, 5, s.sock):
|
||||
fail("[job-window] program end (M2) did not close the armed window")
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X1 F600", s.log)
|
||||
if not wait_for(s.log, ARMED, 5, s.sock) or s.armed_count() != 2:
|
||||
fail("[job-window] next job after M2 did not arm afresh")
|
||||
wait_idle(s.sock, s.log)
|
||||
print("PASS [job-window]: armed once per job, M2 closed the window, "
|
||||
"next job re-armed")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_sender_change_mid_job():
|
||||
"""Rule 3, the hard case: the sender changes while the job is still
|
||||
running with the spindle on. The window closes and the job is HELD
|
||||
where the cut stopped, so the next sender finds it in Hold rather than
|
||||
at the end of a dark pass. Its cycle start goes through the resume
|
||||
gate, which re-arms first: with no button on a host build that is
|
||||
immediate, and the job then runs to its end lit."""
|
||||
s = Session("sender-change-mid-job", disarm_s=60)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X5 F60", s.log) # 5 s of motion
|
||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||
fail("[sender-change-mid-job] first laser-on did not arm")
|
||||
time.sleep(1.0)
|
||||
s.sock.close() # mid-move, spindle on
|
||||
time.sleep(0.5)
|
||||
# The disarm and hold messages are written while no client is
|
||||
# connected, and output with no client is discarded, so the new
|
||||
# session cannot see them; the Hold state and the re-arm below are
|
||||
# the evidence.
|
||||
s.sock = s.connect()
|
||||
st = s.wait_state("Hold", 5)
|
||||
if st is None:
|
||||
fail("[sender-change-mid-job] the job was not held on the sender change "
|
||||
"(state %r)" % s.state())
|
||||
x_held = s.mpos_x()
|
||||
if x_held is None or not 0.5 < x_held < 4.5:
|
||||
fail("[sender-change-mid-job] held outside the move (MPos X %s)" % x_held)
|
||||
before = s.armed_count()
|
||||
s.sock.sendall(b"~") # the new sender resumes
|
||||
end = time.time() + 15
|
||||
while time.time() < end and s.armed_count() != before + 1:
|
||||
read_avail(s.sock, s.log, 0.2)
|
||||
if s.armed_count() != before + 1:
|
||||
fail("[sender-change-mid-job] the resume after a mid-job sender change did "
|
||||
"not re-arm")
|
||||
wait_idle(s.sock, s.log)
|
||||
x_end = s.mpos_x()
|
||||
if x_end is None or abs(x_end - 5.0) > 0.01:
|
||||
fail("[sender-change-mid-job] the resumed job did not finish the move "
|
||||
"(MPos X %s)" % x_end)
|
||||
send_line(s.sock, "M5", s.log)
|
||||
print("PASS [sender-change-mid-job]: the job held at X=%.3f on the sender change, "
|
||||
"re-armed on the resume and finished at X=%.3f" % (x_held, x_end))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_sender_change_holds_and_rearms():
|
||||
"""Rule 3 on a machine with a button: the held job's resume from the
|
||||
new sender prompts for the press (collected from the poll, never
|
||||
"""Rule 3 on a machine with a button: a sender change mid-move, spindle
|
||||
on, holds the job where the cut stopped; the held job's resume from
|
||||
the new sender prompts for the press (collected from the poll, never
|
||||
inside the core's held state), and the press re-arms and resumes."""
|
||||
s = Session("sender-change-rearm", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
@@ -526,6 +512,9 @@ def test_sender_change_holds_and_rearms():
|
||||
s.sock = s.connect()
|
||||
if s.wait_state("Hold", 5) is None:
|
||||
fail("[sender-change-rearm] the job was not held on the sender change")
|
||||
x_held = s.mpos_x()
|
||||
if x_held is None or not 0.5 < x_held < 4.5:
|
||||
fail("[sender-change-rearm] held outside the move (MPos X %s)" % x_held)
|
||||
before = s.armed_count()
|
||||
s.sock.sendall(b"~")
|
||||
if not wait_for(s.log, RESUME_PROMPT, 5, s.sock):
|
||||
@@ -544,33 +533,8 @@ def test_sender_change_holds_and_rearms():
|
||||
fail("[sender-change-rearm] the resumed job did not finish the move "
|
||||
"(MPos X %s)" % x_end)
|
||||
send_line(s.sock, "M5", s.log)
|
||||
print("PASS [sender-change-rearm]: the held job prompted on ~, the press re-armed "
|
||||
"it, and it finished at X=%.3f" % x_end)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_sender_change_then_reset():
|
||||
"""A new sender that does not want the held job resets it: a soft
|
||||
reset from the hold ends the job with the position kept and no alarm."""
|
||||
s = Session("sender-change-reset", disarm_s=60)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X5 F60", s.log)
|
||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||
fail("[sender-change-reset] first laser-on did not arm")
|
||||
time.sleep(1.0)
|
||||
s.sock.close()
|
||||
time.sleep(0.5)
|
||||
s.sock = s.connect()
|
||||
if s.wait_state("Hold", 5) is None:
|
||||
fail("[sender-change-reset] the job was not held on the sender change")
|
||||
s.sock.sendall(b"\x18")
|
||||
st = s.wait_state("Idle", 5)
|
||||
if st is None:
|
||||
fail("[sender-change-reset] a reset from the held job did not end in Idle "
|
||||
"(state %r)" % s.state())
|
||||
print("PASS [sender-change-reset]: a reset from the held job ends in Idle, no alarm")
|
||||
print("PASS [sender-change-rearm]: held at X=%.3f, the held job prompted on ~, the "
|
||||
"press re-armed it, and it finished at X=%.3f" % (x_held, x_end))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
@@ -655,156 +619,6 @@ def test_rx_overrun_aborts():
|
||||
s.close()
|
||||
|
||||
|
||||
def test_sender_change():
|
||||
"""Rule 3: a reconnected sender must re-arm. The grace is set far
|
||||
beyond the test horizon so only the sender change can close the
|
||||
window; the first job ends in M5 and the next M4 is a fresh laser-on."""
|
||||
s = Session("sender-change", disarm_s=60)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X1 F600", s.log)
|
||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||
fail("[sender-change] first laser-on did not arm")
|
||||
send_line(s.sock, "M5", s.log)
|
||||
wait_idle(s.sock, s.log)
|
||||
s.sock.close()
|
||||
time.sleep(0.5)
|
||||
s.sock = s.connect()
|
||||
before = s.armed_count()
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X0 F600", s.log)
|
||||
end = time.time() + 5
|
||||
while time.time() < end and s.armed_count() != before + 1:
|
||||
read_avail(s.sock, s.log, 0.2)
|
||||
if s.armed_count() != before + 1:
|
||||
fail("[sender-change] the reconnected sender inherited the "
|
||||
"displaced session's armed window")
|
||||
wait_idle(s.sock, s.log)
|
||||
print("PASS [sender-change]: displaced consent did not survive; "
|
||||
"the new sender re-armed")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_hold_grace():
|
||||
"""Rule 4: the disarm grace counts down in HOLD."""
|
||||
s = Session("hold-grace", disarm_s=2)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X30 F60", s.log) # ~30 s move
|
||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||
fail("[hold-grace] job did not arm")
|
||||
time.sleep(1.0)
|
||||
s.sock.sendall(b"!") # feed hold
|
||||
if not wait_for(s.log, DISARMED, 10, s.sock):
|
||||
fail("[hold-grace] a job abandoned in Hold stayed armed past "
|
||||
"the disarm grace")
|
||||
print("PASS [hold-grace]: the armed window closed while parked in Hold")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_verdict_blocks_arm():
|
||||
"""Rule 5: a blocking cooling verdict refuses the arm."""
|
||||
s = Session("verdict-blocked", fire_ok=False)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log, tolerate_error=True)
|
||||
send_line(s.sock, "G1 X1 F600", s.log, tolerate_error=True)
|
||||
if not wait_for(s.log, BLOCKED, 5, s.sock):
|
||||
fail("[verdict-blocked] no refusal message for a blocking verdict")
|
||||
if ARMED in "".join(s.log):
|
||||
fail("[verdict-blocked] armed despite a blocking cooling verdict")
|
||||
print("PASS [verdict-blocked]: arming refused while fire_ok=false")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_arm_waits_for_engine_ack():
|
||||
"""Rule 5: the arm does not fire on a verdict computed before it.
|
||||
|
||||
The armed window is reported to the cooling engine when it opens,
|
||||
and the engine answers by applying the cut airflow and the flow
|
||||
interrogation for the job. The verdict standing on file until that
|
||||
answer arrives is the one computed for the idle session that
|
||||
preceded the arm, and at idle nothing is wrong, so it says fire is
|
||||
fine. Firing on it puts the beam on the work with the fans still at
|
||||
their idle duty - observed on the bench as a burn of a few
|
||||
millimeters before the engine's first tick took the job into a hold.
|
||||
|
||||
Here the engine never acknowledges: fire_ok stays true throughout,
|
||||
so the only thing that can refuse the job is the missing
|
||||
acknowledgment. The arm must be refused and no window may open."""
|
||||
s = Session("arm-ack", fire_ok=True, armed_ack=False)
|
||||
try:
|
||||
# The arm blocks in the acknowledgment wait, so the ok for these
|
||||
# lines only arrives once it gives up: send without waiting.
|
||||
s.send_raw("M4 S100")
|
||||
s.send_raw("G1 X1 F600")
|
||||
if not wait_for(s.log, BLOCKED, 15, s.sock):
|
||||
fail("[arm-ack] a job whose cooling engine never took it was not "
|
||||
"refused (the arm fired on the pre-arm verdict)")
|
||||
if ARMED in "".join(s.log):
|
||||
fail("[arm-ack] the armed window opened without the engine's "
|
||||
"acknowledgment")
|
||||
print("PASS [arm-ack]: the arm refused a verdict that predates it")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_arm_proceeds_on_a_late_ack():
|
||||
"""Rule 5: the acknowledgment arriving late is the ordinary case.
|
||||
|
||||
The real engine answers on its next tick, so the arm has to observe
|
||||
a verdict refreshed under it and then go on to open the window. This
|
||||
is the case that proves the wait is a wait and not a refusal: the
|
||||
controller must keep polling the verdict file while it is blocked in
|
||||
the arm, or every job on the machine would fail here."""
|
||||
ack_s = 2.0
|
||||
s = Session("arm-ack-late", fire_ok=True, ack_after_s=ack_s)
|
||||
try:
|
||||
t0 = time.time()
|
||||
s.send_raw("M4 S100")
|
||||
s.send_raw("G1 X1 F600")
|
||||
if not wait_for(s.log, ARMED, 15, s.sock):
|
||||
fail("[arm-ack-late] the arm never completed once the engine took "
|
||||
"the job (the verdict is not refreshed inside the wait)")
|
||||
dt = time.time() - t0
|
||||
if dt < ack_s - 0.5:
|
||||
fail("[arm-ack-late] armed after %.1f s, before the engine took the "
|
||||
"job at %.1f s" % (dt, ack_s))
|
||||
if BLOCKED in "".join(s.log):
|
||||
fail("[arm-ack-late] a late acknowledgment was reported as a refusal")
|
||||
print("PASS [arm-ack-late]: the arm waited %.1f s for the engine, then armed" % dt)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_arm_waits_for_the_fans():
|
||||
"""Rule 5: the acknowledgment budget follows the fan grace. The
|
||||
engine acknowledges once the fans are at their floors, seconds after
|
||||
the arm; with cool_fan_grace_s at 8 the controller waits up to 13 s
|
||||
for it, and an acknowledgment at 9 s arms the job rather than
|
||||
refusing it at the old 5 s budget."""
|
||||
ack_s = 9.0
|
||||
s = Session("arm-fans", fire_ok=True, ack_after_s=ack_s,
|
||||
conf_extra="cool_fan_grace_s = 8\n")
|
||||
try:
|
||||
t0 = time.time()
|
||||
s.send_raw("M4 S100")
|
||||
s.send_raw("G1 X1 F600")
|
||||
if not wait_for(s.log, ARMED, 20, s.sock):
|
||||
fail("[arm-fans] the arm never completed with the acknowledgment at %.0f s "
|
||||
"(the budget did not follow cool_fan_grace_s)" % ack_s)
|
||||
dt = time.time() - t0
|
||||
if dt < ack_s - 0.5:
|
||||
fail("[arm-fans] armed after %.1f s, before the fans were up at %.1f s" % (dt, ack_s))
|
||||
if BLOCKED in "".join(s.log):
|
||||
fail("[arm-fans] the wait for the fans was reported as a refusal")
|
||||
print("PASS [arm-fans]: the arm waited %.1f s for the fans, then armed" % dt)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_sigterm_mid_job():
|
||||
"""Rule 5: a termination signal during an armed job is a STOP, not a
|
||||
"finish the job first". The supervisor's expected-stop path (mode
|
||||
@@ -876,8 +690,8 @@ def test_button_wait_arms():
|
||||
|
||||
def test_lid_open_in_wait():
|
||||
"""Rule 6b: the lid opening during the button wait cancels the job:
|
||||
the reason is reported, an alarm is raised, the window never opens.
|
||||
A press with the lid still open must not arm either."""
|
||||
the lid-open cancel is reported, no alarm is raised, the window never
|
||||
opens. A press with the lid still open must not arm either."""
|
||||
s = Session("lid-in-wait", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
s.send_raw("M4 S100")
|
||||
@@ -936,7 +750,8 @@ def start_armed_move(s, tag, gcode="G1 X30 F60"):
|
||||
def test_button_pause_resume():
|
||||
"""Rule 7: outside the arm wait the button is the pause/resume toggle -
|
||||
a press while running is a feed hold, a press while held is a cycle
|
||||
start; the arming press itself is not a pause press."""
|
||||
start; the arming press itself is not a pause press. A travel job takes
|
||||
the same toggle, and its resumed move lands on its target."""
|
||||
s = Session("button-toggle", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
start_armed_move(s, "button-toggle")
|
||||
@@ -958,6 +773,41 @@ def test_button_pause_resume():
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
# The same toggle on a travel job: no laser, so no arm wait, and the
|
||||
# move resumed from the hold lands where it was programmed to.
|
||||
s = Session("button-toggle-travel", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
x0 = s.mpos_x()
|
||||
if x0 is None:
|
||||
fail("[button-toggle-travel] no position in the status report")
|
||||
s.send_raw("M5")
|
||||
s.send_raw("G91")
|
||||
s.send_raw("G1 X40 F300") # 8 s
|
||||
if not s.wait_state("Run", 5):
|
||||
fail("[button-toggle-travel] the travel job never reported Run")
|
||||
if s.count(PROMPT):
|
||||
fail("[button-toggle-travel] a job with no laser asked for the arm press")
|
||||
time.sleep(1.0)
|
||||
s.press_button()
|
||||
if not s.wait_state("Hold", 3):
|
||||
fail("[button-toggle-travel] a press while running did not feed-hold (state %s)" % s.state())
|
||||
held = s.mpos_x()
|
||||
if held is None or not x0 < held < x0 + 40.0:
|
||||
fail("[button-toggle-travel] held at X %s, not part-way from %s" % (held, x0))
|
||||
time.sleep(0.5)
|
||||
s.press_button()
|
||||
if not s.wait_state("Run", 3):
|
||||
fail("[button-toggle-travel] a press while held did not resume (state %s)" % s.state())
|
||||
if not s.wait_state("Idle", 15):
|
||||
fail("[button-toggle-travel] the resumed move did not complete (state %s)" % s.state())
|
||||
x1 = s.mpos_x()
|
||||
if x1 is None or abs(x1 - x0 - 40.0) > 0.05:
|
||||
fail("[button-toggle-travel] the resumed move ended at X %s, not %.3f" % (x1, x0 + 40.0))
|
||||
print("PASS [button-toggle-travel]: a travel job held at %.3f mm and resumed to its target, "
|
||||
"%.3f mm" % (held - x0, x1 - x0))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_lid_cancels_and_returns():
|
||||
"""Rule 8 (lid_policy = cancel, the default): the lid opening mid-job
|
||||
@@ -1075,6 +925,230 @@ def test_pause_then_lid_cancel_returns_to_the_job_start():
|
||||
s.close()
|
||||
|
||||
|
||||
def test_cancel_and_abort():
|
||||
"""Rule 17: a jog cancel (0x85) stops a jog short of its target and
|
||||
leaves the controller Idle. A ^X mid-move is a controlled abort: the
|
||||
kernel is told to stop with its ramp, Alarm, the position kept, and
|
||||
no return to the job start (that move is the lid policy's alone). The
|
||||
position kept is the X that the shipped stream moved, and the lens,
|
||||
which has no reference here, is still held where it stands. $X
|
||||
recovers to Idle, the head does not move on its own, and the next jog
|
||||
ships steps."""
|
||||
s = Session("cancel-abort", disarm_s=60, dump=True, attr_log=True)
|
||||
try:
|
||||
spm = s.setting(100)
|
||||
if not spm:
|
||||
fail("[cancel-abort] no $100 in the settings dump")
|
||||
x0 = s.mpos_x()
|
||||
if x0 is None:
|
||||
fail("[cancel-abort] no position in the status report")
|
||||
s.send_raw("$J=G91X40F2400")
|
||||
if not s.wait_state("Jog", 3):
|
||||
fail("[cancel-abort] the jog never reported Jog (state %s)" % s.state())
|
||||
time.sleep(0.3)
|
||||
s.sock.sendall(b"\x85")
|
||||
if not s.wait_state("Idle", 5):
|
||||
fail("[cancel-abort] not Idle after the jog cancel (state %s)" % s.state())
|
||||
x1 = s.mpos_x()
|
||||
if x1 is None or not 0.5 < x1 - x0 < 39.0:
|
||||
fail("[cancel-abort] the jog cancel did not stop short of the target (X %s from %.3f)"
|
||||
% (x1, x0))
|
||||
|
||||
s.send_raw("M5")
|
||||
s.send_raw("G91")
|
||||
s.send_raw("G1 X120 F3000") # 2.4 s
|
||||
if not s.wait_state("Run", 5):
|
||||
fail("[cancel-abort] the move never reported Run (state %s)" % s.state())
|
||||
time.sleep(0.5)
|
||||
stops = s.attr_count("cnc/stop 1")
|
||||
s.sock.sendall(b"\x18")
|
||||
if not s.wait_state("Alarm", 5):
|
||||
fail("[cancel-abort] ^X mid-move did not land in Alarm (state %s)" % s.state())
|
||||
if s.attr_count("cnc/stop 1") <= stops:
|
||||
fail("[cancel-abort] ^X mid-move did not ask the kernel to stop with its ramp "
|
||||
"(no cnc/stop write)")
|
||||
x2 = s.mpos_x()
|
||||
if x2 is None or not 0.5 < x2 - x1 < 119.5:
|
||||
fail("[cancel-abort] the abort did not keep a plausible position (X %s from %.3f)" % (x2, x1))
|
||||
moved = s.shipped_x_steps()
|
||||
if abs(x2 * spm - moved) > 0.5:
|
||||
fail("[cancel-abort] the position after the abort is not the X the stream moved "
|
||||
"(X %.3f is %.1f steps, the stream moved %d)" % (x2, x2 * spm, moved))
|
||||
send_line(s.sock, "$X", s.log)
|
||||
if not s.wait_state("Idle", 5):
|
||||
fail("[cancel-abort] $X did not recover to Idle (state %s)" % s.state())
|
||||
read_avail(s.sock, s.log, 1.0)
|
||||
if "returned to the job start" in "".join(s.log):
|
||||
fail("[cancel-abort] a sender abort triggered the return to the job start")
|
||||
x3 = s.mpos_x()
|
||||
if x3 is None or abs(x3 - x2) > 0.05:
|
||||
fail("[cancel-abort] the head moved on its own after the abort (X %s, was %.3f)" % (x3, x2))
|
||||
# The lens has no reference in this session, so it is held where it
|
||||
# stands, and the abort keeps it held.
|
||||
mark = len(s.log)
|
||||
if send_line(s.sock, "$J=G91Z1F100", s.log, tolerate_error=True) != "error" or \
|
||||
"error:15" not in "".join(s.log[mark:]):
|
||||
fail("[cancel-abort] a Z jog after the abort was not refused: the lens is no longer held")
|
||||
shipped = os.path.getsize(s.dump) if os.path.exists(s.dump) else 0
|
||||
s.send_raw("$J=G91X-5F2400")
|
||||
if not s.wait_state("Jog", 3) or not s.wait_state("Idle", 5):
|
||||
fail("[cancel-abort] the jog after $X did not run (state %s)" % s.state())
|
||||
grew = (os.path.getsize(s.dump) if os.path.exists(s.dump) else 0) - shipped
|
||||
if grew <= 0:
|
||||
fail("[cancel-abort] the jog after $X shipped no steps")
|
||||
print("PASS [cancel-abort]: 0x85 stopped the jog at %.3f mm of 40, Idle; ^X mid-move asked "
|
||||
"for the kernel's stop and landed in Alarm at %.3f mm, the X the stream moved, with no "
|
||||
"return and the lens still held; $X recovered to Idle and the next jog shipped %d bytes"
|
||||
% (x1 - x0, x2 - x1, grew))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
HOME_SESSION_CMD = "sleep 47.25"
|
||||
|
||||
|
||||
def home_sessions():
|
||||
"""How many processes of the web-service homing session's command are
|
||||
running (the command is one no other process on the host uses)."""
|
||||
r = subprocess.run(["pgrep", "-f", HOME_SESSION_CMD], capture_output=True, text=True)
|
||||
return len(r.stdout.split())
|
||||
|
||||
|
||||
def test_homing_abort_keeps_z_hold():
|
||||
"""Rule 18: a ^X during the web-service homing session keeps the lens
|
||||
hold. The session owns the device while it runs, so the reset reads
|
||||
nothing back and the position stays lost; the lens, unreferenced in
|
||||
this session, must still be held where it stands once $X has cleared
|
||||
the alarm. The session's command is ended by the reset, and the stream
|
||||
owes the kernel no stop. A program Z move then hits the pinned lens's
|
||||
soft limit."""
|
||||
s = Session("home-reset", disarm_s=60, attr_log=True,
|
||||
conf_extra="homing_mode = gfcloud\ngfcloud_home_cmd = %s\n" % HOME_SESSION_CMD)
|
||||
try:
|
||||
if home_sessions():
|
||||
fail("[home-reset] a homing session's command is already running on the host")
|
||||
mark = len(s.log)
|
||||
if send_line(s.sock, "$J=G91Z1F100", s.log, tolerate_error=True) != "error" or \
|
||||
"error:15" not in "".join(s.log[mark:]):
|
||||
fail("[home-reset] the lens is not held before the session: a Z jog was not refused")
|
||||
send_line(s.sock, "", s.log)
|
||||
z0 = s.mpos_z()
|
||||
if z0 is None:
|
||||
fail("[home-reset] no position in the status report")
|
||||
stops = s.attr_count("cnc/stop 1")
|
||||
s.send_raw("$H")
|
||||
if not s.wait_state("Home", 5):
|
||||
fail("[home-reset] $H never reported Home (state %s)" % s.state())
|
||||
time.sleep(0.5)
|
||||
if not home_sessions():
|
||||
fail("[home-reset] the homing session's command is not running")
|
||||
mark = len(s.log)
|
||||
s.sock.sendall(b"\x18")
|
||||
if not s.wait_state("Alarm", 8):
|
||||
fail("[home-reset] ^X during the homing session did not land in Alarm (state %s)"
|
||||
% s.state())
|
||||
read_avail(s.sock, s.log, 0.5)
|
||||
if "ALARM:6" not in "".join(s.log[mark:]):
|
||||
fail("[home-reset] the reset during homing did not raise ALARM:6")
|
||||
end = time.time() + 3
|
||||
while home_sessions() and time.time() < end:
|
||||
time.sleep(0.1)
|
||||
if home_sessions():
|
||||
fail("[home-reset] the homing session's command outlived the reset")
|
||||
if s.attr_count("cnc/stop 1") != stops:
|
||||
fail("[home-reset] the reset during the session wrote cnc/stop: the stream owed no stop")
|
||||
send_line(s.sock, "$X", s.log)
|
||||
if not s.wait_state("Idle", 5):
|
||||
fail("[home-reset] $X did not recover to Idle (state %s)" % s.state())
|
||||
mark = len(s.log)
|
||||
if send_line(s.sock, "$J=G91Z1F100", s.log, tolerate_error=True) != "error" or \
|
||||
"error:15" not in "".join(s.log[mark:]):
|
||||
fail("[home-reset] a Z jog after the reset was not refused: the lens hold was dropped")
|
||||
time.sleep(0.6)
|
||||
z1 = s.mpos_z()
|
||||
if z1 is None or abs(z1 - z0) > 0.001:
|
||||
fail("[home-reset] the lens moved after the reset (Z %s, was %.3f)" % (z1, z0))
|
||||
send_line(s.sock, "", s.log)
|
||||
mark = len(s.log)
|
||||
s.send_raw("G91 G1 Z1 F100")
|
||||
if read_avail(s.sock, s.log, 5.0, until=("ALARM:", "ok")) != "ALARM:" or \
|
||||
"ALARM:2" not in "".join(s.log[mark:]):
|
||||
fail("[home-reset] a program Z move did not hit the pinned lens's soft limit (ALARM:2)")
|
||||
# The soft-limit alarm asks for a reset before $X unlocks it: one
|
||||
# sent once a status report has come from the alarm's wait. A ^X
|
||||
# on the heels of the ALARM line lands while the soft limit's own
|
||||
# reset is still pending and is folded into it. The '?' polls keep
|
||||
# the controller's output flowing while it waits for the banner.
|
||||
if not s.wait_state("Alarm", 5):
|
||||
fail("[home-reset] no status report from the soft-limit alarm (state %s)" % s.state())
|
||||
banners = s.count("GrblHAL")
|
||||
s.sock.sendall(b"\x18")
|
||||
end = time.time() + 5
|
||||
while s.count("GrblHAL") <= banners and time.time() < end:
|
||||
s.state()
|
||||
if s.count("GrblHAL") <= banners:
|
||||
fail("[home-reset] no reset banner after the ^X that clears the soft limit")
|
||||
send_line(s.sock, "$X", s.log)
|
||||
if not s.wait_state("Idle", 5):
|
||||
fail("[home-reset] $X after the soft limit did not recover to Idle (state %s)" % s.state())
|
||||
print("PASS [home-reset]: ^X during the homing session ended its command, wrote no "
|
||||
"cnc/stop, landed in ALARM:6; after $X the Z jog was refused with Z at %.3f and "
|
||||
"a program Z move raised ALARM:2" % z1)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_lid_cancel_from_hold():
|
||||
"""Rule 16: a job paused on the button is not resumable past a lid
|
||||
open. The lid cancels it from the hold as it does from Run - the
|
||||
reason reported, the reset banner, no alarm, the head back at the job
|
||||
start - and a press once the lid is closed again resumes nothing."""
|
||||
s = Session("hold-lid-cancel", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
x0 = s.mpos_x()
|
||||
if x0 is None:
|
||||
fail("[hold-lid-cancel] no position in the status report")
|
||||
s.send_raw("M5")
|
||||
s.send_raw("G91")
|
||||
s.send_raw("G1 X40 F300") # 8 s
|
||||
if not s.wait_state("Run", 5):
|
||||
fail("[hold-lid-cancel] the job never reported Run")
|
||||
time.sleep(1.0)
|
||||
s.press_button()
|
||||
if not s.wait_state("Hold", 3):
|
||||
fail("[hold-lid-cancel] the press did not feed-hold (state %s)" % s.state())
|
||||
x_held = s.mpos_x()
|
||||
s.set_switches(SW_LID_OPEN)
|
||||
if not wait_for(s.log, "lid opened - job canceled", 5, s.sock):
|
||||
fail("[hold-lid-cancel] the lid open did not cancel the held job")
|
||||
if not wait_for(s.log, "for help]", 5, s.sock):
|
||||
fail("[hold-lid-cancel] no reset banner after the cancel")
|
||||
if not wait_for(s.log, "returned to the job start", 15, s.sock):
|
||||
fail("[hold-lid-cancel] the head did not report returning to the job start")
|
||||
if not s.wait_state("Idle", 5):
|
||||
fail("[hold-lid-cancel] not Idle after the return (state %s)" % s.state())
|
||||
if "ALARM" in "".join(s.log):
|
||||
fail("[hold-lid-cancel] an alarm was raised on the cancel (position should be kept)")
|
||||
x_end = s.mpos_x()
|
||||
if x_end is None or abs(x_end - x0) > 0.05:
|
||||
fail("[hold-lid-cancel] head not back at the job start: MPos X=%s (start %.3f, held "
|
||||
"at %s)" % (x_end, x0, x_held))
|
||||
s.set_switches(SW_CLOSED)
|
||||
time.sleep(0.5)
|
||||
s.press_button()
|
||||
read_avail(s.sock, s.log, 1.0)
|
||||
st = s.state()
|
||||
x_after = s.mpos_x()
|
||||
if not st.startswith("Idle") or x_after is None or abs(x_after - x_end) > 0.05:
|
||||
fail("[hold-lid-cancel] a press after the cancel resumed the job (state %s, X=%s)"
|
||||
% (st, x_after))
|
||||
print("PASS [hold-lid-cancel]: held at X=%.3f, the lid canceled the held job, reset "
|
||||
"banner, no alarm, returned to X=%.3f; a later press resumed nothing"
|
||||
% (x_held, x_end))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_lid_policy_hold():
|
||||
"""lid_policy = hold keeps stock grblHAL behavior: the lid parks the job
|
||||
in Door and a cycle start resumes it once closed."""
|
||||
@@ -1099,76 +1173,6 @@ def test_lid_policy_hold():
|
||||
s.close()
|
||||
|
||||
|
||||
def test_verdict_pause_resumes_without_press():
|
||||
"""Rule 11: the pause tier holds under the open window without a
|
||||
latch write; the clean verdict resumes with no press. The window is
|
||||
proven still open by the absence of any prompt and of a second arm."""
|
||||
s = Session("verdict-pause", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
start_armed_move(s, "verdict-pause", gcode="G1 X30 F120") # 15 s of motion
|
||||
time.sleep(0.5)
|
||||
s.set_verdict("OVERTEMP", fire_ok=False, hold=True, resume_ok=False)
|
||||
if not s.wait_state("Hold", 5):
|
||||
fail("[verdict-pause] the pause tier did not hold the job (state %s)" % s.state())
|
||||
if not wait_for(s.log, "fire masked, job held", 3, s.sock):
|
||||
fail("[verdict-pause] the pause tier did not report the masked hold")
|
||||
if "latch locked" in "".join(s.log):
|
||||
fail("[verdict-pause] the pause tier wrote the latch")
|
||||
if DISARMED in "".join(s.log):
|
||||
fail("[verdict-pause] the pause tier closed the armed window")
|
||||
time.sleep(1.5)
|
||||
s.set_verdict("OK", fire_ok=True, hold=False, resume_ok=True)
|
||||
if not s.wait_state("Run", 5):
|
||||
fail("[verdict-pause] the clean verdict did not resume the job (state %s)" % s.state())
|
||||
if not wait_for(s.log, "resuming", 2, s.sock):
|
||||
fail("[verdict-pause] the resume was not reported")
|
||||
if RESUME_PROMPT in "".join(s.log) or s.count(PROMPT) != 1:
|
||||
fail("[verdict-pause] the resume asked for a button press")
|
||||
if s.armed_count() != 1:
|
||||
fail("[verdict-pause] the resume armed again (armed messages: %d)" % s.armed_count())
|
||||
if "ALARM" in "".join(s.log):
|
||||
fail("[verdict-pause] the pause tier raised an alarm")
|
||||
s.sock.sendall(b"\x18")
|
||||
print("PASS [verdict-pause]: OVERTEMP held under the open window with no latch write; "
|
||||
"the clean verdict resumed with no press")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_verdict_fail_tier_ends_job():
|
||||
"""Rule 12: the fail tier disarms, resets the job with ALARM:3, and
|
||||
nothing resumes it."""
|
||||
s = Session("verdict-fail", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
start_armed_move(s, "verdict-fail", gcode="G1 X30 F120")
|
||||
time.sleep(0.5)
|
||||
s.set_verdict("AIRFLOW", fire_ok=False, hold=True, resume_ok=False)
|
||||
if not s.wait_state("Alarm", 5):
|
||||
fail("[verdict-fail] the fail tier did not end the job in Alarm (state %s)" % s.state())
|
||||
if not wait_for(s.log, DISARMED, 3, s.sock):
|
||||
fail("[verdict-fail] the fail tier did not close the armed window")
|
||||
text = "".join(s.log)
|
||||
if "ALARM:3" not in text:
|
||||
fail("[verdict-fail] no ALARM:3 on the fail tier")
|
||||
if "AIRFLOW" not in text:
|
||||
fail("[verdict-fail] the fail tier did not name the verdict")
|
||||
s.set_verdict("OK", fire_ok=True, hold=False, resume_ok=True)
|
||||
time.sleep(1.5)
|
||||
s.sock.sendall(b"~")
|
||||
read_avail(s.sock, s.log, 1.0)
|
||||
if not s.state().startswith("Alarm"):
|
||||
fail("[verdict-fail] a ~ after the fail tier resumed something (state %s)" % s.state())
|
||||
if "resuming" in "".join(s.log):
|
||||
fail("[verdict-fail] the client resumed after the fail tier")
|
||||
send_line(s.sock, "$X", s.log)
|
||||
if not s.wait_state("Idle", 3):
|
||||
fail("[verdict-fail] $X did not unlock the alarm")
|
||||
print("PASS [verdict-fail]: AIRFLOW disarmed, reset the job with ALARM:3, and nothing "
|
||||
"resumed it")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_sender_change_during_rearm_cancels():
|
||||
"""Rule 13: a sender change while a re-arm waits for the press
|
||||
cancels the re-arm; the press resumes nothing and the job stays
|
||||
@@ -1220,57 +1224,24 @@ def test_sender_change_during_rearm_cancels():
|
||||
s.close()
|
||||
|
||||
|
||||
def test_jog_does_not_extend_window():
|
||||
"""Rule 14: with the spindle off, jogs do not reset the disarm grace:
|
||||
the window closes on time through them."""
|
||||
s = Session("jog-grace", disarm_s=2)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X1 F600", s.log)
|
||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||
fail("[jog-grace] job did not arm")
|
||||
send_line(s.sock, "M5", s.log)
|
||||
wait_idle(s.sock, s.log)
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 5.0 and DISARMED not in "".join(s.log):
|
||||
send_line(s.sock, "$J=G91X1F1200", s.log)
|
||||
read_avail(s.sock, s.log, 0.3)
|
||||
dt = time.time() - t0
|
||||
if DISARMED not in "".join(s.log):
|
||||
fail("[jog-grace] the window stayed open through %.1f s of jogging (grace 2 s)" % dt)
|
||||
wait_idle(s.sock, s.log)
|
||||
print("PASS [jog-grace]: the window closed after %.1f s of jogging with the spindle off" % dt)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isfile(BIN):
|
||||
fail("controller binary not found at %s" % BIN)
|
||||
test_verdict_pause_resumes_without_press()
|
||||
test_verdict_fail_tier_ends_job()
|
||||
test_sender_change_during_rearm_cancels()
|
||||
test_jog_does_not_extend_window()
|
||||
test_job_window()
|
||||
test_status_files()
|
||||
test_laser_keys_reload()
|
||||
test_sender_change()
|
||||
test_sender_change_mid_job()
|
||||
test_sender_change_holds_and_rearms()
|
||||
test_sender_change_then_reset()
|
||||
test_rx_overrun_aborts()
|
||||
test_hold_grace()
|
||||
test_resume_after_grace()
|
||||
test_button_wait_arms()
|
||||
test_lid_open_in_wait()
|
||||
test_button_pause_resume()
|
||||
test_lid_cancels_and_returns()
|
||||
test_pause_then_lid_cancel_returns_to_the_job_start()
|
||||
test_cancel_and_abort()
|
||||
test_homing_abort_keeps_z_hold()
|
||||
test_lid_cancel_from_hold()
|
||||
test_lid_policy_hold()
|
||||
test_verdict_blocks_arm()
|
||||
test_arm_waits_for_engine_ack()
|
||||
test_arm_proceeds_on_a_late_ack()
|
||||
test_arm_waits_for_the_fans()
|
||||
test_sigterm_mid_job()
|
||||
print("PASS: the armed-window lifecycle holds")
|
||||
|
||||
|
||||
+206
-208
@@ -24,7 +24,10 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
the stream must never lean on the kernel's end-of-data backstop
|
||||
8. no FIRE bit ever rides a zero-step gap: a stepless run of stream
|
||||
bytes carrying FIRE longer than any legitimate between-step
|
||||
interval is a stationary dwell burn
|
||||
interval is a stationary dwell burn, and so is a FIRE tick deeper
|
||||
into a stepless run than that interval, however it is spaced (the
|
||||
density dither masks a stale fire state into bursts split by dark
|
||||
ticks, so FIRE continuity says nothing under it; depth does)
|
||||
9. rules 7-8 hold across rapid cycle stop/start churn (planner-starve
|
||||
shaped jobs), where the FIRE state of the previous cycle must not
|
||||
leak into the idle-gap pad bytes
|
||||
@@ -35,10 +38,10 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
fire ticks divide evenly across the rungs, which is what fails if a
|
||||
rung's opening ticks carry the previous rung's duty
|
||||
11. under the density dose model no level ever reaches PWMSAR: every
|
||||
power byte carries full duty (one still leads each kernel run), and
|
||||
a level change inside a run costs no stream byte at all
|
||||
12. density matches the level the core commanded, rung by rung, and no
|
||||
burst is longer than the base period
|
||||
power byte carries full duty (one still leads each kernel run)
|
||||
12. density matches the level the core commanded, rung by rung, each
|
||||
rung lit for its whole length, and no burst is longer than the base
|
||||
period
|
||||
13. the model is a mask and never a source: run the same job under both
|
||||
models and every FIRE tick of the density run is a FIRE tick of the
|
||||
analog run, on an identical motion grid
|
||||
@@ -49,11 +52,14 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
14. a laser state change made while the stream is idle survives to the
|
||||
next run: a standalone S word between moves, from a sender slow
|
||||
enough to drain the planner, must still cut at the level it asked
|
||||
for rather than dark at a stale duty
|
||||
for rather than dark or at a stale level (judged under the shipped
|
||||
density model, where the level is the FIRE density), and over the
|
||||
whole move: a level applied late shows as a lit span shorter than
|
||||
the move
|
||||
16. and the off transition survives the same way: an M5 executed with
|
||||
the planner drained and the kernel run over must darken the rapids
|
||||
that follow it, and a bare G0 sent with the spindle off must ship
|
||||
dark, under both dose models - the stream's wanted fire state is
|
||||
dark under the density dose model - the stream's wanted fire state is
|
||||
the only thing those moves consult, and a stale true there lights
|
||||
the next run at the last level (full duty under density)
|
||||
17. and a job's first cut at the level the previous job ended at
|
||||
@@ -71,13 +77,16 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
18. the floor is derived, never typed: $35 is loaded from the floor
|
||||
key at every precompute, so a $35 typed by the sender is
|
||||
overwritten - the ladder renders through the key's floor, and the
|
||||
arm report names the model, the floor and the curve in force
|
||||
19. the dose curve bends S onto the density that delivers the
|
||||
commanded light fraction: with the bench-default curve in force a
|
||||
ladder of S rungs renders the curve's densities (half light lands
|
||||
near 80 percent density), monotonic, floored and ceiled by
|
||||
$35/$36; every other session runs with laser_dose_curve = off so
|
||||
its S-to-level arithmetic stays exact
|
||||
arm report names the model, the floor and the curve in force;
|
||||
read with $$ on the fresh controller and again after the arm, $31
|
||||
is 0 (the floor, not $31, sets the bottom of the S range) and $35
|
||||
is the board's floor, landing at PWMSAR_FLOOR_MIN or above
|
||||
19. the dose curve's mapping of S onto delivered light is
|
||||
laser_arm_test.c case P's, and the rolloff reference run (rule 23)
|
||||
pins one point of it end to end: the S300 cruise renders the density
|
||||
the curve maps 30 % light onto; every laser session but the rolloff
|
||||
ones (rules 20 and 23) and the jog run (rule 22) runs with
|
||||
laser_dose_curve = off so its S-to-level arithmetic stays exact
|
||||
20. the corner rolloff starves the slow spots: under M4 with the curve
|
||||
in force, the accelerate-in head of a line renders less density at
|
||||
the default gamma of 2 than at gamma 1, and the cruise middle
|
||||
@@ -88,7 +97,8 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
under the open window; the deceleration into that first hold runs
|
||||
lit to the stop, as a feed hold's does (the segments already planned
|
||||
at speed would otherwise play dark and leave a gap in the cut), and
|
||||
the stream engine's per-tick gate masks the stream from the stop on;
|
||||
the stream engine's per-tick gate masks the stream from the stop on,
|
||||
so the stationary stretch of each hold ships no FIRE;
|
||||
a ~ under that verdict, which is what a button press or a sender
|
||||
does, moves the head dark for at most one client poll before the
|
||||
client holds it again and says so; the clean verdict then resumes
|
||||
@@ -110,7 +120,8 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
holds the job the moment the client's cache expires, on its own
|
||||
clock between two of its file reads, lit to the stop like rule 24:
|
||||
no dark cut runs out while the client waits for its next read; the
|
||||
engine's return resumes the cut lit
|
||||
hold's stationary stretch ships no FIRE; the engine's return resumes
|
||||
the cut lit
|
||||
28. a scheduling stall of the step producer (GFSINK_STALL_MS: the
|
||||
producer is kept off the CPU longer than its lead) maps events
|
||||
behind the ship cursor. Inside an armed window that is a fault:
|
||||
@@ -167,6 +178,9 @@ PWM_PERIOD = 127
|
||||
PWM_MIN_PCT = 10.0
|
||||
PWM_MIN = int(PWM_PERIOD * PWM_MIN_PCT / 100.0)
|
||||
RPM_MAX = 1000.0
|
||||
# The floor lands at this PWMSAR count or above: the level the tube needs
|
||||
# for a nonzero S to emit at all (rule 18 reads it off the controller).
|
||||
PWMSAR_FLOOR_MIN = 12
|
||||
|
||||
|
||||
def duty_for(s):
|
||||
@@ -213,7 +227,7 @@ JOB_Z = [
|
||||
# the moves are counted from: Z3 is 9 half-steps on the screw, Z4 is 12.
|
||||
LENS_CONF = "lens_hall_edge_z_mm = 3\n"
|
||||
|
||||
# Session B: M3 constant power to the end of the stream. The core never
|
||||
# M3 constant power to the end of the stream (session F). The core never
|
||||
# issues a laser-off update for M3, so the stream engine itself must
|
||||
# terminate the cycle dark (rule 7).
|
||||
JOB_M3_TERM = [
|
||||
@@ -250,12 +264,16 @@ CHURN_BUDGET_S = (CHURN_MOTION_S + CHURN_GAPS_S) * 1.5 + 0.2
|
||||
LADDER_S = (20, 30, 60, 120, 200, 300)
|
||||
LADDER_DUTY = tuple(duty_for(s) for s in LADDER_S)
|
||||
LADDER_MM = 5.0
|
||||
LADDER_FEED = 300
|
||||
JOB_LADDER = ["G91", "G21", "M3"]
|
||||
for _i, _s in enumerate(LADDER_S):
|
||||
JOB_LADDER.append("S%d" % _s)
|
||||
JOB_LADDER.append("G1 X%g F300" % (LADDER_MM if _i % 2 == 0 else -LADDER_MM))
|
||||
JOB_LADDER.append("G1 X%g F%d" % (LADDER_MM if _i % 2 == 0 else -LADDER_MM,
|
||||
LADDER_FEED))
|
||||
JOB_LADDER.append("G0 Y1")
|
||||
JOB_LADDER.append("M5")
|
||||
# One rung's length in machine ticks: the span each rung must stay lit for.
|
||||
LADDER_TICKS = LADDER_MM / (LADDER_FEED / 60.0) * MACHINE_TICK_HZ
|
||||
|
||||
|
||||
# Sessions E-G: the density dose model. $35 = 0 for the ladder because
|
||||
@@ -293,36 +311,14 @@ DENSITY_CONF_FLOORED = ("laser_power_model = density\n"
|
||||
"laser_dose_curve = off\n" + DENSITY_CONF_BASE)
|
||||
# The shipped default: the bench curve in force (no keys at all).
|
||||
DENSITY_CONF_CURVED = "laser_power_model = density\n" + DENSITY_CONF_BASE
|
||||
# Rule 19's end-to-end point: 30 % light (S300) through the bench-default
|
||||
# curve (glowforge_laser.c curve_default) lands between its (45 % density,
|
||||
# 21 % light) and (60 %, 37 %) points, 45 + 9/16 x 15 = 53.44 % density,
|
||||
# level 68 of 127. Mirrored here the way the floor is: changing the curve
|
||||
# changes this expectation.
|
||||
CURVE_S300_LEVEL = 68
|
||||
CURVE_S300_DENSITY = CURVE_S300_LEVEL / float(PWM_PERIOD)
|
||||
|
||||
# The compiled bench-default curve (glowforge_laser.c curve_default),
|
||||
# mirrored here the way the floor is: changing it changes rule 19.
|
||||
CURVE_DEFAULT = ((10.0, 0.5), (20.0, 2.0), (30.0, 7.0), (45.0, 21.0),
|
||||
(60.0, 37.0), (80.0, 50.0), (100.0, 100.0))
|
||||
|
||||
|
||||
def curve_density_for(s_val):
|
||||
"""The density fraction the bench-default curve maps an S onto,
|
||||
before the $35/$36 clamp (mirrors curve_apply)."""
|
||||
l = s_val / RPM_MAX * 100.0
|
||||
pts = CURVE_DEFAULT
|
||||
if l <= pts[0][1]:
|
||||
return pts[0][0] * (l / pts[0][1]) / 100.0
|
||||
i = 1
|
||||
while i < len(pts) - 1 and l > pts[i][1]:
|
||||
i += 1
|
||||
d0, l0 = pts[i - 1]
|
||||
d1, l1 = pts[i]
|
||||
f = min(1.0, (l - l0) / (l1 - l0))
|
||||
return (d0 + f * (d1 - d0)) / 100.0
|
||||
|
||||
|
||||
CURVE_S = (100, 300, 500, 800, 1000)
|
||||
JOB_CURVE = ["G91", "G21", "M3"]
|
||||
for _s in CURVE_S:
|
||||
JOB_CURVE.append("S%d" % _s)
|
||||
JOB_CURVE.append("G1 X%g F300" % (LADDER_MM if _s % 2 == 0 else LADDER_MM))
|
||||
JOB_CURVE.append("G0 Y1")
|
||||
JOB_CURVE.append("M5")
|
||||
DENSITY_LEVEL = tuple(int(x * PWM_PERIOD / RPM_MAX) for x in LADDER_S)
|
||||
# A $35 typed ahead of the job: rule 18 says the arm overwrites it.
|
||||
JOB_DENSITY = ["$35=0"] + JOB_LADDER
|
||||
@@ -334,17 +330,6 @@ def duty_for_floor(s, floor_pct):
|
||||
return int(s * (PWM_PERIOD - lo) / RPM_MAX) + lo
|
||||
|
||||
|
||||
# Session H: three levels inside one kernel run. The moves are short and
|
||||
# fast so the planner never drains, and each carries its own S word, so
|
||||
# the level changes land mid-run. Analog pays a power byte per level;
|
||||
# density pays none, because the level rides the FIRE bits.
|
||||
JOB_LEVELS = ["G91", "G21", "M3"]
|
||||
for _s in (100, 300, 600):
|
||||
for _ in range(20):
|
||||
JOB_LEVELS.append("G1 X0.5 F3000 S%d" % _s)
|
||||
JOB_LEVELS.append("M5")
|
||||
|
||||
|
||||
# Session I: the levels arrive on their own lines, and the moves are long
|
||||
# enough that the planner drains between them, so each S is executed with
|
||||
# nothing streaming. The state has no event to ride and must be
|
||||
@@ -358,6 +343,8 @@ for _i, _s in enumerate(IDLE_S_LEVELS):
|
||||
JOB_IDLE_S.append("G1 X%g F%d" % (IDLE_S_MM if _i % 2 == 0 else -IDLE_S_MM,
|
||||
IDLE_S_FEED))
|
||||
JOB_IDLE_S.append("M5")
|
||||
# One move's length in machine ticks: the span each level must stay lit for.
|
||||
IDLE_S_TICKS = IDLE_S_MM / (IDLE_S_FEED / 60.0) * MACHINE_TICK_HZ
|
||||
|
||||
|
||||
# Session J: the bench ladder's shape. M5 executes with the planner
|
||||
@@ -704,25 +691,69 @@ def check_decel_lit(name, ticks, hold, what):
|
||||
return lead
|
||||
|
||||
|
||||
# One step pulse straddles each edge of a hold's stationary stretch, so
|
||||
# the edges are left out of its FIRE count (rule 21's margin).
|
||||
HOLD_EDGE_TICKS = int(120 * XY_SCALE)
|
||||
|
||||
|
||||
def check_hold_dark(name, ticks, hold, what):
|
||||
"""No FIRE tick inside the stationary stretch of `hold`, its edges
|
||||
aside: the beam is off for as long as the job is held."""
|
||||
hs, he = hold
|
||||
lit = sum(1 for t in ticks[hs + HOLD_EDGE_TICKS:he - HOLD_EDGE_TICKS] if t & 0x10)
|
||||
if lit:
|
||||
fail("[%s] FIRE while held: %d fire ticks in the stationary stretch of %d ticks (%s)"
|
||||
% (name, lit, he - hs, what))
|
||||
return he - hs
|
||||
|
||||
|
||||
def tick_bytes(data):
|
||||
"""The stream with power bytes stripped (tick bytes only)."""
|
||||
return bytes(b for b in data if not b & 0x80)
|
||||
|
||||
|
||||
def check_fire_gaps(name, data):
|
||||
"""Rule 8: no stepless run carrying FIRE longer than the limit."""
|
||||
run = 0
|
||||
"""Rule 8, read both ways so it holds under either power model: no
|
||||
stepless run carrying FIRE on every tick for the limit (a stale fire
|
||||
state as the analog model renders it), and no FIRE tick farther than
|
||||
the limit from every step, the one before it and the one after it
|
||||
(the same state as the density model renders it: the dither masks it
|
||||
into bursts shorter than the limit, split by dark ticks, so the first
|
||||
reading never trips). A cut's first FIRE tick leads its first step by
|
||||
the accel-from-rest interval and its last trails its last step by
|
||||
less, both inside the limit; a FIRE tick with no step that near on
|
||||
either side is in a gap, and lit."""
|
||||
ticks = tick_bytes(data)
|
||||
n = len(ticks)
|
||||
# Ticks from each tick to the next step tick at or after it.
|
||||
ahead = [n] * n
|
||||
nxt = None
|
||||
for i in range(n - 1, -1, -1):
|
||||
if ticks[i] & 0x25: # an X/Y/Z step
|
||||
nxt = i
|
||||
if nxt is not None:
|
||||
ahead[i] = nxt - i
|
||||
run = 0 # consecutive stepless ticks carrying FIRE
|
||||
since = n # ticks since the last step
|
||||
worst = 0
|
||||
for tick, b in enumerate(tick_bytes(data)):
|
||||
if b & 0x10 and not b & 0x25: # FIRE, no X/Y/Z step
|
||||
run += 1
|
||||
worst = max(worst, run)
|
||||
if run >= FIRE_GAP_LIMIT_TICKS:
|
||||
fail("[%s] FIRE carried across a %d-tick zero-step gap "
|
||||
"ending at tick %d (stationary dwell burn)"
|
||||
% (name, run, tick))
|
||||
else:
|
||||
for tick, b in enumerate(ticks):
|
||||
if b & 0x25:
|
||||
run = since = 0
|
||||
continue
|
||||
since += 1
|
||||
if not b & 0x10:
|
||||
run = 0
|
||||
continue
|
||||
run += 1
|
||||
worst = max(worst, run)
|
||||
if run >= FIRE_GAP_LIMIT_TICKS:
|
||||
fail("[%s] FIRE carried across a %d-tick zero-step gap "
|
||||
"ending at tick %d (stationary dwell burn)"
|
||||
% (name, run, tick))
|
||||
if since > FIRE_GAP_LIMIT_TICKS and ahead[tick] > FIRE_GAP_LIMIT_TICKS:
|
||||
fail("[%s] FIRE %d ticks after a step and %d before the next, "
|
||||
"at tick %d (stationary dwell burn, masked by the dither)"
|
||||
% (name, since, ahead[tick], tick))
|
||||
return worst
|
||||
|
||||
|
||||
@@ -878,8 +909,9 @@ def fire_spans(ticks, gap=RUNG_SPLIT_TICKS):
|
||||
return spans
|
||||
|
||||
|
||||
def check_density(name, data, levels, period, min_ticks):
|
||||
"""Rules 11-12: pinned duty, and density per rung matching the level."""
|
||||
def check_density(name, data, levels, period, min_ticks, move_ticks=None):
|
||||
"""Rules 11-12: pinned duty, density per rung matching the level, and
|
||||
with move_ticks given, each rung's span the whole move long."""
|
||||
# A power byte still leads every kernel run - the run start resets the
|
||||
# hardware duty - but under this model it only ever carries full duty:
|
||||
# the level rides the FIRE bits, never PWMSAR.
|
||||
@@ -896,6 +928,14 @@ def check_density(name, data, levels, period, min_ticks):
|
||||
|
||||
out = []
|
||||
for (a, b), level in zip(spans, levels):
|
||||
# The span is the whole move: a level applied late renders the
|
||||
# right density over a shorter span, which the density alone
|
||||
# cannot see. The lowest levels open and close a few periods of
|
||||
# carried debt inside the move, well under the margin.
|
||||
if move_ticks is not None and not 0.9 * move_ticks <= b - a <= 1.1 * move_ticks:
|
||||
fail("[%s] level %d fire span of %d ticks, expected ~%d (the whole "
|
||||
"move): the move ran dark before the level was applied, or "
|
||||
"FIRE outlived it" % (name, level, b - a, move_ticks))
|
||||
seg = ticks[a:b]
|
||||
got = sum(1 for t in seg if t & 0x10) / float(len(seg))
|
||||
want = level / float(PWM_PERIOD)
|
||||
@@ -991,38 +1031,6 @@ def main():
|
||||
% (len(data), sum(1 for b in data if b & 0x80), len(fire_ticks),
|
||||
powers, x_max, tail_steps, gap_a))
|
||||
|
||||
# --- session B: M3 constant power to stream end, rule 7 -------------
|
||||
data = run_session("m3-term", JOB_M3_TERM, conf=ANALOG_CONF)
|
||||
if not count_fire(data):
|
||||
fail("[m3-term] no FIRE bits in the stream")
|
||||
check_termination("m3-term", data)
|
||||
gap_b = check_fire_gaps("m3-term", data)
|
||||
print("PASS [m3-term]: %d bytes, %d fire ticks end dark, max fire gap %d"
|
||||
% (len(data), count_fire(data), gap_b))
|
||||
|
||||
# --- session C: cycle churn, rules 8-9 ------------------------------
|
||||
data = run_session("churn", JOB_CHURN, conf=ANALOG_CONF)
|
||||
if not count_fire(data):
|
||||
fail("[churn] no FIRE bits in the stream")
|
||||
check_termination("churn", data)
|
||||
gap_c = check_fire_gaps("churn", data)
|
||||
# Rule 17: the churn stream carries no runaway pad. Bytes are the time
|
||||
# axis, one per machine tick, so the stream's length IS how long the
|
||||
# machine plays it. A cycle that resumes while the kernel still drains
|
||||
# re-bases production onto the wall cursor; if the producer's lead lets
|
||||
# production stay ahead of that cursor across the gap, the re-base is
|
||||
# skipped and the overshoot is inherited by every cycle after it. That
|
||||
# is what GFSINK_LEAD_MS_MAX bounds, and this is what catches it.
|
||||
churn_s = len(data) / MACHINE_TICK_HZ
|
||||
if churn_s > CHURN_BUDGET_S:
|
||||
fail("[churn] stream is %.0f ms of playout, over the %.0f ms budget: "
|
||||
"the cycle re-base is leaving pad behind"
|
||||
% (churn_s * 1e3, CHURN_BUDGET_S * 1e3))
|
||||
print("PASS [churn]: %d bytes, %d fire ticks, max fire gap %d, "
|
||||
"%.0f ms of playout (budget %.0f)"
|
||||
% (len(data), count_fire(data), gap_c, churn_s * 1e3,
|
||||
CHURN_BUDGET_S * 1e3))
|
||||
|
||||
# --- session D: power ladder, rule 10 -------------------------------
|
||||
data = run_session("ladder", JOB_LADDER, conf=ANALOG_CONF)
|
||||
counts = check_power_ladder("ladder", data, LADDER_DUTY)
|
||||
@@ -1037,7 +1045,7 @@ def main():
|
||||
# the arm loads into $35.
|
||||
dens = run_session("density", JOB_LADDER, conf=DENSITY_CONF)
|
||||
rendered = check_density("density", dens, DENSITY_LEVEL, DENSITY_PERIOD_TICKS,
|
||||
DENSITY_MIN_MACHINE_TICKS)
|
||||
DENSITY_MIN_MACHINE_TICKS, LADDER_TICKS)
|
||||
check_termination("density", dens)
|
||||
if "laser armed (density, floor 0 %, curve off)" not in run_session.text:
|
||||
fail("[density] the arm did not select the density model at floor 0")
|
||||
@@ -1061,8 +1069,9 @@ def main():
|
||||
if got != 1.0:
|
||||
fail("[density-full] S1000 rendered density %.4f, expected 1.0" % got)
|
||||
check_termination("density-full", full)
|
||||
print("PASS [density-full]: S1000 -> density 1.0000 over %d ticks, ends dark"
|
||||
% (b - a))
|
||||
gap_f = check_fire_gaps("density-full", full)
|
||||
print("PASS [density-full]: S1000 -> density 1.0000 over %d ticks, ends dark, max fire gap %d"
|
||||
% (b - a, gap_f))
|
||||
|
||||
# --- session G: churn under the model (rules 7-9 still hold) --------
|
||||
ch = run_session("density-churn", JOB_CHURN, conf=DENSITY_CONF)
|
||||
@@ -1070,91 +1079,93 @@ def main():
|
||||
fail("[density-churn] no FIRE bits in the stream")
|
||||
check_termination("density-churn", ch)
|
||||
gap_e = check_fire_gaps("density-churn", ch)
|
||||
print("PASS [density-churn]: %d bytes, %d fire ticks, max fire gap %d"
|
||||
% (len(ch), count_fire(ch), gap_e))
|
||||
|
||||
# --- session H: a level change inside a run costs no byte -----------
|
||||
lv_a = run_session("levels-analog", JOB_LEVELS, conf=ANALOG_CONF)
|
||||
lv_d = run_session("levels-density", JOB_LEVELS, conf=DENSITY_CONF)
|
||||
pa = [b & 0x7F for b in lv_a if b & 0x80]
|
||||
pd = [b & 0x7F for b in lv_d if b & 0x80]
|
||||
if len([d for d in set(pa) if d]) < 3:
|
||||
fail("[levels] the analog run shipped duties %s: fewer than the three "
|
||||
"commanded levels, so the job is not exercising in-run changes"
|
||||
% sorted(set(pa)))
|
||||
if set(pd) != {PWM_PERIOD}:
|
||||
fail("[levels] density shipped a level as duty: %s" % sorted(set(pd)))
|
||||
if len(pd) >= len(pa):
|
||||
fail("[levels] density shipped %d power bytes against analog's %d - "
|
||||
"the level changes are still costing stream bytes" % (len(pd), len(pa)))
|
||||
print("PASS [levels]: analog %d power bytes %s, density %d at full duty"
|
||||
% (len(pa), sorted(set(pa)), len(pd)))
|
||||
# Rule 17: the churn stream carries no runaway pad. Bytes are the time
|
||||
# axis, one per machine tick, so the stream's length IS how long the
|
||||
# machine plays it. A cycle that resumes while the kernel still drains
|
||||
# re-bases production onto the wall cursor; if the producer's lead lets
|
||||
# production stay ahead of that cursor across the gap, the re-base is
|
||||
# skipped and the overshoot is inherited by every cycle after it. That
|
||||
# is what GFSINK_LEAD_MS_MAX bounds, and this is what catches it.
|
||||
churn_s = len(ch) / MACHINE_TICK_HZ
|
||||
if churn_s > CHURN_BUDGET_S:
|
||||
fail("[density-churn] stream is %.0f ms of playout, over the %.0f ms budget: "
|
||||
"the cycle re-base is leaving pad behind"
|
||||
% (churn_s * 1e3, CHURN_BUDGET_S * 1e3))
|
||||
print("PASS [density-churn]: %d bytes, %d fire ticks, max fire gap %d, "
|
||||
"%.0f ms of playout (budget %.0f)"
|
||||
% (len(ch), count_fire(ch), gap_e, churn_s * 1e3, CHURN_BUDGET_S * 1e3))
|
||||
|
||||
# --- session I: a level set while idle still cuts (rule 14) ---------
|
||||
idle_s = run_session("idle-s", JOB_IDLE_S, conf=ANALOG_CONF)
|
||||
fire_by_duty = {}
|
||||
cur = None
|
||||
for b in idle_s:
|
||||
if b & 0x80:
|
||||
cur = b & 0x7F
|
||||
elif b & 0x10:
|
||||
fire_by_duty[cur] = fire_by_duty.get(cur, 0) + 1
|
||||
want_ticks = IDLE_S_MM / (IDLE_S_FEED / 60.0) * MACHINE_TICK_HZ
|
||||
for level in IDLE_S_LEVELS:
|
||||
duty = duty_for(level)
|
||||
got = fire_by_duty.get(duty, 0)
|
||||
if got < want_ticks * 0.9:
|
||||
fail("[idle-s] S%d (duty %d) fired %d ticks, expected ~%d: a level "
|
||||
"set while the stream was idle was dropped and the move ran "
|
||||
"dark or at a stale duty (all: %s)"
|
||||
% (level, duty, got, want_ticks, fire_by_duty))
|
||||
# Under the shipped density model the level rides the FIRE bits at full
|
||||
# duty: each move's span must render the level its own S asked for, not
|
||||
# the one before it, and the stream ends dark.
|
||||
idle_s = run_session("idle-s", JOB_IDLE_S, conf=DENSITY_CONF_FLOORED)
|
||||
got = check_density("idle-s", idle_s, tuple(duty_for(l) for l in IDLE_S_LEVELS),
|
||||
DENSITY_PERIOD_TICKS, DENSITY_MIN_MACHINE_TICKS, IDLE_S_TICKS)
|
||||
check_termination("idle-s", idle_s)
|
||||
print("PASS [idle-s]: standalone S across idle gaps -> fire ticks per duty %s"
|
||||
% {duty_for(l): fire_by_duty[duty_for(l)] for l in IDLE_S_LEVELS})
|
||||
print("PASS [idle-s]: standalone S across idle gaps -> densities %s, each its own level"
|
||||
% [d for _l, d in got])
|
||||
|
||||
# --- session J: M5 executed while idle darkens the next run (rule 16) ---
|
||||
for model, conf in (("analog", ANALOG_CONF), ("density", DENSITY_CONF)):
|
||||
name = "m5-idle-" + model
|
||||
data = run_session(name, JOB_M5_IDLE, conf=conf)
|
||||
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
|
||||
"the two G1 moves: FIRE rode a rapid after M5, "
|
||||
"or the bare G0 sent with the spindle off")
|
||||
check_termination(name, data)
|
||||
print("PASS [%s]: M5 at idle -> the rapids after it and a bare G0 ship "
|
||||
"dark; 2 fire spans of %s ticks"
|
||||
% (name, [s1 - s0 for s0, s1 in spans]))
|
||||
name = "m5-idle-density"
|
||||
data = run_session(name, JOB_M5_IDLE, conf=DENSITY_CONF)
|
||||
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
|
||||
"the two G1 moves: FIRE rode a rapid after M5, "
|
||||
"or the bare G0 sent with the spindle off")
|
||||
check_termination(name, data)
|
||||
print("PASS [%s]: M5 at idle -> the rapids after it and a bare G0 ship "
|
||||
"dark; 2 fire spans of %s ticks"
|
||||
% (name, [s1 - s0 for s0, s1 in spans]))
|
||||
|
||||
# --- session K: the next job, at the same level, fires (rule 17) ---
|
||||
for model, conf in (("analog", ANALOG_CONF), ("density", DENSITY_CONF)):
|
||||
name = "next-job-" + model
|
||||
data = run_session(name, JOB_NEXT + JOB_NEXT, conf=conf)
|
||||
text = run_session.text
|
||||
if text.count("laser armed") != 2 or text.count("laser disarmed") != 2:
|
||||
fail("[%s] expected two armed windows closed by M2 (armed %d, "
|
||||
"disarmed %d)" % (name, text.count("laser armed"),
|
||||
text.count("laser disarmed")))
|
||||
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
|
||||
"one G1 per job: the second job's M3 at the "
|
||||
"first job's S lit nothing, or a rapid fired")
|
||||
check_termination(name, data)
|
||||
print("PASS [%s]: the next job's M3 at the previous job's S fires its "
|
||||
"G1; 2 fire spans of %s ticks"
|
||||
% (name, [s1 - s0 for s0, s1 in spans]))
|
||||
name = "next-job-density"
|
||||
data = run_session(name, JOB_NEXT + JOB_NEXT, conf=DENSITY_CONF)
|
||||
text = run_session.text
|
||||
if text.count("laser armed") != 2 or text.count("laser disarmed") != 2:
|
||||
fail("[%s] expected two armed windows closed by M2 (armed %d, "
|
||||
"disarmed %d)" % (name, text.count("laser armed"),
|
||||
text.count("laser disarmed")))
|
||||
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
|
||||
"one G1 per job: the second job's M3 at the "
|
||||
"first job's S lit nothing, or a rapid fired")
|
||||
check_termination(name, data)
|
||||
print("PASS [%s]: the next job's M3 at the previous job's S fires its "
|
||||
"G1; 2 fire spans of %s ticks"
|
||||
% (name, [s1 - s0 for s0, s1 in spans]))
|
||||
|
||||
# --- rule 18: the floor is derived from the key, never typed --------
|
||||
# The same ladder with a $35=0 typed ahead of it, under the shipped
|
||||
# density default (no floor key): the arm loads the board's floor and
|
||||
# every rung renders through it.
|
||||
floored = run_session("floor-derived", JOB_DENSITY, conf=DENSITY_CONF_FLOORED)
|
||||
floored = run_session("floor-derived", ["$$"] + JOB_DENSITY + [WAIT_IDLE, "$$"],
|
||||
conf=DENSITY_CONF_FLOORED)
|
||||
text = run_session.text
|
||||
expect_levels = tuple(duty_for(x) for x in LADDER_S)
|
||||
check_density("floor-derived", floored, expect_levels, DENSITY_PERIOD_TICKS,
|
||||
DENSITY_MIN_MACHINE_TICKS)
|
||||
if "laser armed (density, floor %g %%, curve off)" % PWM_MIN_PCT not in run_session.text:
|
||||
DENSITY_MIN_MACHINE_TICKS, LADDER_TICKS)
|
||||
if "laser armed (density, floor %g %%, curve off)" % PWM_MIN_PCT not in text:
|
||||
fail("[floor-derived] the arm report does not name the derived floor "
|
||||
"(text: %r)" % run_session.text[-400:])
|
||||
"(text: %r)" % text[-400:])
|
||||
# The two $$ reads: the fresh controller's, and the one after the arm
|
||||
# overwrote the typed $35. The typed line itself is not echoed.
|
||||
reads = {k: [float(v) for v in re.findall(r"^\$%s=([-\d.]+)" % k, text, re.M)]
|
||||
for k in ("31", "35")}
|
||||
if len(reads["31"]) != 2 or len(reads["35"]) != 2:
|
||||
fail("[floor-derived] expected two $$ reads of $31 and $35, got %s" % reads)
|
||||
if reads["31"] != [0.0, 0.0]:
|
||||
fail("[floor-derived] $31 reads %s, not 0: the bottom of the S range is no longer "
|
||||
"the floor" % reads["31"])
|
||||
if reads["35"] != [PWM_MIN_PCT, PWM_MIN_PCT]:
|
||||
fail("[floor-derived] $35 reads %s (fresh, after the arm), expected the board's floor "
|
||||
"%g both times" % (reads["35"], PWM_MIN_PCT))
|
||||
counts = int(PWM_PERIOD * reads["35"][0] / 100.0)
|
||||
if counts < PWMSAR_FLOOR_MIN:
|
||||
fail("[floor-derived] the floor lands at PWMSAR %d, below the %d the tube needs"
|
||||
% (counts, PWMSAR_FLOOR_MIN))
|
||||
print("PASS [floor-derived]: a typed $35=0 is overwritten at the arm; the "
|
||||
"ladder renders through the %g %% floor key, levels %s"
|
||||
% (PWM_MIN_PCT, list(expect_levels)))
|
||||
"ladder renders through the %g %% floor key, levels %s; $31=0 and $35=%g "
|
||||
"(PWMSAR %d) fresh and after the arm"
|
||||
% (PWM_MIN_PCT, list(expect_levels), PWM_MIN_PCT, counts))
|
||||
|
||||
# --- rule 20: the corner rolloff starves the accel head -------------
|
||||
# One long M4 line from rest under the curve, at gamma 1 and the
|
||||
@@ -1191,34 +1202,6 @@ def main():
|
||||
"default 2; cruise %.3f alike" % (dens_head["g1"], dens_head["g2"],
|
||||
dens_mid["g1"]))
|
||||
|
||||
# --- rule 19: the dose curve bends S onto delivered light -----------
|
||||
cur = run_session("curve", JOB_CURVE, conf=DENSITY_CONF_CURVED)
|
||||
if "curve bench-default" not in run_session.text:
|
||||
fail("[curve] the arm does not name the bench-default curve (text: %r)"
|
||||
% run_session.text[-300:])
|
||||
floor_frac = PWM_MIN / float(PWM_PERIOD)
|
||||
expect = []
|
||||
for s_val in CURVE_S:
|
||||
d = curve_density_for(s_val)
|
||||
expect.append(min(1.0, max(d, floor_frac)))
|
||||
ticks = tick_bytes(cur)
|
||||
spans = fire_spans(ticks)
|
||||
if len(spans) != len(CURVE_S):
|
||||
fail("[curve] %d fire spans, expected %d" % (len(spans), len(CURVE_S)))
|
||||
got = []
|
||||
for (a, b) in spans:
|
||||
seg = ticks[a:b]
|
||||
got.append(sum(1 for t in seg if t & 0x10) / float(len(seg)))
|
||||
for g, w, s_val in zip(got, expect, CURVE_S):
|
||||
if abs(g - w) > max(0.012, w * 0.06):
|
||||
fail("[curve] S%d rendered density %.4f, expected %.4f through the "
|
||||
"bench-default curve" % (s_val, g, w))
|
||||
if not all(b > a for a, b in zip(got, got[1:])):
|
||||
fail("[curve] densities not monotonic: %s" % [round(g, 4) for g in got])
|
||||
check_termination("curve", cur)
|
||||
print("PASS [curve]: S %s -> densities %s through the bench-default curve "
|
||||
"(floored at %.3f)" % (list(CURVE_S), [round(g, 3) for g in got], floor_frac))
|
||||
|
||||
# --- rule 21: a feed hold leaves no dark ground, in either mode -----
|
||||
# One long line at 100 mm/s, held mid-move and resumed. The planned
|
||||
# deceleration runs lit (M4 velocity-scaled, M3 constant), the
|
||||
@@ -1286,6 +1269,7 @@ def main():
|
||||
("sleep", 0.5), ("verdict", "clean"), WAIT_IDLE, "M5", "M2",
|
||||
("expect_text", "laser disarmed")]
|
||||
data = run_session("verdict-rehold", steps, conf=DENSITY_CONF_FLOORED)
|
||||
check_fire_gaps("verdict-rehold", data)
|
||||
text = run_session.text
|
||||
if "held again" not in text:
|
||||
fail("[verdict-rehold] the client did not say it held the job again")
|
||||
@@ -1293,6 +1277,9 @@ def main():
|
||||
fail("[verdict-rehold] the client did not resume its own hold once the verdict cleared")
|
||||
if "fire masked, job held" not in text:
|
||||
fail("[verdict-rehold] the pause tier did not report the masked hold")
|
||||
if text.count("laser armed") != 1:
|
||||
fail("[verdict-rehold] armed %d times: the pause tier closed the window and the "
|
||||
"resume re-armed it (it must hold under the open window)" % text.count("laser armed"))
|
||||
if "latch locked" in text.split("Pgm End")[0]:
|
||||
fail("[verdict-rehold] the pause tier locked the latch")
|
||||
if "ALARM" in text:
|
||||
@@ -1308,6 +1295,8 @@ def main():
|
||||
(_h1s, h1e), (h2s, h2e) = holds
|
||||
lit_lead = check_decel_lit("verdict-rehold", ticks, holds[0],
|
||||
"a pause keeps the beam through its first deceleration")
|
||||
held = [check_hold_dark("verdict-rehold", ticks, h, "the pause tier masks FIRE while it holds")
|
||||
for h in holds]
|
||||
between = sum(fire[h1e:h2s])
|
||||
if between:
|
||||
fail("[verdict-rehold] FIRE while resumed under the hold verdict: %d fire ticks "
|
||||
@@ -1323,9 +1312,10 @@ def main():
|
||||
if seq != ["unlock", "lock"]:
|
||||
fail("[verdict-rehold] latch ownership sequence %s, expected unlock (arm) and lock "
|
||||
"(program end) only: a pause must not write the latch" % seq)
|
||||
print("PASS [verdict-rehold]: held lit to %d ticks before the stop, resumed dark for "
|
||||
"%d ticks (%.2f s), held again, lit after the clear (%d fire ticks), latch %s"
|
||||
% (lit_lead, h2s - h1e, (h2s - h1e) / float(TICK_HZ), lit_after, seq))
|
||||
print("PASS [verdict-rehold]: held lit to %d ticks before the stop, dark while held (%d and "
|
||||
"%d ticks), resumed dark for %d ticks (%.2f s), held again, lit after the clear (%d fire "
|
||||
"ticks), latch %s"
|
||||
% (lit_lead, held[0], held[1], h2s - h1e, (h2s - h1e) / float(TICK_HZ), lit_after, seq))
|
||||
|
||||
# --- rule 25: the verdict's fail tier --------------------------------
|
||||
# The same line; mid-move the verdict goes to AIRFLOW. The job ends:
|
||||
@@ -1390,6 +1380,7 @@ def main():
|
||||
("verdict", "stale"), ("wait_state", "Hold:0", 6.0), ("sleep", 0.5),
|
||||
("verdict", "clean"), WAIT_IDLE, "M5", "M2", ("expect_text", "laser disarmed")]
|
||||
data = run_session("verdict-stale", steps, conf=DENSITY_CONF_FLOORED)
|
||||
check_fire_gaps("verdict-stale", data)
|
||||
text = run_session.text
|
||||
if "cooling service lost" not in text:
|
||||
fail("[verdict-stale] the client did not report the engine gone")
|
||||
@@ -1401,6 +1392,7 @@ def main():
|
||||
fail("[verdict-stale] expected one hold in the stream, found %d: %s" % (len(holds), holds))
|
||||
lit_lead = check_decel_lit("verdict-stale", ticks, holds[0],
|
||||
"the hold must land at the cache's expiry, lit to the stop")
|
||||
held = check_hold_dark("verdict-stale", ticks, holds[0], "a stale verdict masks FIRE while it holds")
|
||||
fire = [1 if t & 0x10 else 0 for t in ticks]
|
||||
step = [1 if t & 0x05 else 0 for t in ticks]
|
||||
last = len(step) - 1 - step[::-1].index(1)
|
||||
@@ -1412,8 +1404,8 @@ def main():
|
||||
fail("[verdict-stale] latch ownership sequence %s, expected unlock (arm) and lock "
|
||||
"(program end) only" % seq)
|
||||
print("PASS [verdict-stale]: the expired cache held the job lit to %d ticks (%.0f ms) "
|
||||
"before the stop, lit after the return (%d fire ticks), latch %s"
|
||||
% (lit_lead, lit_lead * 1e3 / MACHINE_TICK_HZ, lit_after, seq))
|
||||
"before the stop, dark while held (%d ticks), lit after the return (%d fire ticks), "
|
||||
"latch %s" % (lit_lead, lit_lead * 1e3 / MACHINE_TICK_HZ, held, lit_after, seq))
|
||||
|
||||
# --- rules 28 and 29: the stalls -------------------------------------
|
||||
# The step density the job plans: F3000 at 53.333 steps/mm is 2667
|
||||
@@ -1548,6 +1540,12 @@ def main():
|
||||
a, b = rs[0]
|
||||
mid = rt[(a + b) // 2 - 1000:(a + b) // 2 + 1000]
|
||||
dens_ref = sum(1 for t in mid if t & 0x10) / float(len(mid))
|
||||
# Rule 19: the reference cut is one M4 line at a known S under the
|
||||
# shipped curve, so its cruise (velocity ratio 1, no rolloff) pins the
|
||||
# curve's rendering end to end.
|
||||
if abs(dens_ref - CURVE_S300_DENSITY) > 0.02:
|
||||
fail("[rolloff-ref] the S300 cruise renders %.3f, expected %.3f through the "
|
||||
"bench-default curve" % (dens_ref, CURVE_S300_DENSITY))
|
||||
tt = tick_bytes(two)
|
||||
ts = fire_spans(tt)
|
||||
if len(ts) != 1:
|
||||
|
||||
@@ -96,7 +96,8 @@ Drills (pass a name):
|
||||
reads dark after the M5, and laser_on_sampled never goes
|
||||
nonzero again after its first zero past the line. Prints the
|
||||
9 s after the line at 40 ms steps. The catalog's
|
||||
laser.m5-rapid-dark is its port.
|
||||
laser.emission-witness carries its check: a rapid after
|
||||
the M5, sampled dark.
|
||||
m4corner M4 velocity-scaled power into corners and short segments:
|
||||
a corner-heavy vector pattern (a long line, 1.1 mm zigzag
|
||||
teeth, a 2 mm square, a 180 degree reversal, 0.5 mm teeth)
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Host-side verification of the manual homing provider and the motor release.
|
||||
|
||||
Runs the native grblHAL_glowforge binary in null-sink mode with GFSINK_DUMP
|
||||
capturing the shipped byte stream and GFSINK_ATTR_LOG capturing the sysfs
|
||||
writes the hardware would get (the release is the X and Y step currents at 0). An operator's hands are on a released gantry,
|
||||
so the rule these cases pin is that nothing moves it and nothing but the
|
||||
operator's own $ME or a manual $H energizes it:
|
||||
|
||||
1. $H with homing_mode = manual ships no step, declares X0 Y0 where the
|
||||
head stands, marks X and Y homed and turns their soft limits on, leaves
|
||||
Z where it was, and tells the sender the home was set by hand
|
||||
2. $H is refused while a program runs
|
||||
3. $MD writes the release, drops the X and Y reference at once, and locks
|
||||
the machine in the alarm state; it is refused under an open armed window
|
||||
4. while released, every motion source is refused and ships nothing: a
|
||||
sender jog, a sender G0, a program line, a port jog, and a homing runner;
|
||||
$X cannot unlock it and a soft reset does not either
|
||||
5. only $ME and a manual $H write the energize, each exactly once; after
|
||||
$ME the position is still unknown, and after the $H it is homed
|
||||
6. the port's panel operations (release, energize, home) do the same with
|
||||
a sender connected, and the sender's response count stays exact; home
|
||||
is refused unless homing_mode = manual
|
||||
7. manual_home_x and manual_home_y: how far in front of the gantry stops
|
||||
the origin lies, never negative. With an offset the head at the stops
|
||||
is declared at minus it and jogs to the origin, dark, before $H
|
||||
answers, through the sender and through the port; the envelope starts
|
||||
at the origin (an out-of-range offset clamped is lens_home_test.c's,
|
||||
gfhome_clamp_home_mm); a jog cancel ends the move short of the origin
|
||||
and $H says where it stopped
|
||||
9. a controller that starts over a released gantry (the marker in the
|
||||
state directory) comes up locked and writes no current until $ME
|
||||
8. each provider reads its own offsets and no other's: with both pairs set,
|
||||
a manual home declares minus manual_home_* and a camera home declares
|
||||
gfcloud_home_*, which may be negative, with the envelope reaching back
|
||||
to it
|
||||
|
||||
Usage: manual_home_test.py [path/to/grblHAL_glowforge]
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
from ctlport_test import BIN, PortClient, Sender, fail, publish_verdicts
|
||||
|
||||
PORT = 2396
|
||||
NOTICE = "Manual home: position set where the head was placed"
|
||||
RELEASED = "Motors released"
|
||||
RUNNER_MARK = "runner-ran"
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self):
|
||||
self.workdir = tempfile.mkdtemp(prefix="manual-home-")
|
||||
self.conf = os.path.join(self.workdir, "forgefirm.conf")
|
||||
self.dump = os.path.join(self.workdir, "stream.bin")
|
||||
self.attr_log = os.path.join(self.workdir, "attr.log")
|
||||
self.set_mode("manual")
|
||||
verdict = os.path.join(self.workdir, "cooling.state")
|
||||
self.stop = threading.Event()
|
||||
self.pub = threading.Thread(target=publish_verdicts, args=(verdict, self.stop), daemon=True)
|
||||
self.pub.start()
|
||||
env = dict(os.environ, GFHOME_CONF=self.conf, GF_STATE_DIR=self.workdir,
|
||||
GF_VERDICT_FILE=verdict, GFSINK_DUMP=self.dump,
|
||||
GFSINK_ATTR_LOG=self.attr_log, FFLOG_STDERR="1")
|
||||
env.pop("GFSINK", None)
|
||||
env.pop("GF_SWITCH_FILE", None)
|
||||
self.env = env
|
||||
self.proc = subprocess.Popen([BIN, "-p", str(PORT)], cwd=self.workdir, env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
self.sender = Sender(PORT)
|
||||
path = os.path.join(self.workdir, "grbl.ctl")
|
||||
for _ in range(50):
|
||||
if os.path.exists(path):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
self.port = PortClient(path)
|
||||
# A controller writes the hold currents at its start; what the cases
|
||||
# count comes after that.
|
||||
self.base = 0
|
||||
start = self.attr_writes()
|
||||
if start != [("33", "5")]:
|
||||
fail("the controller's start wrote %r to the step currents, not the hold currents" % start)
|
||||
self.base = len(start)
|
||||
|
||||
def set_mode(self, mode, extra=""):
|
||||
"""The driver re-reads the file at every $H."""
|
||||
with open(self.conf, "w") as f:
|
||||
f.write("cool_fan_grace_s = 0\nlaser_disarm_s = 1\nhoming_mode = %s\n"
|
||||
"gfcloud_home_cmd = touch %s\n%s"
|
||||
% (mode, os.path.join(self.workdir, RUNNER_MARK), extra))
|
||||
|
||||
def motion_ticks(self):
|
||||
"""Step and FIRE ticks shipped so far (a byte with bit 7 set is a power byte)."""
|
||||
try:
|
||||
with open(self.dump, "rb") as f:
|
||||
data = f.read()
|
||||
except OSError:
|
||||
return 0
|
||||
return sum(1 for b in data if not b & 0x80 and b & 0x35)
|
||||
|
||||
def fire_ticks(self):
|
||||
"""FIRE ticks shipped so far."""
|
||||
try:
|
||||
with open(self.dump, "rb") as f:
|
||||
data = f.read()
|
||||
except OSError:
|
||||
return 0
|
||||
return sum(1 for b in data if not b & 0x80 and b & 0x10)
|
||||
|
||||
def attr_writes(self):
|
||||
"""The X and Y step-current writes the hardware would have got, in
|
||||
order, as (x, y) pairs: ("0", "0") is a release, ("33", "5") the hold
|
||||
currents, which is what an energize writes."""
|
||||
vals = {"pic/x_step_current": [], "pic/y_step_current": []}
|
||||
try:
|
||||
with open(self.attr_log) as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) == 2 and parts[0] in vals:
|
||||
vals[parts[0]].append(parts[1])
|
||||
except OSError:
|
||||
pass
|
||||
return list(zip(vals["pic/x_step_current"], vals["pic/y_step_current"]))[self.base:]
|
||||
|
||||
def respawn(self, hard=True):
|
||||
"""End the controller (SIGKILL when hard: it gets no chance to tidy
|
||||
up) and start another over the same state directory."""
|
||||
self.port.close()
|
||||
self.sender.close()
|
||||
if hard:
|
||||
self.proc.kill()
|
||||
else:
|
||||
self.proc.send_signal(signal.SIGINT)
|
||||
self.proc.wait(10)
|
||||
self.proc = subprocess.Popen([BIN, "-p", str(PORT)], cwd=self.workdir, env=self.env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
self.sender = Sender(PORT)
|
||||
time.sleep(0.5)
|
||||
self.port = PortClient(os.path.join(self.workdir, "grbl.ctl"))
|
||||
|
||||
def quiet(self):
|
||||
time.sleep(0.45)
|
||||
|
||||
def close(self):
|
||||
self.port.close()
|
||||
self.sender.close()
|
||||
self.proc.send_signal(signal.SIGINT)
|
||||
try:
|
||||
self.proc.wait(5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
self.stop.set()
|
||||
self.pub.join(2)
|
||||
shutil.rmtree(self.workdir, ignore_errors=True)
|
||||
|
||||
|
||||
def declared(s):
|
||||
"""The position the last manual home with an offset told the sender the
|
||||
head stood at, on the step grid, or None."""
|
||||
with s.sender.lock:
|
||||
lines = list(s.sender.other)
|
||||
for line in reversed(lines):
|
||||
m = re.search(r"Manual home: the head is at X(-?[0-9.]+) Y(-?[0-9.]+); moving it to the origin", line)
|
||||
if m:
|
||||
return float(m.group(1)), float(m.group(2))
|
||||
return None
|
||||
|
||||
|
||||
def refused(s, line):
|
||||
"""Send a line that must be refused, and return its error. The core holds
|
||||
a parser error against every g-code line that follows until an empty line
|
||||
acknowledges it (the streaming contract), so the acknowledgment is sent
|
||||
here: the next line is then judged on its own merits."""
|
||||
r = s.sender.send(line)
|
||||
if r.startswith("error"):
|
||||
if s.sender.send("") != "ok":
|
||||
fail("the empty line that acknowledges %s did not get ok" % r)
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def test_manual_home(s):
|
||||
s.sender.send("G91")
|
||||
s.sender.send("G0 X20 Y10") # away from wherever X0 Y0 was
|
||||
s.sender.wait_state("Idle")
|
||||
before = s.port.state()
|
||||
if abs(before["mpos"][0]) < 1:
|
||||
fail("[manual-home] the head did not move before the home: %r" % before)
|
||||
if before["homed"] & 3:
|
||||
fail("[manual-home] X or Y homed before any home: %r" % before)
|
||||
if s.sender.send("$J=G91 X-100 F3000") != "ok": # unhomed: no X soft limit yet
|
||||
fail("[manual-home] an unhomed jog past X0 was refused")
|
||||
s.sender.wait_state("Idle")
|
||||
time.sleep(0.5)
|
||||
ticks = s.motion_ticks()
|
||||
z_before = s.port.state()["mpos"][2]
|
||||
|
||||
if s.sender.send("$H") != "ok":
|
||||
fail("[manual-home] $H under manual did not get ok")
|
||||
time.sleep(0.5)
|
||||
st = s.port.state()
|
||||
if s.motion_ticks() != ticks:
|
||||
fail("[manual-home] $H shipped %d motion ticks" % (s.motion_ticks() - ticks))
|
||||
if st["mpos"][0] != 0 or st["mpos"][1] != 0:
|
||||
fail("[manual-home] X and Y are not 0 after the home: %r" % st)
|
||||
if st["homed"] & 3 != 3:
|
||||
fail("[manual-home] X and Y are not marked homed: %r" % st)
|
||||
if st["mpos"][2] != z_before:
|
||||
fail("[manual-home] Z changed across the home: %r -> %r" % (z_before, st["mpos"][2]))
|
||||
if st["state"] != "Idle":
|
||||
fail("[manual-home] not idle after the home: %r" % st)
|
||||
if not s.sender.saw(NOTICE):
|
||||
fail("[manual-home] the sender was not told the home was set by hand")
|
||||
r = refused(s, "$J=G91 X-5 F600") # off the bed: the soft limit is on now
|
||||
if r != "error:15":
|
||||
fail("[manual-home] a jog past X0 after the home got %r, not error:15" % r)
|
||||
print("PASS [manual-home]: $H shipped nothing, declared X0 Y0, homed X and Y with soft "
|
||||
"limits on, kept Z, and told the sender")
|
||||
|
||||
|
||||
def test_home_refused_in_cycle(s):
|
||||
s.sender.send("G90")
|
||||
s.sender.send("G1 X40 F300")
|
||||
time.sleep(0.5)
|
||||
if s.port.state()["state"] != "Run":
|
||||
fail("[home-in-cycle] the program is not running: %r" % s.port.state())
|
||||
r = refused(s, "$H")
|
||||
if r is None:
|
||||
fail("[home-in-cycle] $H during a program was accepted")
|
||||
if s.port.state()["homed"] & 3 != 3:
|
||||
fail("[home-in-cycle] the refused $H dropped the reference")
|
||||
s.sender.wait_state("Idle", timeout=15.0)
|
||||
print("PASS [home-in-cycle]: $H during a program got %s and changed nothing" % r)
|
||||
|
||||
|
||||
def test_release_refused_armed(s):
|
||||
s.sender.send("M3 S100")
|
||||
s.sender.send("G1 X45 F1500")
|
||||
s.sender.wait_state("Idle") # motion done, the armed window still open
|
||||
if not s.sender.saw("laser armed"):
|
||||
fail("[release-armed] the laser line did not arm, so the case proves nothing")
|
||||
r = refused(s, "$MD")
|
||||
if r is None:
|
||||
fail("[release-armed] $MD under an open armed window was accepted")
|
||||
if s.attr_writes():
|
||||
fail("[release-armed] the refused $MD wrote the release: %r" % s.attr_writes())
|
||||
s.sender.send("M5")
|
||||
time.sleep(2.5) # past laser_disarm_s: the window closes
|
||||
print("PASS [release-armed]: $MD under an open armed window got %s and wrote nothing" % r)
|
||||
|
||||
|
||||
def test_release_locks(s):
|
||||
if s.sender.send("$MD") != "ok":
|
||||
fail("[release] $MD at idle was refused")
|
||||
st = s.port.state()
|
||||
if s.attr_writes() != [("0", "0")]:
|
||||
fail("[release] the release was not written once: %r" % s.attr_writes())
|
||||
if not st["released"] or st["state"] != "Alarm" or st["homed"] & 3:
|
||||
fail("[release] not released, locked and unreferenced: %r" % st)
|
||||
if not st["homed"] & 4:
|
||||
fail("[release] Z lost its reference: %r" % st)
|
||||
if not s.sender.saw(RELEASED):
|
||||
fail("[release] the sender was not told")
|
||||
|
||||
ticks = s.motion_ticks()
|
||||
x0 = st["mpos"][0]
|
||||
for line in ("$J=G91 X5 F600", "G0 X5", "G1 X5 F500", "G91 G1 Y3 F500"):
|
||||
if refused(s, line) is None:
|
||||
fail("[release] the sender's %r was accepted while released" % line)
|
||||
s.quiet()
|
||||
r = s.port.request("jog G91 X5 F600")
|
||||
if r != "busy:released":
|
||||
fail("[release] a port jog got %r while released" % r)
|
||||
if refused(s, "$X") is None:
|
||||
fail("[release] $X was accepted while released")
|
||||
s.set_mode("gfcloud")
|
||||
r = refused(s, "$H")
|
||||
s.set_mode("manual")
|
||||
if r is None or os.path.exists(os.path.join(s.workdir, RUNNER_MARK)):
|
||||
fail("[release] a homing runner was not refused while released (%r)" % r)
|
||||
s.sender.realtime(b"\x18")
|
||||
time.sleep(1.0)
|
||||
r = refused(s, "$X")
|
||||
st = s.port.state()
|
||||
if r is None or st["state"] != "Alarm" or not st["released"]:
|
||||
fail("[release] a soft reset and $X unlocked a released machine: %r %r" % (r, st))
|
||||
if refused(s, "G0 X5") is None:
|
||||
fail("[release] G0 after a soft reset was accepted while released")
|
||||
time.sleep(0.5)
|
||||
if s.motion_ticks() != ticks or s.port.state()["mpos"][0] != x0:
|
||||
fail("[release] something moved while released")
|
||||
if s.attr_writes() != [("0", "0")]:
|
||||
fail("[release] something other than $ME or $H wrote the step currents: %r" % s.attr_writes())
|
||||
print("PASS [release]: released, locked in alarm, X and Y unreferenced, Z kept; a sender "
|
||||
"jog, G0, program lines, a port jog, a homing runner, $X and a soft reset all "
|
||||
"refused; nothing shipped, nothing energized")
|
||||
|
||||
|
||||
def test_energize(s):
|
||||
if s.sender.send("$ME") != "ok":
|
||||
fail("[energize] $ME was refused")
|
||||
st = s.port.state()
|
||||
if s.attr_writes() != [("0", "0"), ("33", "5")]:
|
||||
fail("[energize] the energize was not written once: %r" % s.attr_writes())
|
||||
if st["released"] or st["state"] != "Idle" or st["homed"] & 3:
|
||||
fail("[energize] not energized, idle and still unreferenced: %r" % st)
|
||||
if s.sender.send("$ME") != "ok" or s.attr_writes() != [("0", "0"), ("33", "5")]:
|
||||
fail("[energize] a second $ME wrote again: %r" % s.attr_writes())
|
||||
if s.sender.send("$J=G91 X1 F600") != "ok":
|
||||
fail("[energize] a jog after $ME was refused")
|
||||
s.sender.wait_state("Idle")
|
||||
|
||||
s.sender.send("$MD")
|
||||
if s.sender.send("$H") != "ok":
|
||||
fail("[energize] a manual $H while released was refused")
|
||||
st = s.port.state()
|
||||
if s.attr_writes() != [("0", "0"), ("33", "5"), ("0", "0"), ("33", "5")]:
|
||||
fail("[energize] the manual $H did not energize exactly once: %r" % s.attr_writes())
|
||||
if st["released"] or st["state"] != "Idle" or st["homed"] & 3 != 3:
|
||||
fail("[energize] not energized, idle and homed after the manual $H: %r" % st)
|
||||
print("PASS [energize]: $ME energized once and left X and Y unreferenced; a manual $H "
|
||||
"energized once and homed")
|
||||
|
||||
|
||||
def test_port_panel_ops(s):
|
||||
s.sender.wait_state("Idle")
|
||||
s.quiet()
|
||||
before = s.sender.count()
|
||||
for op, want in (("release", True), ("energize", False), ("release", True)):
|
||||
if s.port.request(op) != "ok":
|
||||
fail("[port] %s was refused" % op)
|
||||
if s.port.state()["released"] != want:
|
||||
fail("[port] %s left released=%r" % (op, not want))
|
||||
s.set_mode("gfcloud")
|
||||
r = s.port.request("home")
|
||||
s.set_mode("manual")
|
||||
if r != "error:mode" or os.path.exists(os.path.join(s.workdir, RUNNER_MARK)):
|
||||
fail("[port] home under gfcloud got %r" % r)
|
||||
if s.port.request("home") != "ok":
|
||||
fail("[port] home under manual was refused")
|
||||
st = s.port.state()
|
||||
if st["released"] or st["homed"] & 3 != 3 or st["state"] != "Idle":
|
||||
fail("[port] not energized, homed and idle after the port's home: %r" % st)
|
||||
if s.sender.count() != before:
|
||||
fail("[port] the sender got %d responses for the port's commands"
|
||||
% (s.sender.count() - before))
|
||||
if s.sender.send("G0 X1") != "ok":
|
||||
fail("[port] the sender's next line did not get ok")
|
||||
print("PASS [port]: release, energize and home work through the port with a sender "
|
||||
"connected, home is refused outside manual mode, and the sender's count is exact")
|
||||
|
||||
|
||||
def test_restart_while_released(s):
|
||||
"""A controller that dies under a released gantry is replaced by one that
|
||||
takes the release over before it writes its first current: it comes up
|
||||
locked, the hold currents are never written, and $ME still ends it."""
|
||||
s.sender.wait_state("Idle")
|
||||
if s.sender.send("$MD") != "ok":
|
||||
fail("[restart] $MD was refused")
|
||||
before = s.attr_writes()
|
||||
marker = os.path.join(s.workdir, "motors.released")
|
||||
if not os.path.exists(marker):
|
||||
fail("[restart] the release left no marker in the state directory")
|
||||
s.respawn(hard=True)
|
||||
st = s.port.state()
|
||||
if not st["released"] or st["state"] != "Alarm":
|
||||
fail("[restart] the new controller did not take the release over: %r" % st)
|
||||
started = s.attr_writes()[len(before):]
|
||||
if started != [("0", "0")]:
|
||||
fail("[restart] the new controller's start wrote %r to a released gantry, not 0 and 0" % started)
|
||||
if refused(s, "$X") is None or refused(s, "$J=G91 X5 F600") is None:
|
||||
fail("[restart] the new controller let a released machine be unlocked or jogged")
|
||||
if s.sender.send("$ME") != "ok":
|
||||
fail("[restart] $ME was refused after the restart")
|
||||
st = s.port.state()
|
||||
if st["released"] or st["state"] != "Idle" or s.attr_writes()[-1] != ("33", "5") or os.path.exists(marker):
|
||||
fail("[restart] $ME did not energize and clear the marker: %r %r" % (st, s.attr_writes()[-1:]))
|
||||
s.respawn(hard=False)
|
||||
st = s.port.state()
|
||||
if st["released"] or st["state"] != "Idle":
|
||||
fail("[restart] a controller started with no marker came up released: %r" % st)
|
||||
print("PASS [restart]: a controller killed under a released gantry was replaced by one that came "
|
||||
"up locked and wrote 0 to X and Y at its start; $ME energized and cleared the marker; the next start was normal")
|
||||
|
||||
|
||||
def test_stop_block_offsets(s):
|
||||
"""manual_home_x and _y are how far in front of the gantry stops the origin
|
||||
lies, never negative: the head at the stops is declared at minus them and
|
||||
jogs to the origin, dark, before $H answers, and the envelope starts at
|
||||
the origin."""
|
||||
s.sender.wait_state("Idle")
|
||||
s.set_mode("manual", "manual_home_x = 12.5\nmanual_home_y = 8\n")
|
||||
s.sender.send("M3 S100") # a modal beam the jog must not carry
|
||||
ticks, fire = s.motion_ticks(), s.fire_ticks()
|
||||
if s.sender.send("$H") != "ok":
|
||||
fail("[offsets] $H under manual with offsets was refused")
|
||||
st = s.port.state() # $H answers once the head has stopped
|
||||
s.sender.send("M5")
|
||||
if st["state"] != "Idle" or st["mpos"][0] != 0 or st["mpos"][1] != 0:
|
||||
fail("[offsets] the head was not idle at the origin when $H answered: %r" % st)
|
||||
if st["homed"] & 3 != 3:
|
||||
fail("[offsets] X and Y are not marked homed: %r" % st)
|
||||
d = declared(s)
|
||||
if d is None or abs(d[0] + 12.5) > 0.01 or abs(d[1] + 8.0) > 0.01:
|
||||
fail("[offsets] the sender was told the head stood at %r, not (-12.5, -8)" % (d,))
|
||||
if s.motion_ticks() == ticks:
|
||||
fail("[offsets] the move to the origin shipped nothing")
|
||||
if s.fire_ticks() != fire:
|
||||
fail("[offsets] the move to the origin shipped %d FIRE ticks" % (s.fire_ticks() - fire))
|
||||
if s.sender.send("$J=G91 X5 Y5 F3000") != "ok":
|
||||
fail("[offsets] a jog from the origin onto the bed was refused")
|
||||
s.sender.wait_state("Idle")
|
||||
if s.sender.send("$J=G91 X-5 Y-5 F3000") != "ok":
|
||||
fail("[offsets] the jog back to the origin was refused")
|
||||
s.sender.wait_state("Idle")
|
||||
for line in ("$J=G91 X-1 F600", "$J=G91 Y-1 F600"):
|
||||
r = refused(s, line)
|
||||
if r != "error:15":
|
||||
fail("[offsets] %s, behind the origin, got %r, not error:15" % (line, r))
|
||||
# The panel's Set home here: the port answers once the head has stopped,
|
||||
# and the sender's count stays exact.
|
||||
s.set_mode("manual", "manual_home_x = 12.5\nmanual_home_y = 8\n")
|
||||
s.quiet()
|
||||
before = s.sender.count()
|
||||
if s.port.request("home") != "ok":
|
||||
fail("[offsets] the port's home with offsets was refused")
|
||||
st = s.port.state()
|
||||
if st["state"] != "Idle" or st["mpos"][0] != 0 or st["mpos"][1] != 0:
|
||||
fail("[offsets] the head was not idle at the origin when the port's home answered: %r" % st)
|
||||
if s.sender.count() != before:
|
||||
fail("[offsets] the sender got %d responses for the port's home" % (s.sender.count() - before))
|
||||
s.set_mode("manual")
|
||||
print("PASS [offsets]: a manual home declared (-12.5, -8) and jogged to the origin before $H "
|
||||
"answered, dark under a modal M3; the envelope started at the origin; the port's home "
|
||||
"answered at the origin")
|
||||
|
||||
|
||||
def test_origin_jog_cancel(s):
|
||||
"""A jog cancel during a manual home's move to the origin ends the move at
|
||||
once, as it ends any jog, though the core's main loop is waiting for $H:
|
||||
$H answers with the head short of the origin, and the sender is told
|
||||
where it stopped."""
|
||||
s.sender.wait_state("Idle")
|
||||
s.set_mode("manual", "manual_home_y = 250\n")
|
||||
n = s.sender.count()
|
||||
s.sender.sock.sendall(("$H" + s.sender.eol).encode())
|
||||
time.sleep(0.4) # into a move of about 1.6 s
|
||||
s.sender.realtime(b"\x85")
|
||||
end = time.time() + 5.0
|
||||
while time.time() < end and s.sender.count() == n:
|
||||
time.sleep(0.01)
|
||||
with s.sender.lock:
|
||||
r = s.sender.responses[n] if len(s.sender.responses) > n else None
|
||||
st = s.port.state()
|
||||
s.set_mode("manual")
|
||||
if r != "ok":
|
||||
fail("[origin-cancel] $H got %r" % r)
|
||||
if st["state"] != "Idle" or st["mpos"][1] > -10.0:
|
||||
fail("[origin-cancel] the jog cancel did not stop the move short of the origin: %r" % st)
|
||||
if not s.sender.saw("Manual home: the move to the origin stopped at"):
|
||||
fail("[origin-cancel] the sender was not told where the move stopped")
|
||||
print("PASS [origin-cancel]: a jog cancel 0.4 s into the move to the origin stopped it at Y%.1f; "
|
||||
"$H answered ok and said where it stopped" % st["mpos"][1])
|
||||
|
||||
|
||||
def test_offsets_belong_to_their_provider(s):
|
||||
"""Both pairs set at once: a manual home declares minus manual_home_* and
|
||||
never looks at gfcloud_home_*, and a camera home (a stand-in runner that
|
||||
exits 0) declares gfcloud_home_* and never looks at manual_home_*. A camera
|
||||
home may lie behind the origin, and the envelope reaches back to it."""
|
||||
both = "manual_home_x = 7\nmanual_home_y = 9\ngfcloud_home_x = -4.5\ngfcloud_home_y = 3.25\n"
|
||||
s.sender.wait_state("Idle")
|
||||
s.set_mode("manual", both)
|
||||
if s.sender.send("$H") != "ok":
|
||||
fail("[own-offsets] the manual home was refused")
|
||||
d = declared(s)
|
||||
if d is None or abs(d[0] + 7.0) > 0.01 or abs(d[1] + 9.0) > 0.01:
|
||||
fail("[own-offsets] a manual home declared %r, not its own (-7, -9)" % (d,))
|
||||
s.set_mode("gfcloud", both)
|
||||
if s.sender.send("$H", timeout=30.0) != "ok":
|
||||
fail("[own-offsets] the camera home (stand-in runner) was refused")
|
||||
s.sender.wait_state("Idle")
|
||||
st = s.port.state()
|
||||
if abs(st["mpos"][0] + 4.5) > 0.01 or abs(st["mpos"][1] - 3.25) > 0.01:
|
||||
fail("[own-offsets] a camera home declared %r, not its own (-4.5, 3.25)" % st["mpos"][:2])
|
||||
if s.sender.send("$J=G91 X2 F3000") != "ok":
|
||||
fail("[own-offsets] a jog from a camera home behind the origin was refused")
|
||||
s.sender.wait_state("Idle")
|
||||
if s.sender.send("$J=G91 X-2 F3000") != "ok":
|
||||
fail("[own-offsets] the jog back to the camera home was refused")
|
||||
s.sender.wait_state("Idle")
|
||||
r = refused(s, "$J=G91 X-1 F600")
|
||||
if r != "error:15":
|
||||
fail("[own-offsets] a jog behind the camera home got %r, not error:15" % r)
|
||||
r = refused(s, "$J=G91 Y-4 F600")
|
||||
if r != "error:15":
|
||||
fail("[own-offsets] a jog to Y-0.75, off the bed, got %r, not error:15" % r)
|
||||
s.set_mode("manual")
|
||||
print("PASS [own-offsets]: with both pairs set, a manual home declared (-7, -9) and a camera home "
|
||||
"(-4.5, 3.25); the envelope reached back to the camera home on X and stopped at the origin on Y")
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(BIN):
|
||||
fail("no controller binary at %s" % BIN)
|
||||
s = Session()
|
||||
try:
|
||||
test_manual_home(s)
|
||||
test_home_refused_in_cycle(s)
|
||||
test_release_refused_armed(s)
|
||||
test_release_locks(s)
|
||||
test_energize(s)
|
||||
test_port_panel_ops(s)
|
||||
test_restart_while_released(s)
|
||||
test_stop_block_offsets(s)
|
||||
test_origin_jog_cancel(s)
|
||||
test_offsets_belong_to_their_provider(s)
|
||||
finally:
|
||||
s.close()
|
||||
print("ALL PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Host-side verification of the M-codes an extension package answers.
|
||||
|
||||
Runs the native grblHAL_glowforge binary in null-sink mode, with the
|
||||
scripted sender and the port client of ctlport_test.py standing in for a
|
||||
sender and for the machine daemon:
|
||||
|
||||
1. the table: the port's mcodes op takes "-" or numbers from M160 to
|
||||
M179, each once, the whole range included, and refuses every other
|
||||
form, leaving the table as it was
|
||||
2. a number nothing answers is an unsupported command where the line is
|
||||
parsed (error:20), and so is every number outside the table
|
||||
3. an answered M-code is a barrier: the sender's line waits for its ok,
|
||||
the port's state names the M-code with its P, Q and R words and its
|
||||
seq, the head does not move, a port jog is refused (busy:mcode), and
|
||||
the answer lets the line finish with a [MSG:] of its words; a second
|
||||
answer, or one under another seq, is stale, and one out of form is
|
||||
refused
|
||||
4. an answer that says the work was not done holds the job with a
|
||||
[MSG:] of its words, and a cycle start resumes it
|
||||
5. a soft reset ends the wait at once, and the controller answers the
|
||||
next line
|
||||
6. no answer in GFMCODE_WAIT_S holds the job: 5 s in the host build
|
||||
(configured with -DGFMCODE_WAIT_S=5.0, as grblHAL-glowforge's CI
|
||||
does; the image keeps 30 s), longer than any case here waits at a
|
||||
barrier on purpose
|
||||
7. the barrier is dark: under an open armed window with M3 S500, a job
|
||||
waiting at an M-code ships no FIRE tick, the window stays open across
|
||||
the wait, and the next laser move fires again
|
||||
|
||||
Usage: mcode_test.py [path/to/grblHAL_glowforge]
|
||||
(a build configured with -DGFMCODE_WAIT_S=5.0)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ctlport_test as ct # noqa: E402
|
||||
|
||||
fail = ct.fail
|
||||
WAIT_S = 5.0 # the host build's GFMCODE_WAIT_S
|
||||
|
||||
|
||||
def send_nowait(s, line):
|
||||
"""A sender line whose response is not waited for; the count before it."""
|
||||
n = s.sender.count()
|
||||
s.sender.sock.sendall((line + "\n").encode())
|
||||
return n
|
||||
|
||||
|
||||
def wait_pending(s, timeout=5.0):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
st = s.port.state()
|
||||
if st.get("mcode"):
|
||||
return st
|
||||
time.sleep(0.05)
|
||||
fail("no M-code waits in the port's state: %r" % s.port.state())
|
||||
|
||||
|
||||
def test_table(s):
|
||||
if s.port.state().get("mcode", "missing") is not None:
|
||||
fail("[table] the state carries no mcode null when nothing waits: %r" % s.port.state())
|
||||
for good in ("-", ",".join(str(n) for n in range(160, 180)), "160", "160,179,165"):
|
||||
r = s.port.request("mcodes " + good)
|
||||
if r != "ok":
|
||||
fail("[table] mcodes %s -> %r" % (good, r))
|
||||
# one form per refusal: out of range, twice, a separator with nothing
|
||||
# after it, not a number, and nothing at all
|
||||
for bad in ("159", "160,160", "160,", ",160", ""):
|
||||
r = s.port.request("mcodes " + bad)
|
||||
if r != "error:invalid":
|
||||
fail("[table] mcodes %r -> %r, not error:invalid" % (bad, r))
|
||||
# the refusals left the last good table: 160, 179 and 165
|
||||
n = send_nowait(s, "M165")
|
||||
m = wait_pending(s)["mcode"]
|
||||
if m.get("code") != 165:
|
||||
fail("[table] a refused list changed the table: %r" % m)
|
||||
s.port.request("mcode_result %d ok" % m["seq"])
|
||||
end = time.time() + 3
|
||||
while s.sender.count() == n and time.time() < end:
|
||||
time.sleep(0.02)
|
||||
if s.sender.responses[n:n + 1] != ["ok"]:
|
||||
fail("[table] M165 got %r" % s.sender.responses[n:])
|
||||
print("PASS [table]: the port takes '-' and numbers from M160 to M179, the whole range; a form "
|
||||
"of each refusal is refused, the table left as it was")
|
||||
|
||||
|
||||
def test_unanswered(s):
|
||||
s.port.request("mcodes 160")
|
||||
# The core holds a parser error against every later block until an empty
|
||||
# line (the sync) or a reset: after an unsupported command a job halts.
|
||||
for line in ("M161", "M179", "M159", "M180 P1"):
|
||||
r = s.sender.send(line)
|
||||
if r != "error:20":
|
||||
fail("[unanswered] %s with only M160 answered got %r, not error:20" % (line, r))
|
||||
s.sender.send("")
|
||||
s.port.request("mcodes -")
|
||||
r = s.sender.send("M160")
|
||||
if r != "error:20":
|
||||
fail("[unanswered] M160 with nothing answered got %r" % r)
|
||||
s.sender.send("")
|
||||
print("PASS [unanswered]: a number nothing answers is error:20 where it is parsed")
|
||||
|
||||
|
||||
def test_answered(s):
|
||||
s.port.request("mcodes 160,161")
|
||||
s.sender.send("G91")
|
||||
s.sender.send("G0 X3")
|
||||
s.sender.wait_state("Idle")
|
||||
n = send_nowait(s, "M160 P2 Q3.5 R-1")
|
||||
st = wait_pending(s)
|
||||
m = st["mcode"]
|
||||
if m.get("code") != 160 or m.get("words") != {"P": 2, "Q": 3.5, "R": -1} or not m.get("seq"):
|
||||
fail("[answered] the state names %r" % m)
|
||||
x0 = st["mpos"]
|
||||
time.sleep(1.0)
|
||||
st2 = s.port.state()
|
||||
if st2["mpos"] != x0 or st2["state"] != "Idle":
|
||||
fail("[answered] the head moved or the state is %s while the job waits: %r -> %r"
|
||||
% (st2["state"], x0, st2["mpos"]))
|
||||
if s.sender.count() != n:
|
||||
fail("[answered] the M-code line got its response before the answer: %r" % s.sender.responses[n:])
|
||||
r = s.port.request("jog G91 X1 F600")
|
||||
if r != "busy:mcode":
|
||||
fail("[answered] a port jog while the job waits -> %r, not busy:mcode" % r)
|
||||
for bad in ("mcode_result %d maybe" % m["seq"], "mcode_result x ok", "mcode_result 0 ok",
|
||||
"mcode_result %d ok [bracket]" % m["seq"], "mcode_result %d ok %s" % (m["seq"], "w" * 97)):
|
||||
r = s.port.request(bad)
|
||||
if r not in ("error:invalid", "error:stale"):
|
||||
fail("[answered] %r -> %r" % (bad, r))
|
||||
if s.port.request("mcode_result %d ok" % (m["seq"] + 7)) != "error:stale":
|
||||
fail("[answered] an answer under another seq was taken")
|
||||
if s.port.state().get("mcode") is None:
|
||||
fail("[answered] a refused answer ended the wait")
|
||||
r = s.port.request("mcode_result %d ok all set" % m["seq"])
|
||||
if r != "ok":
|
||||
fail("[answered] the answer -> %r" % r)
|
||||
end = time.time() + 3
|
||||
while s.sender.count() == n and time.time() < end:
|
||||
time.sleep(0.02)
|
||||
if s.sender.responses[n:n + 1] != ["ok"]:
|
||||
fail("[answered] the M-code line got %r after the answer" % s.sender.responses[n:])
|
||||
if not s.sender.saw("M160: done: all set"):
|
||||
fail("[answered] the sender got no [MSG:] of the answer's words")
|
||||
if s.port.request("mcode_result %d ok" % m["seq"]) != "error:stale":
|
||||
fail("[answered] a second answer was taken")
|
||||
if s.port.state().get("mcode") is not None:
|
||||
fail("[answered] the state still names an M-code")
|
||||
if s.sender.send("G0 X-3") != "ok":
|
||||
fail("[answered] the job did not go on")
|
||||
print("PASS [answered]: the line waited for the answer with the head still and a port jog "
|
||||
"refused; the state named M160 with P, Q and R; the answer let it go on with its words")
|
||||
|
||||
|
||||
def test_fail_holds(s):
|
||||
s.sender.wait_state("Idle")
|
||||
n = send_nowait(s, "M161")
|
||||
m = wait_pending(s)["mcode"]
|
||||
s.port.request("mcode_result %d fail the exhaust did not start" % m["seq"])
|
||||
end = time.time() + 3
|
||||
while s.sender.count() == n and time.time() < end:
|
||||
time.sleep(0.02)
|
||||
s.sender.wait_state("Hold", timeout=5)
|
||||
if not s.sender.saw("M161: the exhaust did not start: the job is held"):
|
||||
fail("[fail] the sender got no [MSG:] of why the job is held")
|
||||
s.sender.realtime(b"~")
|
||||
s.sender.wait_state("Idle", timeout=5)
|
||||
if s.sender.send("G0 X1") != "ok":
|
||||
fail("[fail] the job did not go on after the resume")
|
||||
print("PASS [fail]: an answer that the work was not done held the job with its words; a cycle "
|
||||
"start resumed it")
|
||||
|
||||
|
||||
def test_reset_ends_wait(s):
|
||||
s.sender.wait_state("Idle")
|
||||
send_nowait(s, "M160")
|
||||
wait_pending(s)
|
||||
t0 = time.time()
|
||||
s.sender.realtime(b"\x18")
|
||||
end = time.time() + 3
|
||||
while time.time() < end and s.port.state().get("mcode") is not None:
|
||||
time.sleep(0.02)
|
||||
dt = time.time() - t0
|
||||
if s.port.state().get("mcode") is not None:
|
||||
fail("[reset] the wait outlived a soft reset")
|
||||
time.sleep(0.5)
|
||||
s.sender.send("$X")
|
||||
if s.sender.send("G0 X1") != "ok":
|
||||
fail("[reset] the controller did not answer after the reset")
|
||||
print("PASS [reset]: a soft reset ended the wait in %.2f s" % dt)
|
||||
|
||||
|
||||
def test_timeout_holds(s):
|
||||
s.port.request("mcodes 160")
|
||||
s.sender.wait_state("Idle")
|
||||
t0 = time.time()
|
||||
send_nowait(s, "M160")
|
||||
wait_pending(s)
|
||||
s.sender.wait_state("Hold", timeout=WAIT_S + 5)
|
||||
dt = time.time() - t0
|
||||
if dt < WAIT_S - 0.1:
|
||||
fail("[timeout] the job was held after %.1f s, not after the wait" % dt)
|
||||
if not s.sender.saw("M160 had no answer from its extension"):
|
||||
fail("[timeout] the sender got no [MSG:] of the timeout")
|
||||
s.sender.realtime(b"~")
|
||||
s.sender.wait_state("Idle", timeout=5)
|
||||
print("PASS [timeout]: with no answer the job was held after %.1f s, and resumed" % dt)
|
||||
|
||||
|
||||
def test_dark():
|
||||
s = ct.Session(laser=True)
|
||||
try:
|
||||
s.port.request("mcodes 160")
|
||||
s.sender.send("G91")
|
||||
s.sender.send("M3 S500")
|
||||
s.sender.send("G1 X10 F1500")
|
||||
s.sender.wait_state("Idle")
|
||||
time.sleep(0.5)
|
||||
if not s.sender.saw("laser armed"):
|
||||
fail("[dark] the laser line did not arm: the case would prove nothing")
|
||||
steps0, fire0 = s.ticks()
|
||||
if fire0 == 0:
|
||||
fail("[dark] the armed G1 shipped no FIRE tick: the dump cannot see emission")
|
||||
send_nowait(s, "M160")
|
||||
m = wait_pending(s)["mcode"]
|
||||
time.sleep(3.0)
|
||||
steps1, fire1 = s.ticks()
|
||||
s.port.request("mcode_result %d ok" % m["seq"])
|
||||
time.sleep(0.3)
|
||||
s.sender.send("G1 X5 F1500")
|
||||
s.sender.wait_state("Idle")
|
||||
time.sleep(0.5)
|
||||
steps2, fire2 = s.ticks()
|
||||
if fire1 != fire0 or steps1 != steps0:
|
||||
fail("[dark] the job waiting at M160 under M3 S500 shipped %d FIRE ticks and %d steps"
|
||||
% (fire1 - fire0, steps1 - steps0))
|
||||
if s.sender.saw("laser disarmed"):
|
||||
fail("[dark] the armed window closed across the wait")
|
||||
if fire2 <= fire1:
|
||||
fail("[dark] the laser move after the answer shipped no FIRE: the case would prove nothing")
|
||||
print("PASS [dark]: 3 s waiting at M160 under an open window with M3 S500 shipped no FIRE tick "
|
||||
"and no step; the window stayed open and the next move fired (%d ticks)" % (fire2 - fire1))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(ct.BIN):
|
||||
fail("no controller binary at %s" % ct.BIN)
|
||||
s = ct.Session()
|
||||
try:
|
||||
test_table(s)
|
||||
test_unanswered(s)
|
||||
test_answered(s)
|
||||
test_fail_holds(s)
|
||||
test_reset_ends_wait(s)
|
||||
test_timeout_holds(s)
|
||||
finally:
|
||||
s.close()
|
||||
test_dark()
|
||||
print("ALL PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -18,12 +18,10 @@ Runs the native grblHAL_glowforge binary in null-sink mode (no hardware,
|
||||
no root) and drives it over TCP, keeping one settings store across
|
||||
restarts:
|
||||
|
||||
1. a fresh store starts and reports its default $398
|
||||
2. $398=400 is accepted and read back
|
||||
3. the controller restarted on that store answers on the port, reports
|
||||
400 blocks, and the status report shows a planner that deep
|
||||
4. the same at $398=1000, the top of the range
|
||||
5. a motion line runs to completion on the deep buffer
|
||||
1. $398=1000, the top of the range, is accepted and read back
|
||||
2. the controller restarted on that store answers on the port, reports
|
||||
1000 blocks, and the status report shows a planner that deep
|
||||
3. a motion line runs to completion on the deep buffer
|
||||
|
||||
A controller that connects and never answers after the restart is the
|
||||
failure this harness exists for. Every exit kills the controller it
|
||||
@@ -50,7 +48,6 @@ PORT = 2401
|
||||
CONNECT_S = 8.0
|
||||
ANSWER_S = 5.0
|
||||
|
||||
DEEP = 400
|
||||
TOP = 1000
|
||||
|
||||
_live = None # the controller process a failure has to take down
|
||||
@@ -199,15 +196,6 @@ def main():
|
||||
fail("no controller binary at %s" % BIN)
|
||||
workdir = tempfile.mkdtemp(prefix="planner-blocks-")
|
||||
try:
|
||||
s = Session(workdir)
|
||||
check(s.answers(), "a fresh store starts and answers",
|
||||
"a fresh store does not answer")
|
||||
print(" default $398=%s" % s.setting("$398"))
|
||||
s.stop()
|
||||
|
||||
s = restart_on(workdir, DEEP)
|
||||
s.stop()
|
||||
|
||||
s = restart_on(workdir, TOP)
|
||||
reply = s.send("G91 G1 X1 F600", 1.5)
|
||||
check("ok" in reply and "error" not in reply and "ALARM" not in reply,
|
||||
@@ -216,7 +204,7 @@ def main():
|
||||
check(s.wait_idle(), "the move completes and the controller is Idle",
|
||||
"the controller did not return to Idle after the move")
|
||||
s.stop()
|
||||
print("PASS: the planner buffer starts at %d and %d blocks" % (DEEP, TOP))
|
||||
print("PASS: the planner buffer starts at %d blocks" % TOP)
|
||||
finally:
|
||||
if _live is not None:
|
||||
_live.stop()
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Host-side verification of the crumb tray's mode (glowforge_tray.c).
|
||||
|
||||
With the tray out the work sits on the floor of the cut area, and Z is the
|
||||
focal height above that floor: the Z position and the Z envelope move up by
|
||||
tray_offset_mm on the lens step grid (1.35" is 100 half-steps). M103 P1 sets
|
||||
the tray out, M103 P0 sets it in, and the controller port's "tray out|in"
|
||||
runs the same line for the panel. Runs the native grblHAL_glowforge binary
|
||||
in null-sink mode, with GFSINK_DUMP capturing the shipped stream, a scripted
|
||||
sender on the Grbl socket, and the port client of ctlport_test.py:
|
||||
|
||||
1. M103 P1 and P0 shift the reported Z by the offset's whole half-steps,
|
||||
tell the sender [MSG:Tray out] and [MSG:Tray in], keep Z referenced,
|
||||
write and remove the marker beside the shared config, and ship no
|
||||
step tick; asking for the mode already set changes nothing and says so
|
||||
2. the envelope moves with the position: with the tray out the ends of
|
||||
the lens's reach, shifted, run and two half-steps past them are
|
||||
refused, and a jog to a tray-in Z is refused; with the tray in again
|
||||
the same jog runs
|
||||
3. a P other than 0 or 1, and an axis word on the line, are errors (39,
|
||||
31) that change nothing
|
||||
4. the M-code is a barrier: behind a move it waits for the move to end,
|
||||
and the frame changes only once the machine is idle
|
||||
5. the port's tray op switches the mode with a sender connected, the
|
||||
sender's response count stays exact and it is told the mode; the op
|
||||
is refused out of form (error:invalid), during a program
|
||||
(busy:state), and while a job waits at a package's M-code
|
||||
(busy:mcode)
|
||||
6. the mode survives a soft reset, and a controller restart: a start with
|
||||
the marker present takes the lens reference in the tray-out frame,
|
||||
and one without it in the tray-in frame
|
||||
7. tray_offset_mm sets the shift
|
||||
8. an unreferenced lens shifts the same way, and stays pinned where it
|
||||
reads
|
||||
9. a camera home with the tray out declares Z the offset higher, and
|
||||
hands the runner the same park: the lens parks in the same place
|
||||
|
||||
The kernel-idle half of the barrier (the wait a package's M-code makes)
|
||||
needs the pulse device: a null sink is always idle.
|
||||
|
||||
Usage: tray_test.py [path/to/grblHAL_glowforge]
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ctlport_test as ct # noqa: E402
|
||||
|
||||
BIN = ct.BIN
|
||||
fail = ct.fail
|
||||
PORT = 2395
|
||||
SPM = 2.922
|
||||
EDGE = 3.35 # the edge's grid step is 10
|
||||
EDGE_K = 10
|
||||
SHIFT_K = 100 # the default offset, 34.29 mm
|
||||
DOWN, UP = 10, 12 # the fallback window
|
||||
|
||||
|
||||
def zk(k):
|
||||
return k / SPM
|
||||
|
||||
|
||||
class Session:
|
||||
"""One null-sink controller over its own state directory. `conf` is the
|
||||
shared config; with `referenced` the lens marker forgectrl leaves is
|
||||
there, so the controller takes the lens reference at its start."""
|
||||
|
||||
def __init__(self, conf=None, referenced=True):
|
||||
self.workdir = tempfile.mkdtemp(prefix="tray-")
|
||||
self.conf = os.path.join(self.workdir, "forgefirm.conf")
|
||||
self.dump = os.path.join(self.workdir, "stream.bin")
|
||||
self.marker = os.path.join(self.workdir, "tray.out")
|
||||
self.write_conf(conf or {})
|
||||
if referenced:
|
||||
with open(os.path.join(self.workdir, "lens.home"), "w") as f:
|
||||
f.write("edge 0 3 3\n")
|
||||
verdict = os.path.join(self.workdir, "cooling.state")
|
||||
self.stop = threading.Event()
|
||||
self.pub = threading.Thread(target=ct.publish_verdicts, args=(verdict, self.stop), daemon=True)
|
||||
self.pub.start()
|
||||
env = dict(os.environ, GFHOME_CONF=self.conf, GF_STATE_DIR=self.workdir,
|
||||
GF_VERDICT_FILE=verdict, GFSINK_DUMP=self.dump, FFLOG_STDERR="1")
|
||||
env.pop("GFSINK", None)
|
||||
env.pop("GF_SWITCH_FILE", None)
|
||||
self.env = env
|
||||
self.start()
|
||||
|
||||
def write_conf(self, conf):
|
||||
with open(self.conf, "w") as f:
|
||||
f.write("cool_fan_grace_s = 0\nlaser_disarm_s = 1\nlens_hall_edge_z_mm = %.2f\n" % EDGE)
|
||||
for k, v in conf.items():
|
||||
f.write("%s = %s\n" % (k, v))
|
||||
|
||||
def start(self):
|
||||
self.proc = subprocess.Popen([BIN, "-p", str(PORT)], cwd=self.workdir, env=self.env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
self.sender = ct.Sender(PORT)
|
||||
path = os.path.join(self.workdir, "grbl.ctl")
|
||||
for _ in range(50):
|
||||
if os.path.exists(path):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
fail("the controller did not create %s" % path)
|
||||
self.port = ct.PortClient(path)
|
||||
# The lens reference is taken on the first pass of the realtime hook.
|
||||
time.sleep(0.3)
|
||||
|
||||
def restart(self, hard):
|
||||
"""End the controller (SIGKILL when hard) and start another over the
|
||||
same state directory and shared config."""
|
||||
self.port.close()
|
||||
self.sender.close()
|
||||
if hard:
|
||||
self.proc.kill()
|
||||
else:
|
||||
self.proc.send_signal(signal.SIGINT)
|
||||
self.proc.wait(10)
|
||||
self.start()
|
||||
|
||||
def state(self):
|
||||
return self.port.state()
|
||||
|
||||
def z(self):
|
||||
return self.state()["mpos"][2]
|
||||
|
||||
def tray(self):
|
||||
return self.state()["tray"]
|
||||
|
||||
def ticks(self):
|
||||
"""Step ticks shipped so far (a byte with bit 7 set is a power byte)."""
|
||||
try:
|
||||
with open(self.dump, "rb") as f:
|
||||
data = f.read()
|
||||
except OSError:
|
||||
return 0
|
||||
return sum(1 for b in data if not b & 0x80 and b & 0x25)
|
||||
|
||||
def messages(self, text, want=None):
|
||||
"""How many [MSG:<text>] lines the sender has had. With `want`, wait
|
||||
up to a second for that many: the port answers at once, and the
|
||||
sender's lines go out on the controller's next pass."""
|
||||
end = time.time() + (1.0 if want is not None else 0)
|
||||
while True:
|
||||
n = self.sender.saw("[MSG:%s]" % text)
|
||||
if want is None or n >= want or time.time() > end:
|
||||
return n
|
||||
time.sleep(0.02)
|
||||
|
||||
def quiet(self):
|
||||
time.sleep(0.45)
|
||||
|
||||
def close(self):
|
||||
self.port.close()
|
||||
self.sender.close()
|
||||
self.proc.send_signal(signal.SIGINT)
|
||||
try:
|
||||
self.proc.wait(5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
self.stop.set()
|
||||
self.pub.join(2)
|
||||
shutil.rmtree(self.workdir, ignore_errors=True)
|
||||
|
||||
|
||||
def expect_z(s, k, what):
|
||||
z = s.z()
|
||||
if abs(z - zk(k)) > 0.002:
|
||||
fail("[%s] Z reads %.3f, not %.3f (%d half-steps)" % (what, z, zk(k), k))
|
||||
|
||||
|
||||
def refused(s, line):
|
||||
"""Send a line that must be refused, and return its error, acknowledged
|
||||
with an empty line (the core holds a parser error against the next
|
||||
g-code line until then)."""
|
||||
r = s.sender.send(line)
|
||||
if r.startswith("error"):
|
||||
if s.sender.send("") != "ok":
|
||||
fail("the empty line that acknowledges %s did not get ok" % r)
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def jog_to(s, k):
|
||||
"""An absolute jog to half-step k: its response."""
|
||||
r = s.sender.send("$J=G90 G21 Z%.4f F200" % zk(k))
|
||||
if r == "ok":
|
||||
s.sender.wait_state("Idle")
|
||||
elif s.sender.send("") != "ok":
|
||||
fail("the empty line after a refused jog did not get ok")
|
||||
return r
|
||||
|
||||
|
||||
def test_switch(s):
|
||||
expect_z(s, EDGE_K, "switch")
|
||||
st = s.state()
|
||||
if st["tray"] != "in" or not st["homed"] & 4:
|
||||
fail("[switch] not tray in with Z referenced at the start: %r" % st)
|
||||
ticks = s.ticks()
|
||||
if s.sender.send("M103 P1") != "ok":
|
||||
fail("[switch] M103 P1 was refused")
|
||||
st = s.state()
|
||||
if st["tray"] != "out" or not st["homed"] & 4:
|
||||
fail("[switch] not tray out with Z referenced after M103 P1: %r" % st)
|
||||
expect_z(s, EDGE_K + SHIFT_K, "switch")
|
||||
if not s.messages("Tray out", 1):
|
||||
fail("[switch] the sender was not told [MSG:Tray out]")
|
||||
if not os.path.exists(s.marker):
|
||||
fail("[switch] M103 P1 left no marker beside the shared config")
|
||||
if s.sender.send("M103 P1") != "ok" or s.tray() != "out":
|
||||
fail("[switch] a second M103 P1 was refused or changed the mode")
|
||||
expect_z(s, EDGE_K + SHIFT_K, "switch: the mode already set")
|
||||
if s.messages("Tray out", 2) != 2:
|
||||
fail("[switch] the mode already set was not told again")
|
||||
if s.sender.send("M103 P0") != "ok" or s.tray() != "in":
|
||||
fail("[switch] M103 P0 did not set the tray in")
|
||||
expect_z(s, EDGE_K, "switch: back in")
|
||||
if os.path.exists(s.marker) or not s.messages("Tray in", 1):
|
||||
fail("[switch] M103 P0 left the marker or did not tell the sender")
|
||||
time.sleep(0.3)
|
||||
if s.ticks() != ticks:
|
||||
fail("[switch] the switches shipped %d step ticks" % (s.ticks() - ticks))
|
||||
print("PASS [switch]: M103 P1 moved Z up %d half-steps and P0 back, the sender was told, the "
|
||||
"marker followed, and no step shipped" % SHIFT_K)
|
||||
|
||||
|
||||
def test_envelope(s):
|
||||
s.sender.send("M103 P1")
|
||||
base = EDGE_K + SHIFT_K
|
||||
for k, want, what in ((base + UP, "ok", "the top of the reach"),
|
||||
(base + UP + 2, "error:15", "two half-steps over the top"),
|
||||
(base - DOWN, "ok", "the bottom of the reach"),
|
||||
(base - DOWN - 2, "error:15", "two half-steps under the bottom"),
|
||||
(EDGE_K + UP, "error:15", "a tray-in Z")):
|
||||
r = jog_to(s, k)
|
||||
if r != want:
|
||||
fail("[envelope] tray out: a jog to %s (Z %.3f) got %r, not %s" % (what, zk(k), r, want))
|
||||
if jog_to(s, base) != "ok":
|
||||
fail("[envelope] the jog back to the edge was refused")
|
||||
s.sender.send("M103 P0")
|
||||
expect_z(s, EDGE_K, "envelope")
|
||||
if jog_to(s, EDGE_K + UP) != "ok":
|
||||
fail("[envelope] tray in: the jog to the same tray-in Z was refused")
|
||||
if jog_to(s, EDGE_K) != "ok":
|
||||
fail("[envelope] the jog back to the edge was refused")
|
||||
print("PASS [envelope]: with the tray out the shifted reach ran to its ends, past them and a "
|
||||
"tray-in Z were refused; with the tray in the tray-in Z ran")
|
||||
|
||||
|
||||
def test_errors(s):
|
||||
ticks = s.ticks()
|
||||
for line, want in (("M103 P2", "error:39"), ("M103 P1 X5", "error:31")):
|
||||
r = refused(s, line)
|
||||
if r != want:
|
||||
fail("[errors] %r got %r, not %s" % (line, r, want))
|
||||
if s.tray() != "in" or os.path.exists(s.marker):
|
||||
fail("[errors] %r changed the mode" % line)
|
||||
expect_z(s, EDGE_K, "errors")
|
||||
time.sleep(0.3)
|
||||
if s.ticks() != ticks:
|
||||
fail("[errors] the refused lines shipped %d step ticks" % (s.ticks() - ticks))
|
||||
print("PASS [errors]: P2 and an axis word were refused and changed nothing")
|
||||
|
||||
|
||||
def test_barrier(s):
|
||||
"""Behind a move the switch waits for it to end."""
|
||||
x0 = s.state()["mpos"][0]
|
||||
s.sender.send("G91 G1 X10 F300") # about two seconds
|
||||
n = s.sender.count()
|
||||
s.sender.sock.sendall(b"M103 P1\n")
|
||||
seen_run = False
|
||||
end = time.time() + 10
|
||||
while time.time() < end:
|
||||
st = s.port.state()
|
||||
if st["state"] == "Run":
|
||||
seen_run = True
|
||||
if st["tray"] != "in" or abs(st["mpos"][2] - zk(EDGE_K)) > 0.002:
|
||||
fail("[barrier] the frame changed while the move before M103 ran: %r" % st)
|
||||
if st["tray"] == "out":
|
||||
break
|
||||
time.sleep(0.05)
|
||||
if not seen_run:
|
||||
fail("[barrier] the move was never seen running, so the case proves nothing")
|
||||
st = s.port.state()
|
||||
if st["tray"] != "out" or st["state"] != "Idle" or abs(st["mpos"][0] - x0 - 10) > 0.01:
|
||||
fail("[barrier] the switch did not land after the move ended: %r" % st)
|
||||
for _ in range(100):
|
||||
if s.sender.count() > n:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
s.sender.send("G90")
|
||||
s.sender.send("M103 P0")
|
||||
print("PASS [barrier]: M103 behind a move changed the frame only once the move had ended")
|
||||
|
||||
|
||||
def test_port(s):
|
||||
s.sender.wait_state("Idle")
|
||||
s.quiet()
|
||||
before = s.sender.count()
|
||||
told = s.messages("Tray out")
|
||||
r = s.port.request("tray out")
|
||||
if r != "ok" or s.tray() != "out":
|
||||
fail("[port] tray out got %r" % r)
|
||||
expect_z(s, EDGE_K + SHIFT_K, "port")
|
||||
if s.port.request("tray out") != "ok":
|
||||
fail("[port] a second tray out was refused")
|
||||
if s.messages("Tray out", told + 2) != told + 2:
|
||||
fail("[port] the sender was not told the mode each time")
|
||||
if s.port.request("tray sideways") != "error:invalid":
|
||||
fail("[port] a tray op out of form was not refused")
|
||||
if s.sender.count() != before:
|
||||
fail("[port] the sender got %d responses for the port's tray ops" % (s.sender.count() - before))
|
||||
|
||||
s.sender.send("G91 G1 X-10 F300")
|
||||
time.sleep(0.4)
|
||||
r = s.port.request("tray in")
|
||||
if r != "busy:state" or s.tray() != "out":
|
||||
fail("[port] tray in during a program got %r" % r)
|
||||
s.sender.wait_state("Idle")
|
||||
s.sender.send("G90")
|
||||
|
||||
if s.port.request("mcodes 160") != "ok":
|
||||
fail("[port] the M-code table was refused")
|
||||
n = s.sender.count()
|
||||
s.sender.sock.sendall(b"M160 P1\n")
|
||||
for _ in range(100):
|
||||
st = s.state()
|
||||
if st.get("mcode"):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
fail("[port] no M-code waits: %r" % s.state())
|
||||
r = s.port.request("tray in")
|
||||
if r != "busy:mcode" or s.tray() != "out":
|
||||
fail("[port] tray in while a job waits at an M-code got %r" % r)
|
||||
if s.port.request("mcode_result %d ok" % st["mcode"]["seq"]) != "ok":
|
||||
fail("[port] the M-code's answer was refused")
|
||||
for _ in range(100):
|
||||
if s.sender.count() > n:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
s.port.request("mcodes -")
|
||||
s.quiet()
|
||||
before = s.sender.count()
|
||||
if s.port.request("tray in") != "ok" or s.tray() != "in":
|
||||
fail("[port] tray in was refused")
|
||||
expect_z(s, EDGE_K, "port")
|
||||
if s.sender.count() != before or s.sender.send("G0 X0") != "ok":
|
||||
fail("[port] the sender's count was disturbed by the tray op")
|
||||
s.sender.wait_state("Idle")
|
||||
print("PASS [port]: tray out and in through the port with a sender connected, its count exact "
|
||||
"and the mode told; refused out of form, during a program, and at a package's M-code")
|
||||
|
||||
|
||||
def test_persistence(s):
|
||||
s.sender.send("M103 P1")
|
||||
s.sender.realtime(b"\x18")
|
||||
time.sleep(1.0)
|
||||
s.sender.send("$X")
|
||||
if s.tray() != "out":
|
||||
fail("[persistence] a soft reset set the tray in")
|
||||
expect_z(s, EDGE_K + SHIFT_K, "persistence: soft reset")
|
||||
s.restart(hard=True)
|
||||
st = s.state()
|
||||
if st["tray"] != "out" or not st["homed"] & 4:
|
||||
fail("[persistence] a restart over the marker came up %r" % st)
|
||||
expect_z(s, EDGE_K + SHIFT_K, "persistence: restart with the tray out")
|
||||
if jog_to(s, EDGE_K + SHIFT_K + UP) != "ok" or jog_to(s, EDGE_K + UP) != "error:15":
|
||||
fail("[persistence] the restarted controller's envelope is not the tray-out one")
|
||||
jog_to(s, EDGE_K + SHIFT_K)
|
||||
if s.port.request("tray in") != "ok":
|
||||
fail("[persistence] tray in was refused after the restart")
|
||||
expect_z(s, EDGE_K, "persistence: in after the restart")
|
||||
s.restart(hard=False)
|
||||
if s.tray() != "in":
|
||||
fail("[persistence] a restart with no marker came up tray out")
|
||||
expect_z(s, EDGE_K, "persistence: restart with the tray in")
|
||||
print("PASS [persistence]: the tray out survived a soft reset and a controller kill, the new "
|
||||
"controller referenced Z in the tray-out frame, and a start with no marker in the tray-in frame")
|
||||
|
||||
|
||||
def test_offset():
|
||||
for key, k in (("20", 58),):
|
||||
s = Session(conf={"tray_offset_mm": key})
|
||||
try:
|
||||
s.sender.send("M103 P1")
|
||||
expect_z(s, EDGE_K + k, "offset %s" % key)
|
||||
s.sender.send("M103 P0")
|
||||
expect_z(s, EDGE_K, "offset %s: back in" % key)
|
||||
finally:
|
||||
s.close()
|
||||
print("PASS [offset]: tray_offset_mm 20 moved Z 58 half-steps")
|
||||
|
||||
|
||||
def test_unreferenced():
|
||||
s = Session(referenced=False)
|
||||
try:
|
||||
z0 = s.z()
|
||||
k0 = round(z0 * SPM)
|
||||
s.sender.send("M103 P1")
|
||||
expect_z(s, k0 + SHIFT_K, "unreferenced")
|
||||
for step in ("1", "-1"):
|
||||
r = s.sender.send("$J=G91 G21 Z%s F200" % step)
|
||||
if r != "error:15":
|
||||
fail("[unreferenced] a Z jog of %s got %r: the pinned envelope did not follow" % (step, r))
|
||||
s.sender.send("")
|
||||
s.sender.send("M103 P0")
|
||||
expect_z(s, k0, "unreferenced: back in")
|
||||
finally:
|
||||
s.close()
|
||||
print("PASS [unreferenced]: an unreferenced lens shifted the same way and stayed pinned where it read")
|
||||
|
||||
|
||||
def test_camera_home():
|
||||
s = Session(conf={"homing_mode": "gfcloud"})
|
||||
park = os.path.join(s.workdir, "park.log")
|
||||
s.write_conf({"homing_mode": "gfcloud", "gfcloud_home_cmd": "echo $GFHOME_PARK_HALF_STEPS >> %s" % park})
|
||||
try:
|
||||
z = {}
|
||||
for mode in ("P0", "P1"):
|
||||
s.sender.send("M103 " + mode)
|
||||
if s.sender.send("$H") != "ok":
|
||||
fail("[camera-home] $H with M103 %s was refused" % mode)
|
||||
s.sender.wait_state("Idle")
|
||||
z[mode] = s.z()
|
||||
with open(park) as f:
|
||||
parks = f.read().split()
|
||||
if len(parks) != 2 or parks[0] != parks[1]:
|
||||
fail("[camera-home] the runner was handed parks %r, not the same one twice" % parks)
|
||||
k = EDGE_K + int(parks[0])
|
||||
if abs(z["P0"] - zk(k)) > 0.002 or abs(z["P1"] - zk(k + SHIFT_K)) > 0.002:
|
||||
fail("[camera-home] Z after the homes read %r, not %.3f and %.3f" % (z, zk(k), zk(k + SHIFT_K)))
|
||||
if jog_to(s, EDGE_K + SHIFT_K + UP) != "ok" or jog_to(s, EDGE_K + UP) != "error:15":
|
||||
fail("[camera-home] the envelope after a tray-out home is not the tray-out one")
|
||||
s.sender.send("M103 P0")
|
||||
finally:
|
||||
s.close()
|
||||
print("PASS [camera-home]: the park handed to the runner was %s half-steps in both modes, and Z "
|
||||
"after the home read %.3f tray in, %.3f tray out" % (parks[0], z["P0"], z["P1"]))
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(BIN):
|
||||
fail("no controller binary at %s" % BIN)
|
||||
s = Session()
|
||||
try:
|
||||
if s.sender.send("$102") != "ok" or not s.sender.saw("$102=2.922"):
|
||||
fail("$102 is not 2.922: this harness needs the default")
|
||||
test_switch(s)
|
||||
test_envelope(s)
|
||||
test_errors(s)
|
||||
test_barrier(s)
|
||||
test_port(s)
|
||||
test_persistence(s)
|
||||
finally:
|
||||
s.close()
|
||||
test_offset()
|
||||
test_unreferenced()
|
||||
test_camera_home()
|
||||
print("ALL PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,15 +11,15 @@ never takes them typed: $100/$101, the machine tick, and the kernel stop
|
||||
ramp. This harness drives the native grblHAL_glowforge binary in
|
||||
null-sink mode (no hardware, no root) over TCP, one process per config:
|
||||
|
||||
1. no key: x32, $100/$101 = 213.333, the 112640 Hz tick
|
||||
2. xy_microsteps = 16: 106.667, the 56320 Hz tick; a typed $100 is
|
||||
overwritten on the spot; $110 is left alone under a tick that
|
||||
carries it
|
||||
3. xy_microsteps = 32: 213.333, the 112640 Hz tick
|
||||
4. a value that is not a mode: x32 with a warning in the log
|
||||
5. GFSINK_RATE lowered under the mode's tick: $110/$111 are held at
|
||||
1. no key: x32, $100/$101 = 213.333, and $110 stands
|
||||
2. xy_microsteps = 16: 106.667; a typed $100 is overwritten on the
|
||||
spot; $110 is left alone under a tick that carries it
|
||||
3. GFSINK_RATE lowered under the mode's tick: $110/$111 are held at
|
||||
the feed the tick carries, one step per tick per axis
|
||||
|
||||
Each mode's derivations, and a value that is not a mode, are the host
|
||||
unit test's (xy_scale_test.c in grblHAL-glowforge).
|
||||
|
||||
The binary keeps its settings in EEPROM.DAT in the working directory, so
|
||||
each run starts from defaults in a temporary directory and leaves
|
||||
nothing behind.
|
||||
@@ -142,10 +142,6 @@ def expect_scale(s, mode, spm):
|
||||
"%s is %.3f at x%d, want %.3f" % (key, got, mode, spm))
|
||||
|
||||
|
||||
def expect_log(log, needle, what):
|
||||
check(needle in log, what, "the log does not say %r:\n%s" % (needle, log))
|
||||
|
||||
|
||||
def case_default():
|
||||
s = Session()
|
||||
try:
|
||||
@@ -154,8 +150,7 @@ def case_default():
|
||||
check(near(rate, 12000.0), "$110=%.0f stands at x32" % rate,
|
||||
"$110 is %.3f at x32, want 12000" % rate)
|
||||
finally:
|
||||
log = s.close()
|
||||
expect_log(log, "x32 microsteps, 112640 Hz machine tick", "no key: x32, the 112640 Hz tick")
|
||||
s.close()
|
||||
|
||||
|
||||
def case_x16():
|
||||
@@ -172,27 +167,7 @@ def case_x16():
|
||||
check(near(rate, 12000.0), "$110=%.0f stands under a tick that carries it" % rate,
|
||||
"$110 is %.3f under x16, want 12000" % rate)
|
||||
finally:
|
||||
log = s.close()
|
||||
expect_log(log, "x16 microsteps, 56320 Hz machine tick", "x16: the 56320 Hz tick")
|
||||
|
||||
|
||||
def case_x32():
|
||||
s = Session("xy_microsteps = 32\n")
|
||||
try:
|
||||
expect_scale(s, 32, 213.333)
|
||||
finally:
|
||||
log = s.close()
|
||||
expect_log(log, "x32 microsteps, 112640 Hz machine tick", "x32: the 112640 Hz tick")
|
||||
|
||||
|
||||
def case_invalid():
|
||||
s = Session("xy_microsteps = 24\n")
|
||||
try:
|
||||
expect_scale(s, 32, 213.333)
|
||||
finally:
|
||||
log = s.close()
|
||||
expect_log(log, "xy_microsteps '24' is not 8, 16 or 32", "a value that is not a mode is refused with a warning")
|
||||
expect_log(log, "x32 microsteps, 112640 Hz machine tick", "and the machine runs at x32")
|
||||
s.close()
|
||||
|
||||
|
||||
def case_low_tick():
|
||||
@@ -211,9 +186,7 @@ def case_low_tick():
|
||||
check(near(got, 562.5, 0.05), "a typed $110=6000 comes back held at %.3f" % got,
|
||||
"a typed $110=6000 stuck at %.3f under a 1000 Hz tick" % got)
|
||||
finally:
|
||||
log = s.close()
|
||||
expect_log(log, "1000 Hz machine tick", "GFSINK_RATE took the tick to 1000 Hz")
|
||||
expect_log(log, "held at the ceiling", "the hold is logged")
|
||||
s.close()
|
||||
|
||||
|
||||
def main():
|
||||
@@ -221,8 +194,6 @@ def main():
|
||||
fail("no controller binary at %s" % BIN)
|
||||
case_default()
|
||||
case_x16()
|
||||
case_x32()
|
||||
case_invalid()
|
||||
case_low_tick()
|
||||
print("PASS xy_mode_test")
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@ class Session:
|
||||
for k, v in (conf or {}).items():
|
||||
f.write("%s = %s\n" % (k, v))
|
||||
env["GFHOME_CONF"] = conf_path
|
||||
self.dump = os.path.join(self.workdir, "stream.bin")
|
||||
env["GFSINK_DUMP"] = self.dump
|
||||
if referenced:
|
||||
with open(os.path.join(self.workdir, "lens.home"), "w") as f:
|
||||
f.write("edge 0 3 3\n")
|
||||
@@ -180,6 +182,16 @@ class Session:
|
||||
fail("unexpected reply to %r: %r" % (gcode, reply))
|
||||
return False
|
||||
|
||||
def steps(self):
|
||||
"""Step ticks shipped so far: a tick byte (bit 7 clear) with an X, Y
|
||||
or Z step bit (0x25) set, as ctlport_test.py counts them."""
|
||||
try:
|
||||
with open(self.dump, "rb") as f:
|
||||
data = f.read()
|
||||
except OSError:
|
||||
return 0
|
||||
return sum(1 for b in data if not b & 0x80 and b & 0x25)
|
||||
|
||||
def recover(self):
|
||||
self.sock.sendall(b"\x18")
|
||||
self.read(1.0)
|
||||
@@ -286,15 +298,19 @@ def homed_cases():
|
||||
m = re.search(r"MPos:(-?[\d.]+),(-?[\d.]+)", st)
|
||||
check(m and abs(float(m.group(1))) < 0.01 and abs(float(m.group(2))) < 0.01,
|
||||
"homed: X and Y sit at the origin", "homed: X/Y are not at the origin: %r" % st)
|
||||
check(s.move("G90 G0 X%.1f" % (x_travel + 5)), "homed: X past the bed alarms",
|
||||
"homed: a move past X max ran")
|
||||
s.wait_idle()
|
||||
check(s.move("G90 G0 Y%.1f" % (y_travel + 5)), "homed: Y past the bed alarms",
|
||||
"homed: a move past Y max ran")
|
||||
s.wait_idle()
|
||||
check(s.move("G90 G0 X-1"), "homed: X past the near edge alarms",
|
||||
"homed: a move past X min ran")
|
||||
s.wait_idle()
|
||||
|
||||
def refused_still(gcode, what):
|
||||
# ALARM:2 before any motion: the shipped stream gains no step.
|
||||
n0 = s.steps()
|
||||
blocked = s.move(gcode)
|
||||
n1 = s.steps()
|
||||
check(blocked and n1 == n0, "homed: %s alarms before any motion" % what,
|
||||
"homed: %s %s, %d step ticks shipped" % (what, "alarmed" if blocked else "ran", n1 - n0))
|
||||
s.wait_idle()
|
||||
|
||||
refused_still("G90 G0 X%.1f" % (x_travel + 5), "a move past X max")
|
||||
refused_still("G90 G0 Y%.1f" % (y_travel + 5), "a move past Y max")
|
||||
refused_still("G90 G0 X-1", "a move past X min")
|
||||
reply = s.send("$J=G91X%.1fF1200" % (x_travel + 5), 1.5)
|
||||
check("error:15" in reply, "homed: a jog past the bed is refused with error 15",
|
||||
"homed: a jog past the bed was not refused with error 15: %r" % reply)
|
||||
@@ -302,9 +318,13 @@ def homed_cases():
|
||||
# until an empty line (or a $ command) clears it: the sender's
|
||||
# acknowledgment at compatibility level 0.
|
||||
s.send("", 1.0)
|
||||
n0 = s.steps()
|
||||
check(not s.move("G90 G0 X10 Y10"), "homed: a move inside the bed runs",
|
||||
"homed: a move inside the bed was blocked")
|
||||
s.wait_idle()
|
||||
# The step count the refusals were judged by does see motion.
|
||||
check(s.steps() > n0, "homed: the move inside the bed shipped steps",
|
||||
"homed: the move inside the bed shipped no step: the stream dump sees no motion")
|
||||
# A settings write does not drop them: the driver re-applies the
|
||||
# mask after the core clears it.
|
||||
s.write_setting("$20=0")
|
||||
|
||||
@@ -37,6 +37,7 @@ from forgetest import manifest as manifest_mod # noqa: E402
|
||||
RECIPES = [
|
||||
# (component, recipe path relative to the repo or meta-openglow, layer)
|
||||
("forgectrl", "meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl.bb", "forgefirm"),
|
||||
("forgeext", "meta-forgefirm/recipes-forgefirm/forgeext/forgeext.bb", "forgefirm"),
|
||||
("grblhal-glowforge", "meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb", "forgefirm"),
|
||||
("forgefirm-app", "meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app.inc", "forgefirm"),
|
||||
("kernel-module-glowforge", "meta-glowforge-bsp/recipes-kernel/kernel-modules/kernel-module-glowforge.bb", "meta-openglow"),
|
||||
@@ -201,12 +202,21 @@ def main(argv=None):
|
||||
base = REPO if layer == "forgefirm" else args.meta_openglow
|
||||
path = os.path.join(base, rel)
|
||||
url, rev = parse_recipe(path)
|
||||
repo = fetch(url, rev, args.cache)
|
||||
files = []
|
||||
ls_tree(repo, rev, url, args.cache, "", files)
|
||||
files.sort()
|
||||
if set(rev) == {"0"}:
|
||||
# A pin nothing has been pushed to yet, which is how a work
|
||||
# branch carries a component until the merge. The component is
|
||||
# named with no files, so the coverage lint reports its covers
|
||||
# entries as empty and fails - which is what a pin that has not
|
||||
# been bumped should do - rather than the manifest failing to
|
||||
# build at all.
|
||||
print("%s: the pin is not set (nothing pushed to it yet): no files" % name, file=sys.stderr)
|
||||
else:
|
||||
repo = fetch(url, rev, args.cache)
|
||||
ls_tree(repo, rev, url, args.cache, "", files)
|
||||
files.sort()
|
||||
print("%s: %s (%d files)" % (name, rev[:12], len(files)), file=sys.stderr)
|
||||
components[name] = {"srcrev": rev, "source": url, "files": files, "recipes": [os.path.basename(rel)]}
|
||||
print("%s: %s (%d files)" % (name, rev[:12], len(files)), file=sys.stderr)
|
||||
for name, rel, layer, recipe in FILE_COMPONENTS:
|
||||
base = REPO if layer == "forgefirm" else args.meta_openglow
|
||||
files = dir_files(os.path.join(base, rel))
|
||||
|
||||
+11
-40
@@ -241,18 +241,14 @@ else
|
||||
fi
|
||||
|
||||
# Root policy gate. The release image ships root WITHOUT a password (the
|
||||
# serial console is the recovery path) and sshd refuses root and empty
|
||||
# passwords: PermitRootLogin no and PermitEmptyPasswords no must be
|
||||
# active (uncommented) in the built sshd_config, and root's shadow field
|
||||
# must be empty. Read the built files, not the recipes: this catches a
|
||||
# drift however it got in (recipe, local.conf, an inherited class).
|
||||
# serial console is the recovery path): root's shadow field must be empty.
|
||||
# sshd's refusal of root and of empty passwords is the rootfs lint's
|
||||
# (meta-forgefirm/recipes-forgefirm/images/rootfs-lint.sh), which fails
|
||||
# the build. Read the built file, not the recipes: this catches a drift
|
||||
# however it got in (recipe, local.conf, an inherited class).
|
||||
SSHD_CONFIG=$(debugfs -R "cat /etc/ssh/sshd_config" "$EXT4" 2>/dev/null)
|
||||
[ -n "$SSHD_CONFIG" ] \
|
||||
|| die "release rootfs carries no /etc/ssh/sshd_config"
|
||||
printf '%s\n' "$SSHD_CONFIG" | grep -Eq '^PermitRootLogin[[:space:]]+no[[:space:]]*$' \
|
||||
|| die "release sshd_config has no active 'PermitRootLogin no' (recipes-connectivity/openssh drift?)"
|
||||
printf '%s\n' "$SSHD_CONFIG" | grep -Eq '^PermitEmptyPasswords[[:space:]]+no[[:space:]]*$' \
|
||||
|| die "release sshd_config has no active 'PermitEmptyPasswords no' (recipes-connectivity/openssh drift?)"
|
||||
ROOT_SHADOW=$(debugfs -R "cat /etc/shadow" "$EXT4" 2>/dev/null \
|
||||
| awk -F: '$1=="root"{print; exit}')
|
||||
[ -n "$ROOT_SHADOW" ] \
|
||||
@@ -260,27 +256,22 @@ ROOT_SHADOW=$(debugfs -R "cat /etc/shadow" "$EXT4" 2>/dev/null \
|
||||
ROOT_PW=$(printf '%s\n' "$ROOT_SHADOW" | awk -F: '{print $2}')
|
||||
[ -z "$ROOT_PW" ] \
|
||||
|| die "release rootfs has a non-empty root password field: the policy is an empty field (empty-root-password in forgefirm-image.bb); a build drift"
|
||||
echo "root policy gate OK (root field empty; sshd refuses root and empty passwords)"
|
||||
echo "root policy gate OK (root field empty)"
|
||||
|
||||
# Read-only rootfs gate. The release rootfs mounts read-only: the root
|
||||
# line of the built fstab carries ro, the rcS default agrees (the
|
||||
# read-only-rootfs image feature), no factory-slot mount is in the
|
||||
# release fstab (those belong to the dev image), and sshd keeps its host
|
||||
# keys on /data, where the read-only rootfs cannot hold them.
|
||||
# line of the built fstab carries ro, and sshd keeps its host keys on
|
||||
# /data, where the read-only rootfs cannot hold them. rcS's
|
||||
# ROOTFS_READ_ONLY and the fstab with no factory slot are the rootfs
|
||||
# lint's.
|
||||
FSTAB=$(debugfs -R "cat /etc/fstab" "$EXT4" 2>/dev/null)
|
||||
[ -n "$FSTAB" ] \
|
||||
|| die "release rootfs carries no /etc/fstab"
|
||||
printf '%s\n' "$FSTAB" | awk '$1 == "/dev/root" && $2 == "/" { print $4 }' \
|
||||
| grep -Eq '(^|,)ro(,|$)' \
|
||||
|| die "release fstab does not mount / read-only (base-files fstab or read-only-rootfs drift?)"
|
||||
printf '%s\n' "$FSTAB" | grep -Eq '^[^#]*[[:space:]]/factory/' \
|
||||
&& die "release fstab mounts a factory slot under /factory (dev image only)"
|
||||
RCS=$(debugfs -R "cat /etc/default/rcS" "$EXT4" 2>/dev/null)
|
||||
printf '%s\n' "$RCS" | grep -q '^ROOTFS_READ_ONLY=yes$' \
|
||||
|| die "release rcS has no ROOTFS_READ_ONLY=yes (read-only-rootfs image feature drift?)"
|
||||
[ "$(printf '%s\n' "$SSHD_CONFIG" | grep -c '^HostKey /data/forgefirm/ssh/')" = 3 ] \
|
||||
|| die "release sshd_config does not keep the host keys under /data/forgefirm/ssh (recipes-connectivity/openssh drift?)"
|
||||
echo "read-only rootfs gate OK (/ ro, no /factory mounts, host keys on /data)"
|
||||
echo "read-only rootfs gate OK (/ ro, host keys on /data)"
|
||||
|
||||
# Config-level guard: debug-tweaks must not sit in the shared kas config,
|
||||
# where it would apply to every target including the release image.
|
||||
@@ -419,26 +410,6 @@ cat >> "$STAGE/notes.md" <<'EOF'
|
||||
|
||||
## Installing your own build
|
||||
|
||||
This is the Installation Information that GPLv3 section 6 asks for. The
|
||||
machine runs firmware you built yourself, and no key is needed to install it.
|
||||
|
||||
- **From the control panel.** Upload the `.fw` on the System tab. An archive
|
||||
no key on the machine verifies is reported as unsigned and installs when
|
||||
you hold the machine button while confirming it.
|
||||
- **From a root shell.** The serial console gives a root shell with no
|
||||
password, and `fwup` is stock upstream. Write the archive to the slot the
|
||||
machine is not running from and select it:
|
||||
`fwup -a -d /dev/mmcblk2p2 -i my-build.fw -t upgrade.b` then `ffboot b`
|
||||
(`upgrade.a` writes slot A, `/dev/mmcblk2p1`; `ffboot -l` says which slot
|
||||
is running).
|
||||
- **From an SD card.** Write `forgefirm-image-glowforge.rootfs.wic.gz` to a
|
||||
card and boot from it.
|
||||
|
||||
The trust anchor is replaceable: `/etc/forgefirm/keys` holds public keys only,
|
||||
as ordinary world-readable files. Put your own public key in
|
||||
`forgefirm-release.pub` and the automatic paths verify against your key
|
||||
instead; sign your builds with `fwup -S` and they install with no button held.
|
||||
|
||||
Full instructions: <https://docs.forgefirm.org/install/updating/#installing-your-own-build>.
|
||||
Building the image is documented at
|
||||
<https://docs.forgefirm.org/developers/building/>.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user