mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
152
Commits
v0.0.6
...
c40dc6c24c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c40dc6c24c | ||
|
|
08c7df9cbb | ||
|
|
61652e237d | ||
|
|
c177aabefa | ||
|
|
01f59914e8 | ||
|
|
87155be433 | ||
|
|
893602e44c | ||
|
|
205f9f22f2 | ||
|
|
7f2da68e34 | ||
|
|
0d63f8740e | ||
|
|
558889659c | ||
|
|
4c6ca8d701 | ||
|
|
f9c298a347 | ||
|
|
754c239e08 | ||
|
|
b3a40b4aea | ||
|
|
107676b77d | ||
|
|
6594f57362 | ||
|
|
40c478d56c | ||
|
|
70e13bdd88 | ||
|
|
b90299acdc | ||
|
|
30c0a05b86 | ||
|
|
cfa6557847 | ||
|
|
b5f355cb61 | ||
|
|
b447ae77f1 | ||
|
|
21046cd6ff | ||
|
|
098cfdf193 | ||
|
|
1e28eb92cd | ||
|
|
33e8c6e43f | ||
|
|
71556b2480 | ||
|
|
7172bba92b | ||
|
|
8e1fe5090d | ||
|
|
520d62ac05 | ||
|
|
8536ae7076 | ||
|
|
09cf9242a9 | ||
|
|
280ceee849 | ||
|
|
8149a44f93 | ||
|
|
c91f6daa9e | ||
|
|
f8dc21b309 | ||
|
|
a0a4edb322 | ||
|
|
67ac8f4064 | ||
|
|
0350b943d7 | ||
|
|
35add7cad0 | ||
|
|
d7379d9fc0 | ||
|
|
21f53e4e83 | ||
|
|
ef15c55577 | ||
|
|
571f702113 | ||
|
|
6a434bbf77 | ||
|
|
bef1bf9902 | ||
|
|
7246707df5 | ||
|
|
ed1322518b | ||
|
|
8003d6f18c | ||
|
|
a9c794be34 | ||
|
|
1191389a2b | ||
|
|
4c5dde2549 | ||
|
|
005ff8008b | ||
|
|
748d28686d | ||
|
|
ecbfe64cef | ||
|
|
a729197a8f | ||
|
|
ed52e752ee | ||
|
|
dfd32d5fd1 | ||
|
|
35221de791 | ||
|
|
46b6df1c2d | ||
|
|
16a8ae9ba3 | ||
|
|
72907f311a | ||
|
|
3873013383 | ||
|
|
2ea501f975 | ||
|
|
1bc15fe70a | ||
|
|
66b39c6f2b | ||
|
|
83686fc712 | ||
|
|
f47ddad839 | ||
|
|
eb9e61d3c6 | ||
|
|
55e789bc34 | ||
|
|
11031ad87f | ||
|
|
cd84786fc5 | ||
|
|
6e71ac86f0 | ||
|
|
1727b819cb | ||
|
|
b494bbecb5 | ||
|
|
b2e0f91342 | ||
|
|
a05a226305 | ||
|
|
9f7744b459 | ||
|
|
8dc7c0bc56 | ||
|
|
0eb0a16452 | ||
|
|
bfcfd7a376 | ||
|
|
d2307a899e | ||
|
|
8570c0141e | ||
|
|
800863fb09 | ||
|
|
cfef6a1b84 | ||
|
|
d40ebb090e | ||
|
|
ef2390f7d4 | ||
|
|
82869cc45c | ||
|
|
b7dd23d539 | ||
|
|
308a033146 | ||
|
|
dc9bede034 | ||
|
|
56af6eaab2 | ||
|
|
c95b5aa771 | ||
|
|
d45520c218 | ||
|
|
2870daa2aa | ||
|
|
82c354d70e | ||
|
|
853f862f03 | ||
|
|
ac2456261e | ||
|
|
8fc07c47ee | ||
|
|
69b56ec1ff | ||
|
|
99d9830991 | ||
|
|
4e47b7fbf1 | ||
|
|
675fc11cb1 | ||
|
|
71b44598eb | ||
|
|
fab8920cd9 | ||
|
|
861f5597e6 | ||
|
|
b7e7c0fb2c | ||
|
|
2f51e38178 | ||
|
|
d9cc61f198 | ||
|
|
1bdc11f6fe | ||
|
|
625d0acec7 | ||
|
|
d410c6f696 | ||
|
|
a552541acd | ||
|
|
5915408147 | ||
|
|
59887ac742 | ||
|
|
4388d69816 | ||
|
|
77e4c03369 | ||
|
|
0ba0fb112b | ||
|
|
f74382984c | ||
|
|
75ef8bbc95 | ||
|
|
2216e5d3f2 | ||
|
|
bb28c7855a | ||
|
|
447f90205d | ||
|
|
952e7dc32d | ||
|
|
eb0a1e7838 | ||
|
|
963bded9b3 | ||
|
|
9c36df9ee9 | ||
|
|
121d7d3f81 | ||
|
|
68fd35b8b7 | ||
|
|
ee89b31c07 | ||
|
|
9ee277f977 | ||
|
|
800d6890a4 | ||
|
|
1a306d5d8d | ||
|
|
1e98b37e45 | ||
|
|
2894269115 | ||
|
|
5ad7ee6faa | ||
|
|
9128f0e98e | ||
|
|
619414ed2b | ||
|
|
474bf9db09 | ||
|
|
d627ee32f1 | ||
|
|
965f7c3ca9 | ||
|
|
19ce4d78d2 | ||
|
|
ebc4222182 | ||
|
|
f0ba4b9249 | ||
|
|
888b47abe8 | ||
|
|
520bf6023d | ||
|
|
25cc42b879 | ||
|
|
8856fb0099 | ||
|
|
b29bb9e023 | ||
|
|
11f4c42705 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 @@
|
||||
{"seq":268,"ts_mono":1268.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":true,"reason":"","down_c":22.30,"up_c":22.30}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
255
|
||||
@@ -0,0 +1 @@
|
||||
idle
|
||||
@@ -0,0 +1 @@
|
||||
1023
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -0,0 +1 @@
|
||||
629
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
700
|
||||
@@ -0,0 +1 @@
|
||||
700
|
||||
@@ -0,0 +1 @@
|
||||
65535
|
||||
@@ -0,0 +1 @@
|
||||
26214
|
||||
@@ -0,0 +1 @@
|
||||
43278
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -0,0 +1 @@
|
||||
{"seq":1517,"ts_mono":3127.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":false,"reason":"HEAD CRASH SIGNAL (axes x) - motion stopped, laser locked","down_c":22.30,"up_c":22.30}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
idle
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1 @@
|
||||
204
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -0,0 +1 @@
|
||||
629
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
100
|
||||
@@ -0,0 +1 @@
|
||||
100
|
||||
@@ -0,0 +1 @@
|
||||
100
|
||||
@@ -0,0 +1 @@
|
||||
100
|
||||
@@ -0,0 +1 @@
|
||||
700
|
||||
@@ -0,0 +1 @@
|
||||
700
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -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"])],
|
||||
|
||||
@@ -75,8 +75,12 @@ class Test:
|
||||
invalidates the tests of that module. A test the module does not
|
||||
define in the @test form hashes its whole file."""
|
||||
if self._source_sha is None:
|
||||
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 +144,38 @@ def source_file_sha(path):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
_PARTS = {} # path -> (shared sha, {test id: own sha}); the files never change under a run
|
||||
_PARTS_LOCK = threading.Lock()
|
||||
# The files never change under a run, so each is read and parsed once:
|
||||
# path -> (text, tree or None), path -> (shared sha, {test id: own sha}),
|
||||
# path -> the sibling modules it imports itself.
|
||||
_PARSED = {}
|
||||
_PARTS = {}
|
||||
_SIBLINGS = {}
|
||||
_PARTS_LOCK = threading.RLock()
|
||||
_IMPL_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def forget(path):
|
||||
"""Drop what is known about a module: its file changed (the unit tests
|
||||
edit their modules between readings)."""
|
||||
with _PARTS_LOCK:
|
||||
for cache in (_PARSED, _PARTS, _SIBLINGS):
|
||||
cache.pop(path, None)
|
||||
|
||||
|
||||
def _parsed(path):
|
||||
"""(text, tree) of a suite module, line endings normalized; the tree
|
||||
is None when the file does not parse."""
|
||||
with _PARTS_LOCK:
|
||||
hit = _PARSED.get(path)
|
||||
if hit is None:
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
tree = None
|
||||
hit = _PARSED[path] = (text, tree)
|
||||
return hit
|
||||
|
||||
|
||||
def module_parts(path):
|
||||
@@ -154,14 +188,9 @@ def module_parts(path):
|
||||
hit = _PARTS.get(path)
|
||||
if hit is not None:
|
||||
return hit
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
||||
text, tree = _parsed(path)
|
||||
lines = text.split("\n")
|
||||
spans = {}
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
tree = None
|
||||
for node in (tree.body if tree is not None else []):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
@@ -188,22 +217,30 @@ def sibling_imports(path, seen=None):
|
||||
test's fingerprint."""
|
||||
if seen is None:
|
||||
seen = []
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
return seen
|
||||
here = os.path.dirname(path)
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
|
||||
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
|
||||
if os.path.isfile(sib) and sib not in seen and sib != path:
|
||||
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 _direct_siblings(path):
|
||||
"""The suite modules one module imports itself, in import order."""
|
||||
with _PARTS_LOCK:
|
||||
hit = _SIBLINGS.get(path)
|
||||
if hit is None:
|
||||
_text, tree = _parsed(path)
|
||||
here = os.path.dirname(path)
|
||||
hit = []
|
||||
for node in (tree.body if tree is not None else []):
|
||||
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
|
||||
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
|
||||
if os.path.isfile(sib):
|
||||
hit.append(sib)
|
||||
_SIBLINGS[path] = hit
|
||||
return hit
|
||||
|
||||
|
||||
def implementation_sha(path, test_id):
|
||||
"""The implementation hash of one test (see Test.source_sha): the
|
||||
test's own text, the shared text of its module, and the shared text
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -297,11 +297,11 @@ class Fixture:
|
||||
return bool(st and st.get("button_enabled"))
|
||||
return True
|
||||
|
||||
def act(self, channel, state):
|
||||
def act(self, channel, state, ms=None):
|
||||
if channel == "button":
|
||||
if state != "press":
|
||||
raise FixtureError("the button is only ever pressed")
|
||||
st, body = self._press()
|
||||
st, body = self._press(ms)
|
||||
else:
|
||||
if state not in ("open", "close"):
|
||||
raise FixtureError("%s: unknown state %r" % (channel, state))
|
||||
@@ -312,8 +312,11 @@ class Fixture:
|
||||
self.last_state = body
|
||||
return body
|
||||
|
||||
def _press(self):
|
||||
"""One press, spaced from the last one. The controller detects
|
||||
def _press(self, ms=None):
|
||||
"""One press, spaced from the last one. `ms` is how long the box
|
||||
holds it (the firmware clamps it into 20 to 500, and takes 200 when
|
||||
it is not said): a check that must reach the machine *while* the
|
||||
button is down asks for the longest press the box can make. The controller detects
|
||||
the button's rising edge, so a press must follow a release it has
|
||||
seen: the next press waits for the last pulse to end (the
|
||||
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
|
||||
@@ -322,7 +325,7 @@ class Fixture:
|
||||
wait = self._pulse_end + BUTTON_GAP_S - time.time()
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
st, body = self._request("POST", "/button", {})
|
||||
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
|
||||
if st == 409 and "in progress" in str(body.get("error", "")):
|
||||
deadline = time.time() + PULSE_WAIT_S
|
||||
while time.time() < deadline:
|
||||
@@ -331,7 +334,7 @@ class Fixture:
|
||||
if s == 200 and not b.get("button_pulsing"):
|
||||
break
|
||||
time.sleep(BUTTON_GAP_S)
|
||||
st, body = self._request("POST", "/button", {})
|
||||
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
|
||||
if st == 200:
|
||||
self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0
|
||||
return st, body
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -19,6 +19,7 @@ import functools
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
|
||||
DEFAULT_PATH = "/etc/forgefirm-manifest.json"
|
||||
@@ -93,10 +94,136 @@ NON_BEHAVIORAL = [
|
||||
("*", ".vscode/**"),
|
||||
("*", ".env.example"),
|
||||
("forgectrl", "tools/**"), # host-side dev tools (panel dev server)
|
||||
("forgectrl", "examples/**"), # clients of the remote API, run on another computer
|
||||
# forgeext's recipe installs the binary and its init script, and nothing of these: the author's kit
|
||||
# and the host tools run off the image (OpenGlow's own packages have repositories of their own).
|
||||
("forgeext", "sdk/**"),
|
||||
("forgeext", "template/**"),
|
||||
("forgeext", "tools/**"),
|
||||
]
|
||||
|
||||
|
||||
# Paths the list above would take out that are behavior all the same: forgectrl embeds its advisory
|
||||
# documents in the binary, serves them, and records the operator's consent to a document by its hash, so
|
||||
# an edited advisory is a changed consent and must move every fingerprint that covers it.
|
||||
BEHAVIORAL = [
|
||||
("forgectrl", "docs/advisories/**"),
|
||||
]
|
||||
|
||||
|
||||
# Paths a host test in CI proves, which no catalog test covers. The catalog
|
||||
# is for what needs the machine or an operator; software whose inputs can be
|
||||
# emulated is proven on a host. A row is (component, glob, host test, CI
|
||||
# workflow), the last two named as "<repository>:<path>": a component of
|
||||
# the manifest (its tests/ and .github/ files are listed there), or
|
||||
# "forgefirm" for this repository (the null-sink harnesses under
|
||||
# scripts/bench/, which grblhal-glowforge's workflow runs). The workflow
|
||||
# must run the host test: name it, or discover it (a glob, unittest
|
||||
# discover, pytest on its directory). A host-tested path counts as covered
|
||||
# in the coverage lint, and like every path no test covers it is in no
|
||||
# fingerprint: a change there makes no acceptance run necessary. A path a
|
||||
# catalog test exercises on the hardware belongs in that test's covers, not
|
||||
# here.
|
||||
HOST_TESTED = [
|
||||
# An extension package's holds as the engine reads them (forgeext's
|
||||
# holdkeep_test and run_test prove the writer's side).
|
||||
("forgectrl", "src/holds.*", "forgectrl:tests/holds_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The M-code relay to the package that answers (the controller's side is
|
||||
# the null-sink mcode_test harness, under grblhal-glowforge's src/**).
|
||||
("forgectrl", "src/mcode.*", "forgectrl:tests/mcode_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The digest primitives (SHA-256, HMAC, the constant-time compare) that
|
||||
# the advisories' hashes, the tokens and the camera key are made with.
|
||||
("forgectrl", "src/sha256.*", "forgectrl:tests/sha256_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The built-in extensions' table: the cloud entry, its refusals while it
|
||||
# is off, the sweep when it goes off, the list.
|
||||
("forgectrl", "src/builtin.*", "forgectrl:tests/builtin_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The setup record as a printable page: no script, the sheet id, a
|
||||
# heading for each completed step, everything escaped.
|
||||
("forgectrl", "src/recordhtml.*", "forgectrl:tests/recordhtml_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# An extension package's claim on the Grbl sender as the machine keeps
|
||||
# it: the claim and its refusals, its end by the package, the operator
|
||||
# or a stale keeper (forgeext's run_test proves the writer's side).
|
||||
("forgectrl", "src/senderout.*", "forgectrl:tests/senderout_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# A package's check on the Setup page: each step held to its form, the
|
||||
# check run through the host, the result the package's own (the routes
|
||||
# against a live host's stand-in are tests/host_ext.sh).
|
||||
("forgectrl", "src/wizpkg.*", "forgectrl:tests/wizpkg_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The camera key: made, kept, checked and rotated (the reads it opens
|
||||
# from the LAN with the reads closed are tests/host_lan.sh).
|
||||
("forgectrl", "src/camkey.*", "forgectrl:tests/camkey_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The log export's sanitizer: every identifying class taken out, the
|
||||
# ordinary log text left as it was (the export's routes and the
|
||||
# installer's directory on a host are tests/host_guard.sh).
|
||||
("forgectrl", "src/sanitize.*", "forgectrl:tests/sanitize_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The package routes' relay to the extension host: the argument vector,
|
||||
# the closed list of actions, the host's words, the catalog's fetch (the
|
||||
# routes against a live host's stand-in are tests/host_ext.sh).
|
||||
("forgectrl", "src/extpkg.*", "forgectrl:tests/extpkg_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The event stream: a button edge, a lease changing hands, telemetry
|
||||
# (the live order, the take before the free, is tests/host_lease.sh).
|
||||
("forgectrl", "src/events.*", "forgectrl:tests/events_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The crumb tray's mode as the daemon keeps it: its offset and what it
|
||||
# refuses (the route and the panel's switch are tests/host_routes.sh).
|
||||
("forgectrl", "src/tray.*", "forgectrl:tests/tray_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The product gate on the firmware paths: firmware only, an extension
|
||||
# package refused in words that say what it is (the upload route over
|
||||
# a stand-in fwup is tests/host_update.sh).
|
||||
("forgectrl", "src/fwproduct.*", "forgectrl:tests/fwproduct_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The controller port's client and its two operation sets (the motion
|
||||
# routes and a package's jog through the host's credential are
|
||||
# tests/host_routes.sh).
|
||||
("forgectrl", "src/grblport.*", "forgectrl:tests/grblport_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The panel page as the daemon serves it: bundled, the theme and the
|
||||
# save bar in it, and its policy frame-src 'none'.
|
||||
("forgectrl", "src/ui.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
# The data and run directories from the environment: the SSH flag in the
|
||||
# run directory, written and removed by POST /system/ssh.
|
||||
("forgectrl", "src/paths.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
# The first run in the page's order: the four documents at their hashes,
|
||||
# the press step breathing teal, the press taken as the acceptance, the
|
||||
# form steps through main.c's hooks (extensions.md stays with
|
||||
# exthost.service, which covers it).
|
||||
("forgectrl", "src/advisories.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "docs/advisories/safety-and-risk.md", "forgectrl:tests/host_setup.sh",
|
||||
"forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "docs/advisories/licenses.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "docs/advisories/privacy.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "docs/advisories/cloud-service.md", "forgectrl:tests/host_setup.sh",
|
||||
"forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "src/button.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "src/led.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "src/hooks.h", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
# The setup sheet id from the fuse serial, against golden ids.
|
||||
("forgectrl", "src/sheetid.*", "forgectrl:tests/sheetid_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The logging path: the image's rsyslog.conf and the rendered rules, each
|
||||
# logger in its own file at its level, a stray tag in system/, the kern
|
||||
# facility in kernel/.
|
||||
("forgectrl", "src/fflog.*", "forgectrl:tests/fflog_e2e.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
# POST /job's multipart reader: the form's parts reach the runner's
|
||||
# start; a missing name, a $ line and a malformed boundary are refused
|
||||
# with nothing started.
|
||||
("forgectrl", "src/jobpost.*", "forgectrl:tests/jobpost_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The scoped tokens: the closed capability list, the judgment, the
|
||||
# store that holds only hashes, and the extension host's credential
|
||||
# (mode 0600, exactly the host's capabilities).
|
||||
("forgectrl", "src/tokens.*", "forgectrl:tests/tokens_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
]
|
||||
|
||||
# This repository's root, when the package runs from a checkout of it; a
|
||||
# "forgefirm:" file is looked for there. Installed on an image, the package
|
||||
# is not in a checkout, and such a reference is not checked.
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
REPO_ROOT = _ROOT if os.path.isdir(os.path.join(_ROOT, "meta-forgefirm")) else None
|
||||
# The pinned sources scripts/manifest-from-tree.py fetches (one bare repo
|
||||
# per source URL); a component's workflow is read from there. Without them
|
||||
# (on an image) whether a workflow runs a host test is not checked.
|
||||
SOURCES = os.path.join(REPO_ROOT, ".manifest-cache") if REPO_ROOT else None
|
||||
|
||||
|
||||
def non_behavioral(comp, path, allow=NON_BEHAVIORAL):
|
||||
for c, pat in BEHAVIORAL:
|
||||
if c == comp and glob_to_regex(pat).match(path):
|
||||
return False
|
||||
for c, pat in allow:
|
||||
if c in ("*", comp) and glob_to_regex(pat).match(path):
|
||||
return True
|
||||
@@ -199,17 +326,332 @@ def empty_covers(manifest, tests):
|
||||
return out
|
||||
|
||||
|
||||
def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
|
||||
"""Which manifest paths no test covers.
|
||||
def _ref_problem(manifest, ref, what, root):
|
||||
"""Why a "<repository>:<path>" reference names no file, or None. A
|
||||
component's file must be in the manifest; a "forgefirm:" one must exist
|
||||
under `root`, and is not checked without one."""
|
||||
repo, sep, path = ref.partition(":")
|
||||
if not sep or not path:
|
||||
return "names no repository:path for its %s" % what
|
||||
if repo == "forgefirm":
|
||||
if root is not None and not os.path.isfile(os.path.join(root, path)):
|
||||
return "no such %s" % what
|
||||
return None
|
||||
if repo in DEV_ONLY_COMPONENTS or manifest.files(repo) is None:
|
||||
return "no repository %r for its %s" % (repo, what)
|
||||
if path not in {p for p, _b in manifest.files(repo)}:
|
||||
return "no such %s" % what
|
||||
return None
|
||||
|
||||
|
||||
def read_source(manifest, ref, root=REPO_ROOT, sources=SOURCES):
|
||||
"""(text, why) for a "<repository>:<path>" file: a "forgefirm:" file
|
||||
from `root`, a component's from its pinned blob in the bare repo
|
||||
`sources` holds for its source URL. (None, None) when it cannot be read
|
||||
here (no root, no sources); (None, why) when it should have been and
|
||||
was not, which is a defect, never a pass."""
|
||||
import subprocess
|
||||
repo, _sep, path = ref.partition(":")
|
||||
if repo == "forgefirm":
|
||||
if root is None:
|
||||
return None, None
|
||||
try:
|
||||
with open(os.path.join(root, path), encoding="utf-8") as f:
|
||||
return f.read(), None
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
return None, "cannot be read: %s" % e
|
||||
if not sources:
|
||||
return None, None
|
||||
comp = manifest.components.get(repo) or {}
|
||||
blob = dict(manifest.files(repo) or []).get(path)
|
||||
url = comp.get("source")
|
||||
if not (blob and url):
|
||||
return None, "cannot be read: no blob or source URL in the manifest"
|
||||
cache = os.path.join(sources, hashlib.sha1(url.encode()).hexdigest()[:16])
|
||||
try:
|
||||
return subprocess.run(["git", "cat-file", "-p", blob], cwd=cache, capture_output=True,
|
||||
check=True).stdout.decode("utf-8", "replace"), None
|
||||
except (OSError, subprocess.CalledProcessError) as e:
|
||||
return None, "cannot be read from %s: %s" % (cache, e)
|
||||
|
||||
|
||||
def repo_name(manifest, repo):
|
||||
"""A repository's GitHub name: its source URL's last part, or this
|
||||
repository's."""
|
||||
if repo == "forgefirm":
|
||||
return "forgefirm"
|
||||
url = ((manifest.components.get(repo) or {}).get("source") or "").rstrip("/")
|
||||
name = url.rsplit("/", 1)[-1]
|
||||
return name[:-4] if name.endswith(".git") else name
|
||||
|
||||
|
||||
_STEP_KEYS = ("name", "uses", "run", "id", "if", "with", "env", "working-directory", "shell",
|
||||
"continue-on-error", "timeout-minutes")
|
||||
_STEP_RX = re.compile(r"^(\s*)-\s+(%s)\s*:" % "|".join(re.escape(k) for k in _STEP_KEYS))
|
||||
_KEY_RX = re.compile(r"^(\s*)([\w-]+)\s*:\s*(.*)$")
|
||||
|
||||
|
||||
def _indent(line):
|
||||
return len(line) - len(line.lstrip(" "))
|
||||
|
||||
|
||||
def workflow_steps(text):
|
||||
"""A workflow's steps, each a dict of its keys: "uses", "run" (the
|
||||
command text, a block's lines joined), "working-directory", and "with"
|
||||
(a dict). YAML read by its indentation, as far as these workflows use
|
||||
it; comments, whole lines and from ' #' to the end of a line, are not
|
||||
read. Anything outside a step (a trigger's paths, a job's name) is not
|
||||
a step."""
|
||||
lines = []
|
||||
for ln in text.splitlines():
|
||||
if ln.lstrip().startswith("#"):
|
||||
continue
|
||||
lines.append(re.sub(r"\s+#.*$", "", ln).rstrip())
|
||||
steps, i = [], 0
|
||||
while i < len(lines):
|
||||
m = _STEP_RX.match(lines[i])
|
||||
if not m:
|
||||
i += 1
|
||||
continue
|
||||
dash = len(m.group(1))
|
||||
body = [" " * (dash + 2) + lines[i][dash + 1:].lstrip()]
|
||||
i += 1
|
||||
while i < len(lines) and (not lines[i].strip() or _indent(lines[i]) > dash):
|
||||
body.append(lines[i])
|
||||
i += 1
|
||||
step, k = {}, 0
|
||||
key_col = _indent(body[0])
|
||||
while k < len(body):
|
||||
km = _KEY_RX.match(body[k])
|
||||
if not km or _indent(body[k]) != key_col:
|
||||
k += 1
|
||||
continue
|
||||
key, val = km.group(2), km.group(3).strip()
|
||||
if len(val) > 1 and val[0] == val[-1] and val[0] in "'\"":
|
||||
val = val[1:-1] # a quoted scalar, not a quote inside a command
|
||||
k += 1
|
||||
child = []
|
||||
while k < len(body) and (not body[k].strip() or _indent(body[k]) > key_col):
|
||||
child.append(body[k])
|
||||
k += 1
|
||||
if key == "with":
|
||||
step["with"] = {c.group(2): c.group(3).strip().strip("'\"")
|
||||
for c in (_KEY_RX.match(x) for x in child) if c}
|
||||
elif key == "run":
|
||||
step["run"] = "\n".join(x.strip() for x in child) if val[:1] in ("|", ">") else val
|
||||
else:
|
||||
step[key] = val
|
||||
steps.append(step)
|
||||
return steps
|
||||
|
||||
|
||||
def checkout_path(steps, name, own):
|
||||
"""Where a workflow checks the repository `name` out (its path, "" for
|
||||
the workspace), or None when it does not: its own repository is the
|
||||
checkout that names no repository, another one the checkout whose
|
||||
repository is <owner>/<name>."""
|
||||
for s in steps:
|
||||
if not s.get("uses", "").startswith("actions/checkout"):
|
||||
continue
|
||||
w = s.get("with", {})
|
||||
repo = w.get("repository")
|
||||
if (repo is None and own) or (repo is not None and repo.rsplit("/", 1)[-1].lower() == name.lower()):
|
||||
return posixpath.normpath(w.get("path") or ".")
|
||||
return None
|
||||
|
||||
|
||||
_SPLIT_RX = re.compile(r"&&|\|\||[;|\n]|(?:^|(?<=\s))(?:then|do|else)(?=\s|$)")
|
||||
_PREFIXES = ("sudo", "env", "time", "exec", "command", "nice")
|
||||
_INTERPRETER_RX = re.compile(r"^(?:python[0-9.]*|bash|sh)$")
|
||||
|
||||
|
||||
def _commands(run):
|
||||
"""The simple commands of a step's run text, each as its words, with
|
||||
what only starts the real command taken off: sudo and its flags, env,
|
||||
time, exec, and variable assignments."""
|
||||
import shlex
|
||||
out = []
|
||||
for part in _SPLIT_RX.split(run.replace("\\\n", " ")):
|
||||
try:
|
||||
words = shlex.split(part)
|
||||
except ValueError:
|
||||
words = part.split()
|
||||
while words:
|
||||
if words[0] in _PREFIXES:
|
||||
words = words[1:]
|
||||
while words and words[0].startswith("-"):
|
||||
words = words[1:]
|
||||
elif re.match(r"^[A-Za-z_]\w*=", words[0]):
|
||||
words = words[1:]
|
||||
else:
|
||||
break
|
||||
if words:
|
||||
out.append(words)
|
||||
return out
|
||||
|
||||
|
||||
def _segments_match(path, pattern):
|
||||
"""A path against a path or a glob, one segment against one segment
|
||||
('*' never crosses a '/')."""
|
||||
import fnmatch
|
||||
a, b = path.split("/"), pattern.split("/")
|
||||
return len(a) == len(b) and all(fnmatch.fnmatchcase(x, y) for x, y in zip(a, b))
|
||||
|
||||
|
||||
def _discovers(args, full, at):
|
||||
"""Whether `python -m unittest discover ARGS` finds the test: its
|
||||
directory is the start directory (-s, default the working directory)
|
||||
and its file name matches the pattern (-p, default test*.py)."""
|
||||
import fnmatch
|
||||
start, pattern = ".", "test*.py"
|
||||
for i, a in enumerate(args):
|
||||
if a in ("-s", "--start-directory") and i + 1 < len(args):
|
||||
start = args[i + 1]
|
||||
elif a in ("-p", "--pattern") and i + 1 < len(args):
|
||||
pattern = args[i + 1]
|
||||
return posixpath.dirname(full) == at(start) and fnmatch.fnmatchcase(posixpath.basename(full), pattern)
|
||||
|
||||
|
||||
def _unittest_names(args, full, at):
|
||||
"""Whether `python -m unittest ARGS` (not discover) runs the test: an
|
||||
argument is its file (tests/test_x.py), or a dotted name whose module
|
||||
is its file (tests.test_x, tests.test_x.Case.test_one)."""
|
||||
skip = False
|
||||
for a in args:
|
||||
if skip:
|
||||
skip = False
|
||||
continue
|
||||
if a in ("-k",):
|
||||
skip = True
|
||||
continue
|
||||
if a.startswith("-") or "$" in a:
|
||||
continue
|
||||
if a.endswith(".py"):
|
||||
if at(a) == full:
|
||||
return True
|
||||
continue
|
||||
parts = a.split(".")
|
||||
if any(at("/".join(parts[:k]) + ".py") == full for k in range(len(parts), 0, -1)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _pytest_runs(args, full, at):
|
||||
"""Whether pytest ARGS collects the test: named, or its directory given
|
||||
and its file name one pytest collects (test_*.py, *_test.py)."""
|
||||
import fnmatch
|
||||
name = posixpath.basename(full)
|
||||
collected = fnmatch.fnmatchcase(name, "test_*.py") or fnmatch.fnmatchcase(name, "*_test.py")
|
||||
for a in args:
|
||||
if a.startswith("-") or "$" in a:
|
||||
continue
|
||||
if at(a) == full or (collected and at(a) == posixpath.dirname(full)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def workflow_runs(steps, test_path):
|
||||
"""Whether a step of the workflow runs the test at test_path (its path
|
||||
in the workspace: the checkout's path joined to the test's path in its
|
||||
repository). Only what a step's run commands execute counts, each path
|
||||
resolved against the step's working-directory: the command itself
|
||||
(a script run directly, or a C test's built binary, <dir>/<stem>);
|
||||
the script an interpreter runs (python, bash, sh), a glob matching the
|
||||
whole path; a for loop's list over the test; python -m unittest given
|
||||
the test's file or a dotted name whose module is that file
|
||||
(tests.test_x, tests.test_x.Case.test_one); unittest discover from the
|
||||
test's own directory with a pattern its name matches; pytest on the
|
||||
test or its own directory. An argument to anything else (echo, a build,
|
||||
an install) does not run the test."""
|
||||
full = posixpath.normpath(test_path)
|
||||
stem, ext = posixpath.splitext(posixpath.basename(full))
|
||||
compiled = ext in (".c", ".cc", ".cpp")
|
||||
for s in steps:
|
||||
run = s.get("run")
|
||||
if not run:
|
||||
continue
|
||||
wd = s.get("working-directory") or "."
|
||||
|
||||
def at(p):
|
||||
return posixpath.normpath(posixpath.join(wd, p))
|
||||
|
||||
def names(p):
|
||||
return "$" not in p and _segments_match(full, at(p))
|
||||
for words in _commands(run):
|
||||
cmd, args = words[0], words[1:]
|
||||
if cmd == "for" and "in" in args:
|
||||
if any(names(p) for p in args[args.index("in") + 1:]):
|
||||
return True
|
||||
elif _INTERPRETER_RX.match(posixpath.basename(cmd)):
|
||||
if "-m" in args:
|
||||
i = args.index("-m")
|
||||
mod, rest = (args[i + 1], args[i + 2:]) if i + 1 < len(args) else ("", [])
|
||||
if mod == "unittest" and rest[:1] == ["discover"]:
|
||||
if _discovers(rest[1:], full, at):
|
||||
return True
|
||||
elif (mod == "unittest" and _unittest_names(rest, full, at)) or \
|
||||
(mod == "pytest" and _pytest_runs(rest, full, at)):
|
||||
return True
|
||||
else:
|
||||
script = next((a for a in args if not a.startswith("-")), None)
|
||||
if script and names(script):
|
||||
return True
|
||||
elif posixpath.basename(cmd) == "pytest":
|
||||
if _pytest_runs(args, full, at):
|
||||
return True
|
||||
elif names(cmd) or (compiled and "/" in cmd and posixpath.basename(cmd) == stem):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def host_row_problems(manifest, host=HOST_TESTED, root=REPO_ROOT, sources=SOURCES):
|
||||
"""HOST_TESTED rows that prove nothing: a glob that selects no
|
||||
behavioral file of its component (the same defect as an empty covers
|
||||
entry), a host test or a workflow that does not exist, a workflow that
|
||||
cannot be read where it should be, one that does not check out the
|
||||
test's repository, or one with no step that runs the test. Whether a
|
||||
workflow runs its test is not checked where it cannot be read (see
|
||||
read_source). Returns [(component, glob, host test, why)]."""
|
||||
out = []
|
||||
for comp, pat, ref, ci in host:
|
||||
files = manifest.files(comp)
|
||||
if files is None or not [p for p, _b in match_files(files, pat) if not non_behavioral(comp, p)]:
|
||||
out.append((comp, pat, ref, "selects nothing"))
|
||||
why = _ref_problem(manifest, ref, "host test", root) or _ref_problem(manifest, ci, "workflow", root)
|
||||
if why:
|
||||
out.append((comp, pat, ref, why))
|
||||
continue
|
||||
text, why = read_source(manifest, ci, root, sources)
|
||||
if why:
|
||||
out.append((comp, pat, ref, "%s %s" % (ci, why)))
|
||||
continue
|
||||
if text is None:
|
||||
continue
|
||||
steps = workflow_steps(text)
|
||||
test_repo, _sep, test_path = ref.partition(":")
|
||||
prefix = checkout_path(steps, repo_name(manifest, test_repo), test_repo == ci.partition(":")[0])
|
||||
if prefix is None:
|
||||
out.append((comp, pat, ref, "%s does not check out %s" % (ci, test_repo)))
|
||||
elif not workflow_runs(steps, posixpath.join(prefix, test_path)):
|
||||
out.append((comp, pat, ref, "%s does not run it" % ci))
|
||||
return out
|
||||
|
||||
|
||||
def coverage_report(manifest, tests, allow=NON_BEHAVIORAL, host=HOST_TESTED):
|
||||
"""Which manifest paths neither a test nor a host test covers.
|
||||
|
||||
tests: iterable with .covers. allow: iterable of (component, glob)
|
||||
that need no coverage (docs, CI, licenses...). Returns
|
||||
that need no coverage (docs, CI, licenses...); a BEHAVIORAL path is
|
||||
never allowed away, as it is never taken out of a fingerprint. host:
|
||||
the HOST_TESTED rows, whose paths count as covered. Returns
|
||||
{component: [uncovered paths]} for the non-dev-only components.
|
||||
"""
|
||||
covered = {}
|
||||
for t in tests:
|
||||
for comp, pat in t.covers:
|
||||
covered.setdefault(comp, []).append(glob_to_regex(pat))
|
||||
for comp, pat, _ref, _ci in host:
|
||||
covered.setdefault(comp, []).append(glob_to_regex(pat))
|
||||
allowed = {}
|
||||
for comp, pat in allow:
|
||||
allowed.setdefault(comp, []).append(glob_to_regex(pat))
|
||||
@@ -217,11 +659,14 @@ def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
|
||||
for comp in manifest.component_names():
|
||||
if comp in DEV_ONLY_COMPONENTS:
|
||||
continue
|
||||
rxs = covered.get(comp, []) + allowed.get(comp, [])
|
||||
star = allowed.get("*", [])
|
||||
rxs = covered.get(comp, [])
|
||||
free = allowed.get(comp, []) + allowed.get("*", [])
|
||||
behavior = [glob_to_regex(pat) for c, pat in BEHAVIORAL if c == comp]
|
||||
missing = []
|
||||
for p, _b in manifest.files(comp):
|
||||
if any(rx.match(p) for rx in rxs) or any(rx.match(p) for rx in star):
|
||||
if any(rx.match(p) for rx in rxs):
|
||||
continue
|
||||
if any(rx.match(p) for rx in free) and not any(rx.match(p) for rx in behavior):
|
||||
continue
|
||||
missing.append(p)
|
||||
if missing:
|
||||
|
||||
+220
-10
@@ -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:
|
||||
@@ -461,6 +477,7 @@ class Context:
|
||||
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
|
||||
"gone - asking the operator for the arm press")
|
||||
self.notice(text)
|
||||
self._clear_when_taken()
|
||||
return False
|
||||
try:
|
||||
fixture.act("button", "press")
|
||||
@@ -473,6 +490,36 @@ class Context:
|
||||
self.log("ARM press by the fixture, at the machine's own press prompt")
|
||||
return True
|
||||
|
||||
def _clear_when_taken(self, lit_timeout=ARM_LIT_WAIT_S):
|
||||
"""Take the arm notice down the moment the press is taken: the
|
||||
controller lights the button white for its arm wait and puts it out
|
||||
at the press. Without this the notice stood until the test's next
|
||||
step, and an operator who still saw it pressed again, which pauses
|
||||
the job. A notice replaced meanwhile is left alone."""
|
||||
with self.run._lock:
|
||||
nid = (self.run.notice or {}).get("id")
|
||||
if nid is None:
|
||||
return
|
||||
|
||||
def watch():
|
||||
t0 = time.time()
|
||||
while hw.button_lit() is not True:
|
||||
if time.time() - t0 > lit_timeout:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
while hw.button_lit() is not False:
|
||||
if time.time() - t0 > lit_timeout + ARM_PRESS_WAIT_S:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
with self.run._lock:
|
||||
same = (self.run.notice or {}).get("id") == nid
|
||||
if same:
|
||||
self.run.notice = None
|
||||
if same:
|
||||
self.log("ARM: the button went out at the press; the notice is down")
|
||||
|
||||
threading.Thread(target=watch, daemon=True, name="forgetest-arm-notice").start()
|
||||
|
||||
def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.",
|
||||
lit_timeout=60):
|
||||
"""The arm cue of a live test. A person's press by default: a
|
||||
@@ -499,6 +546,7 @@ class Context:
|
||||
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
|
||||
"gone - asking the operator for the arm press")
|
||||
self.notice(text)
|
||||
self._clear_when_taken()
|
||||
return False
|
||||
self.log("ARM: the fixture presses when the button lights (the bench's arm_press opt-in)")
|
||||
|
||||
@@ -539,7 +587,7 @@ class Context:
|
||||
return hw.Grbl()
|
||||
|
||||
def takeover(self):
|
||||
return Takeover(self.run.log, self.test.id)
|
||||
return Takeover(self.run.log, self.test.id, run=self.run)
|
||||
|
||||
def counters_rezeroed(self):
|
||||
"""Tell the baseline the kernel position counters were re-zeroed
|
||||
@@ -549,6 +597,10 @@ class Context:
|
||||
cap = self.run.baseline_captured
|
||||
if cap and cap.get("position") is not None:
|
||||
cap["position"] = [0, 0, 0]
|
||||
cap["rezero_declared"] = True # the test vouches for the new frame
|
||||
# ... and it is the frame now in force, so a takeover later in
|
||||
# the run can still judge where the head stands against it
|
||||
cap["frame"] = _baseline.counter_frame()
|
||||
self.log("position counters re-zeroed at the starting position; the baseline "
|
||||
"expects (0,0,0) at the end")
|
||||
|
||||
@@ -578,11 +630,16 @@ class Takeover:
|
||||
"cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
|
||||
"pic/x_step_current", "pic/y_step_current")
|
||||
|
||||
def __init__(self, log, who):
|
||||
def __init__(self, log, who, run=None):
|
||||
self.log = log # callable(str)
|
||||
self.who = who
|
||||
self.marker = marker_path()
|
||||
self.saved = {}
|
||||
self.run = run # the test's run, whose baseline the head is judged against
|
||||
self.cloud = False
|
||||
self.offline = False # the cloud client found is the offline service
|
||||
self.frame = None # the counters' frame, read before the controller is stopped
|
||||
self.found = None # (counters, microstep mode) once the controller is gone
|
||||
|
||||
def wait_settled(self):
|
||||
return _baseline.Baseline(self.log).wait_settled()
|
||||
@@ -609,6 +666,18 @@ class Takeover:
|
||||
self.saved[attr] = v
|
||||
if self.saved:
|
||||
log("takeover: preserving %s" % ", ".join("%s=%s" % kv for kv in self.saved.items()))
|
||||
try:
|
||||
st, mode = hw.Forgectrl().get("/mode")
|
||||
self.cloud = st == 200 and isinstance(mode, dict) and mode.get("mode") == "cloud"
|
||||
self.offline = self.cloud and hw.listens_on(mode.get("pid"), _baseline.OFFLINE_SOCKET)
|
||||
except hw.HwError:
|
||||
pass
|
||||
if self.offline:
|
||||
log("takeover: the cloud client is the offline service (pid %s); forgectrl's start "
|
||||
"brings it back offline" % mode.get("pid"))
|
||||
# The frame is read while the controller runs: its exit takes the
|
||||
# anchor with it (forgectrl unlinks it), though not the counters.
|
||||
self.frame = _baseline.counter_frame()
|
||||
log("takeover: stopping the controller through forgectrl")
|
||||
try:
|
||||
st, body = hw.Forgectrl().post("/controller/stop")
|
||||
@@ -631,10 +700,146 @@ class Takeover:
|
||||
self.__exit__(None, None, None)
|
||||
raise Failed("takeover: processes still alive after stop: %s" % left)
|
||||
log("takeover: pulse device free")
|
||||
try:
|
||||
self.position_at_start()
|
||||
except Exception as e: # noqa: BLE001 - a reading never keeps forgectrl down
|
||||
log("takeover: WARNING the head's position could not be judged: %s: %s" % (type(e).__name__, e))
|
||||
return self
|
||||
|
||||
# A controller start re-zeroes the step counters where the head then
|
||||
# stands, and forgectrl's start after a takeover is one, so the
|
||||
# baseline's check after the run cannot see a head left out before it.
|
||||
# The takeover judges it at the two moments it can: as the controller
|
||||
# goes away (against where the run expects the head) and before it
|
||||
# comes back (against where the takeover found it). A miss is recorded
|
||||
# for the baseline's post pass, which fails the run; nothing is moved,
|
||||
# because a jog sized from these counters is only as good as the frame
|
||||
# they are in. Cloud mode is not judged: its counters are the cloud
|
||||
# client's, as in the baseline.
|
||||
|
||||
def _record(self, where, found, expected):
|
||||
cap = self.run.baseline_captured if self.run is not None else None
|
||||
if cap is not None:
|
||||
cap.setdefault("restart_positions", []).append(
|
||||
{"where": where, "found": found, "expected": expected})
|
||||
self.log("takeover: the head is not where it belongs at the %s: counters %s, expected %s; "
|
||||
"recorded as a leftover, nothing moved" % (where, found, expected))
|
||||
|
||||
def position_at_start(self):
|
||||
cap = self.run.baseline_captured if self.run is not None else None
|
||||
if not cap or self.cloud:
|
||||
return
|
||||
now = _baseline.read_position()
|
||||
self.found = (now, hw.sysfs_read("cnc/x_mode"))
|
||||
was = cap.get("position")
|
||||
if now is None or was is None:
|
||||
return
|
||||
if self.frame is None or cap.get("frame") != self.frame:
|
||||
self.log("takeover: the counters are not in the frame the run began in (a controller "
|
||||
"start or a home the test did not declare), so the head is not judged here")
|
||||
return
|
||||
if now != was and not _baseline.position_quantized(was, now, _baseline.counter_steps_per_mm()):
|
||||
self._record("takeover start", now, was)
|
||||
|
||||
def position_at_end(self):
|
||||
if self.found is None:
|
||||
return
|
||||
was, mode = self.found
|
||||
now = _baseline.read_position()
|
||||
if was is None or now is None:
|
||||
return
|
||||
if hw.sysfs_read("cnc/x_mode") != mode:
|
||||
self.log("takeover: the microstep mode changed under the takeover, so the counters "
|
||||
"before and after it are not one scale; the head is not judged here")
|
||||
return
|
||||
if now != was and not _baseline.position_quantized(was, now, _baseline.counter_steps_per_mm()):
|
||||
self._record("takeover end", now, was)
|
||||
|
||||
# In cloud mode forgectrl's start at the end starts a cloud client, and
|
||||
# what that client is comes only from the one-start markers gfcloud
|
||||
# reads and takes down as it starts. Started bare, it is the online
|
||||
# client with the service's connect-time hunt, and the moves the
|
||||
# service sends after the hunt are still running as the run ends and
|
||||
# the next one begins, whatever the takeover found. The start is made
|
||||
# under the no-hunt marker, as every client start the runner makes is,
|
||||
# and under the offline marker as well when the takeover found the
|
||||
# offline service; that client is then proven to be the offline
|
||||
# service, and a miss is recorded for the baseline's post pass, which
|
||||
# fails the run.
|
||||
OFFLINE_BACK_S = 60 # the start to the offline service's listener
|
||||
|
||||
def client_markers(self):
|
||||
"""(path, name) of the markers forgectrl's start is made under."""
|
||||
if not self.cloud:
|
||||
return []
|
||||
marks = [(_baseline.NOHUNT_MARKER, "no-hunt")]
|
||||
if self.offline:
|
||||
marks.append((_baseline.OFFLINE_MARKER, "offline"))
|
||||
return marks
|
||||
|
||||
def markers_on(self):
|
||||
for path, name in self.client_markers():
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
f.write("forgetest takeover %s\n" % self.who)
|
||||
except OSError as e:
|
||||
self.log("takeover: WARNING the %s marker could not be written: %s" % (name, e))
|
||||
|
||||
def wait_offline(self):
|
||||
"""The cloud client's pid once it listens on the offline socket, or
|
||||
None after OFFLINE_BACK_S."""
|
||||
deadline = time.time() + self.OFFLINE_BACK_S
|
||||
while True:
|
||||
try:
|
||||
st, m = hw.Forgectrl().get("/mode")
|
||||
except hw.HwError:
|
||||
st, m = None, None
|
||||
pid = m.get("pid") if st == 200 and isinstance(m, dict) else None
|
||||
if hw.listens_on(pid, _baseline.OFFLINE_SOCKET):
|
||||
return pid
|
||||
if time.time() >= deadline:
|
||||
return None
|
||||
time.sleep(0.5)
|
||||
|
||||
def client_back(self, mode):
|
||||
"""After forgectrl's start: a marker no client read is taken down
|
||||
(no client started - the gate, standby, a fault - and a later
|
||||
start must not come up under it), and the offline service found is
|
||||
judged against the client that came up."""
|
||||
if not self.cloud:
|
||||
return
|
||||
mode = mode if isinstance(mode, dict) else {}
|
||||
running = mode.get("mode") == "cloud" and mode.get("controller") == "running"
|
||||
pid = self.wait_offline() if self.offline and running else None
|
||||
for path, name in self.client_markers():
|
||||
if (not running or name == "offline") and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
self.log("takeover: the %s marker was never read - taken down" % name)
|
||||
except OSError:
|
||||
pass
|
||||
if not self.offline:
|
||||
return
|
||||
if pid:
|
||||
self.log("takeover: the cloud client is back as the offline service (pid %s)" % pid)
|
||||
return
|
||||
found = ("a client that is not the offline service (pid %s)" % mode.get("pid") if running
|
||||
else "no cloud client running (mode %s, controller %s)"
|
||||
% (mode.get("mode"), mode.get("controller")))
|
||||
cap = self.run.baseline_captured if self.run is not None else None
|
||||
if cap is not None:
|
||||
cap.setdefault("restart_clients", []).append(
|
||||
{"where": "takeover end", "found": found, "expected": "the offline service"})
|
||||
self.log("takeover: the offline service did not come back: %s; recorded as a leftover" % found)
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
try:
|
||||
self.position_at_end()
|
||||
except Exception as e: # noqa: BLE001 - a reading never keeps forgectrl down
|
||||
self.log("takeover: WARNING the head's position could not be judged: %s: %s"
|
||||
% (type(e).__name__, e))
|
||||
self.restore_attrs()
|
||||
self.markers_on()
|
||||
rc, out = hw.initd("forgectrl", "start")
|
||||
self.log("takeover: forgectrl start -> rc %s" % rc)
|
||||
try:
|
||||
@@ -643,7 +848,12 @@ class Takeover:
|
||||
pass
|
||||
# leave the machine settled for whatever runs next: the probe
|
||||
# move done, the controller back (or the ladder's verdict logged)
|
||||
self.wait_settled()
|
||||
mode = self.wait_settled()
|
||||
try:
|
||||
self.client_back(mode)
|
||||
except Exception as e: # noqa: BLE001 - the judge never breaks the exit path
|
||||
self.log("takeover: WARNING the cloud client could not be judged: %s: %s"
|
||||
% (type(e).__name__, e))
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,10 @@
|
||||
|
||||
"""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
|
||||
@@ -142,14 +140,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 +184,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}
|
||||
@@ -289,131 +311,3 @@ def settings_bounds(ctx):
|
||||
ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied)
|
||||
ctx.check(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)
|
||||
+105
-120
@@ -111,7 +111,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 +120,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 init ordering, the extension host as one process that starts after forgectrl and "
|
||||
"stops before it on a machine with no package installed and nothing running under a "
|
||||
"pool account, the data directory open for search on a machine with an account, the file modes the release depends on, and the mounts: the "
|
||||
"rootfs read-only, /data writable, the account files and the banner rendered "
|
||||
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only.")
|
||||
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only. "
|
||||
"And the boot does not wait on the network: ifup has returned for wlan0 (it is "
|
||||
"in the state file and no ifup is running) and the console login is up; each "
|
||||
"DHCP client runs with -b and has left ifup (its parent is pid 1); no DHCPv6 "
|
||||
"client is running or on the image, nor its hook script; and IPv6 is the "
|
||||
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
|
||||
"address up (a global address is logged, not checked).")
|
||||
def image_health(ctx):
|
||||
ev = ctx.evidence
|
||||
manifest = ctx.runner.manifest
|
||||
@@ -258,6 +269,21 @@ 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")
|
||||
|
||||
# 4b. the board temperatures /status carries, watched and not gated:
|
||||
# the chassis and the SoC die in degrees, the supply as its raw count,
|
||||
# and the CPU unthrottled at idle.
|
||||
temps = (ctx.forgectrl.status() or {}).get("temps") or {}
|
||||
ev["temps"] = temps
|
||||
ctx.log("temps: %s", temps)
|
||||
ctx.check(isinstance(temps.get("chassis_c"), (int, float)) and 5.0 <= temps["chassis_c"] <= 80.0,
|
||||
"/status temps.chassis_c is not a plausible chassis temperature: %s", temps)
|
||||
ctx.check(isinstance(temps.get("soc_c"), (int, float)) and 10.0 <= temps["soc_c"] <= 100.0,
|
||||
"/status temps.soc_c is not a plausible die temperature: %s", temps)
|
||||
ctx.check(isinstance(temps.get("supply_raw"), int) and 0 <= temps["supply_raw"] <= 1023,
|
||||
"/status temps.supply_raw is not a 10-bit count: %s", temps)
|
||||
ctx.check(temps.get("soc_throttle") == 0,
|
||||
"the CPU is throttled (or its cooling device is missing) at idle: %s", temps)
|
||||
|
||||
# 5. init ordering: controllers stop before the daemon
|
||||
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*"))
|
||||
ev["rc6_kill"] = k
|
||||
@@ -267,6 +293,29 @@ def image_health(ctx):
|
||||
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])
|
||||
|
||||
# 5b. the extension host: one process, up after forgectrl and down
|
||||
# before it, and a campaign's machine is extension-free
|
||||
host = []
|
||||
for pid in hw.pidof("forgeext"):
|
||||
argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
|
||||
if argv[:2] == [hw.FORGEEXT, "run"]:
|
||||
host.append(pid)
|
||||
ev["extension_host_pids"] = host
|
||||
ctx.check(len(host) == 1, "the extension host is not one running process: %s", host)
|
||||
s5 = sorted(os.path.basename(x) for x in glob.glob("/etc/rc5.d/S*"))
|
||||
sx = [x for x in s5 if x.endswith("forgeext")]
|
||||
sf = [x for x in s5 if x.endswith("forgectrl")]
|
||||
kx = [x for x in k if x.endswith("forgeext")]
|
||||
ev["forgeext_links"] = {"start": sx, "kill": kx}
|
||||
ctx.check(sx and sf and sf[0] < sx[0], "the extension host must start after forgectrl: %s, %s", sf, sx)
|
||||
ctx.check(kx and kx[0] < kf[0], "the extension host must stop before forgectrl: %s, %s", kx, kf)
|
||||
ev["extension_packages"] = hw.ext_packages()
|
||||
ev["pool_processes"] = len(hw.pool_pids())
|
||||
ctx.log("extension host pid %s, packages %s, pool processes %d", host, ev["extension_packages"], ev["pool_processes"])
|
||||
ctx.check(not ev["extension_packages"], "a campaign wants an extension-free machine: installed %s",
|
||||
ev["extension_packages"])
|
||||
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"])
|
||||
|
||||
# 6. logging lever present, no userspace watchdog daemon
|
||||
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
|
||||
ev["logging_init"] = logging
|
||||
@@ -284,6 +333,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)
|
||||
@@ -348,59 +405,52 @@ 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: not running, not on the image
|
||||
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
|
||||
ev["dhcp6_running"] = alive
|
||||
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
|
||||
for name in DHCP6_CLIENTS:
|
||||
ctx.check(shutil.which(name) is None, "%s is on the image", name)
|
||||
ctx.check(not os.path.exists("/usr/share/udhcpc/default6.script"),
|
||||
"the DHCPv6 hook script is on the image")
|
||||
# IPv6 is the kernel's own. A global address is evidence only: a
|
||||
# network whose router advertisement offers no SLAAC prefix gives none.
|
||||
conf = "/proc/sys/net/ipv6/conf/wlan0/"
|
||||
ev["disable_ipv6"] = (_read(conf + "disable_ipv6", "") or "").strip()
|
||||
ev["accept_ra"] = (_read(conf + "accept_ra", "") or "").strip()
|
||||
ctx.check(ev["disable_ipv6"] == "0", "IPv6 is off on wlan0 (disable_ipv6=%r)", ev["disable_ipv6"])
|
||||
ctx.check(ev["accept_ra"] not in ("", "0"),
|
||||
"wlan0 does not accept router advertisements (accept_ra=%r)", ev["accept_ra"])
|
||||
local, world = link_addrs(_read("/proc/net/if_inet6", ""), "wlan0")
|
||||
ev["link_local"] = local
|
||||
ev["global"] = world
|
||||
ctx.check(local, "wlan0 has no link-local IPv6 address")
|
||||
ctx.log("wlan0: %d link-local, %d global IPv6 address(es)", len(local), len(world))
|
||||
|
||||
@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 +523,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))
|
||||
|
||||
+119
-500
@@ -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.")
|
||||
@@ -404,45 +374,6 @@ def wait_disarm(ctx, timeout):
|
||||
return None
|
||||
|
||||
|
||||
@test("laser.power-floor", title="The derived floor is the one in force",
|
||||
subsystem="laser", kind="auto", mode="grbl", est_min=1,
|
||||
covers=[("grblhal-glowforge", "src/**"), ("forgectrl", "src/main.c")],
|
||||
description="The controller derives $35 from the floor key (laser_floor_density) at "
|
||||
"every spindle precompute, boot included, and never from a typed value. "
|
||||
"Unfloored, the low end of S asks for pulses too far apart to re-strike, "
|
||||
"so a commanded 1 % would emit nothing. Reads the floor key from forgectrl "
|
||||
"and $$ from the controller, and checks $35 is that floor, that it lands at "
|
||||
"or above the measured minimum, and that $31 is 0 (the floor, not $31, sets "
|
||||
"the bottom of the range).")
|
||||
def power_floor(ctx):
|
||||
ev = ctx.evidence
|
||||
want = _floor(ctx.forgectrl)
|
||||
ev["configured_floor"] = want
|
||||
with ctx.grbl() as g:
|
||||
lines = g.command("$$", timeout=5)
|
||||
vals = _grbl_settings(lines)
|
||||
ctx.check(vals, "no $-settings in the response to $$")
|
||||
|
||||
floor_pct = vals.get("$35")
|
||||
rpm_min = vals.get("$31")
|
||||
ev["settings"] = {k: vals.get(k) for k in ("$30", "$31", "$32", "$35", "$36")}
|
||||
ctx.check(floor_pct is not None, "$35 missing from the settings report")
|
||||
counts = int(PWM_PERIOD * floor_pct / 100.0)
|
||||
ev["pwmsar_floor"] = counts
|
||||
ctx.log("$35=%.1f%% -> PWMSAR %d of %d ($31=%s, $32=%s, $36=%s)", floor_pct, counts,
|
||||
PWM_PERIOD, rpm_min, vals.get("$32"), vals.get("$36"))
|
||||
|
||||
ctx.check(abs(floor_pct - want) < 0.05,
|
||||
"$35 is %.1f %%, expected the derived floor %.1f %% from the machine config",
|
||||
floor_pct, want)
|
||||
ctx.check(counts >= PWMSAR_FLOOR_MIN,
|
||||
"the floor lands at PWMSAR %d, below the %d the tube needs",
|
||||
counts, PWMSAR_FLOOR_MIN)
|
||||
ctx.check(rpm_min == 0, "$31 is %s, not 0: the bottom of the S range is no longer the floor",
|
||||
rpm_min)
|
||||
ctx.log("PASS: nonzero S never commands less than PWMSAR %d", counts)
|
||||
|
||||
|
||||
@test("laser.emission-witness", title="Live emission witness (S400 vector mark) and job-based disarm",
|
||||
subsystem="laser", kind="live", mode="grbl", always=True, est_min=5,
|
||||
covers=_LASER_COVERS,
|
||||
@@ -456,15 +387,45 @@ def power_floor(ctx):
|
||||
"(G4) between its second and third sides: emission_samples (the kernel's "
|
||||
"LASER_ON sample count) goes nonzero during the fire window and returns to 0 "
|
||||
"at Idle, HV current rises during the burn, the head's beam detector sees the "
|
||||
"beam, the armed window is observed, the M2 program end disarms promptly at "
|
||||
"Idle (job-based, not the 60 s idle grace) and the button goes dark. Across "
|
||||
"the dwell the kernel run ends and restarts: HV_ENABLE drops with the "
|
||||
"charge-pump watchdog and is back for the third side, while the hardware "
|
||||
"button latch stays clear (cnc/button_latch 0 in every armed sample), so the "
|
||||
"second half of the square marks without a second press. The operator "
|
||||
"confirms all four sides.")
|
||||
"beam, and the armed window is observed. Across the dwell the kernel run ends "
|
||||
"and restarts: HV_ENABLE drops with the charge-pump watchdog and is back for "
|
||||
"the third side, while the hardware button latch stays clear (cnc/button_latch "
|
||||
"0 in every armed sample), so the second half of the square marks without a "
|
||||
"second press. The square ends at M5 with the window still open; once the "
|
||||
"LASER_ON count reads 0 (the dark point), a rapid runs 20 mm out over the "
|
||||
"first side and back, then the program ends. From the dark point until the "
|
||||
"window closes, every sample reads no emission and the HV current at its idle "
|
||||
"reading, and the head's reports place the rapid inside that stretch: LASER_ON "
|
||||
"stays off with FIRE clear while the chain can fire. The M2 program end "
|
||||
"disarms promptly (job-based, not the 60 s idle grace) and the button goes "
|
||||
"dark. The operator confirms all four sides.")
|
||||
def emission_witness(ctx):
|
||||
ev = ctx.evidence
|
||||
# The rapid after the M5 (G91 is modal from the square): out over the
|
||||
# first side and back, then the program end.
|
||||
rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P0.5", "G90", "M2"]
|
||||
|
||||
def sample_to_disarm(g, timeout=20.0, sample_hz=8):
|
||||
"""Sample until the armed window closes, or the timeout, with the
|
||||
controller's state and machine X beside each sample ("x"). A G4
|
||||
dwell reports Idle, so Idle cannot end this; the window closing at
|
||||
the program end does."""
|
||||
samples = []
|
||||
period = 1.0 / sample_hz
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
ctx.checkpoint()
|
||||
smp = sample(ctx)
|
||||
rep = g.status_report()
|
||||
if smp:
|
||||
smp["gstate"] = rep.get("state")
|
||||
smp["x"] = (rep.get("MPos") or (None,))[0]
|
||||
samples.append(smp)
|
||||
if not smp["armed"]:
|
||||
break
|
||||
time.sleep(period)
|
||||
return samples
|
||||
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
base = sample(ctx)
|
||||
@@ -474,21 +435,46 @@ def emission_witness(ctx):
|
||||
base["armed"], base["verdict"])
|
||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
ctx.ready(ARM_CUE % "20 mm +X and +Y")
|
||||
# The square ends at M5 with no program end: the window stays open
|
||||
# for the rapid that follows.
|
||||
job = ["G91", "G21", "M4", "S400",
|
||||
"G1 X20 F600", "G1 Y20 F600", "G4 P2", "G1 X-20 F600", "G1 Y-20 F600",
|
||||
"M5", "G90", "M2"]
|
||||
"M5"]
|
||||
ctx.arm_press()
|
||||
try:
|
||||
samples = run_and_sample(ctx, g, job)
|
||||
cut = run_and_sample(ctx, g, job)
|
||||
# The dark point: the M5 has run, the LASER_ON count reads 0, and
|
||||
# the window is still open. The rapid is sent only after it, so
|
||||
# the dark stretch covers the rapid by construction, and the
|
||||
# mid-square dwell's own zero cannot be taken for it.
|
||||
ctx.wait_for(lambda: (sample(ctx) or {}).get("emission") == 0, 10, poll=0.125)
|
||||
dark = sample(ctx)
|
||||
ctx.check(dark and dark["emission"] == 0,
|
||||
"the LASER_ON count did not return to 0 after the M5 (%s)", dark and dark["emission"])
|
||||
ctx.check(dark["armed"], "the armed window closed after the M5, before the rapid "
|
||||
"(phase %s): the rapid would not run under it", dark["phase"])
|
||||
# The engine's armed flag can still read true after the latch has
|
||||
# locked, and a dark reading with the latch locked is the unarmed
|
||||
# case this sample is here to exclude: the SoC lock line must read
|
||||
# released.
|
||||
ctx.check(dark["locked"] is False, "the laser latch is locked at the dark point "
|
||||
"(laser_locked %s): the rapid would run unarmed", dark["locked"])
|
||||
rep = g.status_report()
|
||||
dark["gstate"] = rep.get("state")
|
||||
dark["x"] = (rep.get("MPos") or (None,))[0]
|
||||
ctx.check(dark["x"] is not None, "the controller reports no machine position: %s", rep)
|
||||
stream(g, rapid_tail)
|
||||
tail = sample_to_disarm(g)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
samples = cut + [dark] + tail
|
||||
ev["trail_fields"] = list(TRAIL_FIELDS)
|
||||
ev["trail"] = trail(samples)
|
||||
beam = beam_witness(ctx, ev, samples, base)
|
||||
emis = [s["emission"] for s in samples if s["emission"] is not None]
|
||||
beam = beam_witness(ctx, ev, cut, base)
|
||||
emis = [s["emission"] for s in cut if s["emission"] is not None]
|
||||
peak = max(emis) if emis else 0
|
||||
end = emis[-1] if emis else None
|
||||
hv = [s["hv"] for s in samples if s["hv"] is not None]
|
||||
end = next((s["emission"] for s in reversed(samples) if s["emission"] is not None), None)
|
||||
hv = [s["hv"] for s in cut if s["hv"] is not None]
|
||||
ir_peak = [0, 0, 0, 0]
|
||||
for s in samples:
|
||||
if s["ir"] and len(s["ir"]) == 4:
|
||||
@@ -532,12 +518,34 @@ def emission_witness(ctx):
|
||||
ev["exhaust_floor"] = floor
|
||||
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
|
||||
ev["exhaust_at_first_fire"], floor)
|
||||
# X-3: job-based disarm at Idle after M2
|
||||
dt = wait_disarm(ctx, 75)
|
||||
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 window closes reads no emission
|
||||
# and the HV current at its idle reading. The head's reports place the
|
||||
# rapid inside that stretch: out to +20 mm, then back at the start.
|
||||
stretch = [dark] + tail
|
||||
x0 = dark["x"]
|
||||
i_out = next((i for i, s in enumerate(stretch) if s.get("x") is not None and s["x"] - x0 >= 19.5), None)
|
||||
i_back = (next((i for i in range(i_out + 1, len(stretch))
|
||||
if stretch[i].get("x") is not None and abs(stretch[i]["x"] - x0) < 0.05), None)
|
||||
if i_out is not None else None)
|
||||
closed = stretch[-1] if not stretch[-1]["armed"] else None
|
||||
relit = [s for s in stretch if s["emission"]]
|
||||
hv_dark = max((s["hv"] for s in stretch if s["hv"] is not None), default=0)
|
||||
dt = (closed["t"] - stretch[i_back]["t"]) if (closed and i_back is not None) else None
|
||||
ev["rapid"] = {"samples": len(stretch), "span_s": round(stretch[-1]["t"] - dark["t"], 2),
|
||||
"x_start": x0, "x_max": max((s["x"] for s in stretch if s.get("x") is not None), default=None),
|
||||
"out_at_s": round(stretch[i_out]["t"] - dark["t"], 2) if i_out is not None else None,
|
||||
"back_at_s": round(stretch[i_back]["t"] - dark["t"], 2) if i_back is not None else None,
|
||||
"relit": [(round(s["t"] - dark["t"], 2), s["emission"]) for s in relit][:8],
|
||||
"hv_max": hv_dark, "locked_at_dark": dark["locked"],
|
||||
"locked_at_out": stretch[i_out]["locked"] if i_out is not None else None}
|
||||
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
||||
ctx.log("the rapid after the M5: %d samples over %.1f s from the dark point, out at +%s s, back at +%s s, "
|
||||
"%d with emission, HV max %s; the window closed %s s after the head was back", len(stretch),
|
||||
ev["rapid"]["span_s"], ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"], len(relit), hv_dark,
|
||||
ev["disarm_after_idle_s"])
|
||||
ctx.check(armed_seen, "the armed window was never observed (arm refused, or no button press)")
|
||||
ctx.check(peak > 0, "no emission witnessed (emission_samples stayed 0)")
|
||||
ctx.check(peak > 0, "no emission witnessed on the square (emission_samples stayed 0)")
|
||||
first_idle_fire = ev["idle_airflow_fire"][0] if idle_fire else (None, None, None)
|
||||
ctx.check(not idle_fire,
|
||||
"the laser fired while the cooling engine had no run session, so the fans were at "
|
||||
@@ -549,8 +557,18 @@ def emission_witness(ctx):
|
||||
"emission sample, floor %.0f)", ev["exhaust_at_first_fire"], floor)
|
||||
ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)",
|
||||
ev["hv_min"], ev["hv_max"])
|
||||
ctx.check(i_out is not None and i_back is not None,
|
||||
"the rapid did not run inside the dark stretch: out at %s, back at %s (X from %s, max %s)",
|
||||
ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"], x0, ev["rapid"]["x_max"])
|
||||
ctx.check(stretch[i_out]["locked"] is False, "the laser latch was locked with the head at +20 mm "
|
||||
"(laser_locked %s): the rapid ran unarmed", stretch[i_out]["locked"])
|
||||
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%s s (emission_samples %s) "
|
||||
"- a rapid after M5 fired", len(relit), relit[0]["t"] - dark["t"] if relit else None,
|
||||
relit[0]["emission"] if relit else None)
|
||||
ctx.check(hv_dark <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge ran "
|
||||
"through the rapid", hv_dark)
|
||||
ctx.check(dt is not None and dt < 10.0,
|
||||
"the M2 job did not disarm promptly at Idle (%s s; the idle grace is ~60 s)",
|
||||
"the M2 job did not disarm promptly (%s s after the head was back; the idle grace is ~60 s)",
|
||||
ev["disarm_after_idle_s"])
|
||||
ctx.check(gap["button_latch_unlocked_samples"] and gap["button_latch_unlocked_max"] == 0,
|
||||
"the hardware button latch read SET while the laser latch was unlocked (max %s over %d "
|
||||
@@ -562,172 +580,14 @@ def emission_witness(ctx):
|
||||
judge_beam(ctx, beam)
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.confirm("Did the laser mark a 20 mm square outline on the scrap, all four sides?")
|
||||
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s, disarmed %.1f s after Idle, button "
|
||||
"dark, mark confirmed", peak, ev["hv_min"], ev["hv_max"], beam["delta"], dt)
|
||||
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s; the rapid after the M5 dark (HV max %s); "
|
||||
"disarmed %.1f s after the head was back, button dark, mark confirmed", peak, ev["hv_min"],
|
||||
ev["hv_max"], beam["delta"], hv_dark, dt)
|
||||
|
||||
|
||||
HV_DARK_MAX = 20 # hv_current_raw reads 0 with the tube off; hundreds under fire
|
||||
|
||||
|
||||
@test("laser.m5-rapid-dark", title="The rapids after an M5 ship dark",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=3,
|
||||
covers=_LASER_COVERS,
|
||||
requires=["laser.emission-witness"], actions=["button"],
|
||||
steps=["Scrap under the head with 20 mm of free +X travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white (the arm)."],
|
||||
description="A 20 mm line at constant power (M3 S400/F600), then M5, a dwell, a rapid back "
|
||||
"over the line, a dwell, a rapid forward, a dwell, and the program end. M5 "
|
||||
"executes with the planner drained and the kernel run over, and the core "
|
||||
"issues no per-segment laser update for moves made with the spindle off, so "
|
||||
"only the stream's own wanted state decides whether those rapids fire; a stale "
|
||||
"true there lights them at the last level, full duty under the density model. "
|
||||
"The kernel's LASER_ON sample count must go to 0 after the M5 and stay 0 "
|
||||
"through both rapids, and the HV current must stay at its idle reading.")
|
||||
def m5_rapid_dark(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
base = sample(ctx)
|
||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
ctx.ready(ARM_CUE % "20 mm +X")
|
||||
# +20 out on the cut, then the two rapids the test is about, then
|
||||
# a third that puts the head back where the job found it: the
|
||||
# first two net to +20 and a job that walks the head 20 mm and
|
||||
# leaves it there is dirt the hand-back has to jog out. The
|
||||
# return is inside the sampling window and after the M5, so it is
|
||||
# one more rapid that must be dark, which is what the test asks
|
||||
# of the other two.
|
||||
job = ["G91", "G21", "M3", "S400",
|
||||
"G1 X20 F600",
|
||||
"M5", "G4 P2.5",
|
||||
"G0 X-20", "G4 P2.5",
|
||||
"G0 X20", "G4 P2.5",
|
||||
"G0 X-20", "G4 P0.5",
|
||||
"G90", "M2"]
|
||||
ctx.arm_press()
|
||||
try:
|
||||
samples = run_and_sample(ctx, g, job)
|
||||
# The controller reports Idle inside a G4 dwell, so the sampler
|
||||
# above can return before the rapids; the window closing at M2
|
||||
# is the end of the job. Keep sampling until then.
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 30:
|
||||
ctx.checkpoint()
|
||||
smp = sample(ctx)
|
||||
if smp:
|
||||
samples.append(smp)
|
||||
if not smp["armed"]:
|
||||
break
|
||||
time.sleep(0.125)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
emis = [(s["t"], s["emission"], s["hv"]) for s in samples if s["emission"] is not None]
|
||||
peak = max((e for _t, e, _hv in emis), default=0)
|
||||
ctx.check(peak > 0, "no emission witnessed on the G1 (emission_samples stayed 0)")
|
||||
# The first zero after the peak is the dark window the M5 and its dwell
|
||||
# produce; everything after it is the two rapids and their dwells.
|
||||
i_peak = max(range(len(emis)), key=lambda i: emis[i][1])
|
||||
after = [x for x in emis[i_peak:] if x[1] == 0]
|
||||
ctx.check(after, "emission_samples never returned to 0 after the M5")
|
||||
t_dark = after[0][0]
|
||||
tail = [x for x in emis if x[0] >= t_dark]
|
||||
relit = [x for x in tail if x[1]]
|
||||
hv_tail = max((hv for _t, _e, hv in tail if hv is not None), default=0)
|
||||
ev.update({"samples": len(samples), "emission_peak": peak, "tail_samples": len(tail),
|
||||
"relit_samples": len(relit), "hv_tail_max": hv_tail,
|
||||
"relit_first": relit[0] if relit else None})
|
||||
ctx.log("emission peak %s; dark from +%.1f s; %d samples after it spanning %.1f s, %d with "
|
||||
"emission, HV max after dark %s", peak, t_dark - emis[0][0], len(tail),
|
||||
tail[-1][0] - t_dark, len(relit), hv_tail)
|
||||
ctx.check(tail[-1][0] - t_dark >= 5.0, "sampling ended %.1f s after the dark point, before "
|
||||
"both rapids and their dwells (~5.2 s) had run", tail[-1][0] - t_dark)
|
||||
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%.1f s "
|
||||
"(emission_samples %s) - a rapid after M5 fired at the last level",
|
||||
len(relit), (relit[0][0] - t_dark) if relit else 0, relit[0][1] if relit else None)
|
||||
ctx.check(hv_tail <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge "
|
||||
"ran through a rapid", hv_tail)
|
||||
ctx.log("PASS: emission peak %s on the G1, 0 through both rapids, HV %s after the M5",
|
||||
peak, hv_tail)
|
||||
|
||||
|
||||
@test("laser.disarm-in-hold", title="Disarm grace counts down in Hold", subsystem="laser",
|
||||
kind="live", mode="grbl", est_min=4,
|
||||
covers=_LASER_COVERS,
|
||||
requires=["laser.emission-witness"], actions=["button"],
|
||||
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white. Nothing else: the test holds the "
|
||||
"job itself and waits about a minute for the disarm."],
|
||||
description="Arm and start a +X move at S400/F300, feed-hold it after ~2 s of motion, and "
|
||||
"hold: the disarm grace must count down while held and close the armed window "
|
||||
"(armed -> false) without the job resuming, and the button goes dark with it.")
|
||||
def disarm_in_hold(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
k0 = kernel_start(ctx)
|
||||
ctx.ready(ARM_CUE % "40 mm +X")
|
||||
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
|
||||
ctx.log("armed; waiting for motion to start (arm + your button press)...")
|
||||
w = Watch(g)
|
||||
ctx.act("button", "press", text="The button is lit white: the press arms the job and the "
|
||||
"move starts.", until=w.in_state("Run"), timeout=180, fail=False)
|
||||
st = w.last["state"] if w.last else None
|
||||
ctx.check(st and st.startswith("Run"), "motion never started (state=%s) - arm refused or no press", st)
|
||||
ctx.log("moving under laser: %s; feed-hold in 2 s", st)
|
||||
ctx.sleep(2)
|
||||
g.realtime(ord("!"))
|
||||
t1 = time.time()
|
||||
while time.time() - t1 < 5:
|
||||
st = g.status_report()["state"]
|
||||
if st.startswith("Hold"):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
ev["held_state"] = st
|
||||
ctx.log("feed-held mid-move: %s; watching the disarm grace count down IN HOLD", st)
|
||||
ctx.check(st.startswith("Hold"), "feed hold did not park (state %s)", st)
|
||||
t0 = time.time()
|
||||
disarmed_at = None
|
||||
left_hold = None
|
||||
while time.time() - t0 < 120:
|
||||
ctx.checkpoint()
|
||||
s = sample(ctx)
|
||||
held = g.status_report()["state"].startswith("Hold")
|
||||
if s and not s["armed"]:
|
||||
disarmed_at = time.time() - t0
|
||||
break
|
||||
if not held and left_hold is None:
|
||||
left_hold = g.status_report()["state"]
|
||||
ctx.log("note: left Hold (state=%s) before disarm", left_hold)
|
||||
time.sleep(1)
|
||||
ev["disarmed_after_s"] = round(disarmed_at, 1) if disarmed_at is not None else None
|
||||
ev["left_hold"] = left_hold
|
||||
# recover: laser off, abort out of hold
|
||||
g.command("M5", timeout=1)
|
||||
g.realtime(0x18)
|
||||
ctx.sleep(1)
|
||||
if "Alarm" in g.status_report()["state"]:
|
||||
g.command("$X")
|
||||
# The hold parks the head part-way through the move and the reset
|
||||
# abandons the rest of it, so the job ends wherever the feed hold
|
||||
# caught it - about 11 mm in, on the bench reference. That is the
|
||||
# test's own doing and it goes back: the kernel counters say how
|
||||
# far the head actually went, which is what a jog has to undo.
|
||||
wait_idle(ctx, g, 15)
|
||||
dx, dy = kernel_xy_mm(ctx)
|
||||
back_x, back_y = k0[0] - dx, k0[1] - dy
|
||||
ev["left_at_mm"] = [round(dx - k0[0], 3), round(dy - k0[1], 3)]
|
||||
if abs(back_x) >= 0.02 or abs(back_y) >= 0.02:
|
||||
ctx.log("returning the head: %+.3f/%+.3f mm", back_x, back_y)
|
||||
g.command("$J=G91X%.3fY%.3fF1200" % (back_x, back_y))
|
||||
wait_idle(ctx, g, 30)
|
||||
ev["returned_mm"] = list(kernel_xy_mm(ctx))
|
||||
ctx.check(disarmed_at is not None, "still armed after 120 s in Hold")
|
||||
ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold)
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.log("PASS: disarmed in Hold after %.1f s, button dark", disarmed_at)
|
||||
|
||||
|
||||
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=6,
|
||||
covers=_LASER_COVERS + [("forgectrl", "src/main.c")],
|
||||
@@ -840,253 +700,12 @@ def armed_kill(ctx):
|
||||
"respawned with the latch still locked, button dark", ev["expected"]["zero_at_s"], zero_at)
|
||||
|
||||
|
||||
@test("laser.verdict-cut", title="A blocked verdict mid-cut locks the latch and holds; the clean "
|
||||
"verdict resumes with no press",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=4,
|
||||
covers=_LASER_COVERS,
|
||||
requires=["laser.emission-witness"], actions=["button"],
|
||||
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white (the arm). Nothing else: the test "
|
||||
"takes the cooling verdict away itself and gives it back."],
|
||||
description="A 40 mm line at constant power (M3 S400/F300, 8 s). About 1.5 s in, the test "
|
||||
"pauses the machine-services daemon for 3.5 s, so the cooling verdict the "
|
||||
"controller reads goes stale: the controller's pause tier. The controller must "
|
||||
"hold the job (grbl Hold) under the still-open armed window with the stream "
|
||||
"masked dark, so the kernel's LASER_ON sample count reads 0 before the cut "
|
||||
"resumes, and it must not write the latch: a lock sets the hardware button "
|
||||
"latch, which only a press clears, so both bit 3 (the SoC lock) and bit 2 "
|
||||
"(the button latch) of interlock_circuit stay clear through the whole run. "
|
||||
"When the daemon returns (the verdict fresh, clean, resume_ok), the controller "
|
||||
"must resume the cut lit from where it stopped, with no button press and no "
|
||||
"prompt; the M2 program end then disarms as usual. The daemon's own dead-man "
|
||||
"(5 s of silence) is not reached: the controller keeps reporting through the "
|
||||
"pause. The beam detector witnesses the cut on both sides of the hold.")
|
||||
def verdict_cut(ctx):
|
||||
import os as _os
|
||||
import signal as _signal
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
# The daemon is frozen long enough for the verdict the controller
|
||||
# caches to expire (VERDICT_MAX_AGE 2.0 s), so the driver's pause tier
|
||||
# holds the job - but not so long that the engine's report dead-man
|
||||
# trips on resume. That dead-man locks the laser latch when the
|
||||
# controller's report is older than REPORT_TIMEOUT_S (5.0 s) while
|
||||
# armed, which is exactly the "latch stayed locked" this test refuses.
|
||||
# The last report is up to 1 s old when the freeze starts, so the
|
||||
# freeze must stay under 4.0 s; 3.0 s clears the stale floor with room
|
||||
# to see the hold and stays a full second under the dead-man.
|
||||
PAUSE_S = 3.0
|
||||
pids = hw.pidof("forgectrl")
|
||||
ctx.check(pids, "no forgectrl process found to pause")
|
||||
ev["daemon_pids"] = pids
|
||||
state_path = "/run/forgefirm/grbl.state"
|
||||
|
||||
def grbl_state_file():
|
||||
try:
|
||||
with open(state_path) as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
def resume_daemon():
|
||||
for p in pids:
|
||||
try:
|
||||
_os.kill(p, _signal.SIGCONT)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
trail = []
|
||||
text = ""
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
k0 = kernel_start(ctx)
|
||||
base = sample(ctx)
|
||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
ctx.ready(ARM_CUE % "40 mm +X")
|
||||
job = ["G91", "G21", "M3", "S400", "G1 X40 F300", "M5", "G0 X-40", "G90", "M2"]
|
||||
smp = arm_and_fire(ctx, g, room="40 mm +X", job=job)
|
||||
beams = [(smp.get("beam"), smp.get("beam_d"))]
|
||||
ctx.log("emission live (%s); pausing the daemon in 1 s", smp["emission"])
|
||||
ctx.sleep(1.0)
|
||||
g.drain()
|
||||
t0 = time.time()
|
||||
try:
|
||||
for p in pids:
|
||||
_os.kill(p, _signal.SIGSTOP)
|
||||
ctx.log("daemon paused (SIGSTOP %s) for %.1f s: the verdict goes stale", pids, PAUSE_S)
|
||||
# No HTTP while the daemon is paused: the controller's socket,
|
||||
# sysfs and the controller's state file are the witnesses.
|
||||
while time.time() - t0 < PAUSE_S:
|
||||
st = g.status_report()["state"]
|
||||
text += g.drain()
|
||||
il = hw.sysfs_int("cnc/interlock_circuit")
|
||||
trail.append({"t": round(time.time() - t0, 2), "gstate": st, "il": il,
|
||||
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
|
||||
"armed": '"armed":true' in grbl_state_file()})
|
||||
time.sleep(0.12)
|
||||
finally:
|
||||
resume_daemon()
|
||||
ctx.log("daemon resumed (SIGCONT) at +%.2f s", time.time() - t0)
|
||||
# Through the resume and to the end of the cut, sampling the same
|
||||
# witnesses (the daemon's own once it answers again).
|
||||
resumed_at = None
|
||||
end = time.time() + 30
|
||||
while time.time() < end:
|
||||
ctx.checkpoint()
|
||||
st = g.status_report()["state"]
|
||||
text += g.drain()
|
||||
il = hw.sysfs_int("cnc/interlock_circuit")
|
||||
row = {"t": round(time.time() - t0, 2), "gstate": st, "il": il,
|
||||
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
|
||||
"armed": '"armed":true' in grbl_state_file()}
|
||||
if time.time() - t0 > PAUSE_S + 1.0:
|
||||
s = sample(ctx)
|
||||
if s:
|
||||
row["beam"] = s.get("beam")
|
||||
beams.append((s.get("beam"), s.get("beam_d")))
|
||||
trail.append(row)
|
||||
if resumed_at is None and st.startswith("Run"):
|
||||
resumed_at = time.time() - t0
|
||||
if st.startswith("Idle") and resumed_at is not None and time.time() - t0 > resumed_at + 3.0:
|
||||
break
|
||||
time.sleep(0.12)
|
||||
for r in trail:
|
||||
ctx.log(" %s", r)
|
||||
ev["trail"] = trail
|
||||
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
|
||||
ctx.log("controller: %s", ev["messages"])
|
||||
dt = wait_disarm(ctx, 75)
|
||||
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
||||
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the job")
|
||||
check_kernel_returned(ctx, ev, k0)
|
||||
|
||||
def locked(r):
|
||||
return r["il"] is not None and bool(r["il"] & IL_LASER_LATCH)
|
||||
|
||||
def button_latched(r):
|
||||
return r["il"] is not None and bool(r["il"] & IL_BUTTON_LATCH)
|
||||
|
||||
held = [r for r in trail if r["gstate"].startswith("Hold")]
|
||||
ev.update({"held_at_s": held[0]["t"] if held else None,
|
||||
"resumed_at_s": round(resumed_at, 2) if resumed_at else None,
|
||||
"latch_locked_samples": sum(1 for r in trail if locked(r)),
|
||||
"button_latch_set_samples": sum(1 for r in trail if button_latched(r))})
|
||||
ctx.check(held and held[0]["t"] < PAUSE_S, "the stale verdict did not hold the job during the "
|
||||
"pause (first Hold at %s)", ev["held_at_s"])
|
||||
ctx.check("fire masked, job held" in text, "the controller did not report the pause tier")
|
||||
ctx.check(ev["latch_locked_samples"] == 0,
|
||||
"the pause tier wrote the latch (locked in %d samples): a lock sets the hardware "
|
||||
"button latch and the resume would need a press", ev["latch_locked_samples"])
|
||||
ctx.check(ev["button_latch_set_samples"] == 0,
|
||||
"the hardware button latch read SET in %d samples: the resume needed a press",
|
||||
ev["button_latch_set_samples"])
|
||||
ctx.check(all(r["armed"] for r in trail if r["t"] < (resumed_at or PAUSE_S + 5)),
|
||||
"the armed window closed during the pause")
|
||||
ctx.check("press the button" not in text, "the resume asked for a button press")
|
||||
ctx.check(DISARMED_MSG not in text.split("resuming")[0], "the pause tier disarmed the job")
|
||||
# Dark from the hold until the resume: the gate masked the stream.
|
||||
span = [r for r in trail if held and r["t"] >= held[0]["t"]
|
||||
and (resumed_at is None or r["t"] < resumed_at)]
|
||||
zero = next((r for r in span if r["emission"] == 0), None)
|
||||
ev["emission_zero_after_hold_s"] = round(zero["t"] - held[0]["t"], 2) if zero else None
|
||||
ctx.check(zero is not None and zero["t"] - held[0]["t"] <= 2.5,
|
||||
"emission did not read 0 within 2.5 s of the hold (before the resume)")
|
||||
ctx.check(resumed_at is not None, "the clean verdict did not resume the cut")
|
||||
ctx.check("resuming" in text, "the controller did not report the resume")
|
||||
after = [r for r in trail if resumed_at and r["t"] >= resumed_at]
|
||||
ctx.check(any(r["emission"] for r in after), "the resumed cut ran dark (no emission after the resume)")
|
||||
ctx.check("ALARM" not in text, "an alarm was raised on the pause tier")
|
||||
ctx.check(dt is not None and dt < 10.0, "the M2 job did not disarm promptly at Idle (%s s)", dt)
|
||||
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base)
|
||||
judge_beam(ctx, ev["beam"], "the cut")
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.log("PASS: held at +%s s with the latch untouched, emission 0 %s s after the hold, resumed "
|
||||
"lit at +%s s with no press, disarmed %.1f s after Idle", ev["held_at_s"],
|
||||
ev["emission_zero_after_hold_s"], ev["resumed_at_s"], dt)
|
||||
|
||||
|
||||
DISARMED_MSG = "laser disarmed - latch locked"
|
||||
|
||||
|
||||
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
|
||||
subsystem="laser", kind="operator", mode="grbl", est_min=3,
|
||||
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_laser.c"),
|
||||
("grblhal-glowforge", "src/glowforge_switches.c"),
|
||||
("grblhal-glowforge", "src/glowforge_switch_map.h")],
|
||||
requires=["kernel.latch-locked-idle", "motion.jog-roundtrip"], actions=["lid"],
|
||||
steps=["Lid closed; nothing under the head needs to be in place - the machine will not fire.",
|
||||
"When the white button lights, do NOT press it: open the lid instead, then close it "
|
||||
"when told."],
|
||||
description="Start a laser job so the controller unlocks the latch and lights the button, "
|
||||
"then open the lid while it waits. The wait must abort with the lid named as the "
|
||||
"reason, canceled with a soft reset (no alarm - nothing to unlock), the armed "
|
||||
"window closed (armed -> false), the kernel latch back to locked, and no emission; "
|
||||
"the controller ends Idle. No press is given, so nothing can fire.")
|
||||
def arm_wait_lid(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
base = sample(ctx)
|
||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||
ctx.check(not base["armed"], "armed window already open before the job")
|
||||
stream(g, ["G91", "G21", "M4", "S400", "G1 X5 F600"])
|
||||
# The prompt: the latch is unlocked and the button lit from here on.
|
||||
t0 = time.time()
|
||||
text = ""
|
||||
while time.time() - t0 < 30 and "press the button" not in text:
|
||||
ctx.checkpoint()
|
||||
text += g.drain()
|
||||
time.sleep(0.1)
|
||||
ctx.check("press the button" in text, "no arm prompt within 30 s (job did not reach the arm)")
|
||||
s = sample(ctx)
|
||||
ev["armed_during_wait"] = s["armed"] if s else None
|
||||
ctx.log("arm prompt seen; armed=%s; asking the operator to open the lid", ev["armed_during_wait"])
|
||||
ctx.act("lid", "open", text="The button is lit white: do NOT press it.", timeout=60)
|
||||
t1 = time.time()
|
||||
while time.time() - t1 < 15:
|
||||
ctx.checkpoint()
|
||||
text += g.drain()
|
||||
if "job canceled" in text and "help]" in text:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
|
||||
ctx.log("controller: %s", ev["messages"])
|
||||
ctx.check("lid opened during arm - job canceled" in text,
|
||||
"the lid open was not reported as canceling the arm")
|
||||
ctx.check("help]" in text, "no reset banner after the lid-open cancel (it must be a clean cancel)")
|
||||
ctx.check("ALARM" not in text, "an alarm was raised on the lid-open cancel")
|
||||
# Armed window closed and the kernel latch locked.
|
||||
t2 = time.time()
|
||||
s = None
|
||||
while time.time() - t2 < 10:
|
||||
s = sample(ctx)
|
||||
if s and not s["armed"]:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
ev["armed_after"] = s["armed"] if s else None
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
locked = ilk is not None and bool(ilk & (1 << 3))
|
||||
ev["latch_locked"] = locked
|
||||
ev["emission"] = s["emission"] if s else None
|
||||
ctx.check(s and not s["armed"], "the armed window stayed open after the lid-open cancel")
|
||||
ctx.check(locked, "kernel latch not locked after the lid-open cancel (interlock_circuit=%s)", ilk)
|
||||
ctx.check(not ev["emission"], "emission_samples nonzero (%s) - nothing may have fired", ev["emission"])
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.act("lid", "close")
|
||||
ctx.sleep(1)
|
||||
st = g.status_report()["state"]
|
||||
ev["state_after"] = st
|
||||
ctx.check(st.startswith("Idle"), "controller is %s after the cancel, expected Idle (no unlock needed)", st)
|
||||
ctx.log("PASS: lid open during the arm wait canceled the job (clean reset, no alarm), armed=false, latch locked")
|
||||
|
||||
|
||||
@test("laser.pause-resume-lid-cancel", title="One live cut: the button pauses and resumes it, the lid "
|
||||
"cancels it and sends the head home",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=7,
|
||||
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
|
||||
("grblhal-glowforge", "src/glowforge_switch_map.h")],
|
||||
requires=["laser.emission-witness", "motion.lid-cancel-home", "motion.button-hold-resume"],
|
||||
requires=["laser.emission-witness"],
|
||||
actions=["button", "lid"],
|
||||
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white (arm). Once the cut is under way the "
|
||||
|
||||
@@ -1,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)
|
||||
+195
-627
@@ -10,7 +10,9 @@ Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
|
||||
and round-trip; the laser stays latched (the tests never touch it); the
|
||||
suite is the only Grbl client while a test runs.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
@@ -61,39 +63,6 @@ def wait_state(ctx, g, prefix, timeout):
|
||||
return None
|
||||
|
||||
|
||||
def wait_state_text(ctx, g, prefix, timeout):
|
||||
"""wait_state, keeping everything the controller said while polling.
|
||||
The driver reports a press with a [MSG:] line the moment it acts on it -
|
||||
inside the poll window - so a test that wants both the state and the
|
||||
message has to collect them together."""
|
||||
end = time.time() + timeout
|
||||
text = ""
|
||||
while time.time() < end:
|
||||
ctx.checkpoint()
|
||||
st = g.status_report()
|
||||
text += g.drain()
|
||||
if st["state"].startswith(prefix):
|
||||
return st, text
|
||||
time.sleep(0.1)
|
||||
return None, text + g.drain()
|
||||
|
||||
|
||||
def wait_left_state(ctx, g, prefix, timeout):
|
||||
"""The first report whose state is no longer `prefix`, with the text.
|
||||
Leaving a state is what proves a command was acted on; catching the
|
||||
state it moves INTO is a race whenever the remaining work is short."""
|
||||
end = time.time() + timeout
|
||||
text = ""
|
||||
while time.time() < end:
|
||||
ctx.checkpoint()
|
||||
st = g.status_report()
|
||||
text += g.drain()
|
||||
if not st["state"].startswith(prefix):
|
||||
return st, text
|
||||
time.sleep(0.1)
|
||||
return None, text + g.drain()
|
||||
|
||||
|
||||
def wait_idle(ctx, g, timeout=30.0, poll=0.05, grace=0.3):
|
||||
"""Poll until Idle; returns (peak_feed_mm_min, states_seen, final_report).
|
||||
An Idle report inside the first `grace` seconds counts only once a
|
||||
@@ -162,47 +131,6 @@ def clear_error(ctx, g):
|
||||
"the controller answered %s to a blank line at the start - not a clean parser", r)
|
||||
|
||||
|
||||
MOVE_START_S = 2.0 # Run is there within 100 ms; a loaded board gets this long
|
||||
|
||||
|
||||
def start_move(ctx, g, line):
|
||||
"""Start a travel job with `line` and see the controller in Run. The
|
||||
wait is bounded, not a fixed sleep. When Run never comes, the failure
|
||||
names what happened instead: the reply (a refused block answers
|
||||
error), the state the controller sits in (Hold, Door, Alarm, or an
|
||||
Idle that never took the block), every line it said meanwhile, and
|
||||
the machine as forgectrl sees it - the evidence a bare "did not
|
||||
start" leaves out. Recorded in the evidence either way."""
|
||||
reply = g.command(line, timeout=1.0)
|
||||
end = time.time() + MOVE_START_S
|
||||
t0 = time.time()
|
||||
said = ""
|
||||
st = None
|
||||
while time.time() < end:
|
||||
ctx.checkpoint()
|
||||
st = g.status_report()
|
||||
if st["state"].startswith("Run"):
|
||||
break
|
||||
said += g.drain()
|
||||
time.sleep(0.1)
|
||||
rec = {"line": line, "reply": reply, "state": st["state"] if st else None,
|
||||
"after_s": round(time.time() - t0, 2)}
|
||||
ctx.evidence.setdefault("move_start", []).append(rec)
|
||||
if st is not None and st["state"].startswith("Run"):
|
||||
return st
|
||||
msgs = [ln.strip() for ln in said.splitlines() if ln.strip()]
|
||||
rec["said"] = msgs
|
||||
s = ctx.forgectrl.status() or {}
|
||||
gr = ((s.get("grbl") or {}).get("report") or {})
|
||||
rec["forgectrl"] = {"state": s.get("state"), "switches": s.get("switches"),
|
||||
"laser_locked": s.get("laser_locked"), "grbl": gr.get("state"),
|
||||
"alarm": gr.get("alarm"), "sender": (gr.get("sender") or {}).get("connected")}
|
||||
ctx.check(False, "the move did not start: %s answered %s; controller %s %.1f s later, said %s; "
|
||||
"forgectrl sees kernel %s, grbl %s (alarm %s), switches %s, laser locked %s",
|
||||
line, reply, st["state"] if st else "no report", rec["after_s"], msgs or "nothing",
|
||||
s.get("state"), gr.get("state"), gr.get("alarm"), s.get("switches"), s.get("laser_locked"))
|
||||
|
||||
|
||||
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=1,
|
||||
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
|
||||
@@ -631,99 +559,6 @@ def _wait_controller(ctx, fc, states, timeout, motion=None):
|
||||
return m if isinstance(m, dict) else {}
|
||||
|
||||
|
||||
def _button_led(ctx, window_s=1.2):
|
||||
"""The commanded level of the button's red, green, and blue channels
|
||||
(ledtrig_smooth's target), sampled over a blink period or two and
|
||||
reduced to the maximum per channel: with a pulse set, target reads 0
|
||||
through the off half of every blink. None when the LEDs are not
|
||||
readable."""
|
||||
best = None
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < window_s:
|
||||
out = []
|
||||
for ch in (1, 2, 3):
|
||||
try:
|
||||
with open("/sys/class/leds/button_led_%d/target" % ch, "r", encoding="utf-8") as f:
|
||||
out.append(int(f.read().strip()))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
best = out if best is None else [max(a, b) for a, b in zip(best, out)]
|
||||
ctx.sleep(0.1)
|
||||
return best
|
||||
|
||||
|
||||
@test("motion.gate-waits-for-lid", title="The motion gate waits for the lid", subsystem="motion",
|
||||
kind="operator", est_min=2,
|
||||
covers=[("forgectrl", "src/super.*"), ("forgectrl", "src/liveness.*"), ("forgectrl", "src/led.*"),
|
||||
("forgectrl", "src/ui/panel.js")],
|
||||
requires=["motion.liveness-probe"], actions=["lid"],
|
||||
steps=["The lid opens, forgectrl is restarted (the gate runs at the first spawn of a "
|
||||
"session: power-on, a daemon restart), the lid closes: the head does not move "
|
||||
"while the lid is open, then makes the 15 mm probe move (+X first)."],
|
||||
description="With the lid open when forgectrl starts (power-on with the lid up, or a "
|
||||
"daemon restart) the supervisor starts nothing: GET /mode reports controller "
|
||||
"waiting with why naming the lid, motion unverified, no pid, the button blinks "
|
||||
"amber, and no probe line reaches the log. When the lid closes the probe runs "
|
||||
"(MOTION OK), the lens takes its reference, and the controller comes up "
|
||||
"verified with the button handed back.")
|
||||
def gate_waits_for_lid(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
|
||||
try:
|
||||
ctx.act("lid", "open")
|
||||
off = _log_offset(FORGECTRL_LOG)
|
||||
rc, out = hw.initd("forgectrl", "restart")
|
||||
ctx.check(rc == 0, "forgectrl restart -> rc %s", rc)
|
||||
m = _wait_controller(ctx, fc, ("waiting", "running", "motion-fault"), 60)
|
||||
ev["waiting"] = m
|
||||
ctx.log("mode with the lid open: %s", m)
|
||||
ctx.check(m.get("controller") == "waiting",
|
||||
"controller is %r with the lid open, expected waiting", m.get("controller"))
|
||||
ctx.check("lid" in (m.get("why") or ""), "why does not name the lid: %r", m.get("why"))
|
||||
ctx.check(m.get("motion") == "unverified", "motion reads %r while waiting", m.get("motion"))
|
||||
ctx.check(not m.get("pid"), "a controller (pid %s) runs while the gate waits", m.get("pid"))
|
||||
led = _button_led(ctx)
|
||||
ev["led_waiting"] = led
|
||||
ctx.log("button LED targets while waiting (r, g, b): %s", led)
|
||||
ctx.check(led is not None and led[0] > 0 and led[1] > 0 and led[2] == 0,
|
||||
"the button does not blink amber while the gate waits: %s", led)
|
||||
ctx.sleep(3)
|
||||
st, m2 = fc.get("/mode")
|
||||
ctx.check(isinstance(m2, dict) and m2.get("controller") == "waiting", "the wait did not hold: %s", m2)
|
||||
lines = _probe_lines(FORGECTRL_LOG, off)
|
||||
ctx.check(not lines, "the probe ran with the lid open: %s", lines[:1])
|
||||
|
||||
t_close = time.time()
|
||||
ctx.act("lid", "close")
|
||||
m = _wait_controller(ctx, fc, ("running", "motion-fault"), 150, motion="verified")
|
||||
ev["after_close"] = m
|
||||
ev["close_to_running_s"] = round(time.time() - t_close, 1)
|
||||
ctx.log("mode %.1f s after the lid closed: %s", time.time() - t_close, m)
|
||||
ctx.check(m.get("controller") == "running" and m.get("motion") == "verified",
|
||||
"the controller did not come up verified after the lid closed: %s", m)
|
||||
t1 = time.time()
|
||||
lines = _probe_lines(FORGECTRL_LOG, off)
|
||||
while not lines and time.time() - t1 < 10:
|
||||
ctx.sleep(0.5)
|
||||
lines = _probe_lines(FORGECTRL_LOG, off)
|
||||
ev["probe_lines"] = lines[-3:]
|
||||
ctx.check(lines and "MOTION OK" in lines[0],
|
||||
"the probe after the lid closed was not MOTION OK: %s", lines[:1])
|
||||
led = _button_led(ctx)
|
||||
ev["led_running"] = led
|
||||
ctx.check(led is None or led[0] == 0 or led[2] > 0,
|
||||
"the amber wait pattern is still on the button after the start: %s", led)
|
||||
finally:
|
||||
if ctx.switch("lid") is False:
|
||||
ctx.act("lid", "close", fail=False)
|
||||
m = _wait_controller(ctx, fc, ("running", "motion-fault"), 150)
|
||||
if m.get("controller") != "running":
|
||||
fc.post("/controller/start")
|
||||
_wait_controller(ctx, fc, ("running", "motion-fault"), 150)
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the drill")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- cancel / abort
|
||||
|
||||
@test("motion.cancel-abort", title="Jog cancel and controlled abort recover cleanly", subsystem="motion",
|
||||
@@ -1144,139 +979,8 @@ def homing_kill(ctx, wait_running):
|
||||
ctx.log("restore homing_mode=%r -> %s", hm, st)
|
||||
|
||||
|
||||
@test("motion.respawn-gate", title="A respawn waits for the lid the way a first spawn does",
|
||||
subsystem="motion", kind="operator", mode="grbl", est_min=2,
|
||||
covers=_MOTION_COVERS, requires=["motion.deadman", "motion.gate-waits-for-lid"], actions=["lid"],
|
||||
steps=["Bed clear. Open the lid when told and close it when told; nothing moves."],
|
||||
description="The enclosure check runs before every controller spawn, respawns included. "
|
||||
"With the lid open, the controller is killed: the supervisor safes the machine "
|
||||
"(latch locked), reports waiting with why naming the lid, and starts no controller "
|
||||
"while the lid stays open. When the lid closes the controller comes back verified, "
|
||||
"without a second motion probe (the probe is once per broker hold).")
|
||||
def respawn_gate(ctx):
|
||||
import os as _os
|
||||
import signal as _signal
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
|
||||
st, m0 = fc.get("/mode")
|
||||
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
|
||||
pid0 = m0.get("pid")
|
||||
off = _log_offset(FORGECTRL_LOG)
|
||||
ctx.act("lid", "open")
|
||||
try:
|
||||
ctx.sleep(1.0)
|
||||
_os.kill(pid0, _signal.SIGKILL)
|
||||
t_kill = time.time()
|
||||
ctx.log("SIGKILL sent to controller pid %d with the lid open", pid0)
|
||||
waited = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("controller") == "waiting", 15)
|
||||
st, m1 = fc.get("/mode")
|
||||
ev["after_kill"] = {"waiting_s": waited, "mode": m1}
|
||||
ctx.log("mode %.1f s after the kill: %s", time.time() - t_kill, m1)
|
||||
ctx.check(waited is not None, "the supervisor did not report waiting for the lid: %s", m1)
|
||||
ctx.check("lid" in (m1.get("why") or ""), "why does not name the lid: %r", m1.get("why"))
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
ctx.check(ilk is not None and ilk & (1 << 3), "latch not locked after the kill")
|
||||
ctx.sleep(4.0)
|
||||
st, m2 = fc.get("/mode")
|
||||
ctx.check(isinstance(m2, dict) and m2.get("controller") == "waiting" and not m2.get("pid"),
|
||||
"the wait did not hold with the lid open: %s", m2)
|
||||
finally:
|
||||
ctx.act("lid", "close")
|
||||
t_close = time.time()
|
||||
came = ctx.wait_for(lambda: ((fc.get("/mode")[1] or {}).get("controller") == "running"
|
||||
and (fc.get("/mode")[1] or {}).get("pid") != pid0), 60)
|
||||
st, m3 = fc.get("/mode")
|
||||
ev["after_close"] = {"running_s": came, "mode": m3}
|
||||
ctx.log("mode %.1f s after the lid closed: %s", time.time() - t_close, m3)
|
||||
ctx.check(came is not None and m3.get("motion") == "verified",
|
||||
"the controller did not come back verified after the lid closed: %s", m3)
|
||||
lines = _probe_lines(FORGECTRL_LOG, off)
|
||||
ev["probe_lines"] = lines[-2:]
|
||||
ctx.check(not lines, "the motion probe ran again for the respawn: %s", lines[:1])
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the end")
|
||||
ctx.log("PASS: the respawn waited %.1f s for the lid, then came back verified in %.1f s with no probe",
|
||||
t_close - t_kill, came)
|
||||
|
||||
|
||||
@test("motion.soft-limits", title="After a home the bed is the X/Y envelope",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=3,
|
||||
covers=_MOTION_COVERS, requires=["motion.jog-roundtrip", "cloud.mode-switch"],
|
||||
steps=["Bed clear, lid closed. The test homes the machine through the web service if it is "
|
||||
"not homed (about a minute), then sends moves the controller must refuse."],
|
||||
description="The machine has no limit switches, so the core's $20 cannot be turned on and "
|
||||
"the X/Y soft limits are the driver's: off while the position is not trusted, on "
|
||||
"after a home, when the envelope is the bed ($130 by $131 from the home corner). "
|
||||
"Homed, a program move 5 mm past X max or Y max, or past the near edge, raises "
|
||||
"ALARM:2 before any motion (the kernel counters do not move), a jog past the bed "
|
||||
"is refused with error 15, and a move inside the bed runs. The Z envelope is the "
|
||||
"lens window, as before.")
|
||||
def soft_limits(ctx):
|
||||
from .cloud import gfhome_homing
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
if not fc.status().get("homed"):
|
||||
gfhome_homing(ctx, ev, g)
|
||||
ctx.check(fc.status().get("homed"), "the machine is not homed")
|
||||
x_travel = float(grbl_setting(g, "$130"))
|
||||
y_travel = float(grbl_setting(g, "$131"))
|
||||
ev["travel"] = {"x": x_travel, "y": y_travel}
|
||||
k0 = kernel_xy_mm(ctx)
|
||||
|
||||
def refused(cmd):
|
||||
lines = g.command(cmd, timeout=2)
|
||||
ctx.sleep(0.5)
|
||||
text = "\n".join(lines) + g.drain()
|
||||
k1 = kernel_xy_mm(ctx)
|
||||
moved = max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1]))
|
||||
st = g.status_report()["state"]
|
||||
rec = {"cmd": cmd, "reply": lines, "alarm": "ALARM:2" in text, "moved_mm": round(moved, 3), "state": st}
|
||||
ctx.log("%s", rec)
|
||||
ctx.check(rec["alarm"], "%s was not refused with ALARM:2: %s", cmd, lines)
|
||||
ctx.check(moved < 0.05, "%s moved the kernel %.3f mm before the alarm", cmd, moved)
|
||||
g.realtime(0x18)
|
||||
ctx.sleep(1.5)
|
||||
g.drain()
|
||||
unlock = g.command("$X")
|
||||
ctx.check(unlock and unlock[-1] == "ok", "$X after the soft-limit alarm: %s", unlock)
|
||||
st = g.status_report()["state"]
|
||||
ctx.check(st.startswith("Idle"), "controller is %s after the recovery", st)
|
||||
return rec
|
||||
|
||||
ev["refused"] = [refused("G90 G1 X%.1f F600" % (x_travel + 5)),
|
||||
refused("G90 G1 Y%.1f F600" % (y_travel + 5)),
|
||||
refused("G90 G1 X-1 F600")]
|
||||
# Homed still: the soft-limit alarm and its reset keep the reference.
|
||||
ctx.check(fc.status().get("homed"), "the soft-limit alarm and the reset un-homed the machine")
|
||||
jog = g.command("$J=G91X%.1fF1200" % (x_travel + 5))
|
||||
ev["jog"] = jog
|
||||
ctx.check(any(l.startswith("error:15") for l in jog), "a jog past the bed was not refused with error 15: %s", jog)
|
||||
# The core answers the line after an error with that error again
|
||||
# until an empty line clears it (the sender's acknowledgment).
|
||||
g.command("")
|
||||
g.command("G90 G1 X10 Y10 F600", timeout=0.5)
|
||||
peak, states, st = wait_idle(ctx, g, 20)
|
||||
ev["inside"] = states
|
||||
ctx.check("TIMEOUT" not in states, "a move inside the bed did not run")
|
||||
g.command("G90 G1 X0 Y0 F600", timeout=0.5)
|
||||
wait_idle(ctx, g, 20)
|
||||
k1 = kernel_start(ctx) # at rest: grbl's Idle leads the kernel's tail
|
||||
ev["kernel_back_mm"] = [round(k1[0] - k0[0], 3), round(k1[1] - k0[1], 3)]
|
||||
ctx.check(max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1])) < 0.1, "the head did not come back to the corner: %s",
|
||||
ev["kernel_back_mm"])
|
||||
ctx.log("PASS: X max, Y max and X min refused with ALARM:2 and no motion, the jog refused with "
|
||||
"error 15, a move inside the bed ran")
|
||||
|
||||
|
||||
# ------------------------------------------------- lid / button (the factory's)
|
||||
|
||||
_LID_COVERS = _MOTION_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
|
||||
("grblhal-glowforge", "src/glowforge_switch_map.h"),
|
||||
("grblhal-glowforge", "src/glowforge_laser.c")]
|
||||
|
||||
|
||||
def kernel_xy_mm(ctx):
|
||||
"""The kernel's own position counters, in mm (forgectrl /status pos):
|
||||
what the machine physically did, independent of what grbl believes."""
|
||||
@@ -1344,335 +1048,6 @@ def drain_text(g, seconds):
|
||||
return text
|
||||
|
||||
|
||||
def expect_cancel_and_return(ctx, g, ev, start, k0, why, tag):
|
||||
"""The cancel policy's whole tail, shared by every trigger that ends a
|
||||
job this way (lid or interlock, from Run or from a hold): the reason is
|
||||
reported, the controller resets without an alarm and with the position
|
||||
kept, and the head goes back to where the job started - which the KERNEL
|
||||
counters have to confirm, not grbl's belief about them."""
|
||||
text = drain_text(g, 3.0)
|
||||
msgs = [ln for ln in text.splitlines()
|
||||
if ln.startswith("[MSG:") or "help]" in ln or ln.startswith("ALARM")]
|
||||
ev[tag + "_messages"] = msgs
|
||||
ctx.log("[%s] controller: %s", tag, msgs)
|
||||
cancel_msg = "%s - job canceled" % why
|
||||
ctx.check(cancel_msg in text, "[%s] the job was not canceled with %r as the reason", tag, why)
|
||||
ctx.check("help]" in text, "[%s] no reset banner after the cancel (the sender must see the job end)", tag)
|
||||
ctx.check("ALARM" not in text, "[%s] an alarm was raised on the cancel (position should be kept)", tag)
|
||||
t0 = time.time()
|
||||
returned = "returned to the job start" in text
|
||||
while not returned and time.time() - t0 < 30:
|
||||
ctx.checkpoint()
|
||||
text += g.drain()
|
||||
returned = "returned to the job start" in text
|
||||
time.sleep(0.2)
|
||||
ev[tag + "_returned_message"] = returned
|
||||
ctx.check(returned, "[%s] the head did not report returning to the job start within 30 s", tag)
|
||||
st = wait_state(ctx, g, "Idle", 5)
|
||||
ctx.check(st is not None, "[%s] not Idle after the return (state %s)", tag,
|
||||
g.status_report()["state"])
|
||||
drift = max(abs(st["MPos"][i] - start[i]) for i in range(2))
|
||||
ev[tag + "_drift_mm"] = round(drift, 3)
|
||||
ctx.log("[%s] back at the job start: drift %.3f mm", tag, drift)
|
||||
ctx.check(drift <= 0.05, "[%s] head not back at the job start (drift %.3f mm)", tag, drift)
|
||||
machine_idle(ctx, 10)
|
||||
check_kernel_returned(ctx, ev, k0, tag=tag)
|
||||
return drift
|
||||
|
||||
|
||||
@test("motion.button-hold-resume", title="The button pauses and resumes a job",
|
||||
subsystem="motion", kind="operator", mode="grbl", est_min=2,
|
||||
covers=_LID_COVERS, requires=["motion.pacing"], actions=["button"],
|
||||
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
|
||||
"On Ready the head starts an 8 s move: press the button once while it moves (the "
|
||||
"job holds), then once more when told (it resumes and finishes)."],
|
||||
description="A travel job is running; one press of the big button feed-holds it (the sender "
|
||||
"sees Hold), the next press resumes it (Run) and the move completes with its "
|
||||
"position intact - the factory's pause/resume on the machine.")
|
||||
def button_hold_resume(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
start = g.status_report()["MPos"]
|
||||
ctx.ready("On Ready the head starts an 8 s move along +X. Press the button ONCE while it "
|
||||
"moves; the job holds and the test sees it.")
|
||||
g.command("M5")
|
||||
g.command("G91")
|
||||
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||
g.drain() # the message window opens here
|
||||
w = Watch(g)
|
||||
ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
|
||||
st, text = w.last, w.text
|
||||
ctx.check(st is not None and st["state"].startswith("Hold"),
|
||||
"the press did not hold the job (state %s)", st["state"] if st else "?")
|
||||
ev["held_state"] = st["state"]
|
||||
ev["held_at_mm"] = round(st["MPos"][0] - start[0], 3)
|
||||
ev["pause_message"] = "job paused" in text
|
||||
ctx.log("held: %s at %.3f mm of 40; message seen: %s", st["state"], ev["held_at_mm"],
|
||||
ev["pause_message"])
|
||||
# The press is proven by the job LEAVING the hold. Catching it in Run
|
||||
# is a race: a pause late in the move leaves a fraction of a second of
|
||||
# travel, which can be over before the next poll - the machine did
|
||||
# exactly the right thing and the test would still have called it a
|
||||
# failure.
|
||||
w = Watch(g)
|
||||
ctx.act("button", "press", text="The head is stopped: the press resumes the move.",
|
||||
until=w.left_state("Hold"), timeout=60, fail=False)
|
||||
st, text = w.last, w.text
|
||||
if st is not None and st["state"].startswith("Hold"):
|
||||
st = None
|
||||
ev["state_after_resume"] = st["state"] if st else None
|
||||
ev["resume_message"] = "job resumed" in text
|
||||
ctx.check(st is not None, "the second press did not resume the job (still held: %s)",
|
||||
g.status_report()["state"])
|
||||
# Leaving the hold for Run or Idle is the resume; leaving it for Alarm
|
||||
# or Door is something else entirely, and must not read as a pass.
|
||||
ctx.check(st["state"].startswith(("Run", "Idle")),
|
||||
"the job left the hold into %s, not into motion", st["state"])
|
||||
ctx.log("resumed: %s; message seen: %s", ev["state_after_resume"], ev["resume_message"])
|
||||
peak, states, st = wait_idle(ctx, g, 40)
|
||||
ctx.check("TIMEOUT" not in states, "the resumed move did not complete: %s", states)
|
||||
moved = st["MPos"][0] - start[0]
|
||||
ev["moved_mm"] = round(moved, 3)
|
||||
ctx.log("move completed after pause/resume: %.3f mm of 40", moved)
|
||||
ctx.check(abs(moved - 40.0) <= 0.05, "the resumed move did not land on its target (%.3f mm)", moved)
|
||||
g.command("$J=G91X-40F2400")
|
||||
wait_idle(ctx, g, 30)
|
||||
g.command("G90")
|
||||
machine_idle(ctx)
|
||||
ctx.log("PASS: button press held the job (%s), the next press resumed it, target reached", ev["held_state"])
|
||||
|
||||
|
||||
@test("motion.lid-cancel-home", title="Lid open during a job - running or paused - cancels it and returns "
|
||||
"to the job start",
|
||||
subsystem="motion", kind="operator", mode="grbl", est_min=5,
|
||||
covers=_LID_COVERS, requires=["motion.pacing", "motion.cancel-abort"],
|
||||
actions=["lid", "button"],
|
||||
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
|
||||
"Twice, on Ready the head starts an 8 s move. First: open the lid while it moves and "
|
||||
"leave it open until the head has come back, then close it. Second: press the button "
|
||||
"once while it moves (pause), then open the lid, leave it open until the head is back, "
|
||||
"and close it."],
|
||||
description="A travel job is running when the lid opens: the job parks (planned deceleration), "
|
||||
"the reason is reported, the controller resets (position kept, no alarm - the "
|
||||
"sender's job is over), and the head returns on its own to where the job started "
|
||||
"with the lid still open; the controller ends Idle at the start position. The same "
|
||||
"job paused on the button takes the same path - a lid open from the hold cancels "
|
||||
"and returns, it never resumes. With lid_policy=cancel (the default).")
|
||||
def lid_cancel_home(ctx):
|
||||
ev = ctx.evidence
|
||||
policy = (ctx.forgectrl.settings() or {}).get("lid_policy") or "cancel"
|
||||
ev["lid_policy"] = policy
|
||||
ctx.check(policy == "cancel", "lid_policy is %r; this test needs cancel", policy)
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
k0 = kernel_start(ctx)
|
||||
start = g.status_report()["MPos"]
|
||||
ev["kernel_start"] = k0
|
||||
ev["start"] = start
|
||||
ctx.ready("On Ready the head starts an 8 s move along +X. Open the lid while it moves and "
|
||||
"leave it open until the head has come back on its own.")
|
||||
g.command("M5")
|
||||
g.command("G91")
|
||||
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||
g.drain() # the message window opens here
|
||||
ctx.act("lid", "open", text="Leave it open until the head has come back.", timeout=12)
|
||||
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "lid opened", "running")
|
||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
||||
ev["lid_at_return"] = sw.get("lid")
|
||||
ctx.act("lid", "close")
|
||||
ctx.sleep(1)
|
||||
# a jog afterward proves the controller is usable without $X
|
||||
r = g.command("$J=G91X5F1200")
|
||||
ctx.check(not any(x.startswith("error") for x in r), "jog refused after the cancel: %s", r)
|
||||
wait_idle(ctx, g, 15)
|
||||
g.command("$J=G91X-5F1200")
|
||||
wait_idle(ctx, g, 15)
|
||||
|
||||
# -- the same cancel, entered from a hold ---------------------------
|
||||
# A job paused on the button must not be resumable past a lid open:
|
||||
# the armed window and the hardware button latch have to agree, so
|
||||
# the lid ends the job here exactly as it does from Run.
|
||||
# The reference for this phase is taken only once the MACHINE is at
|
||||
# rest: the jogs above waited for grblHAL's Idle, which arrives while
|
||||
# the kernel is still playing their tail.
|
||||
k1 = kernel_start(ctx)
|
||||
start2 = g.status_report()["MPos"]
|
||||
ev["hold_start"] = start2
|
||||
ctx.ready("On Ready the head starts the 8 s move again. Press the button ONCE while it "
|
||||
"moves (the job pauses); then open the lid and leave it open until the head has "
|
||||
"come back.")
|
||||
g.command("G91") # the reset restored G90
|
||||
g.command("G1X40F300", timeout=0.5)
|
||||
ctx.sleep(0.5)
|
||||
ctx.check(g.status_report()["state"].startswith("Run"), "the second move did not start")
|
||||
g.drain()
|
||||
w = Watch(g)
|
||||
ctx.act("button", "press", text="The job pauses.", until=w.in_state("Hold"), timeout=12,
|
||||
fail=False)
|
||||
st, held = w.last, w.text
|
||||
ctx.check(st is not None and st["state"].startswith("Hold"),
|
||||
"the press did not hold the job (state %s)", st["state"] if st else "?")
|
||||
ev["hold_state"] = st["state"]
|
||||
ev["hold_pause_message"] = "job paused" in held
|
||||
ctx.log("paused: %s; message seen: %s", st["state"], ev["hold_pause_message"])
|
||||
g.drain()
|
||||
ctx.act("lid", "open", text="The job is paused: leave the lid open until the head has come "
|
||||
"back.", timeout=60)
|
||||
hold_drift = expect_cancel_and_return(ctx, g, ev, start2, k1, "lid opened", "hold")
|
||||
ctx.check(not g.status_report()["state"].startswith("Hold"),
|
||||
"the controller is still holding after the lid canceled the paused job")
|
||||
ctx.act("lid", "close")
|
||||
ctx.sleep(1)
|
||||
r = g.command("$J=G91X5F1200")
|
||||
ctx.check(not any(x.startswith("error") for x in r), "jog refused after the paused cancel: %s", r)
|
||||
wait_idle(ctx, g, 15)
|
||||
g.command("$J=G91X-5F1200")
|
||||
wait_idle(ctx, g, 15)
|
||||
g.command("G90")
|
||||
machine_idle(ctx)
|
||||
ctx.log("PASS: lid open canceled the job from Run (drift %.3f mm) and from the hold (drift %.3f mm), "
|
||||
"reset without alarm, head returned to the start both times", drift, hold_drift)
|
||||
|
||||
|
||||
@test("motion.interlock-cancel-home", title="The interlock loop cancels a job like the lid and returns to "
|
||||
"the job start",
|
||||
subsystem="motion", kind="operator", mode="grbl", est_min=4,
|
||||
covers=_LID_COVERS, requires=["motion.lid-cancel-home"], actions=["interlock"],
|
||||
steps=["Bed clear; the head needs 60 mm of free +X travel. No laser is involved.",
|
||||
"Be able to open the remote-interlock loop: unplug the Pro's interlock plug, or pull the "
|
||||
"jumper at J8 on a Basic/Plus.",
|
||||
"On Ready the head starts a 12 s move: open the interlock while it moves and leave it "
|
||||
"open until the head has come back, then restore it."],
|
||||
description="The remote-interlock loop is the lid's equal in the cancel policy: opening it mid-job "
|
||||
"cancels the job with 'interlock open' named as the reason - the lid's own message would "
|
||||
"be wrong here - and sends the head back to the job start with the loop still open. "
|
||||
"(An open lid not stopping the park is the lid test's park, which runs with the lid open "
|
||||
"throughout; in GRBL mode the park is a rapid too short to open a lid during, so the "
|
||||
"mid-park lid edge is the cloud test's.)")
|
||||
def interlock_cancel_home(ctx):
|
||||
ev = ctx.evidence
|
||||
policy = (ctx.forgectrl.settings() or {}).get("lid_policy") or "cancel"
|
||||
ev["lid_policy"] = policy
|
||||
ctx.check(policy == "cancel", "lid_policy is %r; this test needs cancel", policy)
|
||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
||||
ctx.check(sw.get("interlock_ok"), "the interlock loop already reads open - close it before this test")
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
k0 = kernel_start(ctx)
|
||||
start = g.status_report()["MPos"]
|
||||
ev["start"] = start
|
||||
ev["kernel_start"] = k0
|
||||
ctx.ready("On Ready the head starts a 12 s move along +X. Open the interlock loop while it "
|
||||
"moves and leave it open until the head has come back on its own.")
|
||||
g.command("M5")
|
||||
g.command("G91")
|
||||
start_move(ctx, g, "G1X60F300") # a 12 s move
|
||||
g.drain()
|
||||
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
|
||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
||||
ev["interlock_ok_after_pull"] = sw.get("interlock_ok")
|
||||
ctx.check(sw.get("interlock_ok") is False,
|
||||
"the interlock still reads closed - the loop was not opened (switches: %s)", sw)
|
||||
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "interlock open", "interlock")
|
||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
||||
ev["switches_at_return"] = {"lid": sw.get("lid"), "interlock_ok": sw.get("interlock_ok")}
|
||||
ctx.log("at the end of the park: %s", ev["switches_at_return"])
|
||||
ctx.check(sw.get("interlock_ok") is False,
|
||||
"the interlock was closed again before the park finished - the park ran with the loop "
|
||||
"restored, not open")
|
||||
ctx.act("interlock", "close")
|
||||
ctx.sleep(1)
|
||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
||||
ev["restored"] = {"lid": sw.get("lid"), "interlock_ok": sw.get("interlock_ok")}
|
||||
ctx.check(sw.get("interlock_ok"), "the interlock loop is still open - restore it before continuing")
|
||||
r = g.command("$J=G91X5F1200")
|
||||
ctx.check(not any(x.startswith("error") for x in r), "jog refused after the cancel: %s", r)
|
||||
wait_idle(ctx, g, 15)
|
||||
g.command("$J=G91X-5F1200")
|
||||
wait_idle(ctx, g, 15)
|
||||
g.command("G90")
|
||||
machine_idle(ctx)
|
||||
ctx.log("PASS: interlock open canceled the job with its own reason and the head returned to the "
|
||||
"start (drift %.3f mm) with the loop still open", drift)
|
||||
|
||||
|
||||
@test("motion.lid-policy-hold", title="lid_policy=hold parks the job in Door and a cycle start resumes it",
|
||||
subsystem="motion", kind="operator", mode="grbl", est_min=4,
|
||||
covers=_LID_COVERS + [("forgectrl", "src/settings.*")], requires=["motion.lid-cancel-home"],
|
||||
actions=["lid"],
|
||||
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
|
||||
"On Ready the head starts an 8 s move: open the lid while it moves (the job parks in "
|
||||
"Door), then close it when told; the job finishes after that."],
|
||||
description="The other lid policy, kept for senders that expect stock grblHAL: with "
|
||||
"lid_policy=hold a lid open parks the job in the door state and holds it there - "
|
||||
"no cancel, no return home - and once the lid is closed a cycle start finishes the "
|
||||
"move with its position intact. The setting is restored to cancel at the end.")
|
||||
def lid_policy_hold(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
# What the machine had, exactly: an unset lid_policy reads as the empty
|
||||
# string and behaves as cancel, and writing the word back where the
|
||||
# machine had nothing is a leftover the hand-back reports. The default
|
||||
# is for reading the value, never for restoring it.
|
||||
was = (fc.settings() or {}).get("lid_policy", "")
|
||||
ev["lid_policy_before"] = was
|
||||
ev["lid_policy_in_force"] = was or "cancel"
|
||||
st, _b = fc.post("/settings", data={"lid_policy": "hold"})
|
||||
ctx.check(st == 200, "could not set lid_policy=hold (%s)", st)
|
||||
ctx.check(((fc.settings() or {}).get("lid_policy")) == "hold", "lid_policy did not take")
|
||||
try:
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
start = g.status_report()["MPos"]
|
||||
ctx.ready("On Ready the head starts an 8 s move along +X. Open the lid while it moves; "
|
||||
"the job parks in the door state and waits.")
|
||||
g.command("M5")
|
||||
g.command("G91")
|
||||
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||
g.drain()
|
||||
ctx.act("lid", "open", timeout=12)
|
||||
st, text = wait_state_text(ctx, g, "Door", 8)
|
||||
ev["door_state"] = st["state"] if st else g.status_report()["state"]
|
||||
ctx.check(st is not None, "the lid did not park the job in Door (state %s)", ev["door_state"])
|
||||
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:")]
|
||||
ctx.check("job canceled" not in text, "the job was canceled under lid_policy=hold: %s", ev["messages"])
|
||||
ctx.check("returned to the job start" not in text,
|
||||
"the head returned to the job start under lid_policy=hold")
|
||||
held = g.status_report()["MPos"]
|
||||
ev["parked_at"] = held
|
||||
ctx.log("parked in %s at %s", ev["door_state"], held)
|
||||
ctx.act("lid", "close")
|
||||
ctx.sleep(1)
|
||||
ev["state_after_close"] = g.status_report()["state"]
|
||||
ctx.log("after the lid closed: %s (a cycle start is needed)", ev["state_after_close"])
|
||||
g.realtime(0x7E) # ~ cycle start
|
||||
peak, states, st = wait_idle(ctx, g, 30)
|
||||
ev["states_after_resume"] = states
|
||||
ctx.check("TIMEOUT" not in states, "the job did not finish after the resume: %s", states)
|
||||
final = st["MPos"]
|
||||
moved = final[0] - start[0]
|
||||
ev["moved_mm"] = round(moved, 3)
|
||||
ctx.log("finished: moved %.3f mm of 40 (states %s)", moved, states)
|
||||
ctx.check(abs(moved - 40.0) <= 0.05,
|
||||
"the resumed job did not finish its move (%.3f mm of 40)", moved)
|
||||
g.command("$J=G91X-40F1200")
|
||||
wait_idle(ctx, g, 30)
|
||||
g.command("G90")
|
||||
machine_idle(ctx)
|
||||
finally:
|
||||
# An empty value clears the key, and only the query-string form
|
||||
# carries one: an empty form field never reaches the request, and
|
||||
# the write is refused with "no known setting in request".
|
||||
st, _b = (fc.post("/settings", params={"lid_policy": ""}) if not was
|
||||
else fc.post("/settings", data={"lid_policy": was}))
|
||||
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy", "")
|
||||
ctx.log("lid_policy restored to %s", ev["lid_policy_restored"])
|
||||
ctx.check(ev["lid_policy_restored"] == was, "lid_policy was not restored to %r", was)
|
||||
ctx.log("PASS: lid_policy=hold parked the job in Door and the cycle start finished it (%.3f mm)",
|
||||
ev["moved_mm"])
|
||||
|
||||
|
||||
GRBLHAL_LOG = "/data/log/forgefirm/grblhal/grblhal.log"
|
||||
SCHED_FIFO = 1
|
||||
|
||||
@@ -1778,3 +1153,196 @@ def step_timing_under_load(ctx):
|
||||
"; ".join(clamped))
|
||||
ctx.log("PASS: %d legs at 2000 mm/min against a nice-5 CPU hog, no clamped events",
|
||||
ev["legs"])
|
||||
|
||||
|
||||
# ------------------------------------------------- motor release
|
||||
|
||||
HOLD_CURRENTS = ("33", "5") # pic/x_step_current, pic/y_step_current at idle
|
||||
|
||||
|
||||
def _step_currents():
|
||||
return (hw.sysfs_read("pic/x_step_current"), hw.sysfs_read("pic/y_step_current"))
|
||||
|
||||
|
||||
def _rail_lines():
|
||||
"""How many times the kernel has logged the 40 V supply going on or off."""
|
||||
rc, out = hw.run(["dmesg"])
|
||||
if rc != 0:
|
||||
return -1
|
||||
return sum(1 for line in out.splitlines() if "40V on" in line or "40V off" in line)
|
||||
|
||||
|
||||
def _kernel_position():
|
||||
"""The kernel's position record: (x, y, z) in steps, then the bytes it
|
||||
has played and the bytes it was handed. Nothing moves without the last
|
||||
two moving, which makes them the witness for "nothing was shipped": the
|
||||
head accelerometer cannot say it, since the rotors relax off their held
|
||||
microstep when the drivers let go, when the currents drop to hold, and at
|
||||
a reset, and the head feels each of those."""
|
||||
with open(hw.sysfs_root() + "cnc/position", "rb") as f:
|
||||
raw = f.read(32)
|
||||
return struct.unpack_from("<3i", raw, 0) + struct.unpack_from("<2I", raw, 12)
|
||||
|
||||
|
||||
def _refused(ctx, g, line):
|
||||
"""Send a line that must be refused; acknowledge the parser error it
|
||||
leaves (clear_error has the why) and return the error."""
|
||||
r = g.command(line)
|
||||
err = next((x for x in r if x.startswith("error")), None)
|
||||
if err is not None:
|
||||
g.command("")
|
||||
return err
|
||||
|
||||
|
||||
def _drop_reference(ctx, fc):
|
||||
"""A false home is not left behind: a fresh controller starts unhomed."""
|
||||
fc.post("/controller/stop")
|
||||
_wait_controller(ctx, fc, ("stopped", "standby"), 30)
|
||||
fc.post("/controller/start")
|
||||
m = _wait_controller(ctx, fc, ("running",), 120, motion="verified")
|
||||
ctx.check(m.get("controller") == "running", "the controller did not come back: %s", m)
|
||||
|
||||
|
||||
@test("motion.release", title="Motor release: nothing moves a released gantry, only the operator energizes it",
|
||||
subsystem="motion", kind="auto", hardware="takeover", mode="grbl", est_min=8,
|
||||
covers=_MOTION_COVERS + [("forgectrl", "src/status.*"), ("forgectrl", "src/wizdark.*")],
|
||||
requires=["motion.jog-roundtrip"],
|
||||
steps=["Bed clear; the head needs 50 mm of free travel each way on X and Y. Nobody touches the gantry."],
|
||||
description="$MD releases X and Y by taking their step currents to 0, with the 40 V rail "
|
||||
"untouched (no supply line in the kernel log, cnc/state idle, no fault), drops the "
|
||||
"X and Y reference and locks the machine in alarm. While released a jog, a G0 and "
|
||||
"$X are refused, a soft reset does not unlock it, and forgectrl refuses a switch "
|
||||
"to cloud mode; the kernel's position record "
|
||||
"is the witness that nothing was shipped (no step and no byte played), and the head "
|
||||
"accelerometer stays under the moving threshold in the window after the release. "
|
||||
"$ME energizes with no fault. The drivers are then proven alive the way the machine "
|
||||
"proves it to itself: the Setup motion check runs the liveness probe on the held "
|
||||
"device and the witnessed 50 mm jogs.")
|
||||
def motor_release(ctx):
|
||||
from .setup_dark import run_check
|
||||
|
||||
ev = ctx.evidence
|
||||
fc = hw.Forgectrl()
|
||||
accel = hw.AccelSampler()
|
||||
ctx.check(accel.available, "no head accelerometer found (i2c %s): the motion witness is missing",
|
||||
hw.HEAD_ACCEL_I2C)
|
||||
rail0 = _rail_lines()
|
||||
ctx.check(rail0 >= 0, "the kernel log is unreadable: the rail witness is missing")
|
||||
with ctx.grbl() as g, accel:
|
||||
clean_slate(ctx, g)
|
||||
r = g.command("$MD")
|
||||
ctx.check(not any(x.startswith("error") for x in r), "$MD refused at idle: %s", r)
|
||||
text = "\n".join(r) + drain_text(g, 1.0)
|
||||
ev["released_currents"] = list(_step_currents())
|
||||
ev["state_released"] = g.status_report()["state"]
|
||||
ev["kernel_state_released"] = hw.sysfs_read("cnc/state")
|
||||
ev["faults_released"] = hw.sysfs_int("cnc/faults")
|
||||
ev["homed_axes_released"] = fc.status().get("homed_axes")
|
||||
ctx.log("$MD: step currents %s, %s, cnc/state %s, faults %s, homed_axes %s",
|
||||
ev["released_currents"], ev["state_released"], ev["kernel_state_released"],
|
||||
ev["faults_released"], ev["homed_axes_released"])
|
||||
ctx.check(ev["released_currents"] == ["0", "0"],
|
||||
"the step currents read %s after $MD, not 0 and 0", ev["released_currents"])
|
||||
ctx.check(ev["state_released"].startswith("Alarm"), "not locked in alarm: %s", ev["state_released"])
|
||||
ctx.check(ev["kernel_state_released"] == "idle", "the kernel left idle: %s", ev["kernel_state_released"])
|
||||
ctx.check(ev["faults_released"] == 0, "a driver fault on release: %s", ev["faults_released"])
|
||||
ctx.check(not (ev["homed_axes_released"] or 0) & 3, "X or Y still referenced while released")
|
||||
ctx.check("Motors released" in text, "the sender was not told the motors are released")
|
||||
|
||||
# The rotors have relaxed by now: from here on the head is still.
|
||||
ctx.sleep(1.0)
|
||||
k0 = _kernel_position()
|
||||
t0 = time.time()
|
||||
refusals = {}
|
||||
for line in ("$J=G91X10F2400", "G0X10", "$X"):
|
||||
refusals[line] = _refused(ctx, g, line)
|
||||
ctx.check(refusals[line], "%s was accepted while the motors are released", line)
|
||||
ctx.sleep(1.0)
|
||||
p2px, p2py, n = accel.p2p(t0)
|
||||
ev["released_accel_p2p"] = [p2px, p2py, n]
|
||||
ctx.check(n > 0, "no accelerometer samples in the released window")
|
||||
ctx.check(max(p2px, p2py) < hw.ACCEL_P2P_MOVING,
|
||||
"the head moved while released (accel p2p x=%d y=%d)", p2px, p2py)
|
||||
g.realtime(0x18)
|
||||
ctx.sleep(2)
|
||||
g.drain()
|
||||
refusals["^X then $X"] = _refused(ctx, g, "$X")
|
||||
ctx.check(refusals["^X then $X"], "a soft reset and $X unlocked a released machine")
|
||||
ev["state_after_reset"] = g.status_report()["state"]
|
||||
ctx.check(ev["state_after_reset"].startswith("Alarm"),
|
||||
"a soft reset unlocked a released machine: %s", ev["state_after_reset"])
|
||||
k1 = _kernel_position()
|
||||
ev["refusals"] = refusals
|
||||
ev["kernel_position"] = [list(k0), list(k1)]
|
||||
ctx.log("refused while released: %s; accel p2p x=%d y=%d over %d samples; kernel record %s -> %s",
|
||||
refusals, p2px, p2py, n, k0, k1)
|
||||
ctx.check(k1 == k0, "the kernel played something while released: %s -> %s", k0, k1)
|
||||
|
||||
# forgectrl reads the release too: cloud homing moves the head, so the
|
||||
# switch is refused (for the release where cloud mode is enabled, for
|
||||
# cloud mode being off where it is not; the mode stays either way).
|
||||
st, body = fc.post("/mode", data={"controller": "cloud"})
|
||||
text = body if isinstance(body, str) else json.dumps(body)
|
||||
cloud_on = str(fc.settings().get("cloud_enabled", "")).lower() in ("1", "true", "yes", "on")
|
||||
ev["mode_cloud_released"] = {"status": st, "body": text, "cloud_enabled": cloud_on}
|
||||
ctx.log("POST /mode controller=cloud while released -> %s %s", st, text)
|
||||
ctx.check(st == 409, "a switch to cloud mode while released -> %s, expected 409", st)
|
||||
if cloud_on:
|
||||
ctx.check("released" in text, "the refusal does not name the release: %r", text)
|
||||
ctx.check(fc.get("/mode")[1].get("mode") == "grbl", "the mode changed under a released gantry")
|
||||
ctx.check(_step_currents() == ("0", "0"),
|
||||
"something energized the motors: step currents %s", _step_currents())
|
||||
|
||||
r = g.command("$ME")
|
||||
ctx.check(not any(x.startswith("error") for x in r), "$ME refused: %s", r)
|
||||
ctx.sleep(0.5)
|
||||
ev["energized_currents"] = list(_step_currents())
|
||||
ev["faults_energized"] = hw.sysfs_int("cnc/faults")
|
||||
ev["state_energized"] = g.status_report()["state"]
|
||||
ctx.check(tuple(ev["energized_currents"]) == HOLD_CURRENTS,
|
||||
"the step currents read %s after $ME, not the hold currents", ev["energized_currents"])
|
||||
ctx.check(ev["faults_energized"] == 0, "a driver fault on energize: %s", ev["faults_energized"])
|
||||
ctx.check(ev["state_energized"].startswith("Idle"), "not idle after $ME: %s", ev["state_energized"])
|
||||
|
||||
# The drivers are alive: the machine's own witness says so. The liveness
|
||||
# probe is built for the accelerometer (a slow, rough move sampled for
|
||||
# seconds); a quick smooth jog is not, and reads as nothing half the time.
|
||||
ctx.notice("The head moves 50 mm each way on X and on Y. Keep the bed clear.")
|
||||
last = run_check(ctx, "motion", lambda p: "Continue" if p.get("id") == "jogs" else None, 420)
|
||||
ctx.clear_notice()
|
||||
res = last.get("result") or {}
|
||||
ev["probe"] = res.get("probe")
|
||||
ev["moves"] = res.get("moves")
|
||||
ctx.log("after energize: probe '%s', moves %s", res.get("probe"),
|
||||
{k: v.get("witnessed") for k, v in (res.get("moves") or {}).items()})
|
||||
ctx.check(res.get("probe") and last.get("result") is not None and not last.get("error"),
|
||||
"the motion check did not complete after the energize: %s", last.get("error") or last)
|
||||
for name in ("+X", "-X", "+Y", "-Y"):
|
||||
m = (res.get("moves") or {}).get(name) or {}
|
||||
ctx.check(m.get("witnessed") is True,
|
||||
"the %s jog was not witnessed after the energize: the drivers did not come back (%s)", name, m)
|
||||
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("local") is False
|
||||
and (fc.get("/mode")[1] or {}).get("controller") == "running", 90)
|
||||
ctx.check(ok is not None, "the controller is not back in its normal posture")
|
||||
ev["rail_lines"] = [rail0, _rail_lines()]
|
||||
ctx.check(ev["rail_lines"][0] == ev["rail_lines"][1],
|
||||
"the kernel logged the 40 V supply changing across a release: %s", ev["rail_lines"])
|
||||
machine_idle(ctx)
|
||||
ctx.log("PASS: released with the rail untouched, every motion refused and nothing shipped, "
|
||||
"energized with no fault, and the liveness probe and the witnessed jogs say the drivers are alive")
|
||||
|
||||
|
||||
def _port_state(fc):
|
||||
st, body = fc.get("/motion/state")
|
||||
if st != 200 or not isinstance(body, dict):
|
||||
raise hw.HwError("forgectrl /motion/state -> %s %s" % (st, body))
|
||||
return body
|
||||
|
||||
|
||||
def _port_idle(ctx, fc, timeout=30.0):
|
||||
"""The port's own word that its jog is over. The suite's Grbl client
|
||||
sends no line while a port jog runs: a line from it is what cancels one."""
|
||||
ok = ctx.wait_for(lambda: (lambda s: s["state"] == "Idle" and not s["port_jog"])(_port_state(fc)),
|
||||
timeout, poll=0.1)
|
||||
ctx.check(ok is not None, "the port jog did not end: %s", _port_state(fc))
|
||||
machine_idle(ctx)
|
||||
|
||||
+131
-1284
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,317 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""setup.focus - the setup's focus burn (a live wizard), driven the way
|
||||
the page drives it: POST /wiz/<id>/start, GET /wiz/dark polled, the
|
||||
prompts answered from here, the burn armed by a press, the result
|
||||
judged, and the setting the burn wrote put back as found. The burn
|
||||
stands alone on its own scrap of wood in the back-left corner, drawn
|
||||
from the head's home against the gantry stops, and re-runs on the real
|
||||
record, so a completed burn completes again at the same version.
|
||||
|
||||
The operator's part is the scrap and one press on the machine at the
|
||||
start (the presence check of the ready gate; with the bench actuator up
|
||||
it then makes the arm press). The judgment a person makes by eye on the
|
||||
scrap (the narrowest line) is answered here with a fixed pick: the test
|
||||
proves the wizard's mechanics and the emission witnesses, not the
|
||||
operator's eye, and the setting it writes is restored. The page is where
|
||||
the real numbers go in.
|
||||
"""
|
||||
import re
|
||||
import time
|
||||
|
||||
from .. import hw
|
||||
from ..catalog import test
|
||||
from .setup_dark import run_check, Restore, FALLBACK_WINDOW, LENS_WINDOW_KEYS, dark
|
||||
|
||||
BURN_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
||||
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
|
||||
("forgectrl", "src/lenshome.*"),
|
||||
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
||||
("forgectrl", "src/jobrun.*"), ("forgectrl", "src/lease.*"),
|
||||
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
||||
("forgectrl", "src/super.c"),
|
||||
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
||||
("grblhal-glowforge", "src/glowforge_laser.c"),
|
||||
("grblhal-glowforge", "src/glowforge_status.c"),
|
||||
("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||
("grblhal-glowforge", "src/glowforge_io.*")]
|
||||
|
||||
# Every burn asks first that the gantry stops are installed and the head
|
||||
# is at its home against them; the bench's head stands there, and every
|
||||
# burn's tail brings it back.
|
||||
HOME = "The head is home"
|
||||
EDGE_KEY = "lens_hall_edge_z_mm"
|
||||
FOCUS_KEYS = ["thickness_mm", "z_passes", "ladder_z", "window", "emission", "pick", "picked_lines",
|
||||
"pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm", "focus_range_mm", "summary"]
|
||||
|
||||
|
||||
def preview_ok(ctx, wid):
|
||||
"""The page's preview and the program for the burn are served."""
|
||||
fc = ctx.forgectrl
|
||||
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
|
||||
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
|
||||
ctx.evidence["preview_bytes"] = len(body)
|
||||
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
|
||||
# The text is M4 at the mark dose; the ladders bring their own M3 or
|
||||
# M4 lines. A program is one with a laser-on command.
|
||||
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
||||
"no program for %s (%s)", wid, st)
|
||||
ctx.evidence["program_lines"] = body.count(b"\n")
|
||||
# Every Z the program commands is a height the head reaches in the
|
||||
# window the machine holds now: the Lens travel check's, or the
|
||||
# fallback on a head whose travel is not known yet. The controller
|
||||
# refuses a Z outside it with ALARM:2, and it refuses it while the
|
||||
# body is still burning, so the burn dies where the parser is and not
|
||||
# where the bad line is.
|
||||
lens = (fc.status() or {}).get("lens") or {}
|
||||
lo, hi = lens.get("reach_min"), lens.get("reach_max")
|
||||
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
|
||||
ctx.evidence.setdefault("program_z", {})[wid] = {
|
||||
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
|
||||
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
|
||||
if lo is not None and hi is not None:
|
||||
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
|
||||
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
|
||||
wid, lo, hi, out)
|
||||
# The tail the wizard streams after the body: the lens back on the
|
||||
# reference the program's head declared, the head back to X0 Y0, the
|
||||
# G92 origin cleared (the controller keeps a G92 offset and restores it
|
||||
# at its next start, where it would shift a sender's own work offsets),
|
||||
# and the program end.
|
||||
lines = [ln.strip() for ln in (body or b"").decode("utf-8", "replace").splitlines()]
|
||||
tail = [ln for ln in lines if ln and not ln.startswith(";")][-4:]
|
||||
edge = lens.get("edge_z")
|
||||
want = ["G0 Z%.2f" % edge, "G0 X0 Y0", "G92.1", "M2"] if isinstance(edge, (int, float)) else None
|
||||
ctx.evidence.setdefault("program_tail", {})[wid] = tail
|
||||
ctx.check(tail == want, "the %s program ends %s, not the tail %s", wid, tail, want)
|
||||
|
||||
|
||||
def emission_ok(ctx, last):
|
||||
"""The two witnesses of the burn, from the wizard's result. The head's
|
||||
beam sensor is recorded, never required: the result reports its rise
|
||||
and whether it read as the beam seen."""
|
||||
e = (last.get("result") or {}).get("emission") or {}
|
||||
ctx.evidence["emission"] = e
|
||||
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
|
||||
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
|
||||
ctx.check(isinstance(e.get("thermopile_delta"), int) and isinstance(e.get("beam_sensor_seen"), bool),
|
||||
"the result does not record the beam sensor: %s", e)
|
||||
ctx.log("the beam sensor rose %s (seen: %s)", e.get("thermopile_delta"), e.get("beam_sensor_seen"))
|
||||
|
||||
|
||||
def thickness_answer(ctx):
|
||||
"""The thickness the burn is answered with, in the machine's units
|
||||
(the number prompts read ui_units), and the millimeters the record
|
||||
must carry for it."""
|
||||
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
|
||||
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
|
||||
|
||||
|
||||
def burn(ctx, wid, arm, pick, want):
|
||||
"""One burn: the preview, then the burn after the press with every
|
||||
prompt answered (the gantry stops installed, the head at home, the
|
||||
scrap in the corner, its thickness in the machine's units, the arm,
|
||||
and the pick in `pick`), the witnesses, and the keys of the result in
|
||||
`want`. run_check makes the arm press when the burn asks for it. A
|
||||
prompt the test does not answer, or one asked again because its
|
||||
answer was refused, ends the burn. Returns the result."""
|
||||
answer, want_mm = thickness_answer(ctx)
|
||||
ctx.evidence["thickness_answer"] = answer
|
||||
answers = {"gantry-stops": "Yes", "home": HOME, "scrap": "Continue", "thickness": answer,
|
||||
arm: "Continue"}
|
||||
answers.update(pick)
|
||||
preview_ok(ctx, wid)
|
||||
asked = set()
|
||||
|
||||
def on_prompt(p):
|
||||
pid = p.get("id")
|
||||
if pid == "press":
|
||||
return None
|
||||
if pid not in answers:
|
||||
# the motors found released ask for the head pushed home by
|
||||
# hand, which the bench's head never needs
|
||||
ctx.fail("the %s burn asked %s, which the test does not answer: %s", wid, pid, p.get("text"))
|
||||
if pid in asked:
|
||||
ctx.fail("the %s burn asked %s again: it refused %r", wid, pid, answers[pid])
|
||||
asked.add(pid)
|
||||
return answers[pid]
|
||||
|
||||
try:
|
||||
last = run_check(ctx, wid, on_prompt, 900)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
emission_ok(ctx, last)
|
||||
r = last.get("result") or {}
|
||||
missing = [k for k in want if k not in r]
|
||||
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
|
||||
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
|
||||
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
|
||||
ctx.check(len(r.get("z_passes") or []) >= 1, "the lens was not referenced: %s", r.get("z_passes"))
|
||||
return r
|
||||
|
||||
|
||||
def window_held(ctx):
|
||||
"""The lens window the machine holds: the two settings, and the lens
|
||||
block of /status, which reads them (the fallback where they are
|
||||
unset)."""
|
||||
fc = ctx.forgectrl
|
||||
s = fc.settings() or {}
|
||||
lens = (fc.status() or {}).get("lens") or {}
|
||||
return {"settings": {k: s.get(k) for k in LENS_WINDOW_KEYS},
|
||||
"window": (lens.get("below"), lens.get("above")), "stops_found": lens.get("stops_found")}
|
||||
|
||||
|
||||
def focus_window_ok(ctx, r, before):
|
||||
"""The ladder burned in the window the machine held before the burn
|
||||
(`before`, from window_held): the Lens travel check's, or the fallback
|
||||
while the settings are unset. The burn left that window as it found
|
||||
it and wrote the edge alone, the one its result reports; every ladder
|
||||
height lies in the reach, and the program served now agrees with
|
||||
/status."""
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
w = r.get("window") or {}
|
||||
window = (w.get("below"), w.get("above"))
|
||||
after = window_held(ctx)
|
||||
ctx.check(after["settings"] == before["settings"], "the focus burn wrote the lens window: %s, was %s",
|
||||
after["settings"], before["settings"])
|
||||
ctx.check(window == before["window"], "the ladder ran in %s/%s, the machine holds %s",
|
||||
window[0], window[1], before["window"])
|
||||
held = before["settings"]
|
||||
if before["stops_found"]:
|
||||
ctx.check(tuple(str(held[k]) for k in LENS_WINDOW_KEYS) == tuple(str(v) for v in window),
|
||||
"the ladder ran in %s/%s, the settings hold %s", window[0], window[1], held)
|
||||
else:
|
||||
ctx.check(window == FALLBACK_WINDOW, "the settings hold no window (%s), but the ladder ran in %s/%s",
|
||||
held, window[0], window[1])
|
||||
edge = (fc.settings() or {}).get(EDGE_KEY)
|
||||
try:
|
||||
edge_ok = abs(float(edge) - float(r.get("edge_z_mm"))) < 0.006
|
||||
except (TypeError, ValueError):
|
||||
edge_ok = False
|
||||
ctx.check(edge_ok, "%s reads %r, the burn reports %s", EDGE_KEY, edge, r.get("edge_z_mm"))
|
||||
zs = r.get("ladder_z") or []
|
||||
lo, hi = w.get("reach_min"), w.get("reach_max")
|
||||
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
|
||||
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
|
||||
first = dict(ev.get("program_z") or {}).get("laser.focus")
|
||||
preview_ok(ctx, "laser.focus")
|
||||
ev["focus_window"] = {"window": w, "ladder_z": zs, "held": before, "edge": edge,
|
||||
"program_z_before": first,
|
||||
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
|
||||
"preview_bytes": ev.pop("preview_bytes", None),
|
||||
"program_lines": ev.pop("program_lines", None)}
|
||||
|
||||
|
||||
@test("setup.focus", title="The focus burn: the ladder in the lens window, and the edge it writes",
|
||||
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=10,
|
||||
covers=BURN_COVERS,
|
||||
requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "setup.check-lens"],
|
||||
actions=["button"], hands=["scrap"],
|
||||
steps=["The gantry stops installed and the head at its home against them. A fresh flat scrap "
|
||||
"of wood about 3 mm (1/8 in) thick and at least 78 x 68 mm (3.1 x 2.7 in), pushed into "
|
||||
"the back-left corner of the cut area: as far left as it goes, its top edge at the top "
|
||||
"of the cut area; lid closed. Eye protection on, exhaust on, extinguisher in reach.",
|
||||
"Start the test and press the machine's button once. That press says you are at the "
|
||||
"machine, and the bench actuator makes the arm press after it, when the burn itself "
|
||||
"asks for it. With no actuator the start is the Ready answer instead, and you press "
|
||||
"the button again at the arm, as the page asks.",
|
||||
"Nothing else: the test answers every prompt itself and puts back the setting the "
|
||||
"burn writes."],
|
||||
description="The focus burn as a fresh head runs it: the edge setting is cleared first, so "
|
||||
"the burn starts from the bench reference's edge, while the lens window stays "
|
||||
"the one the Lens travel check wrote (or the fallback). POST "
|
||||
"/wiz/laser.focus/start is answered that the gantry stops are installed, "
|
||||
"references the lens, takes the controller in loopback posture, is answered that "
|
||||
"the head is home and that the scrap is in the corner, and takes the thickness "
|
||||
"in the machine's units (3.2 mm or 0.125 in); its program declares X0 Y0 at the "
|
||||
"head, and its tail puts the lens back on the reference, returns the head there, "
|
||||
"clears the G92 origin (G92.1), and ends (M2). With its preview and program "
|
||||
"served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the program commands "
|
||||
"inside the reach /status reports, and the program ending in that tail at the "
|
||||
"edge /status reports, it burns twelve lines after the press, from the top of "
|
||||
"the window to the bottom, seen by the two witnesses (the tube current and the "
|
||||
"kernel's LASER_ON samples), with the beam sensor's rise recorded and not "
|
||||
"required. The pick is line 11. The "
|
||||
"result carries its keys; the ladder ran in the window the machine held, which "
|
||||
"the burn leaves as found (it writes the edge alone, the one its result "
|
||||
"reports); every ladder height lies in the reach; the program served afterward "
|
||||
"agrees with /status; the record carries laser.focus at version 1. The edge and "
|
||||
"the window are restored at the end. grblHAL reads them only at its start, and "
|
||||
"the burn started it before the restore, so the controller is stopped and "
|
||||
"started again on the settings as found (on every exit path), and it must be "
|
||||
"running with motion verified under a new process.")
|
||||
def focus(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
restart = {}
|
||||
|
||||
def restart_on_found():
|
||||
"""A fresh controller start on the settings as found, once the
|
||||
burn has ended. Plain waits, not the run's, so an abort gets it
|
||||
too; a burn still ending starts its own controller after the
|
||||
restore, which reads them already."""
|
||||
try:
|
||||
t_end = time.time() + 120
|
||||
while time.time() < t_end and dark(fc).get("running"):
|
||||
time.sleep(1)
|
||||
if dark(fc).get("running"):
|
||||
restart["skipped"] = "the burn is still ending"
|
||||
ctx.log("the burn is still ending: its own controller start reads the settings as found")
|
||||
return
|
||||
st, m = fc.get("/mode")
|
||||
m = m if isinstance(m, dict) else {}
|
||||
restart.update(mode=m.get("mode"), pid=m.get("pid"))
|
||||
if m.get("mode") != "grbl":
|
||||
return # the cloud client reads neither the edge nor the window
|
||||
restart["stop"], body = fc.post("/controller/stop")
|
||||
ctx.log("restarting the controller on the edge and the window as found: POST "
|
||||
"/controller/stop -> %s %s", restart["stop"], body)
|
||||
for _ in range(10):
|
||||
restart["start"], body = fc.post("/controller/start")
|
||||
if restart["start"] != 409:
|
||||
break
|
||||
time.sleep(1) # the burn's lease goes just after its end
|
||||
ctx.log("POST /controller/start -> %s %s", restart["start"], body)
|
||||
except hw.HwError as e:
|
||||
restart["error"] = str(e)
|
||||
ctx.log("the controller could not be restarted on the settings as found: %s", e)
|
||||
ctx.ready("The focus burn: twelve lines on the scrap after a press. The bench presses when the "
|
||||
"button lights.")
|
||||
try:
|
||||
with Restore(ctx, [EDGE_KEY] + LENS_WINDOW_KEYS):
|
||||
# A fresh head's focus: the edge unknown, so the burn runs from the
|
||||
# bench reference's number and writes the head's own. The window
|
||||
# stays: it is the Lens travel check's.
|
||||
st, _ = fc.post("/settings", params={EDGE_KEY: ""})
|
||||
ctx.check(st == 200, "clearing %s -> %s", EDGE_KEY, st)
|
||||
before = window_held(ctx)
|
||||
ev["lens_fresh"] = (fc.status() or {}).get("lens") or {}
|
||||
r = burn(ctx, "laser.focus", "focus-arm", {"focus-pick": "11"}, FOCUS_KEYS)
|
||||
ctx.check(r.get("pick") == 11 and r.get("picked_lines") == [11, 11],
|
||||
"the pick reads %s (lines %s), answered 11", r.get("pick"), r.get("picked_lines"))
|
||||
ctx.check("stops" not in r, "the focus burn searched for the stops: %s", r.get("stops"))
|
||||
focus_window_ok(ctx, r, before)
|
||||
ctx.log("focus: line %s, the edge at %s mm, the window %s/%s", r.get("pick"), r.get("edge_z_mm"),
|
||||
(r.get("window") or {}).get("below"), (r.get("window") or {}).get("above"))
|
||||
finally:
|
||||
restart_on_found()
|
||||
ev["restart"] = dict(restart)
|
||||
if restart.get("mode") == "grbl":
|
||||
ctx.check(restart.get("stop") == 200 and restart.get("start") == 200,
|
||||
"the controller was not restarted on the settings as found: %s", restart)
|
||||
|
||||
def back():
|
||||
st, m = fc.get("/mode")
|
||||
m = m if isinstance(m, dict) else {}
|
||||
return (m.get("controller") == "running" and m.get("motion") == "verified"
|
||||
and (restart.get("start") != 200 or m.get("pid") != restart.get("pid")))
|
||||
ok = ctx.wait_for(back, 150)
|
||||
ctx.check(ok is not None, "the controller did not come back on the settings as found")
|
||||
ctx.log("PASS: the focus burn ran in the window the machine holds and wrote the edge alone; "
|
||||
"settings restored and the controller restarted on them")
|
||||
@@ -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.
|
||||
@@ -29,6 +29,27 @@ PURGE_SPINUP_S = 30
|
||||
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
||||
# The lens window: the free half-steps below and above the hall edge the
|
||||
# Lens travel check writes, and the fallback the lens frame holds while
|
||||
# they are unset (lens.h).
|
||||
LENS_WINDOW_KEYS = ["lens_stop_below_steps", "lens_stop_above_steps"]
|
||||
FALLBACK_WINDOW = (10, 12)
|
||||
|
||||
|
||||
def intakes_counted():
|
||||
"""Precheck: the intake fan count is set. The setup gate holds every
|
||||
controller without it, and a test that puts the count back as found
|
||||
would put an unset count back and close the gate on the tests after
|
||||
it."""
|
||||
try:
|
||||
s = hw.Forgectrl().settings() or {}
|
||||
except hw.HwError as e:
|
||||
return "forgectrl unreachable: %s" % e
|
||||
if not s.get(INTAKE_KEY):
|
||||
return ("the intake fans are not counted, so the setup gate holds every controller; this "
|
||||
"test puts the count back as it finds it, and an uncounted machine handed back "
|
||||
"would hold the controllers of every test after it")
|
||||
return None
|
||||
|
||||
|
||||
def dark(fc):
|
||||
@@ -121,7 +142,7 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
||||
@test("setup.check-switches", title="The switches check follows the lid and the button",
|
||||
subsystem="setup", kind="operator", est_min=3,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
||||
requires=["forgectrl.auth"], actions=["lid", "button"],
|
||||
requires=["forgectrl.auth"], actions=["lid", "button", "interlock"],
|
||||
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
||||
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
|
||||
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
|
||||
@@ -153,58 +174,48 @@ def check_switches(ctx):
|
||||
ctx.log("switches: %s", json.dumps(r))
|
||||
|
||||
|
||||
@test("setup.check-sensors", title="The sensors check reads a plausible machine at rest",
|
||||
subsystem="setup", kind="auto", est_min=2,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
|
||||
("forgectrl", "src/cool.c")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="POST /wiz/sensors/start; ten seconds of readings and no question asked. The "
|
||||
"result carries both coolant temperatures, the chassis and SoC, the lid IR "
|
||||
"maxima, the accelerometer event count, the supply power-good, the HV current, "
|
||||
"and the idle fan speeds; the record carries sensors at version 1 and the "
|
||||
"settings read as before (the check writes none).")
|
||||
def check_sensors(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings() or {}
|
||||
ev["settings_before"] = dict(before)
|
||||
|
||||
def on_prompt(p):
|
||||
ctx.fail("the sensors check asked a question: %s", p)
|
||||
last = run_check(ctx, "sensors", on_prompt, 120)
|
||||
r = last.get("result") or {}
|
||||
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
||||
"exhaust_rpm_idle", "intake_rpm_idle"):
|
||||
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
||||
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
|
||||
after = fc.settings() or {}
|
||||
changed = {k: (before.get(k), after.get(k)) for k in set(before) | set(after)
|
||||
if before.get(k) != after.get(k)}
|
||||
ctx.check(not changed, "the sensors check wrote a setting: %s", changed)
|
||||
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
|
||||
r.get("lid_ir_max"), r.get("hv_current_max"))
|
||||
|
||||
|
||||
@test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
||||
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
||||
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
||||
requires=["forgectrl.auth"], precheck=intakes_counted,
|
||||
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
|
||||
"for 35 s, the purge is measured on and off, and five settings are written: the "
|
||||
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
|
||||
"fan must read at least 1000 rpm. The five settings are put back as found at the "
|
||||
"end, the purge fan is on again and drawing over the floor the check just wrote, "
|
||||
"and the controller must be running again.")
|
||||
"for 35 s, the purge is measured on and off, and the five floors are written: the "
|
||||
"four at 55 percent of steady and the grace from the slowest spin-up. The intake "
|
||||
"fan count is the machine's: intake 2 turning proves two fans, both intakes are "
|
||||
"judged, and the count is written as 2; intake 2 silent on a machine counted with "
|
||||
"one is reported not fitted and not turning, intake 1 alone sets the intake "
|
||||
"floor, and the count is not written; intake 2 silent on a machine counted with "
|
||||
"two fails the check. Both intakes turn on the bench reference, so the bench "
|
||||
"path writes 2. Each fan judged must read at least 1000 rpm. The settings are put "
|
||||
"back as found at the end, the purge fan is on again and drawing over the floor "
|
||||
"the check just wrote, and the controller must be running again.")
|
||||
def check_airflow(ctx):
|
||||
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
|
||||
"cool_purge_min_current", "cool_fan_grace_s"]
|
||||
with Restore(ctx, keys):
|
||||
with Restore(ctx, keys + ["cool_intake_fans"]) as held:
|
||||
counted = held.before.get("cool_intake_fans")
|
||||
last = run_check(ctx, "airflow", lambda p: None, 240)
|
||||
r = last.get("result") or {}
|
||||
fans = r.get("fans") or {}
|
||||
intakes = r.get("intake_fans")
|
||||
silent2 = (fans.get("intake 2") or {}).get("turning") is False
|
||||
ctx.evidence["intakes"] = {"counted": counted, "found": intakes, "intake_2_silent": silent2}
|
||||
ctx.check(intakes in (1, 2), "the result carries no intake fan count: %r", intakes)
|
||||
if silent2:
|
||||
# one intake fan, as the machine is counted (on a machine
|
||||
# counted with two the check itself fails, and run_check says so)
|
||||
ctx.check(counted == "1" and intakes == 1,
|
||||
"intake 2 gave no reading on a machine counted with %r intake fans, and the check "
|
||||
"found %r", counted, intakes)
|
||||
else:
|
||||
ctx.check(intakes == 2, "intake 2 turned, and the check found %r intake fans", intakes)
|
||||
ctx.log("intake fans: %s (the machine counted %s)", intakes, counted)
|
||||
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
||||
f = fans.get(name) or {}
|
||||
if name == "intake 2" and silent2:
|
||||
ctx.check(f == {"fitted": False, "turning": False}, "intake 2 with one intake fan: %s", f)
|
||||
continue
|
||||
ctx.check(f.get("ok") is True, "%s: %s", name, f)
|
||||
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
|
||||
floors = r.get("floors") or {}
|
||||
@@ -213,6 +224,12 @@ def check_airflow(ctx):
|
||||
s = ctx.forgectrl.settings() or {}
|
||||
for k in keys:
|
||||
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
|
||||
# The count: 2 written when intake 2 turned; left as counted when it
|
||||
# was silent.
|
||||
want = counted if silent2 else "2"
|
||||
ctx.check(s.get("cool_intake_fans") == want,
|
||||
"cool_intake_fans reads %r, expected %r (intake 2 %s, the machine counted %r)",
|
||||
s.get("cool_intake_fans"), want, "silent" if silent2 else "turning", counted)
|
||||
ctx.log("floors: %s", json.dumps(floors))
|
||||
# The check switches purge air off to read its off current. Leaving
|
||||
# it off costs the next job an airflow hold mid-cut, judged against
|
||||
@@ -244,41 +261,6 @@ def check_airflow(ctx):
|
||||
ctx.check(ok is not None, "the controller did not come back after the check")
|
||||
|
||||
|
||||
@test("setup.check-cameras", title="The cameras check captures both cameras",
|
||||
subsystem="setup", kind="operator", est_min=2,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
|
||||
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
|
||||
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
|
||||
"head snapshot, the question. Each question is answered yes once GET /wiz/shot "
|
||||
"serves the snapshot as image/jpeg. The result names the sensor and says both "
|
||||
"views were accepted.")
|
||||
def check_cameras(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.act("lid", "close")
|
||||
shots = {}
|
||||
|
||||
def on_prompt(p):
|
||||
pid = p.get("id", "")
|
||||
if pid in ("lid-view", "head-view"):
|
||||
cam = "lid" if pid == "lid-view" else "head"
|
||||
st, body = fc.get("/wiz/shot", params={"cam": cam}, raw=True)
|
||||
shots[cam] = {"status": st, "bytes": len(body) if body else 0,
|
||||
"jpeg": bool(body) and body[:2] == b"\xff\xd8"}
|
||||
ctx.check(st == 200 and shots[cam]["jpeg"], "no %s snapshot to judge: %s", cam, shots[cam])
|
||||
return "Yes"
|
||||
if pid == "lid-close":
|
||||
ctx.act("lid", "close", until=lambda: (dark(fc).get("prompt") or {}).get("seq") != p["seq"])
|
||||
return None
|
||||
last = run_check(ctx, "cameras", on_prompt, 180)
|
||||
ev["shots"] = shots
|
||||
r = last.get("result") or {}
|
||||
ctx.check(r.get("lid_ok") is True and r.get("head_ok") is True, "a view was not accepted: %s", r)
|
||||
ctx.check(bool(r.get("sensor")) and r.get("sensor") != "unknown", "no sensor named: %s", r)
|
||||
ctx.log("cameras: %s, lid %d bytes, head %d bytes", r.get("sensor"),
|
||||
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
|
||||
|
||||
|
||||
@test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
||||
@@ -320,22 +302,103 @@ def check_motion(ctx):
|
||||
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
|
||||
|
||||
|
||||
@test("setup.check-flow-verify", title="The flow check runs as a setup check",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
||||
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
||||
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
||||
"check runner: the diagnostic's phases show as the check's, its result becomes the "
|
||||
"check's result with pass true, and the record carries cooling.flow-verify at "
|
||||
"version 1. A thin margin recommends the calibration in the record's flags. The "
|
||||
"air-assist offset calibration is named first among the prerequisites so a queue "
|
||||
"keeps it ahead of every heater tool this check pulls forward.")
|
||||
def check_flow_verify(ctx):
|
||||
last = run_check(ctx, "cooling.flow-verify", lambda p: None, 600)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
2026-08-17T09:44:00.417337+00:00 gfcloud[1927] INFO cnc:set_step_freq 28160
|
||||
2026-08-17T09:44:00.418627+00:00 gfcloud[1927] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:44:00.419858+00:00 gfcloud[1927] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:44:00.421083+00:00 gfcloud[1927] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:44:00.422535+00:00 gfcloud[1927] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:44:00.423794+00:00 gfcloud[1927] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:44:00.425014+00:00 gfcloud[1927] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:44:00.432591+00:00 gfcloud[1927] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:44:00.434005+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:44:00.438277+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:44:01.785230+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:44:01.797728+00:00 gfcloud[1927] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:44:01.800661+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:01.808504+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (actual/expected): X (-10313/-10313), Y (-649/-649), Z (0/0)
|
||||
2026-08-17T09:44:01.809326+00:00 gfcloud[1927] INFO machine:_motion_locked motion bytes actual:34952, expected: 34952
|
||||
2026-08-17T09:44:01.810066+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:44:01.811352+00:00 gfcloud[1927] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:44:01.812874+00:00 gfcloud[1927] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:44:01.820631+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (-10313, -649, 0)
|
||||
2026-08-17T09:44:01.822062+00:00 gfcloud[1927] INFO machine:_motion end motion
|
||||
2026-08-17T09:44:01.822829+00:00 gfcloud[1927] INFO basemachine:_finish_action motion [1576550614]: finished with event ":completed"
|
||||
2026-08-17T09:44:01.824200+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:07.993647+00:00 gfcloud[1927] INFO gfuiservice:run service action request: print (ready)
|
||||
2026-08-17T09:44:07.998655+00:00 gfcloud[1927] INFO machine:_motion start motion
|
||||
2026-08-17T09:44:07.999643+00:00 gfcloud[1927] INFO gfuiservice:run print (ready) service action print
|
||||
2026-08-17T09:44:14.199666+00:00 gfcloud[1927] INFO cnc:laser_latch 0
|
||||
2026-08-17T09:44:14.208291+00:00 gfcloud[1927] INFO machine:_button_wait waiting for button
|
||||
2026-08-17T09:44:21.270846+00:00 gfcloud[1927] INFO machine:_switch_event lid opened
|
||||
2026-08-17T09:44:21.275703+00:00 gfcloud[1927] WARNING machine:_button_wait button wait lid opened - relocking the laser
|
||||
2026-08-17T09:44:21.278615+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:21.288305+00:00 gfcloud[1927] INFO machine:_return_home start return home
|
||||
2026-08-17T09:44:21.291373+00:00 gfcloud[1927] INFO machine:_return_home return home: already at the job start
|
||||
2026-08-17T09:44:21.292501+00:00 gfcloud[1927] INFO machine:_motion_locked start cool down
|
||||
2026-08-17T09:44:21.305578+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:44:21.307322+00:00 gfcloud[1927] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:44:21.309185+00:00 gfcloud[1927] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:44:21.316340+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (0, 0, 0)
|
||||
2026-08-17T09:44:21.318139+00:00 gfcloud[1927] INFO machine:_motion end motion
|
||||
2026-08-17T09:44:21.318860+00:00 gfcloud[1927] INFO basemachine:_finish_action print [1576550621]: finished with event ":cancelled"
|
||||
2026-08-17T09:44:21.320214+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:34.058320+00:00 gfcloud[1927] INFO machine:_switch_event lid closed
|
||||
-207
@@ -1,207 +0,0 @@
|
||||
2026-08-17T09:44:35.789556+00:00 gfcloud[1927] INFO websocket:img_upload START
|
||||
2026-08-17T09:44:36.105516+00:00 gfcloud[1927] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:44:36.109421+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:38.461442+00:00 gfcloud[1927] INFO gfcloud:_shutdown shutdown requested
|
||||
2026-08-17T09:44:38.910494+00:00 gfcloud[1927] INFO machine:_shutdown shutting down
|
||||
2026-08-17T09:44:38.913049+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:38.950354+00:00 gfcloud[1927] INFO machine:_shutdown joining switch thread
|
||||
2026-08-17T09:44:38.956909+00:00 gfcloud[1927] INFO machine:_shutdown shut down complete
|
||||
2026-08-17T09:44:38.986287+00:00 gfcloud[1927] INFO websocket:_on_close RX-EVENT: closed (None, None)
|
||||
2026-08-17T09:44:38.989003+00:00 gfcloud[1927] INFO websocket:run CLOSING
|
||||
2026-08-17T09:44:38.992724+00:00 gfcloud[1927] INFO gfcloud:main gfcloud exit
|
||||
2026-08-17T09:45:08.449553+00:00 gfcloud[2278] INFO gfuiservice:__init__ INITIALIZED
|
||||
2026-08-17T09:45:08.451929+00:00 gfcloud[2278] INFO authentication:authenticate_machine START
|
||||
2026-08-17T09:45:08.965893+00:00 gfcloud[2278] INFO authentication:authenticate_machine SUCCESS
|
||||
2026-08-17T09:45:08.967947+00:00 gfcloud[2278] INFO websocket:ws_connect CONNECTING
|
||||
2026-08-17T09:45:09.421918+00:00 gfcloud[2278] INFO _logging:info Websocket connected
|
||||
2026-08-17T09:45:09.424224+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready
|
||||
2026-08-17T09:45:09.973615+00:00 gfcloud[2278] INFO websocket:ws_connect ESTABLISHED
|
||||
2026-08-17T09:45:09.994287+00:00 gfcloud[2278] INFO cnc:set_ignored_faults 0
|
||||
2026-08-17T09:45:09.996281+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||
2026-08-17T09:45:09.998803+00:00 gfcloud[2278] INFO cnc:set_x_mode Microstep.M_8
|
||||
2026-08-17T09:45:09.999688+00:00 gfcloud[2278] INFO cnc:set_y_mode Microstep.M_8
|
||||
2026-08-17T09:45:10.000483+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:45:10.001229+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:45:10.002000+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:45:10.007267+00:00 gfcloud[2278] INFO cnc:set_y_current 33
|
||||
2026-08-17T09:45:10.008695+00:00 gfcloud[2278] INFO z_axis:reset resetting z
|
||||
2026-08-17T09:45:10.009124+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: False, current: ZCur.LOW, mode: Microstep.HALF
|
||||
2026-08-17T09:45:10.021592+00:00 gfcloud[2278] INFO gfuiservice:run service action request: settings (ready)
|
||||
2026-08-17T09:45:10.024928+00:00 gfcloud[2278] INFO settings:send_report START
|
||||
2026-08-17T09:45:10.039856+00:00 gfcloud[2278] INFO settings:send_report COMPLETE
|
||||
2026-08-17T09:45:10.040650+00:00 gfcloud[2278] INFO gfuiservice:run settings (ready) service action dispatched
|
||||
2026-08-17T09:45:10.591813+00:00 gfcloud[2278] INFO gfuiservice:run service action request: hunt (ready)
|
||||
2026-08-17T09:45:10.596213+00:00 gfcloud[2278] INFO z_axis:home starting z homing cycle
|
||||
2026-08-17T09:45:10.599109+00:00 gfcloud[2278] INFO gfuiservice:run hunt (ready) service action hunt
|
||||
2026-08-17T09:45:10.600433+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: True, current: ZCur.HIGH, mode: Microstep.FULL
|
||||
2026-08-17T09:45:10.607559+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:11.160422+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:11.161195+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:11.714663+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:11.717907+00:00 gfcloud[2278] INFO z_axis:home pass: 1
|
||||
2026-08-17T09:45:11.719134+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:12.272747+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:12.274301+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:12.829585+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:12.831305+00:00 gfcloud[2278] INFO z_axis:home pass: 2
|
||||
2026-08-17T09:45:12.832786+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:13.385567+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:13.397106+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:13.947958+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:13.949754+00:00 gfcloud[2278] INFO z_axis:home pass: 3
|
||||
2026-08-17T09:45:13.951244+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:14.504840+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:14.506743+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:15.060854+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:15.062536+00:00 gfcloud[2278] INFO z_axis:home pass: 4
|
||||
2026-08-17T09:45:15.063941+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:15.617907+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:15.619505+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:16.175270+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:16.177421+00:00 gfcloud[2278] INFO z_axis:home pass: 5
|
||||
2026-08-17T09:45:16.179808+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:16.733698+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:16.735355+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:17.289377+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:17.291109+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: None, current: ZCur.LOW, mode: Microstep.HALF
|
||||
2026-08-17T09:45:17.296301+00:00 gfcloud[2278] INFO z_axis:home homing cycle complete: [0, 0, 0, 0, 0]
|
||||
2026-08-17T09:45:17.298209+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:17.968494+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||
2026-08-17T09:45:17.969759+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:45:17.970993+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:45:17.972200+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:45:17.973616+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:45:17.974870+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:45:17.976077+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:45:17.983820+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:45:17.985159+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:17.989360+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:45:19.630807+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:19.636283+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:45:19.637979+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:19.651159+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (0/0), Y (0/0), Z (-4/-4)
|
||||
2026-08-17T09:45:19.652103+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:15874, expected: 15874
|
||||
2026-08-17T09:45:19.652852+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:45:19.653964+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:45:19.655305+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:45:19.664032+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (0, 0, -4)
|
||||
2026-08-17T09:45:19.664853+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:45:19.665575+00:00 gfcloud[2278] INFO basemachine:_finish_action hunt [1576550660]: finished with event ":completed"
|
||||
2026-08-17T09:45:19.667114+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:20.027338+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:45:20.030024+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:45:20.040337+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:45:21.195560+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:45:21.196529+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:45:21.721382+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:45:21.725394+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:23.732857+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||
2026-08-17T09:45:23.736044+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:23.737995+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||
2026-08-17T09:45:23.738941+00:00 gfcloud[2278] INFO machine:_safe_to_move lid opened, unsafe to move
|
||||
2026-08-17T09:45:23.739814+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:45:23.740605+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550667]: finished with event ":cancelled"
|
||||
2026-08-17T09:45:23.742169+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:42.629674+00:00 gfcloud[2278] INFO machine:_switch_event lid closed
|
||||
2026-08-17T09:45:42.920194+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:45:42.924152+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:45:42.927934+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:45:43.989152+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:45:43.990022+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:45:44.312060+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:45:44.315952+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:46.652633+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||
2026-08-17T09:45:46.654950+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:46.657522+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||
2026-08-17T09:45:48.089456+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||
2026-08-17T09:45:48.090706+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:45:48.091970+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:45:48.093155+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:45:48.094587+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:45:48.095866+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:45:48.097182+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:45:48.104758+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:45:48.106128+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:48.110477+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:45:50.393091+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:50.398748+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:45:50.399629+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:50.423949+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (12939/12939), Y (7402/7402), Z (0/0)
|
||||
2026-08-17T09:45:50.425534+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:22740, expected: 22740
|
||||
2026-08-17T09:45:50.426258+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:45:50.427835+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:45:50.429387+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:45:50.437659+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (12939, 7402, 0)
|
||||
2026-08-17T09:45:50.438474+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:45:50.439167+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550679]: finished with event ":completed"
|
||||
2026-08-17T09:45:50.440481+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:50.770056+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:45:50.772800+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:45:50.782017+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:45:51.427848+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:45:51.428456+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:45:51.757479+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:45:51.760692+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:54.222123+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||
2026-08-17T09:45:54.224786+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:54.226855+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||
2026-08-17T09:45:54.557705+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||
2026-08-17T09:45:54.562210+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:45:54.571212+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:45:54.573373+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:45:54.596668+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:45:54.597612+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:45:54.598479+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:45:54.622488+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:45:54.624223+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:54.629307+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:45:54.835600+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:54.838848+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:45:54.839726+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:54.849011+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (159/159), Y (5/5), Z (0/0)
|
||||
2026-08-17T09:45:54.849946+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:1222, expected: 1222
|
||||
2026-08-17T09:45:54.850790+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:45:54.852317+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:45:54.854073+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:45:54.880450+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (159, 5, 0)
|
||||
2026-08-17T09:45:54.881925+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:45:54.882641+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550683]: finished with event ":completed"
|
||||
2026-08-17T09:45:54.884500+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:55.137070+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:45:55.139449+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:45:55.142346+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:45:55.809276+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:45:55.810155+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:45:56.158888+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:45:56.162644+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:58.467590+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||
2026-08-17T09:45:58.469426+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:58.471091+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||
2026-08-17T09:46:00.721960+00:00 gfcloud[2278] INFO cnc:set_step_freq 28160
|
||||
2026-08-17T09:46:00.725688+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:46:00.727477+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:46:00.728738+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:46:00.730198+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:46:00.731506+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:46:00.732735+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:46:00.740459+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:46:00.741865+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:46:00.745983+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:46:02.491897+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:46:02.494137+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:46:02.495155+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:46:02.530565+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (-13096/-13096), Y (-7400/-7400), Z (0/0)
|
||||
2026-08-17T09:46:02.531413+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:47243, expected: 47243
|
||||
2026-08-17T09:46:02.538168+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:46:02.539603+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:46:02.541147+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:46:02.549578+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (-13096, -7400, 0)
|
||||
2026-08-17T09:46:02.550385+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:46:02.551153+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550685]: finished with event ":completed"
|
||||
2026-08-17T09:46:02.552496+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:46:03.007623+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:46:03.009488+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:46:03.037155+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:46:03.758520+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:46:03.760049+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:46:04.128126+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
+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()
|
||||
|
||||
+99
-232
@@ -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"
|
||||
|
||||
@@ -113,38 +110,6 @@ class FakeFixture:
|
||||
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"), [])
|
||||
|
||||
|
||||
class ConfigTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
|
||||
@@ -207,6 +172,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 check that must reach the machine while the button is down asks for the longest press
|
||||
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,37 +196,6 @@ 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"
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
f.act("button", "press")
|
||||
self.assertIn("in progress", str(cm.exception))
|
||||
|
||||
def test_a_wrong_key_is_refused(self):
|
||||
f = self.client(key="wrong")
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
@@ -275,10 +213,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 +224,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 +246,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 +284,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 +362,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 +394,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 +448,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 +468,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 +482,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 +512,73 @@ 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.")
|
||||
finally:
|
||||
runner_mod.hw.button_lit = saved
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+251
-19
@@ -3,27 +3,121 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class NetworkBootTests(unittest.TestCase):
|
||||
@@ -32,11 +126,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 +154,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 +177,142 @@ 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))
|
||||
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -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,226 @@ 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: it
|
||||
# checks itself out at the workspace root and runs the one harness.
|
||||
CI_YML = ("on:\n push:\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")])
|
||||
|
||||
def _runs(self, yml, path):
|
||||
return m.workflow_runs(m.workflow_steps(yml), path)
|
||||
|
||||
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
|
||||
block = ("steps:\n - name: every module\n working-directory: %s\n run: |\n"
|
||||
" set -e\n for t in tests/test_*.py; do\n python -m unittest \"tests.$t\"\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(step % "for t in tests/test_*.py; do python $t; done",
|
||||
"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 % "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_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 = "steps:\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):
|
||||
@@ -180,10 +426,6 @@ class CatalogTests(unittest.TestCase):
|
||||
self.assertEqual(len(t.source_sha), 64)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class ImplementationHashTests(unittest.TestCase):
|
||||
"""A test's implementation hash is its own function plus the code its
|
||||
module shares: a body edit moves one test, a helper edit moves them
|
||||
@@ -219,7 +461,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 +496,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)
|
||||
@@ -271,7 +513,7 @@ def two(ctx):
|
||||
|
||||
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 +527,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,485 @@
|
||||
# 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 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 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 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):
|
||||
self.fake = helpers.FakeForgectrl().start()
|
||||
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
|
||||
self.fake.stop()
|
||||
|
||||
def run_test(self, tid):
|
||||
t = catalog.load_suite()[tid]
|
||||
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))
|
||||
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)
|
||||
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))
|
||||
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.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,271 @@ 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_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()
|
||||
@@ -5,27 +5,20 @@
|
||||
|
||||
"""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."""
|
||||
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
|
||||
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
|
||||
|
||||
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")
|
||||
IDS = ("setup.account-login",)
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
@@ -35,80 +28,36 @@ class RegistrationTests(unittest.TestCase):
|
||||
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
|
||||
self.assertEqual(kinds[tid], "auto", tid)
|
||||
|
||||
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_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_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_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_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_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_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, ())
|
||||
self.assertEqual(attended, ["setup.cloud-header-capture", "setup.focus"])
|
||||
|
||||
def test_the_login_test_makes_its_own_account(self):
|
||||
# No bench credentials, no precheck: the test installs a temporary
|
||||
@@ -152,13 +101,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 +160,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 +175,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 +194,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: b7626be45c7708a8e71bd03af61ca968796fc8f7
|
||||
|
||||
@@ -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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user