mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
65
Commits
v0.0.7
..
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 |
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -140,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
|
||||
@@ -743,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -19,6 +19,7 @@ import functools
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
|
||||
DEFAULT_PATH = "/etc/forgefirm-manifest.json"
|
||||
@@ -110,6 +111,115 @@ BEHAVIORAL = [
|
||||
]
|
||||
|
||||
|
||||
# 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):
|
||||
@@ -216,18 +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...); a BEHAVIORAL path is
|
||||
never allowed away, as it is never taken out of a fingerprint. Returns
|
||||
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))
|
||||
|
||||
@@ -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",
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ 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
|
||||
@@ -21,17 +20,5 @@ 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 extcore # noqa: F401,E402
|
||||
from . import extcall # noqa: F401,E402
|
||||
from . import extdest # noqa: F401,E402
|
||||
from . import extlife # noqa: F401,E402
|
||||
from . import evmore # noqa: F401,E402
|
||||
from . import extcat # noqa: F401,E402
|
||||
from . import extmcode # noqa: F401,E402
|
||||
from . import extwizard # noqa: F401,E402
|
||||
from . import updlock # noqa: F401,E402
|
||||
from . import homeoff # noqa: F401,E402
|
||||
from . import bedsize # noqa: F401,E402
|
||||
from . import extsender # noqa: F401,E402
|
||||
from . import tray # noqa: F401,E402
|
||||
from . import tlsrec # noqa: F401,E402
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The Setup page's bed check, on the machine.
|
||||
|
||||
Its own module, so that no other test's fingerprint moves. The check is
|
||||
driven from here inside the travel the machine already knows: a few jogs
|
||||
out from the camera home, never toward an end, so it needs no operator.
|
||||
Measuring the real ends is the operator's own run of the check. The
|
||||
homing is the web-service session homing.cloud-offsets runs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .cloud import _HOMING_PATH, gfhome_homing
|
||||
from .homeoff import camera_home_return, judge_whole_motions, session_mark
|
||||
from .setup import read_file, record_path, write_file
|
||||
from .setup_dark import DARK_COVERS, answer, dark, start
|
||||
|
||||
WID = "motion.envelope"
|
||||
END = "This is the end"
|
||||
# The operator's answers, as the page sends them: out 60.1 mm in X and
|
||||
# 61.1 mm in Y from the home, the fine steps included. A far edge is 50 mm
|
||||
# at the least, so the ends are well past that and well inside the bed.
|
||||
X_STEPS = ["X+10"] * 6 + ["X+1", "X+0.1", "X-1"]
|
||||
Y_STEPS = ["Y+10"] * 6 + ["Y+1", "Y+0.1"]
|
||||
X_OUT, Y_OUT = 60.1, 61.1
|
||||
MARGIN_MM = 1.0
|
||||
# The head is back on the home's step: half a step (213.3 steps/mm) and
|
||||
# the port's three decimals.
|
||||
HOME_TOL_MM = 0.003
|
||||
KEYS =("envelope_x_mm", "envelope_y_mm", "homing_mode")
|
||||
CLOSED = "closed the check's envelope"
|
||||
CLOSED_MSG = "Bed check envelope closed: a sender line"
|
||||
|
||||
|
||||
def _port(fc):
|
||||
st, body = fc.get("/motion/state")
|
||||
return body if st == 200 and isinstance(body, dict) else {}
|
||||
|
||||
|
||||
def _drive(ctx, fc, x_steps, y_steps, hook=None, timeout=300):
|
||||
"""Start the check and answer each prompt from the steps in turn, then
|
||||
This is the end; hook(prompt_id, n) runs before the answer to the n-th
|
||||
prompt. The final status document, and the answers given."""
|
||||
# The daemon calls the machine busy for a moment after the last jog.
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "the machine is not idle: %s", fc.status().get("state"))
|
||||
start(ctx, fc, WID)
|
||||
queue = {"x-end": list(x_steps) + [END], "y-end": list(y_steps) + [END]}
|
||||
seen, given = set(), []
|
||||
t0 = time.time()
|
||||
try:
|
||||
while time.time() - t0 < timeout:
|
||||
ctx.checkpoint()
|
||||
d = dark(fc)
|
||||
p = d.get("prompt")
|
||||
if d.get("running") and p and p.get("seq") not in seen:
|
||||
seen.add(p["seq"])
|
||||
pid = p.get("id")
|
||||
ctx.check(pid in queue and queue[pid], "the check asked %s: %s (the lid must be closed)",
|
||||
pid, p.get("text"))
|
||||
ctx.check(p.get("kind") == "jog" and END in (p.get("options") or []),
|
||||
"the %s prompt is not a jog with %r: %s", pid, END, p)
|
||||
if hook:
|
||||
hook(pid, len(given))
|
||||
value = queue[pid].pop(0)
|
||||
st, body = answer(fc, WID, p, value)
|
||||
ctx.check(st == 200, "the answer %s to %s -> %s %s", value, pid, st, body)
|
||||
given.append(value)
|
||||
if not d.get("running"):
|
||||
return d, given
|
||||
time.sleep(0.3)
|
||||
except BaseException:
|
||||
fc.post("/wiz/%s/abort" % WID)
|
||||
raise
|
||||
fc.post("/wiz/%s/abort" % WID)
|
||||
ctx.fail("the bed check did not end within %d s", timeout)
|
||||
|
||||
|
||||
def _jog(fc, dx=0.0, dy=0.0):
|
||||
params = {"feed": "1200"}
|
||||
if dx:
|
||||
params["x"] = "%.3f" % dx
|
||||
if dy:
|
||||
params["y"] = "%.3f" % dy
|
||||
return fc.post("/motion/jog", params=params)
|
||||
|
||||
|
||||
def _settled(ctx, fc):
|
||||
"""After the suite's Grbl client closes, the daemon calls the machine
|
||||
busy for a moment: the check and a settings write each wait for it."""
|
||||
ctx.wait_for(lambda: _port(fc).get("sender") is False, 20, poll=0.5)
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "the machine is not idle: %s", fc.status().get("state"))
|
||||
|
||||
|
||||
def _put_back(ctx, fc, found):
|
||||
for k in KEYS:
|
||||
v = found.get(k) or ""
|
||||
|
||||
def put():
|
||||
st_, body_ = fc.post("/settings", params={k: ""}) if v == "" else fc.post("/settings", data={k: v})
|
||||
if st_ != 200:
|
||||
ctx.log("restore %s=%r -> %s %s", k, v, st_, body_)
|
||||
return st_ == 200 or None
|
||||
took = ctx.wait_for(put, 20, poll=0.5)
|
||||
ctx.log("restore %s=%r -> %s", k, v, "taken" if took is not None else "not taken")
|
||||
|
||||
|
||||
def _back_home(ctx, fc, home):
|
||||
"""The head back where the home put it: one jog to the home in machine
|
||||
coordinates, from a Grbl client. A relative jog aimed at the home from
|
||||
the port's position can be refused whole: the port reports the step
|
||||
counters, the core adds a relative jog to its own position, which the
|
||||
check's jogs leave up to half a step off the counters, and the envelope
|
||||
begins at the home, so the target can land microns behind it (error:15).
|
||||
Where the head stands after, from the port; None when unread."""
|
||||
# 0.5 um inside an envelope that begins at the home (a camera home
|
||||
# behind the origin), far under half a step: the head lands on the
|
||||
# home's own step.
|
||||
line = "$J=G90 G53 X%.4f Y%.4f F1200" % (home[0] + 0.0005, home[1] + 0.0005)
|
||||
try:
|
||||
with ctx.grbl() as g:
|
||||
reply = g.command(line, timeout=10)
|
||||
ctx.log("the jog back to the home: %s -> %s", line, " | ".join(reply))
|
||||
if reply[-1:] == ["ok"]:
|
||||
t0 = time.time()
|
||||
ctx.wait_for(lambda: time.time() - t0 > 0.5 and g.status_report()["state"].startswith("Idle"),
|
||||
60, poll=0.2)
|
||||
except (OSError, ValueError) as e:
|
||||
ctx.log("the jog back to the home: %s", e)
|
||||
ctx.wait_for(lambda: _port(fc).get("sender") is False and _port(fc).get("state") == "Idle", 20, poll=0.5)
|
||||
at = _port(fc).get("mpos")
|
||||
ctx.log("the head after the jog back: %s (the home %s)", at, home)
|
||||
return at
|
||||
|
||||
|
||||
def _port_idle(ctx, fc):
|
||||
ok = ctx.wait_for(lambda: (lambda s: s.get("state") == "Idle" and not s.get("port_jog"))(_port(fc)), 30,
|
||||
poll=0.1)
|
||||
ctx.check(ok is not None, "the port jog did not end: %s", _port(fc))
|
||||
|
||||
|
||||
def _edge(ctx, fc, ev, axis, edge):
|
||||
"""From where the head stands, a jog 0.5 mm short of the far edge runs
|
||||
and comes back, and one 0.5 mm past it is refused with nothing moved."""
|
||||
s = _port(fc)
|
||||
at = s["mpos"]["XY".index(axis)]
|
||||
short, past = edge - 0.5 - at, edge + 0.5 - at
|
||||
kw = (lambda d: {"dx": d}) if axis == "X" else (lambda d: {"dy": d})
|
||||
st1, b1 = _jog(fc, **kw(short))
|
||||
_port_idle(ctx, fc)
|
||||
st2, b2 = _jog(fc, **kw(-short))
|
||||
_port_idle(ctx, fc)
|
||||
st3, b3 = _jog(fc, **kw(past))
|
||||
_port_idle(ctx, fc)
|
||||
back = _port(fc)["mpos"]["XY".index(axis)]
|
||||
ev["edge_" + axis] = {"edge": edge, "short": [st1, st2], "past": [st3, b3], "at": [at, back]}
|
||||
ctx.check(st1 == 200 and st2 == 200, "a jog 0.5 mm short of the %s edge %.2f and back -> %s %s, %s %s",
|
||||
axis, edge, st1, b1, st2, b2)
|
||||
ctx.check(st3 == 409 and "envelope" in json.dumps(b3) and abs(back - at) < 0.02,
|
||||
"a jog 0.5 mm past the %s edge %.2f -> %s %s, the head at %.3f from %.3f", axis, edge, st3, b3, back, at)
|
||||
|
||||
|
||||
@test("setup.check-envelope", title="The bed check measures the far edges from the operator's jogs, and a Grbl "
|
||||
"client's line ends it with nothing written",
|
||||
subsystem="setup", kind="auto", mode="grbl", est_min=10,
|
||||
covers=DARK_COVERS + _HOMING_PATH + [("forgectrl", "src/grblport.*"),
|
||||
("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||
("grblhal-glowforge", "src/ctlport.*"),
|
||||
("grblhal-glowforge", "src/driver.c")],
|
||||
requires=["forgectrl.auth", "motion.pacing"],
|
||||
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. The head moves "
|
||||
"at most about 62 mm out from the camera home in X and in Y, and ends where it was found."],
|
||||
description="With cloud mode on, the machine is homed with the camera (the web-service session), every "
|
||||
"service motion of it run whole. "
|
||||
"POST /wiz/motion.envelope/start runs the Bed size check; its prompts are jogs with This is the "
|
||||
"end among the options, and are answered as the page would, out 60.1 mm in X and 61.1 mm in Y "
|
||||
"(steps of 10, 1, and 0.1 mm). The check ends complete: the ends it reports are the home plus "
|
||||
"those, the far edges 1 mm short of them are written as envelope_x_mm and envelope_y_mm, and the "
|
||||
"controller holds them at once, without a home: a panel jog 0.5 mm short of each edge runs, "
|
||||
"and one 0.5 mm past it is refused with nothing moved. A second run is opened, and while its "
|
||||
"first prompt waits a Grbl client sends one line, which draws the controller's word that it "
|
||||
"closed the envelope and then ok; the port's state then says the envelope is closed, and the "
|
||||
"next answer ends the check in words, with the keys as the "
|
||||
"first run left them and the edges still in force. The keys, the homing mode, and the setup "
|
||||
"record are put back as found, the record under a restart, and the head goes back to the "
|
||||
"home by a Grbl client's jog in machine coordinates; the run fails unless the port reads "
|
||||
"the head on the home's step before the restart. Then the camera home is dropped and the "
|
||||
"head goes back to where the test found it, by the travel the session's motions logged.")
|
||||
def check_envelope(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
s0 = fc.settings() or {}
|
||||
ctx.check(s0.get("cloud_enabled") == "1", "cloud mode is off: a camera home needs it on")
|
||||
raw = read_file(record_path())
|
||||
found = {k: s0.get(k) or "" for k in KEYS}
|
||||
ev["found"] = found
|
||||
home = back = session_at = None
|
||||
try:
|
||||
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
|
||||
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
|
||||
with ctx.grbl() as g:
|
||||
session_at = session_mark()
|
||||
gfhome_homing(ctx, ev, g)
|
||||
home = g.status_report().get("MPos")
|
||||
judge_whole_motions(ctx, ev, session_at)
|
||||
ev["home"] = home
|
||||
ctx.log("the camera home declared %s", home)
|
||||
ctx.check(home and home[0] is not None, "no position after the home: %s", home)
|
||||
_settled(ctx, fc)
|
||||
|
||||
last, given = _drive(ctx, fc, X_STEPS, Y_STEPS)
|
||||
r = last.get("result") or {}
|
||||
ev["run1"] = {"answers": given, "error": last.get("error"), "result": r, "log": last.get("log", [])[-8:]}
|
||||
ctx.log("the bed check ended: %s", json.dumps(r))
|
||||
ctx.check(not last.get("error"), "the bed check failed: %s", last.get("error"))
|
||||
want_x, want_y = home[0] + X_OUT, home[1] + Y_OUT
|
||||
ctx.check(abs((r.get("x_end_mm") or -1) - want_x) < 0.02 and abs((r.get("y_end_mm") or -1) - want_y) < 0.02,
|
||||
"the ends are not the home plus the jogs (%.2f, %.2f): %s", want_x, want_y, r)
|
||||
edge_x, edge_y = round(want_x - MARGIN_MM, 2), round(want_y - MARGIN_MM, 2)
|
||||
s1 = fc.settings() or {}
|
||||
ev["keys_after_run1"] = {k: s1.get(k) for k in ("envelope_x_mm", "envelope_y_mm")}
|
||||
ctx.check(abs(float(s1.get("envelope_x_mm") or 0) - edge_x) < 0.011 and
|
||||
abs(float(s1.get("envelope_y_mm") or 0) - edge_y) < 0.011,
|
||||
"the keys are not the ends less %.1f mm (%.2f, %.2f): %s", MARGIN_MM, edge_x, edge_y,
|
||||
ev["keys_after_run1"])
|
||||
ctx.check(_port(fc).get("envelope_open") is False, "the envelope is still open after the check: %s",
|
||||
_port(fc))
|
||||
_edge(ctx, fc, ev, "X", edge_x)
|
||||
_edge(ctx, fc, ev, "Y", edge_y)
|
||||
|
||||
# A Grbl client's line while the envelope is open ends the check.
|
||||
said = {}
|
||||
|
||||
def client_line(pid, n):
|
||||
if n:
|
||||
return
|
||||
ctx.check(_port(fc).get("envelope_open") is True, "the second run did not open the envelope: %s",
|
||||
_port(fc))
|
||||
with ctx.grbl() as g2:
|
||||
said["reply"] = g2.command("G90", timeout=10)
|
||||
said["text"] = g2.drain()
|
||||
said["port"] = _port(fc)
|
||||
|
||||
last2, given2 = _drive(ctx, fc, ["X+1"], [], hook=client_line)
|
||||
ev["run2"] = {"answers": given2, "said": said, "error": last2.get("error"), "log": last2.get("log", [])[-6:]}
|
||||
ctx.log("the second run: %s", json.dumps(ev["run2"]))
|
||||
ctx.check((said.get("reply") or [])[-1:] == ["ok"], "the client's line did not draw ok: %s", said)
|
||||
ctx.check(any(CLOSED_MSG in l for l in (said.get("reply") or [])) or CLOSED_MSG in (said.get("text") or ""),
|
||||
"the client was not told its line closed the envelope: %s", said)
|
||||
ctx.check((said.get("port") or {}).get("envelope_open") is False,
|
||||
"the client's line did not close the envelope: %s", said.get("port"))
|
||||
ctx.check(CLOSED in (last2.get("error") or ""), "the second run did not end on the closed envelope: %s",
|
||||
last2.get("error"))
|
||||
s2 = fc.settings() or {}
|
||||
ctx.check({k: s2.get(k) for k in ("envelope_x_mm", "envelope_y_mm")} == ev["keys_after_run1"],
|
||||
"the second run wrote the keys: %s", s2)
|
||||
_settled(ctx, fc)
|
||||
_edge(ctx, fc, ev, "X", edge_x)
|
||||
finally:
|
||||
fc.post("/wiz/%s/abort" % WID)
|
||||
if home and home[0] is not None:
|
||||
ctx.wait_for(lambda: not dark(fc).get("running"), 30, poll=0.5)
|
||||
back = _back_home(ctx, fc, home)
|
||||
_put_back(ctx, fc, found)
|
||||
try:
|
||||
# Before the record's restart: the counters since the home say
|
||||
# where the head stands until a controller start zeroes them.
|
||||
if session_at is not None:
|
||||
camera_home_return(ctx, ev, session_at)
|
||||
finally:
|
||||
# The check records itself when it completes; the record goes back
|
||||
# as found under a restart, which also ends the envelope it set.
|
||||
if read_file(record_path()) != raw:
|
||||
with ctx.takeover():
|
||||
write_file(record_path(), raw)
|
||||
ctx.log("the previous setup record is back under a restart")
|
||||
# Judged from the reading before the restart: the restart zeroes the
|
||||
# counters where the head stands, so the baseline's position check
|
||||
# after it cannot see a head left out.
|
||||
ev["back"] = back
|
||||
ctx.check(back and all(abs(back[i] - home[i]) < HOME_TOL_MM for i in (0, 1)),
|
||||
"the head is not back at the home %s: %s", home, back)
|
||||
@@ -4,8 +4,6 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""camera.* - the lid camera pipeline through forgectrl."""
|
||||
import json
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
from ..baseline import LID_LAMP_ATTR
|
||||
@@ -57,7 +55,7 @@ def _jpeg_size(data):
|
||||
|
||||
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
|
||||
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 "
|
||||
@@ -171,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 "
|
||||
@@ -214,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 "
|
||||
@@ -276,7 +274,7 @@ 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, restarts the stream if they persist, and withholds the "
|
||||
@@ -364,7 +362,7 @@ def frame_health(ctx):
|
||||
|
||||
@test("camera.lid-privacy", title="Cameras see the room only with the lid closed", subsystem="camera",
|
||||
kind="operator", est_min=3,
|
||||
covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")], requires=["forgectrl.panel-serves"],
|
||||
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."],
|
||||
@@ -496,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})
|
||||
|
||||
@@ -1262,123 +1262,7 @@ 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_button_wait(ctx, offset, 180, also=["job is longer than the ring"])
|
||||
ctx.check(got["job is longer than the ring"],
|
||||
"the job fit the ring: this test needs a job the ring cannot hold")
|
||||
ctx.act("lid", "open", text="The button is lit: do NOT press it.", timeout=120)
|
||||
relock = "button wait lid opened - relocking the laser"
|
||||
got = wait_log(ctx, offset, [relock], 60)
|
||||
fin = wait_action_finished(ctx, offset, "print", 60)
|
||||
ev["log"] = {relock: bool(got[relock]), "print finished": message(fin)}
|
||||
ctx.check(got[relock], "the lid did not end the button wait")
|
||||
ctx.check(fin and CANCELLED in fin, "the print did not end ':cancelled': %s",
|
||||
message(fin) or "no finish line")
|
||||
# No run may start between the button wait and the cancel: the print
|
||||
# itself, or a park (the head never moved, there is nothing to park).
|
||||
# The service's moves BEFORE the print are legitimate runs and are
|
||||
# outside this window.
|
||||
lines = log_lines_since(GFCLOUD_LOG, offset)
|
||||
wait_i = next((i for i, ln in enumerate(lines) if "waiting for button" in ln), None)
|
||||
end_i = action_finish_index(lines, "print")
|
||||
if end_i is None or wait_i is None or end_i < wait_i:
|
||||
end_i = len(lines)
|
||||
ran = ([ln for ln in lines[wait_i:end_i] if "starting run" in ln]
|
||||
if wait_i is not None else [])
|
||||
ev["runs_started_after_wait"] = len(ran)
|
||||
ctx.check(not ran, "a run started after the lid-open cancel (%d)", len(ran))
|
||||
st, cs = ctx.forgectrl.get("/cool/status")
|
||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
||||
ev["latch_locked"] = latch_locked()
|
||||
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
|
||||
ctx.check(ev["latch_locked"], "kernel latch not locked after the cancel")
|
||||
ev["button_dark"] = hw.button_lit()
|
||||
ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
|
||||
# The park cleared the ring and nothing refilled it: the feeder is gone,
|
||||
# the device is out of live-feed mode, and the program total stands at
|
||||
# zero and stays there. A feeder left alive shows here as a total that
|
||||
# comes back within its retry period.
|
||||
total = read_program_total()
|
||||
ctx.sleep(2)
|
||||
later = read_program_total()
|
||||
streaming = hw.sysfs_int("cnc/streaming", 0)
|
||||
ev["program_total_after"] = {"first": total, "later": later}
|
||||
ev["streaming_after"] = streaming
|
||||
ctx.check(total == 0 and later == 0,
|
||||
"the ring is not empty after the cancel (program total %s, then %s): the feeder "
|
||||
"outlived the job", total, later)
|
||||
ctx.check(streaming == 0, "cnc/streaming is %s after the cancel", streaming)
|
||||
ctx.act("lid", "close")
|
||||
settle_cloud(ctx, offset)
|
||||
ev["events"] = offline_events(off)
|
||||
ctx.log("PASS: lid open at the button prompt canceled the print; latch locked, armed=false, "
|
||||
"button dark, ring empty with the feed stopped")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the cooling verdict
|
||||
|
||||
HOLD_KEYS = ("cool_temp_min", "cool_temp_start")
|
||||
REFUSE_KEYS = HOLD_KEYS + ("cloud_hold_max_s",)
|
||||
HOLD_REFUSE_S = 60 # the shortest bound the client takes
|
||||
HOLD_REFUSE_LINE = "cooling hold held 60 s - relocking the laser"
|
||||
WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP"
|
||||
|
||||
|
||||
@test("cloud.dark-print", title="A dark cloud print: the latch locked until the run, unlocked "
|
||||
"for it, locked after it",
|
||||
subsystem="cloud", kind="operator", est_min=3,
|
||||
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
||||
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
|
||||
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
||||
"when it lights: the print waits for the engine, runs, and finishes."],
|
||||
description="The cooling-engine contract in cloud mode at its ordinary end, on the engine "
|
||||
"itself: the print arms on the press, waits for the engine's acknowledgment (the "
|
||||
"fans at their floors), and runs. The laser latch is locked at the button and "
|
||||
"through the wait, unlocks only for the run (immediately before it starts), and "
|
||||
"is locked again when the job ends; the print completes. A hold the engine never "
|
||||
"clears is cloud.verdict-refuse; the engine's own warm-up release is "
|
||||
"cooling.floor-and-warm-up.")
|
||||
def dark_print(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "dark.puls", seconds=30)
|
||||
off = Offline().__enter__()
|
||||
try:
|
||||
dark_print_body(ctx, ev, off, job, offset, fc)
|
||||
finally:
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
# ---------------------------------------------------------------- the dark print (exthost.armed-freeze)
|
||||
|
||||
def dark_print_body(ctx, ev, off, job, offset, fc):
|
||||
off.print_ready(9005, job)
|
||||
@@ -1404,91 +1288,6 @@ def dark_print_body(ctx, ev, off, job, offset, fc):
|
||||
ctx.log("PASS: locked at the button, unlocked for the run, locked after it; the print completed")
|
||||
|
||||
|
||||
@test("cloud.verdict-refuse", title="A cloud print held by the engine past the bound is canceled with "
|
||||
"the latch never unlocked",
|
||||
subsystem="cloud", kind="operator", est_min=4,
|
||||
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
||||
("forgectrl", "src/cool.*")],
|
||||
requires=["cloud.dark-print"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate well above "
|
||||
"the coolant and the hold bound to its minimum, and restores both. Press the button "
|
||||
"when it lights: nothing runs; a minute later the print cancels by itself."],
|
||||
description="The other end of the cooling-engine contract before a run: a verdict that "
|
||||
"refuses fire and never clears. The start gate far above the coolant keeps the "
|
||||
"armed session under the warm-up for longer than the hold bound (cloud_hold_max_s "
|
||||
"at its minimum, 60 s). The client waits with the laser latch locked the whole "
|
||||
"time - it is never unlocked before the verdict passes, so there is nothing to "
|
||||
"relock - then cancels the print at the bound: no run, the armed window closed, "
|
||||
"the print ':cancelled'. Dark by construction.")
|
||||
def verdict_refuse(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
before = fc.settings()
|
||||
orig = {k: before.get(k, "") for k in REFUSE_KEYS}
|
||||
ev["orig"] = orig
|
||||
up = (fc.status().get("coolant") or {}).get("up_c")
|
||||
ctx.check(up is not None and 10.0 <= up <= 30.0, "coolant outside the test's range (up_c %s)", up)
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "refuse.puls", seconds=30)
|
||||
off = Offline().__enter__()
|
||||
try:
|
||||
verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig)
|
||||
finally:
|
||||
st, body = fc.post("/settings", params=orig)
|
||||
ctx.log("restore: POST /settings %s -> %s", orig, st)
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
|
||||
def verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig):
|
||||
gate = round(up + 8.0, 1) # eight degrees: the heater cannot get there in a minute
|
||||
st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
|
||||
"cool_temp_min": orig["cool_temp_min"],
|
||||
"cloud_hold_max_s": str(HOLD_REFUSE_S)})
|
||||
ctx.check(st == 200, "POST /settings cool_temp_start=%s cloud_hold_max_s=%s -> %s %s",
|
||||
gate, HOLD_REFUSE_S, st, body)
|
||||
ev["gate"] = gate
|
||||
off.print_ready(9006, job)
|
||||
wait_button_wait(ctx, offset, 120)
|
||||
ev["latch_locked_at_button"] = latch_locked()
|
||||
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
|
||||
ctx.act("button", "press", text="The button is lit: press it. Nothing runs; the print cancels "
|
||||
"itself a minute later.",
|
||||
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
|
||||
got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30)
|
||||
ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up")
|
||||
t0 = time.time()
|
||||
samples = []
|
||||
ctx.notice("The engine holds the print; the client gives up at the bound. Do nothing.")
|
||||
try:
|
||||
while time.time() - t0 < HOLD_REFUSE_S + 30:
|
||||
ctx.checkpoint()
|
||||
samples.append(latch_locked())
|
||||
if log_has(offset, HOLD_REFUSE_LINE):
|
||||
break
|
||||
ctx.sleep(2)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
ev["hold_s"] = round(time.time() - t0)
|
||||
ev["latch_samples"] = {"n": len(samples), "unlocked": samples.count(False)}
|
||||
ctx.check(log_has(offset, HOLD_REFUSE_LINE), "the client did not cancel at the hold bound (%d s)",
|
||||
ev["hold_s"])
|
||||
ctx.check(all(samples), "the latch unlocked during the hold (%d of %d samples)",
|
||||
samples.count(False), len(samples))
|
||||
ctx.check(wait_print_running(ctx, offset, 1) is None, "the run started under the refused verdict")
|
||||
fin = wait_action_finished(ctx, offset, "print", 120)
|
||||
ev["print finished"] = message(fin)
|
||||
ctx.check(fin and CANCELLED in fin, "the held print did not end ':cancelled': %s",
|
||||
message(fin) or "no finish line")
|
||||
st, cs = fc.get("/cool/status")
|
||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
||||
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
|
||||
ctx.check(latch_locked(), "the latch is unlocked after the cancel")
|
||||
ev["events"] = offline_events(off)
|
||||
ctx.log("PASS: held %s s with the latch locked throughout, then canceled at the bound", ev["hold_s"])
|
||||
|
||||
|
||||
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
|
||||
subsystem="cloud", kind="live", est_min=8,
|
||||
covers=_CLOUD_ALL,
|
||||
@@ -1722,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
@@ -1,100 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The event stream's button and its telemetry, on the machine.
|
||||
|
||||
Its own module, so that no other test's fingerprint moves. The stream is
|
||||
opened straight at forgectrl's read-only listener, as a client off the
|
||||
machine would, from a loopback source address of its own so that it takes
|
||||
a place of its own among the three.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .exthost import _stream
|
||||
|
||||
SOURCE = "127.0.0.9"
|
||||
|
||||
|
||||
def _reader(sock, seen, stop):
|
||||
"""Every event on the stream into seen, as (name, data), until stop."""
|
||||
buf = b""
|
||||
sock.settimeout(1.0)
|
||||
while not stop.is_set():
|
||||
try:
|
||||
k = sock.recv(4096)
|
||||
except OSError:
|
||||
continue
|
||||
if not k:
|
||||
break
|
||||
buf += k
|
||||
while b"\n\n" in buf:
|
||||
block, buf = buf.split(b"\n\n", 1)
|
||||
name, data = None, None
|
||||
for line in block.decode("utf-8", "replace").split("\n"):
|
||||
if line.startswith("event: "):
|
||||
name = line[7:]
|
||||
elif line.startswith("data: "):
|
||||
data = line[6:]
|
||||
if name:
|
||||
try:
|
||||
seen.append((name, json.loads(data or "null"), time.time()))
|
||||
except ValueError:
|
||||
seen.append((name, data, time.time()))
|
||||
|
||||
|
||||
@test("events.button-telemetry", title="The event stream tells a press of the button, and the telemetry",
|
||||
subsystem="forgectrl", kind="auto", mode="grbl", est_min=2,
|
||||
covers=[("forgectrl", "src/events.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*")],
|
||||
description="A stream opened at forgectrl's read-only listener gets a telemetry.tick within 12 s, "
|
||||
"and it is what the daemon already holds: the cooling phase, the verdict, fire_ok, the "
|
||||
"two coolant temperatures, the controller's state, and the lid, with no sensor read of "
|
||||
"its own. The machine idle and nothing waiting for the button, one press (the bench "
|
||||
"fixture's, or the operator's) arrives as two button events, pressed true and then false, "
|
||||
"in that order: the button is looked at 25 times a second, so a press shorter than the "
|
||||
"rest of the stream's 5 Hz look is not lost. That a press the machine is waiting for - a "
|
||||
"job arming or armed, a wizard under the lease, the daemon's own wait - is no event is "
|
||||
"forgectrl's events_test's, as are update.available and setup.flag, which a machine in "
|
||||
"use cannot be made to show on demand.")
|
||||
def button_telemetry(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
|
||||
code, why, sock = _stream("127.0.0.1", keep=True, source=SOURCE)
|
||||
ctx.check(code == 200 and sock, "the event stream: %s %s", code, why)
|
||||
seen, stop = [], threading.Event()
|
||||
th = threading.Thread(target=_reader, args=(sock, seen, stop), daemon=True)
|
||||
th.start()
|
||||
try:
|
||||
ctx.wait_for(lambda: any(n == "telemetry.tick" for n, _d, _t in seen), 14)
|
||||
tick = next((d for n, d, _t in seen if n == "telemetry.tick"), None)
|
||||
ev["telemetry"] = tick
|
||||
ctx.log("telemetry.tick: %s", tick)
|
||||
ctx.check(isinstance(tick, dict) and set(tick) == {"phase", "verdict", "fire_ok", "down_c", "up_c", "state", "lid"},
|
||||
"the telemetry's fields: %s", tick)
|
||||
ctx.check(tick.get("lid") in ("closed", "open") and isinstance(tick.get("down_c"), (int, float)),
|
||||
"its values: %s", tick)
|
||||
mark = len(seen)
|
||||
|
||||
def both():
|
||||
got = [d for n, d, _t in seen[mark:] if n == "button"]
|
||||
return got if len(got) >= 2 else None
|
||||
ctx.act("button", "press", until=both, text="Press the button once: nothing is waiting for it, and "
|
||||
"the press is only reported.")
|
||||
presses = [d for n, d, _t in seen[mark:] if n == "button"]
|
||||
ev["button"] = presses
|
||||
ctx.log("button events: %s", presses)
|
||||
ctx.check(presses[:2] == [{"pressed": True}, {"pressed": False}], "a press is pressed, then released: %s",
|
||||
presses)
|
||||
finally:
|
||||
stop.set()
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
th.join(3)
|
||||
@@ -1,265 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""A package's page asking its own service, through the panel's relay.
|
||||
|
||||
Its own module rather than a test in exthost.py, for the reason extcore.py
|
||||
gives: a test added there moves the fingerprint of every test in that
|
||||
module. The helpers come from exthost, and importing them puts that
|
||||
module's shared text in this test's fingerprint and not the other way
|
||||
round. The package here is the reference package's id and key with a
|
||||
service of its own, so exthost's put-back takes it away like the others.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
|
||||
from ..catalog import test
|
||||
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until,
|
||||
_write)
|
||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||
|
||||
CALL_DIR = "/run/forgefirm/ext/call"
|
||||
|
||||
# The service: it says what it was handed, then answers its page's calls
|
||||
# on the listening end the host gave it, one connection at a time.
|
||||
CALL_SERVICE = r'''
|
||||
import json, os, socket, stat, time
|
||||
data = os.environ["FFX_DATA"]
|
||||
|
||||
|
||||
def api(method, path):
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(5)
|
||||
try:
|
||||
s.connect(os.environ["FFX_API"])
|
||||
s.sendall(("%s %s HTTP/1.1\r\nHost: forgeext\r\n\r\n" % (method, path)).encode())
|
||||
buf = b""
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf += c
|
||||
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def kind(n):
|
||||
try:
|
||||
return "socket" if stat.S_ISSOCK(os.fstat(n).st_mode) else "other"
|
||||
except OSError:
|
||||
return "closed"
|
||||
|
||||
|
||||
fd = os.environ.get("FFX_CALL_FD")
|
||||
listener = socket.socket(fileno=int(fd)) if fd else None
|
||||
report = {"call_env": fd, "fds": {str(n): kind(n) for n in (3, 4, 5)},
|
||||
"listening": listener.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN) if listener else None}
|
||||
with open(os.path.join(data, "report.json.new"), "w") as f:
|
||||
json.dump(report, f)
|
||||
os.rename(os.path.join(data, "report.json.new"), os.path.join(data, "report.json"))
|
||||
print("page-call service up", flush=True)
|
||||
|
||||
|
||||
def answer(conn, status, doc):
|
||||
body = json.dumps(doc).encode()
|
||||
conn.sendall(("HTTP/1.1 %d Answer\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n"
|
||||
% (status, len(body))).encode() + body)
|
||||
|
||||
|
||||
while listener:
|
||||
conn, _ = listener.accept()
|
||||
try:
|
||||
conn.settimeout(5)
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
c = conn.recv(4096)
|
||||
if not c:
|
||||
break
|
||||
buf += c
|
||||
head, _, body = buf.partition(b"\r\n\r\n")
|
||||
lines = head.decode("latin-1").split("\r\n")
|
||||
method, path = lines[0].split()[:2]
|
||||
want = 0
|
||||
for line in lines[1:]:
|
||||
k, _, v = line.partition(":")
|
||||
if k.strip().lower() == "content-length":
|
||||
want = int(v)
|
||||
while len(body) < want:
|
||||
c = conn.recv(4096)
|
||||
if not c:
|
||||
break
|
||||
body += c
|
||||
if path == "/echo":
|
||||
answer(conn, 200, {"method": method, "path": path, "body": json.loads(body or b"{}")})
|
||||
elif path == "/refuse":
|
||||
answer(conn, 409, {"error": "not now"})
|
||||
elif path == "/me":
|
||||
answer(conn, 200, (api("GET", "/v0/self") or {}).get("id"))
|
||||
else:
|
||||
answer(conn, 404, {"error": "there is no " + path})
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
while True:
|
||||
time.sleep(60)
|
||||
'''
|
||||
|
||||
CALL_UI = "<!doctype html><title>forgetest page call</title>\n<p>the page that calls its own service</p>\n"
|
||||
|
||||
|
||||
def _pack_caller(work):
|
||||
"""The reference package's id and key, with a page and the service above: (archive, public key)."""
|
||||
import io
|
||||
import subprocess
|
||||
import tarfile
|
||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest page call", "version": "1.0.0",
|
||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||
"service": {"exec": "bin/caller.py"}, "capabilities": ["ui", "machine.read"]}
|
||||
payload = os.path.join(work, "payload.tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644),
|
||||
("bin/caller.py", CALL_SERVICE, 0o755), ("ui/index.html", CALL_UI, 0o644)):
|
||||
info = tarfile.TarInfo(name)
|
||||
data = text.encode()
|
||||
info.size, info.mode = len(data), mode
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, "fwup.conf")
|
||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||
% (REF_ID, payload))
|
||||
key = os.path.join(work, REF_KEY)
|
||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "caller.ffx")
|
||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||
return signed, key + ".pub"
|
||||
|
||||
|
||||
@test("exthost.page-call", title="A package's page asks its own service, through the panel",
|
||||
subsystem="exthost", kind="auto", hardware="api", est_min=4,
|
||||
covers=[("forgeext", "src/call.*"), ("forgeext", "src/run.*"), ("forgeext", "src/sandbox.*"),
|
||||
("forgeext", "src/main.c"), ("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c")],
|
||||
requires=["exthost.service"],
|
||||
description="A package with a page and a service may have the one ask the other. The host binds the "
|
||||
"service's call socket in /run/forgefirm/ext/call, a directory that is root's alone "
|
||||
"(0700), as root's 0600 socket, and hands the service only the listening end, at "
|
||||
"descriptor 4 (FFX_CALL_FD=4), with nothing at 3 or 5. POST /ext/call relays a call "
|
||||
"through the host's command line: a POST's JSON object reaches the service and its "
|
||||
"answer comes back as its status and its JSON; a GET carries no body; the service's own "
|
||||
"refusal is its status and its words, not the relay's; a service may ask the machine "
|
||||
"while it answers. The form is held before anything runs: a method that is not GET or "
|
||||
"POST, a GET with a body, and a body that is not a JSON object are 400, and a path with "
|
||||
".. in it and a package that is not installed are the host's refusals, 409 in its words. "
|
||||
"The command line answers alike. Disabled, the package's service stops, a call is refused "
|
||||
"in words, and the socket's name is gone; enabled again, the service answers again. A "
|
||||
"call to a frozen service and the relay's bridge in a browser are not this test's: "
|
||||
"forgeext's sdk_test and the frame-isolation harness hold those. The package, the key, "
|
||||
"the setting, and the setup record are put back as found.")
|
||||
def page_call(ctx):
|
||||
import shutil
|
||||
import tempfile
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||
prior = fc.settings().get("ext_enabled") or ""
|
||||
raw = read_file(record_path())
|
||||
found_tree = _tree(EXT_ROOT)
|
||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||
etag = hdrs.get("etag")
|
||||
work = tempfile.mkdtemp(prefix="forgetest-call.")
|
||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||
sock = os.path.join(CALL_DIR, REF_ID + ".sock")
|
||||
report = os.path.join(EXT_ROOT, "data", REF_ID, "report.json")
|
||||
|
||||
def call(method, path, body=None, id_=REF_ID):
|
||||
form = {"id": id_, "method": method, "path": path}
|
||||
if body is not None:
|
||||
form["body"] = body
|
||||
return fc.post("/ext/call", data=form)
|
||||
|
||||
try:
|
||||
archive, pub = _pack_caller(work)
|
||||
shutil.copy(pub, owner_key)
|
||||
os.chmod(owner_key, 0o644)
|
||||
r = _forgeext("install", archive, "--consent-community")
|
||||
ctx.check(r.get("ok") is True, "the install of a package with a page and a service -> %s", r.get("error"))
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||
up = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and os.path.exists(report), 60)
|
||||
ctx.check(up, "the service did not come up: %s", _svc(REF_ID))
|
||||
|
||||
# What the service was handed, from inside, and the socket from outside.
|
||||
with open(report) as f:
|
||||
inside = json.load(f)
|
||||
ev["inside"] = inside
|
||||
ctx.log("inside: %s", inside)
|
||||
ctx.check(inside.get("call_env") == "4", "FFX_CALL_FD is %r, not 4", inside.get("call_env"))
|
||||
ctx.check(inside.get("fds") == {"3": "closed", "4": "socket", "5": "closed"},
|
||||
"its descriptors 3, 4, 5: %s", inside.get("fds"))
|
||||
ctx.check(inside.get("listening") == 1, "descriptor 4 is not a listening socket: %s", inside.get("listening"))
|
||||
d, s = os.lstat(CALL_DIR), os.lstat(sock)
|
||||
ev["socket"] = {"dir": [oct(d.st_mode & 0o7777), d.st_uid], "sock": [oct(s.st_mode & 0o7777), s.st_uid]}
|
||||
ctx.check(stat.S_ISDIR(d.st_mode) and d.st_uid == 0 and d.st_mode & 0o7777 == 0o700,
|
||||
"%s is not root's 0700 directory: %s", CALL_DIR, ev["socket"]["dir"])
|
||||
ctx.check(stat.S_ISSOCK(s.st_mode) and s.st_uid == 0 and s.st_mode & 0o777 == 0o600,
|
||||
"%s is not root's 0600 socket: %s", sock, ev["socket"]["sock"])
|
||||
|
||||
# Calls through the relay, as the panel's bridge makes them.
|
||||
st_, doc = call("POST", "/echo", '{"a": 1, "b": "two"}')
|
||||
ev["post"] = [st_, doc]
|
||||
ctx.log("POST /ext/call /echo -> %s %s", st_, doc)
|
||||
ctx.check(st_ == 200 and (doc or {}).get("status") == 200
|
||||
and doc.get("body") == {"method": "POST", "path": "/echo", "body": {"a": 1, "b": "two"}},
|
||||
"a POST -> %s %s", st_, doc)
|
||||
st_, doc = call("GET", "/echo")
|
||||
ev["get"] = [st_, doc]
|
||||
ctx.check(st_ == 200 and (doc or {}).get("body") == {"method": "GET", "path": "/echo", "body": {}},
|
||||
"a GET -> %s %s", st_, doc)
|
||||
st_, doc = call("GET", "/refuse")
|
||||
ev["refuse"] = [st_, doc]
|
||||
ctx.check(st_ == 200 and (doc or {}).get("status") == 409 and doc.get("body") == {"error": "not now"},
|
||||
"the service's own refusal -> %s %s", st_, doc)
|
||||
st_, doc = call("GET", "/me")
|
||||
ev["me"] = [st_, doc]
|
||||
ctx.check(st_ == 200 and (doc or {}).get("body") == REF_ID, "a service asking the machine while it answers "
|
||||
"-> %s %s", st_, doc)
|
||||
for args, want, words in ((("PUT", "/echo"), 400, "a call is GET or POST"),
|
||||
(("GET", "/echo", "{}"), 400, "a GET call has no body"),
|
||||
(("POST", "/echo", "[1]"), 400, "a call's body is a JSON object"),
|
||||
(("GET", "/../etc"), 409, "a call's path starts with"),
|
||||
(("GET", "/echo", None, "org.forgetest.nothere"), 409, "that package is not installed")):
|
||||
st_, why = call(*args)
|
||||
ev.setdefault("refusals", []).append([list(args), st_, why])
|
||||
ctx.check(st_ == want and words in json.dumps(why), "%s -> %s %s, wanted %s %s", args, st_, why, want, words)
|
||||
|
||||
# The command line answers alike.
|
||||
r = _forgeext("call", REF_ID, "POST", "/echo", '{"from": "the console"}')
|
||||
ev["cli"] = r
|
||||
ctx.check(r.get("ok") is True and r.get("status") == 200
|
||||
and (r.get("body") or {}).get("body") == {"from": "the console"}, "forgeext call -> %s", r)
|
||||
|
||||
# Disabled: no service, no socket, a refusal in words; and back.
|
||||
r = _forgeext("disable", REF_ID)
|
||||
ctx.check(r.get("ok") is True, "disable -> %s", r.get("error"))
|
||||
st_, why = call("GET", "/echo")
|
||||
ev["disabled"] = [st_, why]
|
||||
ctx.check(st_ == 409 and "disabled" in json.dumps(why), "a call to a disabled package -> %s %s", st_, why)
|
||||
gone = _until(ctx, lambda: not os.path.exists(sock) and _svc(REF_ID).get("state") != "running", 30)
|
||||
ctx.check(gone, "disabled, the socket's name is still there or the service still runs: %s", _svc(REF_ID))
|
||||
r = _forgeext("enable", REF_ID)
|
||||
ctx.check(r.get("ok") is True, "enable -> %s", r.get("error"))
|
||||
back = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and os.path.exists(sock), 60)
|
||||
ctx.check(back, "enabled again, the service did not come back: %s", _svc(REF_ID))
|
||||
st_, doc = _until(ctx, lambda: (lambda a: a if a[0] == 200 else None)(call("GET", "/echo")), 20) or (0, None)
|
||||
ctx.check(st_ == 200 and (doc or {}).get("status") == 200, "enabled again, it answers -> %s %s", st_, doc)
|
||||
finally:
|
||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||
ctx.check(not os.path.exists(sock), "the call socket's name is left after the package went")
|
||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||
@@ -1,188 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The catalog: the signed index, on the machine.
|
||||
|
||||
Its own module, for the reason extcore.py gives. The endorsement, the
|
||||
judgment of each listed version, the index that never goes back, and a
|
||||
withdrawn version are shown on a scratch root under /tmp with a throwaway
|
||||
key standing in for the OpenGlow extension key, because only OpenGlow holds
|
||||
the real one; the machine's own root is where every index that key did not
|
||||
sign is refused.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
from ..catalog import test
|
||||
from .exthost import FWUP, _forgeext, _write
|
||||
|
||||
INDEX_URL = "https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/index-1.ffi"
|
||||
STAGE_DIR = "/data/forgefirm/tmp"
|
||||
PROBE_ID, OTHER_ID = "org.example.catprobe", "org.example.catother"
|
||||
|
||||
|
||||
def _key(work, name):
|
||||
subprocess.run([FWUP, "-g", "-o", os.path.join(work, name)], check=True, capture_output=True, timeout=60, cwd=work)
|
||||
return os.path.join(work, name)
|
||||
|
||||
|
||||
def _archive(work, name, product, members, version, key):
|
||||
"""An fwup archive of the extension container's form: payload.tar.gz of members {name: text}, signed with key."""
|
||||
payload = os.path.join(work, name + ".tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
for member, text in members.items():
|
||||
info = tarfile.TarInfo(member)
|
||||
data = text.encode()
|
||||
info.size, info.mode = len(data), 0o644
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, name + ".conf")
|
||||
_write(conf, 'meta-product = "%s"\nmeta-description = "%s"\nmeta-version = "%s"\nmeta-platform = "forgefirm-ext"\n'
|
||||
'file-resource payload.tar.gz {\n host-path = "%s"\n}\n' % (product, name, version, payload))
|
||||
raw, signed = os.path.join(work, name + ".raw"), os.path.join(work, name + ".signed")
|
||||
subprocess.run([FWUP, "-c", "-f", conf, "-o", raw], check=True, capture_output=True, timeout=60, cwd=work)
|
||||
subprocess.run([FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed], check=True, capture_output=True,
|
||||
timeout=60, cwd=work)
|
||||
return signed
|
||||
|
||||
|
||||
def _package(work, id_, key):
|
||||
m = {"manifest": 1, "id": id_, "name": "catalog probe", "version": "1.0.0", "author": "forgetest", "license": "MIT",
|
||||
"api": "0.1", "runtime": "data", "capabilities": []}
|
||||
return _archive(work, id_, "ForgeFIRM extension", {"manifest.json": json.dumps(m)}, "1.0.0", key)
|
||||
|
||||
|
||||
def _index(work, doc, key, version):
|
||||
return _archive(work, "index-" + version, "ForgeFIRM extension index", {"index.json": json.dumps(doc)}, version, key)
|
||||
|
||||
|
||||
def _staged():
|
||||
try:
|
||||
return sorted(n for n in os.listdir(STAGE_DIR) if n.startswith("ext-"))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
@test("exthost.catalog", title="The catalog: the signed index verified, kept, judged, and endorsing one key for one id",
|
||||
subsystem="exthost", kind="auto", hardware="api", est_min=2,
|
||||
covers=[("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c"), ("forgeext", "src/index.*"),
|
||||
("forgeext", "src/pkg.*"), ("forgeext", "src/install.*"), ("forgeext", "src/main.c")],
|
||||
description="GET /ext/catalog answers the index the host keeps (or null), judged against this firmware, and "
|
||||
"the one address it is fetched from. On a scratch root, with a throwaway key standing in for the "
|
||||
"OpenGlow extension key, the machine's own forgeext keeps an index signed with it, and the author "
|
||||
"key it names for one id makes a package of that id signed with it read as community and "
|
||||
"endorsed, where before it was unverified; the same key on another id counts for nothing. Read "
|
||||
"back, each listed version is judged on the machine: one that asks for a capability this "
|
||||
"firmware does not have is kept and not offered, and the offer is the newest it runs. An index "
|
||||
"older than the one kept is refused however well signed, and one that withdraws the probe's "
|
||||
"version makes the machine refuse to install it, in OpenGlow's words. On the machine's own root, "
|
||||
"the stand-in's index (signed by a key that is not the OpenGlow extension key) is refused in "
|
||||
"words, a package handed over as an index is refused by the product gate, and the index kept "
|
||||
"is left as it was. POST /ext/catalog/get refuses an id with no such form (400), and one the "
|
||||
"kept index does not list (404, or 409 with no index kept), before anything is fetched. Nothing "
|
||||
"is left in the staging directory. POST /ext/catalog/refresh is not asked here: GitHub counts "
|
||||
"every request of the index's address as a download, and that count is the operators'. The "
|
||||
"refresh (curl, https alone and bounded, 502 in curl's words, 409 in the host's, the file "
|
||||
"removed), the fetch of the offered version held to its size and SHA-256, and the tiers an "
|
||||
"install takes from it, are forgectrl's extpkg_test and forgeext's install_test.")
|
||||
def catalog(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
|
||||
staged_before = _staged()
|
||||
kept = _forgeext("index")
|
||||
ctx.check(kept.get("ok") is True, "forgeext index -> %s", kept.get("error"))
|
||||
ev["kept_before"] = (kept.get("index") or {}).get("version")
|
||||
work = tempfile.mkdtemp(prefix="ft-catalog-", dir="/tmp")
|
||||
try:
|
||||
st, doc = fc.get("/ext/catalog")
|
||||
ctx.check(st == 200 and isinstance(doc, dict) and doc.get("url") == INDEX_URL and "index" in doc
|
||||
and doc.get("index") == kept.get("index") and "core_checked" in doc,
|
||||
"GET /ext/catalog -> %s %s", st, doc)
|
||||
|
||||
# The endorsement, on a scratch root with a stand-in for the OpenGlow extension key.
|
||||
root = os.path.join(work, "root")
|
||||
os.makedirs(os.path.join(root, "keys"))
|
||||
og, author = _key(work, "standin"), _key(work, "author")
|
||||
probe, other = _package(work, PROBE_ID, author), _package(work, OTHER_ID, author)
|
||||
with open(probe, "rb") as f:
|
||||
data = f.read()
|
||||
with open(author + ".pub") as f:
|
||||
author_pub = f.read().strip()
|
||||
listed = {"version": "1.0.0", "url": "https://example.org/catprobe.ffx", "sha256": hashlib.sha256(data).hexdigest(),
|
||||
"size": len(data), "capabilities": [], "api": "0.1"}
|
||||
later = dict(listed, version="1.1.0", url="https://example.org/catprobe-1.1.0.ffx",
|
||||
capabilities=["forgetest.not-a-capability"])
|
||||
entry = {"id": PROBE_ID, "name": "catalog probe", "author": "forgetest", "key": author_pub,
|
||||
"versions": [later, listed]}
|
||||
idx = _index(work, {"index": 1, "packages": [entry]}, og, "2026.923.2")
|
||||
|
||||
def scratch(*args):
|
||||
return _forgeext("--root", root, "--official-key", og + ".pub", "--no-reserve", *args)
|
||||
r = scratch("inspect", probe)
|
||||
ctx.check(r.get("ok") is True and r.get("tier") == "unverified", "before an index: %s %s", r.get("tier"), r.get("error"))
|
||||
r = scratch("index-verify", idx)
|
||||
ev["scratch_verify"] = r
|
||||
ctx.check(r.get("ok") is True and r.get("packages") == 1 and r.get("version") == "2026.923.2",
|
||||
"the stand-in's index is kept on the scratch root: %s", r)
|
||||
read = scratch("index")
|
||||
mine = ((read.get("index") or {}).get("packages") or [{}])[0]
|
||||
vs = {v.get("version"): v for v in mine.get("versions", [])}
|
||||
ev["judged"] = {"offer": mine.get("offer"), "1.1.0": vs.get("1.1.0", {}).get("why"),
|
||||
"core_checked": read.get("core_checked")}
|
||||
ctx.check(mine.get("id") == PROBE_ID and len(mine.get("key_id", "")) == 64, "and read back: %s", mine)
|
||||
ctx.check(vs.get("1.1.0", {}).get("usable") is False and "not a capability" in vs.get("1.1.0", {}).get("why", "")
|
||||
and vs.get("1.0.0", {}).get("usable") is True and mine.get("offer") == "1.0.0",
|
||||
"judged on the machine: 1.1.0 kept and not offered, 1.0.0 offered: %s", ev["judged"])
|
||||
r = scratch("inspect", probe)
|
||||
ev["endorsed"] = {"tier": r.get("tier"), "endorsed": r.get("endorsed")}
|
||||
ctx.check(r.get("tier") == "community" and r.get("endorsed") is True,
|
||||
"signed with the key it endorses for its id: community, endorsed (%s %s)", r.get("tier"), r.get("endorsed"))
|
||||
r = scratch("inspect", other)
|
||||
ctx.check(r.get("tier") == "unverified" and r.get("endorsed") is False,
|
||||
"the same key on another id: %s %s", r.get("tier"), r.get("endorsed"))
|
||||
|
||||
# Never back: an older index is refused, and the one kept stays.
|
||||
r = scratch("index-verify", _index(work, {"index": 1, "packages": []}, og, "2026.923.1"))
|
||||
ev["older"] = r.get("error")
|
||||
ctx.check(r.get("ok") is False and "never goes back" in (r.get("error") or ""), "an older index -> %s", r.get("error"))
|
||||
ctx.check((scratch("index").get("index") or {}).get("version") == "2026.923.2", "and the one kept stays")
|
||||
|
||||
# A withdrawn version does not install, from the catalog or from anywhere else.
|
||||
gone = dict(entry, versions=[later], withdrawn=[{"version": "1.0.0", "reason": "forgetest withdrew it"}])
|
||||
r = scratch("index-verify", _index(work, {"index": 1, "packages": [gone]}, og, "2026.923.3"))
|
||||
ctx.check(r.get("ok") is True, "an index withdrawing the probe's version is kept: %s", r.get("error"))
|
||||
r = scratch("inspect", probe)
|
||||
ev["withdrawn"] = r.get("error")
|
||||
ctx.check(r.get("ok") is False and "OpenGlow withdrew %s 1.0.0 from its catalog: forgetest withdrew it" % PROBE_ID
|
||||
in (r.get("error") or ""), "the withdrawn version -> %s", r.get("error"))
|
||||
|
||||
# The machine's own root keeps no index but OpenGlow's.
|
||||
refused = {}
|
||||
for name, path, words in (("the stand-in's index", idx, "not signed with the OpenGlow extension key"),
|
||||
("a package as an index", probe, "product")):
|
||||
r = _forgeext("index-verify", path)
|
||||
refused[name] = r.get("error")
|
||||
ctx.check(r.get("ok") is False and words in (r.get("error") or ""), "%s -> %s", name, r.get("error"))
|
||||
ev["refused"] = refused
|
||||
ctx.check(_forgeext("index").get("index") == kept.get("index"), "a refused index changed the one kept")
|
||||
|
||||
# The relay's refusals, before anything is fetched.
|
||||
st, why = fc.post("/ext/catalog/get", data={"id": "org.example;reboot"})
|
||||
ctx.check(st == 400, "an id with no such form -> %s %s", st, why)
|
||||
st, why = fc.post("/ext/catalog/get", data={"id": PROBE_ID})
|
||||
want = (404, "not in the catalog") if kept.get("index") else (409, "fetch it first")
|
||||
ev["get_unlisted"] = [st, why]
|
||||
ctx.check(st == want[0] and want[1] in json.dumps(why), "an id the kept index does not list -> %s %s", st, why)
|
||||
ctx.check(_forgeext("index").get("index") == kept.get("index"), "the refusals changed the index kept")
|
||||
ctx.check(_staged() == staged_before, "the staging directory holds %s, and held %s before", _staged(), staged_before)
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
@@ -1,117 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""What the extension host makes of the firmware's own version file.
|
||||
|
||||
A campaign only ever runs on a dev image - the release image does not
|
||||
ship this suite - and a dev image stamps ${DATETIME} (dev), which is no
|
||||
version at all. So the branch where a core range is actually judged
|
||||
cannot be reached from here by any machine this suite runs on, and it is
|
||||
not this module's to prove: that is forgeext's version_test, over the
|
||||
file as each image build writes it. What is left here is the half a
|
||||
machine can show, and it is the half that ties the daemon to the image:
|
||||
that the host reads the image's own file, finds a build stamp, and
|
||||
judges nothing rather than guessing.
|
||||
|
||||
Its own module rather than a test in exthost.py: a test's fingerprint
|
||||
holds the shared text of the module it lives in, and the blank lines
|
||||
between tests are shared text, so a test added to exthost.py moves the
|
||||
fingerprint of every other test there and costs a campaign a re-run of
|
||||
each - exthost.panel-install among them, which is an operator drill with
|
||||
the button held. The helpers come from exthost, and importing them puts
|
||||
that module's shared text in this test's fingerprint and not the other
|
||||
way round.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from ..catalog import test
|
||||
from .exthost import FWUP, _forgeext, _read, _write
|
||||
|
||||
|
||||
@test("exthost.core-range", title="The host reads the image's own version file and judges by it",
|
||||
subsystem="exthost", kind="auto", est_min=2,
|
||||
covers=[("forgeext", "src/install.*"), ("forgeext", "src/manifest.*"), ("forgeext", "src/main.c")],
|
||||
description="The firmware's version reaches the host from /etc/forgefirm-version, which the image "
|
||||
"build writes. This test asks the host what it made of that file rather than reading the "
|
||||
"file and believing it. On the dev image a campaign runs on, the first word is a build "
|
||||
"stamp and no version: inspect answers core_checked false, and a package asking for a "
|
||||
"minimum far above or a maximum far below is accepted all the same, because a range "
|
||||
"cannot be judged against a stamp and the host does not guess one. A dev image that "
|
||||
"began stamping something version-shaped would fail here, which is the regression this "
|
||||
"test is for; the judging branch belongs to forgeext's version_test, since no image "
|
||||
"carrying this suite can reach it. Nothing is installed, nothing runs, and nothing "
|
||||
"moves: inspect changes nothing and the work directory goes at the end.")
|
||||
def core_range(ctx):
|
||||
import io
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
ev = ctx.evidence
|
||||
|
||||
stamp = (_read("/etc/forgefirm-version", "").split() or [""])[0]
|
||||
ctx.check(stamp, "/etc/forgefirm-version says nothing: the image did not stamp itself")
|
||||
version = stamp[1:] if stamp[:1] == "v" and stamp[1:2].isdigit() else stamp
|
||||
looks_like_a_version = bool(re.match(r"^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$", version))
|
||||
ctx.log("/etc/forgefirm-version: %r -> %r", stamp, version)
|
||||
ev["firmware_version"] = {"file": stamp, "version": version, "looks_like_a_version": looks_like_a_version}
|
||||
|
||||
# The suite ships on the dev image alone, so this is the only shape a
|
||||
# campaign can meet. If it ever is not, the image changed and the
|
||||
# judging path needs a proof that is not here.
|
||||
ctx.check(not looks_like_a_version,
|
||||
"this image stamped %r, which is a version: a campaign cannot run on a release image, so "
|
||||
"either the dev image's stamp changed or this is not the image this suite belongs on", stamp)
|
||||
|
||||
work = tempfile.mkdtemp(prefix="forgetest-core-")
|
||||
try:
|
||||
def packed(core):
|
||||
"""A data package that asks for nothing, with the core range given."""
|
||||
m = {"manifest": 1, "id": "org.forgetest.corerange", "name": "forgetest core range",
|
||||
"version": "1.0.0", "author": "forgetest", "license": "MIT", "api": "0.1",
|
||||
"runtime": "data", "capabilities": []}
|
||||
if core:
|
||||
m["core"] = core
|
||||
payload = os.path.join(work, "payload.tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
data = json.dumps(m).encode()
|
||||
info = tarfile.TarInfo("manifest.json")
|
||||
info.size, info.mode = len(data), 0o644
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, "fwup.conf")
|
||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "core range"\n'
|
||||
'meta-version = "1.0.0"\nmeta-platform = "forgefirm-ext"\n'
|
||||
'file-resource payload.tar.gz {\n host-path = "%s"\n}\n' % payload)
|
||||
out = os.path.join(work, "corerange.ffx")
|
||||
if os.path.exists(out):
|
||||
os.remove(out)
|
||||
subprocess.run([FWUP, "-c", "-f", conf, "-o", out], check=True, capture_output=True,
|
||||
timeout=60, cwd=work)
|
||||
return out
|
||||
|
||||
r = _forgeext("inspect", packed(None))
|
||||
ctx.check(r.get("ok") is True, "a package with no core range was refused: %s", r.get("error"))
|
||||
ctx.check(r.get("core_checked") is False,
|
||||
"the host judged a range against the stamp %r: core_checked %s", stamp, r.get("core_checked"))
|
||||
ev["no_range"] = r
|
||||
|
||||
# A stamp is no version, so neither end of a range is judged and
|
||||
# neither refuses the package. The host says so rather than
|
||||
# refusing on a comparison it cannot make.
|
||||
ev["ranges"] = []
|
||||
for core in ({"min": "99.0.0"}, {"max": "0.0.1"}, {"min": "0.0.1", "max": "0.0.2"}):
|
||||
r = _forgeext("inspect", packed(core))
|
||||
ctx.check(r.get("ok") is True and r.get("core_checked") is False,
|
||||
"the host judged %s against the stamp anyway: %s", core, r)
|
||||
ev["ranges"].append({"core": core, "inspect": r})
|
||||
|
||||
ctx.check("org.forgetest.corerange" not in
|
||||
[x.get("id") for x in (_forgeext("list").get("packages") or [])],
|
||||
"inspect installed the package: it must change nothing")
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
@@ -1,237 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The destinations the operator names for a package that asks for them.
|
||||
|
||||
Its own module rather than a test in exthost.py, for the reason extcore.py
|
||||
gives. The package here is the reference package's id and key with a
|
||||
service of its own, so exthost's put-back takes it away like the others.
|
||||
|
||||
What it dials is the machine's own DNS resolver on TCP port 53: an address
|
||||
every machine on a network has, that is not the machine, and that answers.
|
||||
Named by address, it gives the service no resolver of its own, so the
|
||||
only way through to it is the one the operator named.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
|
||||
from ..catalog import test
|
||||
from .exthost import (EXT_ROOT, FWUP, NFT, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _read, _svc, _tree,
|
||||
_until, _write)
|
||||
from .forgectrl import lan_ip
|
||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||
|
||||
# The service: at every start, where it may connect and what one dial does.
|
||||
DEST_SERVICE = r'''
|
||||
import errno, json, os, socket, sys, time
|
||||
data, target, port = os.environ["FFX_DATA"], sys.argv[1], int(sys.argv[2])
|
||||
|
||||
|
||||
def api(method, path):
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(5)
|
||||
try:
|
||||
s.connect(os.environ["FFX_API"])
|
||||
s.sendall(("%s %s HTTP/1.1\r\nHost: forgeext\r\n\r\n" % (method, path)).encode())
|
||||
buf = b""
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf += c
|
||||
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def dial(p):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(4)
|
||||
try:
|
||||
s.connect((target, p))
|
||||
return "connected"
|
||||
except socket.timeout:
|
||||
return "timeout"
|
||||
except OSError as e:
|
||||
return errno.errorcode.get(e.errno, str(e.errno))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
report = {"pid": os.getpid(), "destinations": (api("GET", "/v0/self") or {}).get("destinations"),
|
||||
"dial": dial(port), "dial_other_port": dial(port + 1)}
|
||||
with open(os.path.join(data, "report.json.new"), "w") as f:
|
||||
json.dump(report, f)
|
||||
os.rename(os.path.join(data, "report.json.new"), os.path.join(data, "report.json"))
|
||||
print("destinations service up: %s" % report, flush=True)
|
||||
while True:
|
||||
time.sleep(60)
|
||||
'''
|
||||
|
||||
|
||||
def _resolver():
|
||||
"""The first IPv4 nameserver of /etc/resolv.conf, or None."""
|
||||
for line in _read("/etc/resolv.conf").splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0] == "nameserver" and parts[1].count(".") == 3:
|
||||
return parts[1]
|
||||
return None
|
||||
|
||||
|
||||
def _pack_dest(work, target, port):
|
||||
"""The reference package's id and key, asking for the operator's destinations: (archive, public key)."""
|
||||
import io
|
||||
import tarfile
|
||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest destinations", "version": "1.0.0",
|
||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||
"service": {"exec": "bin/dest.py", "args": [target, str(port)]},
|
||||
"capabilities": ["net.outbound.operator"]}
|
||||
payload = os.path.join(work, "payload.tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/dest.py", DEST_SERVICE, 0o755)):
|
||||
info = tarfile.TarInfo(name)
|
||||
data = text.encode()
|
||||
info.size, info.mode = len(data), mode
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, "fwup.conf")
|
||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||
% (REF_ID, payload))
|
||||
key = os.path.join(work, REF_KEY)
|
||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "dest.ffx")
|
||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||
return signed, key + ".pub"
|
||||
|
||||
|
||||
@test("exthost.operator-destinations", title="The operator names where a package may connect, and takes it away",
|
||||
subsystem="exthost", kind="auto", hardware="api", est_min=4,
|
||||
covers=[("forgeext", "src/install.*"), ("forgeext", "src/state.*"), ("forgeext", "src/run.*"),
|
||||
("forgeext", "src/super.*"), ("forgeext", "src/api.*"), ("forgeext", "src/caps.*"),
|
||||
("forgeext", "src/manifest.*"), ("forgeext", "src/netrules.*"), ("forgeext", "src/main.c"),
|
||||
("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c")],
|
||||
requires=["exthost.service"],
|
||||
description="A package that asks for net.outbound.operator may reach the places the operator names for it "
|
||||
"and nothing else. Installed, it has none: its own GET /v0/self names no destination and its "
|
||||
"dial to the machine's DNS resolver on TCP port 53 is refused inside the sandbox (EACCES). "
|
||||
"The operator names that resolver through POST /ext/dest; the host starts the service again "
|
||||
"(said in its log, and no crash), its answer names the destination, its dial connects, the "
|
||||
"next port up is still refused, and the account's chain in the rule table carries the "
|
||||
"address. The machine's own LAN address and loopback are refused in words, a destination "
|
||||
"out of form is 400, and taking away one that was never named is refused. Taken away, the "
|
||||
"service starts again with no destination, the dial is refused again, and the chain no longer "
|
||||
"carries the address. The package, the key, the setting, and the setup record are put back as "
|
||||
"found.")
|
||||
def operator_destinations(ctx):
|
||||
import shutil
|
||||
import tempfile
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
target = _resolver()
|
||||
ctx.check(target, "/etc/resolv.conf names no IPv4 resolver: there is nothing to dial")
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(4)
|
||||
try:
|
||||
s.connect((target, 53))
|
||||
reachable = True
|
||||
except OSError as e:
|
||||
reachable = str(e)
|
||||
finally:
|
||||
s.close()
|
||||
ev["resolver"] = {"address": target, "tcp_53_from_root": reachable}
|
||||
ctx.check(reachable is True, "the resolver %s does not take a TCP connection on port 53 even from outside the "
|
||||
"sandbox (%s): this network cannot show a way out opening", target, reachable)
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||
prior = fc.settings().get("ext_enabled") or ""
|
||||
raw = read_file(record_path())
|
||||
found_tree = _tree(EXT_ROOT)
|
||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||
etag = hdrs.get("etag")
|
||||
work = tempfile.mkdtemp(prefix="forgetest-dest.")
|
||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||
report = os.path.join(EXT_ROOT, "data", REF_ID, "report.json")
|
||||
where = "%s:53" % target
|
||||
|
||||
def seen():
|
||||
try:
|
||||
with open(report) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
def fresh(than):
|
||||
r = seen()
|
||||
return r if r and r.get("pid") != than and _svc(REF_ID).get("state") == "running" else None
|
||||
|
||||
def chain_has(addr):
|
||||
"""Does the package's own chain let it through to addr on TCP 53? No chain is no."""
|
||||
acct = _svc(REF_ID).get("account") or ""
|
||||
if not acct.startswith("ffx"):
|
||||
return False
|
||||
out = subprocess.run([NFT, "list", "chain", "inet", "ffx", "u%d" % (800 + int(acct[3:]))], capture_output=True,
|
||||
text=True, timeout=30).stdout
|
||||
return ("daddr %s tcp dport 53 accept" % addr) in out
|
||||
|
||||
try:
|
||||
archive, pub = _pack_dest(work, target, 53)
|
||||
shutil.copy(pub, owner_key)
|
||||
os.chmod(owner_key, 0o644)
|
||||
r = _forgeext("install", archive, "--consent-community")
|
||||
ctx.check(r.get("ok") is True, "the install of a package asking for the operator's destinations -> %s",
|
||||
r.get("error"))
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||
first = _until(ctx, lambda: fresh(None), 60)
|
||||
ctx.check(first, "the service did not come up: %s", _svc(REF_ID))
|
||||
ev["none_named"] = first
|
||||
ctx.log("none named: %s", first)
|
||||
ctx.check(first.get("destinations") == [] and first.get("dial") == "EACCES",
|
||||
"with nothing named it may reach nothing, and the sandbox refuses the dial: %s", first)
|
||||
ctx.check(not chain_has(target), "the rule table carries %s before the operator named it", target)
|
||||
|
||||
# Refusals, in the host's words or the relay's.
|
||||
refusals = []
|
||||
for dest, want, words in (("%s:80" % lan_ip(), 409, "is this machine"), ("127.0.0.1:80", 409, "is this machine"),
|
||||
("--root", 400, "host:port"), ("%s" % target, 409, "host:port")):
|
||||
st_, why = fc.post("/ext/dest", data={"id": REF_ID, "action": "add", "dest": dest})
|
||||
refusals.append([dest, st_, why])
|
||||
ctx.check(st_ == want and words in json.dumps(why), "add %s -> %s %s, wanted %s %s", dest, st_, why, want, words)
|
||||
st_, why = fc.post("/ext/dest", data={"id": REF_ID, "action": "remove", "dest": where})
|
||||
refusals.append(["remove " + where, st_, why])
|
||||
ctx.check(st_ == 409 and "is not one of" in json.dumps(why), "taking away one never named -> %s %s", st_, why)
|
||||
ev["refusals"] = refusals
|
||||
|
||||
# Named: started again with its way out.
|
||||
st_, doc = fc.post("/ext/dest", data={"id": REF_ID, "action": "add", "dest": where})
|
||||
mine = [p for p in (doc or {}).get("packages", []) if p.get("id") == REF_ID] if isinstance(doc, dict) else []
|
||||
ctx.check(st_ == 200 and mine and mine[0].get("destinations") == [where], "POST /ext/dest add %s -> %s %s",
|
||||
where, st_, doc if st_ != 200 else mine)
|
||||
named = _until(ctx, lambda: fresh(first.get("pid")), 60)
|
||||
ev["named"] = named
|
||||
ctx.log("named: %s", named)
|
||||
ctx.check(named and named.get("destinations") == [where] and named.get("dial") == "connected"
|
||||
and named.get("dial_other_port") == "EACCES",
|
||||
"named, it is started again, names it, reaches it, and still not the next port: %s", named)
|
||||
ctx.check(chain_has(target), "the rule table does not carry %s once named", target)
|
||||
svc = _svc(REF_ID)
|
||||
ctx.check(svc.get("state") == "running" and "quarantined" not in (svc.get("reason") or ""),
|
||||
"the restart was no crash: %s", svc)
|
||||
|
||||
# Taken away: started again with none.
|
||||
st_, doc = fc.post("/ext/dest", data={"id": REF_ID, "action": "remove", "dest": where})
|
||||
ctx.check(st_ == 200, "POST /ext/dest remove %s -> %s %s", where, st_, doc)
|
||||
gone = _until(ctx, lambda: fresh(named.get("pid") if named else None), 60)
|
||||
ev["taken_away"] = gone
|
||||
ctx.check(gone and gone.get("destinations") == [] and gone.get("dial") == "EACCES",
|
||||
"taken away, it starts again and reaches nothing: %s", gone)
|
||||
ctx.check(not chain_has(target), "the rule table still carries %s after it was taken away", target)
|
||||
finally:
|
||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,154 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The host's own events: a service is told before it is stopped.
|
||||
|
||||
Its own module, for the reason extcore.py gives. The package here is the
|
||||
reference package's id and key with a service of its own, so exthost's
|
||||
put-back takes it away like the others.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until, _write)
|
||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||
|
||||
# The service: it follows the host's event feed and keeps every host event it reads, with the
|
||||
# monotonic time it read it, in its data directory.
|
||||
LIFE_SERVICE = r'''
|
||||
import json, os, socket, time
|
||||
data = os.environ["FFX_DATA"]
|
||||
|
||||
|
||||
def api(body):
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(40)
|
||||
try:
|
||||
s.connect(os.environ["FFX_API"])
|
||||
payload = json.dumps(body).encode()
|
||||
s.sendall(b"POST /v0/events HTTP/1.1\r\nHost: forgeext\r\nContent-Type: application/json\r\n"
|
||||
b"Content-Length: %d\r\n\r\n" % len(payload) + payload)
|
||||
buf = b""
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf += c
|
||||
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def keep(name, doc):
|
||||
with open(os.path.join(data, name + ".new"), "w") as f:
|
||||
json.dump(doc, f)
|
||||
os.rename(os.path.join(data, name + ".new"), os.path.join(data, name))
|
||||
|
||||
|
||||
place = api({})["next"]
|
||||
keep("ready.json", {"place": place, "t": time.monotonic()})
|
||||
got = []
|
||||
while True:
|
||||
ans = api({"since": place, "wait": 20})
|
||||
for e in ans.get("events") or []:
|
||||
if e.get("event", "").startswith("ext."):
|
||||
got.append({"event": e["event"], "data": e.get("data"), "t": time.monotonic()})
|
||||
keep("host-events.json", got)
|
||||
place = ans.get("next", place)
|
||||
'''
|
||||
|
||||
|
||||
def _pack_life(work):
|
||||
import io
|
||||
import tarfile
|
||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest lifecycle", "version": "1.0.0",
|
||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||
"service": {"exec": "bin/life.py"}, "capabilities": ["events"]}
|
||||
payload = os.path.join(work, "payload.tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/life.py", LIFE_SERVICE, 0o755)):
|
||||
info = tarfile.TarInfo(name)
|
||||
data = text.encode()
|
||||
info.size, info.mode = len(data), mode
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, "fwup.conf")
|
||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||
% (REF_ID, payload))
|
||||
key = os.path.join(work, REF_KEY)
|
||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "life.ffx")
|
||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||
return signed, key + ".pub"
|
||||
|
||||
|
||||
@test("exthost.lifecycle", title="A package is told, in the feed it reads, before the host stops it",
|
||||
subsystem="exthost", kind="auto", hardware="api", est_min=3,
|
||||
covers=[("forgeext", "src/run.*"), ("forgeext", "src/machine.*"), ("forgeext", "src/evfeed.*"),
|
||||
("forgeext", "src/api.*")],
|
||||
requires=["exthost.service"],
|
||||
description="A package that holds events reads the host's own events beside the machine's. With the "
|
||||
"package running and following its feed, extensions are turned off: the host puts "
|
||||
"ext.shutdown into the feed with the reason (\"extensions are off (ext_enabled)\"), and "
|
||||
"stops the service a second later, so that the package has read it - the event is in its "
|
||||
"data directory, read before its process ended. Holds do not wait for that second: they "
|
||||
"follow extensions off at once, as exthost.hold-pause-tier holds. ext.will_freeze and "
|
||||
"ext.thawed, which an armed window brings, are the automation package's host test's "
|
||||
"(automation_test.py, where a service with job_time.run reads both). The package, the "
|
||||
"key, the setting, and the setup record are put back as found.")
|
||||
def lifecycle(ctx):
|
||||
import shutil
|
||||
import tempfile
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||
prior = fc.settings().get("ext_enabled") or ""
|
||||
raw = read_file(record_path())
|
||||
found_tree = _tree(EXT_ROOT)
|
||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||
etag = hdrs.get("etag")
|
||||
work = tempfile.mkdtemp(prefix="forgetest-life.")
|
||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||
data = os.path.join(EXT_ROOT, "data", REF_ID)
|
||||
|
||||
def doc(name):
|
||||
try:
|
||||
with open(os.path.join(data, name)) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
try:
|
||||
archive, pub = _pack_life(work)
|
||||
shutil.copy(pub, owner_key)
|
||||
os.chmod(owner_key, 0o644)
|
||||
r = _forgeext("install", archive, "--consent-community")
|
||||
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||
ready = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and doc("ready.json"), 60)
|
||||
ctx.check(ready, "the service did not come up and follow its feed: %s", _svc(REF_ID))
|
||||
time.sleep(1.0)
|
||||
pid = _svc(REF_ID).get("pid")
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
|
||||
ctx.check(st == 200, "ext_enabled=0 -> %s %r", st, reply)
|
||||
stopped = _until(ctx, lambda: _svc(REF_ID).get("state") != "running" and time.monotonic(), 20)
|
||||
ctx.check(stopped, "the service was not stopped: %s", _svc(REF_ID))
|
||||
ctx.check(not os.path.exists("/proc/%s" % pid), "its process %s outlived the stop", pid)
|
||||
got = doc("host-events.json") or []
|
||||
ev["host_events"] = got
|
||||
ctx.log("what it read: %s", got)
|
||||
ctx.check(len(got) >= 1 and got[0].get("event") == "ext.shutdown"
|
||||
and (got[0].get("data") or {}).get("reason") == "extensions are off (ext_enabled)",
|
||||
"it read ext.shutdown, with the reason, before it was stopped: %s", got)
|
||||
finally:
|
||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||
@@ -1,238 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""An M-code a package answers, on the machine.
|
||||
|
||||
Its own module, for the reason extcore.py gives. The package is the
|
||||
reference package's id and key with a service of its own that answers
|
||||
M160 on its call socket, so exthost's put-back takes it away like the
|
||||
others. The jobs are dark: they move and never command the laser.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from ..catalog import test
|
||||
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until, _write)
|
||||
from .motion import _job, _job_post, _job_wait, _words, kernel_start, kernel_xy_mm, machine_idle
|
||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||
|
||||
# The service: it answers M160 on the call socket the host hands it (fd 4), and keeps every call it was
|
||||
# asked, with the monotonic time it was asked, in its data directory. P1 takes 2 s and is done; any
|
||||
# other P is refused.
|
||||
MC_SERVICE = r'''
|
||||
import json, os, socket, time
|
||||
data = os.environ["FFX_DATA"]
|
||||
lst = socket.socket(fileno=int(os.environ["FFX_CALL_FD"]))
|
||||
calls = []
|
||||
while True:
|
||||
c, _ = lst.accept()
|
||||
try:
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
k = c.recv(4096)
|
||||
if not k:
|
||||
break
|
||||
buf += k
|
||||
head, _, body = buf.partition(b"\r\n\r\n")
|
||||
n = 0
|
||||
for line in head.split(b"\r\n")[1:]:
|
||||
k, _, v = line.partition(b":")
|
||||
if k.strip().lower() == b"content-length":
|
||||
n = int(v)
|
||||
while len(body) < n:
|
||||
k = c.recv(4096)
|
||||
if not k:
|
||||
break
|
||||
body += k
|
||||
req = json.loads(body[:n] or b"{}")
|
||||
path = head.split(b" ")[1].decode()
|
||||
t0 = time.monotonic()
|
||||
words = req.get("words", {})
|
||||
if path == "/mcode" and words.get("P") == 1:
|
||||
time.sleep(2.0)
|
||||
status, ans = 200, {"message": "forgetest P1"}
|
||||
elif path == "/mcode":
|
||||
status, ans = 409, {"error": "the stand-in refused"}
|
||||
else:
|
||||
status, ans = 404, {"error": "no such call"}
|
||||
calls.append({"path": path, "req": req, "t": t0, "status": status})
|
||||
with open(os.path.join(data, "calls.json.new"), "w") as f:
|
||||
json.dump(calls, f)
|
||||
os.rename(os.path.join(data, "calls.json.new"), os.path.join(data, "calls.json"))
|
||||
out = json.dumps(ans).encode()
|
||||
c.sendall(b"HTTP/1.1 %d X\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"
|
||||
% (status, len(out)) + out)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
c.close()
|
||||
'''
|
||||
|
||||
|
||||
def _pack_mcode(work):
|
||||
import io
|
||||
import tarfile
|
||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest M-code", "version": "1.0.0",
|
||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||
"service": {"exec": "bin/mcode.py"}, "capabilities": ["mcode:160", "job_time.run"]}
|
||||
payload = os.path.join(work, "payload.tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/mcode.py", MC_SERVICE, 0o755)):
|
||||
info = tarfile.TarInfo(name)
|
||||
data = text.encode()
|
||||
info.size, info.mode = len(data), mode
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, "fwup.conf")
|
||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||
% (REF_ID, payload))
|
||||
key = os.path.join(work, REF_KEY)
|
||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "mcode.ffx")
|
||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||
return signed, key + ".pub"
|
||||
|
||||
|
||||
def _port_state(fc):
|
||||
st, body = fc.get("/motion/state")
|
||||
return body if st == 200 and isinstance(body, dict) else {}
|
||||
|
||||
|
||||
@test("exthost.mcode", title="A job waits at an M-code a package answers, dark and still",
|
||||
subsystem="exthost", kind="auto", mode="grbl", est_min=4,
|
||||
covers=[("grblhal-glowforge", "src/glowforge_mcode.*"), ("grblhal-glowforge", "src/ctlport.*"),
|
||||
("forgectrl", "src/mcode.*"), ("forgectrl", "src/extpkg.*"), ("forgectrl", "src/grblport.*"),
|
||||
("forgeext", "src/main.c"), ("forgeext", "src/run.*"), ("forgeext", "src/caps.*"),
|
||||
("forgeext", "src/manifest.*"), ("forgeext", "src/call.*")],
|
||||
requires=["exthost.service", "motion.job"],
|
||||
steps=["Bed clear; the head needs 10 mm of free travel toward +X. Nobody touches the gantry."],
|
||||
description="A package that asks for mcode:160 and job_time.run, the one granted, is installed and "
|
||||
"turned on; its service answers POST /mcode on its call socket. The host's status names it "
|
||||
"as the one that answers M160. A dark job (out 5 mm, M160 P1, back) plays through POST "
|
||||
"/job: at the M-code the controller's port state names M160 with P 1, the head stands "
|
||||
"still across the wait (the kernel's counters and the port's position unchanged over "
|
||||
"1 s), and the service answers after 2 s; the job then goes on and ends done, every line "
|
||||
"acknowledged, the head back where it began, the service asked once with the code and "
|
||||
"its words, no discharge. A job that names M161, which nothing answers, fails at that "
|
||||
"line with nothing moved. A job at M160 P2, which the service refuses, is held: the "
|
||||
"port's state goes to Hold with the head still, and the job is aborted from there. "
|
||||
"Everything is put back as exthost.service puts it back; the refusals the controller "
|
||||
"makes of a table or an answer out of form, the timeout, the reset, and the barrier's "
|
||||
"darkness under an open armed window are the driver's mcode_test harness's.")
|
||||
def mcode(ctx):
|
||||
import tempfile
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
machine_idle(ctx)
|
||||
prior = fc.settings().get("ext_enabled") or ""
|
||||
raw = read_file(record_path())
|
||||
found_tree = _tree(EXT_ROOT)
|
||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||
etag = hdrs.get("etag")
|
||||
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
|
||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||
calls_file = os.path.join(EXT_ROOT, "data", REF_ID, "calls.json")
|
||||
|
||||
def calls():
|
||||
try:
|
||||
return json.loads(read_file(calls_file) or "[]")
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
try:
|
||||
archive, pub = _pack_mcode(work)
|
||||
import shutil
|
||||
shutil.copy(pub, owner_key)
|
||||
os.chmod(owner_key, 0o644)
|
||||
r = _forgeext("install", archive, "--consent-community", "--grant", "job_time.run")
|
||||
ctx.check(r.get("ok") is True, "the install with job_time.run granted -> %s", r.get("error"))
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||
x = _until(ctx, lambda: _svc(REF_ID) if _svc(REF_ID).get("state") == "running" else None, 90, poll=0.5)
|
||||
ctx.check(x, "the service is not running: %s", _svc(REF_ID))
|
||||
|
||||
def listed():
|
||||
try:
|
||||
doc = json.loads(read_file("/run/forgefirm/ext/status.json") or "{}")
|
||||
except ValueError:
|
||||
return None
|
||||
return doc.get("mcodes") if doc.get("mcodes") == [{"code": 160, "id": REF_ID}] else None
|
||||
ev["status_mcodes"] = _until(ctx, listed, 30, poll=0.5)
|
||||
ctx.check(ev["status_mcodes"], "the host's status does not name the package as the one that answers M160")
|
||||
ctx.sleep(3.0) # the relay tells the controller within its 2 s
|
||||
|
||||
# A dark job that waits at M160 P1 for 2 s.
|
||||
x0, _y0 = kernel_start(ctx)
|
||||
st, body = _job_post(fc, "(forgetest exthost.mcode)\nG21\nG91\nG1 X5 F1200\nM160 P1\nG1 X-5 F1200\nG90\n")
|
||||
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "running",
|
||||
"POST /job -> %s %s", st, _words(body)[:200])
|
||||
waiting = _until(ctx, lambda: _port_state(fc) if isinstance(_port_state(fc).get("mcode"), dict) else None,
|
||||
20, poll=0.1)
|
||||
ctx.check(waiting and waiting["mcode"].get("code") == 160 and waiting["mcode"].get("words") == {"P": 1},
|
||||
"the port's state does not name M160 P1 while the job waits: %s", waiting)
|
||||
k1 = kernel_xy_mm(ctx)
|
||||
ctx.sleep(1.0)
|
||||
k2 = kernel_xy_mm(ctx)
|
||||
still = _port_state(fc)
|
||||
ev["wait"] = {"port": waiting, "kernel": [k1, k2], "port_after_1s": still}
|
||||
ctx.log("the job waits at M160: port %s, kernel %s -> %s", waiting, k1, k2)
|
||||
ctx.check(abs(k2[0] - k1[0]) < 0.001 and abs(k2[1] - k1[1]) < 0.001,
|
||||
"the head moved while the job waited: %s -> %s", k1, k2)
|
||||
ctx.check(still.get("mpos") == waiting.get("mpos") and still.get("state") == "Idle",
|
||||
"the port's position or state moved while the job waited: %s -> %s", waiting, still)
|
||||
ctx.check(abs(k1[0] - x0 - 5.0) < 0.05, "the job did not wait at the M-code 5 mm out: %.3f", k1[0] - x0)
|
||||
rec, far = _job_wait(ctx, fc, 40, x0)
|
||||
x1 = kernel_xy_mm(ctx)[0]
|
||||
got = calls()
|
||||
ev["job"] = {"record": rec, "farthest_mm": round(far, 3), "end_mm": round(x1 - x0, 3), "calls": got}
|
||||
ctx.log("the job at M160 P1: %s", ev["job"])
|
||||
ctx.check(rec["state"] == "done" and rec["reason"] == "", "the job did not end well: %s", rec)
|
||||
ctx.check(rec["sent"] == rec["acked"] == rec["lines"] + 1, "lines %s, sent %s, acked %s",
|
||||
rec["lines"], rec["sent"], rec["acked"])
|
||||
ctx.check(rec["lit"] is False and rec["emission"]["laser_on_samples"] == 0, "a dark job's witnesses: %s",
|
||||
rec["emission"])
|
||||
ctx.check(abs(x1 - x0) < 0.1, "the job did not end where it began: %.3f mm off", x1 - x0)
|
||||
ctx.check(len(got) == 1 and got[0]["path"] == "/mcode" and got[0]["req"] == {"code": 160, "words": {"P": 1}}
|
||||
and got[0]["status"] == 200, "the service was not asked M160 P1 once: %s", got)
|
||||
|
||||
# M161: nothing answers it, and the job stops where it is parsed.
|
||||
x0 = kernel_xy_mm(ctx)[0]
|
||||
st, body = _job_post(fc, "G21\nG91\nM161\nG1 X5 F1200\nG1 X-5 F1200\nG90\n")
|
||||
rec, far = _job_wait(ctx, fc, 20, x0)
|
||||
ev["unanswered"] = {"record": rec, "farthest_mm": round(far, 3)}
|
||||
ctx.log("the job at M161: %s", ev["unanswered"])
|
||||
ctx.check(rec["state"] == "failed" and "answered error:20" in (rec.get("reason") or ""),
|
||||
"a job at M161 did not fail with error:20: %s", rec)
|
||||
ctx.check(far < 0.01, "a job at an M-code nothing answers moved the head %.3f mm", far)
|
||||
|
||||
# M160 P2: the service refuses it, and the job is held there.
|
||||
x0 = kernel_xy_mm(ctx)[0]
|
||||
st, body = _job_post(fc, "G21\nG91\nM160 P2\nG1 X5 F1200\nG1 X-5 F1200\nG90\n")
|
||||
held = _until(ctx, lambda: _port_state(fc) if _port_state(fc).get("state") == "Hold" else None, 30, poll=0.2)
|
||||
ev["refused"] = {"port": held, "calls": calls()}
|
||||
ctx.log("the job at M160 P2: %s", ev["refused"])
|
||||
ctx.check(held and held.get("mcode") is None, "a job at a refused M160 is not held: %s", _port_state(fc))
|
||||
ctx.sleep(1.0)
|
||||
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.01, "the held job moved the head")
|
||||
st, body = fc.post("/job/abort")
|
||||
ctx.check(st == 200, "POST /job/abort -> %s %s", st, _words(body)[:160])
|
||||
rec, _far = _job_wait(ctx, fc, 20, x0)
|
||||
ctx.check(rec["state"] == "failed", "the held job did not end aborted: %s", rec)
|
||||
if _port_state(fc).get("state") == "Alarm":
|
||||
st, body = _job_post(fc, "G21\n", unlock="1")
|
||||
_job_wait(ctx, fc, 20, x0)
|
||||
ctx.check(_port_state(fc).get("state") == "Idle", "the controller is not idle after the abort: %s",
|
||||
_port_state(fc))
|
||||
finally:
|
||||
# A check that failed mid-job leaves the job running; it holds the settings until it ends.
|
||||
if _job(fc).get("state") == "running":
|
||||
fc.post("/job/abort")
|
||||
_job_wait(ctx, fc, 20)
|
||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||
@@ -1,286 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""An extension package keeps the Grbl sender out, by the operator's grant.
|
||||
|
||||
Its own module rather than a test in exthost.py, for the reason extcore.py
|
||||
gives: a test added there moves the fingerprint of every test in that
|
||||
module. The package is the reference package's id and key with a service
|
||||
that claims the sender out as it starts and says how its claim stands
|
||||
every half second, so exthost's put-back takes it away like the others.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until,
|
||||
_write)
|
||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||
|
||||
CLAIM_DIR = "/run/forgefirm/sender-out"
|
||||
GRBL_STATE = "/run/forgefirm/grbl.state"
|
||||
KEEP_OUT_MSG = b"[MSG:The machine is in use: senders are kept out for now]"
|
||||
|
||||
# The service: it claims the sender out once, as it starts, writes the
|
||||
# machine's answer, and then writes how its claim stands, twice a second.
|
||||
KEEPER_SERVICE = r'''
|
||||
import json, os, socket, time
|
||||
data = os.environ["FFX_DATA"]
|
||||
|
||||
|
||||
def api(method, path, body=None):
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(10)
|
||||
try:
|
||||
s.connect(os.environ["FFX_API"])
|
||||
payload = b"" if body is None else json.dumps(body).encode()
|
||||
head = "%s %s HTTP/1.1\r\nHost: forgeext\r\n" % (method, path)
|
||||
if body is not None:
|
||||
head += "Content-Type: application/json\r\nContent-Length: %d\r\n" % len(payload)
|
||||
s.sendall(head.encode() + b"\r\n" + payload)
|
||||
buf = b""
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf += c
|
||||
top, _, content = buf.partition(b"\r\n\r\n")
|
||||
return int(top.split()[1]), json.loads(content or b"null")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def write(name, doc):
|
||||
p = os.path.join(data, name)
|
||||
with open(p + ".new", "w") as f:
|
||||
json.dump(doc, f)
|
||||
os.rename(p + ".new", p)
|
||||
|
||||
|
||||
status, doc = api("POST", "/v0/sender", {"out": True})
|
||||
write("claim.json", {"status": status, "doc": doc})
|
||||
print("keeper up: %s %s" % (status, doc), flush=True)
|
||||
while True:
|
||||
try:
|
||||
write("state.json", dict(zip(("status", "doc"), api("GET", "/v0/sender"))))
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
'''
|
||||
|
||||
|
||||
def _pack_keeper(work):
|
||||
"""The reference package's id and key, with the service above: (archive, public key)."""
|
||||
import io
|
||||
import subprocess
|
||||
import tarfile
|
||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest sender keeper", "version": "1.0.0",
|
||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||
"service": {"exec": "bin/keeper.py"}, "capabilities": ["sender.keep_out"]}
|
||||
payload = os.path.join(work, "payload.tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/keeper.py", KEEPER_SERVICE, 0o755)):
|
||||
info = tarfile.TarInfo(name)
|
||||
data = text.encode()
|
||||
info.size, info.mode = len(data), mode
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, "fwup.conf")
|
||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||
% (REF_ID, payload))
|
||||
key = os.path.join(work, REF_KEY)
|
||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "keeper.ffx")
|
||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||
return signed, key + ".pub"
|
||||
|
||||
|
||||
def _lan_address():
|
||||
"""This machine's own address on the network: a sender that connects to it comes from the network
|
||||
as the controller sees it, and not from the loopback."""
|
||||
u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
u.connect(("192.0.2.1", 9))
|
||||
return u.getsockname()[0]
|
||||
except OSError:
|
||||
return ""
|
||||
finally:
|
||||
u.close()
|
||||
|
||||
|
||||
def _sender(addr, secs=2.0):
|
||||
"""A Grbl sender that connects and listens: (what it read, whether the controller closed it)."""
|
||||
s = socket.create_connection((addr, 23), timeout=5)
|
||||
s.settimeout(0.3)
|
||||
got, closed, end = b"", False, time.time() + secs
|
||||
try:
|
||||
while time.time() < end:
|
||||
try:
|
||||
c = s.recv(4096)
|
||||
except socket.timeout:
|
||||
continue
|
||||
if not c:
|
||||
closed = True
|
||||
break
|
||||
got += c
|
||||
finally:
|
||||
s.close()
|
||||
return got, closed
|
||||
|
||||
|
||||
def _read_json(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@test("exthost.sender-keep-out", title="An extension package keeps the Grbl sender out, by the operator's grant",
|
||||
subsystem="exthost", kind="auto", hardware="api", est_min=4,
|
||||
covers=[("forgectrl", "src/senderout.*"), ("forgectrl", "src/lease.*"), ("forgectrl", "src/grblport.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"), ("forgeext", "src/api.*"),
|
||||
("forgeext", "src/run.*"), ("forgeext", "src/machine.*"), ("forgeext", "src/holdkeep.*"),
|
||||
("forgeext", "src/caps.*"), ("grblhal-glowforge", "src/ctlport.*"), ("grblhal-glowforge", "src/serial.*")],
|
||||
requires=["exthost.service"],
|
||||
description="A package the operator granted sender.keep_out keeps the Grbl sender out while it uses the "
|
||||
"machine: the test installs a reference package whose service asks POST /v0/sender "
|
||||
"{\"out\": true} as it starts, on an idle machine with no sender connected. The machine "
|
||||
"grants it: /status names the package in sender_out and holds the machine lease as "
|
||||
"ext:<id>, of the extension kind; the host keeps the claim fresh under "
|
||||
"/run/forgefirm/sender-out; a Grbl sender that connects from the network (the machine's "
|
||||
"own LAN address) reads the controller's message and is closed at once, and one from "
|
||||
"this host still connects. The operator lets the sender back in from the panel "
|
||||
"(POST /motion/sender out=0): the holder and the lease go, a sender from the network is "
|
||||
"welcomed again, and the package's service reads released. Turned off and on, the "
|
||||
"package's service claims again; turned off with its claim standing, forgectrl ends the "
|
||||
"claim within seconds by itself, with the notice that names it, and lets senders in; the "
|
||||
"notice is cleared from the panel. The package, the key, the setting, and the setup "
|
||||
"record are put back as found.")
|
||||
def sender_keep_out(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||
st, m0 = fc.get("/mode")
|
||||
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("mode") == "grbl" and m0.get("controller") == "running",
|
||||
"start this test in grbl mode with the controller running (now %s)", m0)
|
||||
grbl = _read_json(GRBL_STATE) or {}
|
||||
ctx.check(not (grbl.get("sender") or {}).get("connected"),
|
||||
"a Grbl sender is connected: close it first (this test would disconnect it)")
|
||||
lan = _lan_address()
|
||||
ctx.check(lan and not lan.startswith("127."), "the machine has no address on the network: %r", lan)
|
||||
prior = fc.settings().get("ext_enabled") or ""
|
||||
raw = read_file(record_path())
|
||||
found_tree = _tree(EXT_ROOT)
|
||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||
etag = hdrs.get("etag")
|
||||
import shutil
|
||||
import tempfile
|
||||
work = tempfile.mkdtemp(prefix="forgetest-sender.")
|
||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||
data = os.path.join(EXT_ROOT, "data", REF_ID)
|
||||
claim_file = os.path.join(CLAIM_DIR, REF_ID + ".json")
|
||||
|
||||
def sender_out():
|
||||
return fc.status().get("sender_out") or {}
|
||||
|
||||
def holder():
|
||||
return (sender_out().get("holder") or {}).get("id")
|
||||
|
||||
try:
|
||||
got, closed = _sender(lan)
|
||||
ev["before"] = [got[:80].decode(errors="replace"), closed]
|
||||
ctx.check(b"Grbl" in got and not closed, "before: a sender from the network is not welcomed: %r", got[:80])
|
||||
|
||||
archive, pub = _pack_keeper(work)
|
||||
shutil.copy(pub, owner_key)
|
||||
os.chmod(owner_key, 0o644)
|
||||
r = _forgeext("install", archive, "--consent-community")
|
||||
ctx.check(r.get("ok") is False and "sender.keep_out" in (r.get("error") or ""),
|
||||
"an install without the operator's grant is refused -> %s", r.get("error"))
|
||||
r = _forgeext("install", archive, "--consent-community", "--grant", "sender.keep_out")
|
||||
ctx.check(r.get("ok") is True, "the install with the grant -> %s", r.get("error"))
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||
up = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and holder() == REF_ID, 60)
|
||||
claim = _read_json(os.path.join(data, "claim.json"))
|
||||
ev["claim"] = claim
|
||||
ctx.log("the service's claim: %s; sender_out %s", claim, sender_out())
|
||||
ctx.check(up and (claim or {}).get("status") == 200 and (claim.get("doc") or {}).get("out") is True,
|
||||
"the service's claim was not granted: %s, %s", claim, sender_out())
|
||||
|
||||
doc = fc.status()
|
||||
ev["kept"] = {"sender_out": doc.get("sender_out"), "lease": (doc.get("lease") or {}).get("holder")}
|
||||
lease = (doc.get("lease") or {}).get("holder") or {}
|
||||
ctx.check(lease.get("owner") == "ext:" + REF_ID and lease.get("kind") == "extension",
|
||||
"the lease is not the package's: %s", lease)
|
||||
def fresh():
|
||||
k = _read_json(claim_file) or {}
|
||||
return k if (k.get("id") == REF_ID and k.get("raised") is True and
|
||||
time.clock_gettime(time.CLOCK_MONOTONIC) - float(k.get("ts_mono") or 0) < 2.0) else None
|
||||
# The host writes the claim at its next turn; past forgectrl's 3 s grace the claim stands only
|
||||
# because the host keeps it fresh.
|
||||
kept = _until(ctx, fresh, 5)
|
||||
ev["claim_file"] = kept
|
||||
ctx.check(kept, "the host does not write the claim: %s", _read_json(claim_file))
|
||||
time.sleep(5.0)
|
||||
ctx.check(holder() == REF_ID and fresh(), "past the grace, the claim did not stand fresh: %s, %s",
|
||||
sender_out(), _read_json(claim_file))
|
||||
got, closed = _sender(lan)
|
||||
ev["kept_out"] = [got[:120].decode(errors="replace"), closed]
|
||||
ctx.check(closed and KEEP_OUT_MSG in got and b"Grbl" not in got,
|
||||
"a sender from the network was not turned away: %r closed=%s", got[:120], closed)
|
||||
got, closed = _sender("127.0.0.1", 1.0)
|
||||
ev["loopback"] = [got[:80].decode(errors="replace"), closed]
|
||||
ctx.check(b"Grbl" in got, "a sender from this host was not let in: %r", got[:80])
|
||||
|
||||
# The operator lets it back in, from the panel.
|
||||
st, reply = fc.post("/motion/sender", data={"out": "0"})
|
||||
ev["released"] = [st, reply]
|
||||
ctx.check(st == 200 and holder() is None, "the operator's release -> %s %s", st, reply)
|
||||
ctx.check(not ((fc.status().get("lease") or {}).get("holder")), "the lease stayed after the release")
|
||||
got, closed = _sender(lan)
|
||||
ctx.check(b"Grbl" in got and not closed, "released, a sender from the network is not welcomed: %r", got[:80])
|
||||
saw = _until(ctx, lambda: ((_read_json(os.path.join(data, "state.json")) or {}).get("doc") or {}).get("released"), 10)
|
||||
ctx.check(saw is True, "the package's service did not read released: %s",
|
||||
_read_json(os.path.join(data, "state.json")))
|
||||
|
||||
# Off and on: the operator's release lasts until the package stops claiming, and the
|
||||
# service claims again as it starts.
|
||||
ctx.check(_forgeext("disable", REF_ID).get("ok") is True, "disable")
|
||||
ctx.check(_until(ctx, lambda: not os.path.exists(claim_file), 15), "disabled, the host kept the claim file")
|
||||
time.sleep(3.0) # past forgectrl's freshness window, so the release is forgotten
|
||||
ctx.check(_forgeext("enable", REF_ID).get("ok") is True, "enable")
|
||||
again = _until(ctx, lambda: holder() == REF_ID, 60)
|
||||
ctx.check(again, "enabled again, the service's claim was not granted: %s", sender_out())
|
||||
|
||||
# Turned off with its claim standing: forgectrl ends it by itself.
|
||||
t0 = time.time()
|
||||
ctx.check(_forgeext("disable", REF_ID).get("ok") is True, "disable with the claim standing")
|
||||
gone = _until(ctx, lambda: holder() is None, 15)
|
||||
ev["stale_s"] = round(time.time() - t0, 1)
|
||||
notice = sender_out().get("notice") or {}
|
||||
ev["notice"] = notice
|
||||
ctx.log("the claim ended %.1f s after the disable; notice %s", time.time() - t0, notice)
|
||||
ctx.check(gone is True and notice.get("id") == REF_ID and notice.get("why") == "stopped",
|
||||
"a claim its keeper stopped keeping did not end with the notice: %s", sender_out())
|
||||
ctx.check(not os.path.exists(claim_file), "the claim file stayed after the service stopped")
|
||||
got, closed = _sender(lan)
|
||||
ctx.check(b"Grbl" in got and not closed, "after it ended, a sender from the network is not welcomed: %r", got[:80])
|
||||
st, reply = fc.post("/motion/sender", data={"notice": "clear"})
|
||||
ctx.check(st == 200 and not sender_out().get("notice"), "the notice was not cleared -> %s %s", st, reply)
|
||||
finally:
|
||||
if holder():
|
||||
fc.post("/motion/sender", data={"out": "0"})
|
||||
if sender_out().get("notice"):
|
||||
fc.post("/motion/sender", data={"notice": "clear"})
|
||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||
ctx.check(holder() is None, "the sender is still kept out at the end")
|
||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||
@@ -1,224 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""A package's own check on the Setup page, on the machine.
|
||||
|
||||
Its own module, for the reason extcore.py gives. The package is the
|
||||
reference package's id and key with a service of its own that runs the
|
||||
check on its call socket, so exthost's put-back takes it away like the
|
||||
others.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from ..catalog import test
|
||||
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until, _write)
|
||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||
|
||||
# The service: the check's steps on the call socket the host hands it (fd 4), its own record of whether the
|
||||
# check passed, and every request it was asked, kept in its data directory.
|
||||
WZ_SERVICE = r'''
|
||||
import json, os, socket
|
||||
data = os.environ["FFX_DATA"]
|
||||
lst = socket.socket(fileno=int(os.environ["FFX_CALL_FD"]))
|
||||
state = {"done": False, "calls": []}
|
||||
while True:
|
||||
c, _ = lst.accept()
|
||||
try:
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
k = c.recv(4096)
|
||||
if not k:
|
||||
break
|
||||
buf += k
|
||||
head, _, body = buf.partition(b"\r\n\r\n")
|
||||
n = 0
|
||||
for line in head.split(b"\r\n")[1:]:
|
||||
k, _, v = line.partition(b":")
|
||||
if k.strip().lower() == b"content-length":
|
||||
n = int(v)
|
||||
while len(body) < n:
|
||||
k = c.recv(4096)
|
||||
if not k:
|
||||
break
|
||||
body += k
|
||||
req = json.loads(body[:n] or b"{}")
|
||||
method, path = head.split(b" ")[0].decode(), head.split(b" ")[1].decode()
|
||||
if method == "GET" and path == "/wizard":
|
||||
ans = {"title": "forgetest check", "done": state["done"]}
|
||||
elif path == "/wizard/start":
|
||||
ans = {"log": ["the check begins"], "phase": "asking", "progress": 50,
|
||||
"prompt": {"kind": "confirm", "id": "ready", "text": "Is the machine ready?"}}
|
||||
elif path == "/wizard/answer":
|
||||
ok = req.get("id") == "ready" and req.get("answer") == "yes"
|
||||
state["done"] = ok
|
||||
ans = {"log": ["answered " + str(req.get("answer"))], "result": {"ok": ok, "summary": "ready" if ok else "not ready"}}
|
||||
elif path == "/wizard/abort":
|
||||
ans = {}
|
||||
else:
|
||||
ans = {"error": "no such call"}
|
||||
state["calls"].append([method, path, req])
|
||||
with open(os.path.join(data, "check.json.new"), "w") as f:
|
||||
json.dump(state, f)
|
||||
os.rename(os.path.join(data, "check.json.new"), os.path.join(data, "check.json"))
|
||||
out = json.dumps(ans).encode()
|
||||
c.sendall(b"HTTP/1.1 200 X\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"
|
||||
% len(out) + out)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
c.close()
|
||||
'''
|
||||
|
||||
|
||||
def _pack_check(work):
|
||||
import io
|
||||
import tarfile
|
||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest check", "version": "1.0.0",
|
||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||
"service": {"exec": "bin/check.py"}, "capabilities": ["wizard"]}
|
||||
payload = os.path.join(work, "payload.tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/check.py", WZ_SERVICE, 0o755)):
|
||||
info = tarfile.TarInfo(name)
|
||||
data = text.encode()
|
||||
info.size, info.mode = len(data), mode
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, "fwup.conf")
|
||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||
% (REF_ID, payload))
|
||||
key = os.path.join(work, REF_KEY)
|
||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "check.ffx")
|
||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||
return signed, key + ".pub"
|
||||
|
||||
|
||||
@test("exthost.wizard", title="A package's own check runs on the Setup page, and the machine's setup is untouched",
|
||||
subsystem="exthost", kind="auto", est_min=3,
|
||||
covers=[("forgectrl", "src/wizpkg.*"), ("forgectrl", "src/wizdark.c"), ("forgectrl", "src/wiz.c"),
|
||||
("forgectrl", "src/extpkg.*"), ("forgectrl", "src/ui/wizard.js"), ("forgeext", "src/main.c"),
|
||||
("forgeext", "src/run.*"), ("forgeext", "src/caps.*"), ("forgeext", "src/manifest.*")],
|
||||
requires=["exthost.service"],
|
||||
description="A package that asks for wizard is installed and turned on; its service runs a check on its "
|
||||
"call socket and keeps whether it passed. GET /wiz lists it among the extensions as "
|
||||
"pkg:<id> with the title the package gives it, not done. POST /wiz/pkg:<id>/start runs it on "
|
||||
"the wizard runner: GET /wiz/dark shows the package's log line, phase, progress, and its "
|
||||
"confirm prompt, and the machine lease stays free while it waits (a package's check holds the "
|
||||
"machine no more than the package does). The operator's Yes reaches the service as the answer "
|
||||
"yes, the run ends complete with the package's summary, and GET /wiz then lists the check as "
|
||||
"done, from the package's own answer. The machine's setup record is the same before and after: "
|
||||
"no pkg: wizard in it and the gate as it was. A second run is aborted from its prompt and the "
|
||||
"service is told. A check of a package that is not installed is 404. Everything is put back "
|
||||
"as exthost.service puts it back.")
|
||||
def wizard(ctx):
|
||||
import shutil
|
||||
import tempfile
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
|
||||
prior = fc.settings().get("ext_enabled") or ""
|
||||
raw = read_file(record_path())
|
||||
found_tree = _tree(EXT_ROOT)
|
||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||
etag = hdrs.get("etag")
|
||||
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
|
||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||
wid = "pkg:" + REF_ID
|
||||
check_file = os.path.join(EXT_ROOT, "data", REF_ID, "check.json")
|
||||
|
||||
def own():
|
||||
try:
|
||||
return json.loads(read_file(check_file) or "{}")
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
def listed():
|
||||
st_, w = fc.get("/wiz")
|
||||
return next((e for e in (w.get("extensions") or []) if e.get("id") == wid), None) if isinstance(w, dict) else None
|
||||
|
||||
def dark():
|
||||
st_, d = fc.get("/wiz/dark")
|
||||
return d if isinstance(d, dict) else {}
|
||||
|
||||
def record_view():
|
||||
try:
|
||||
rec = json.loads(read_file(record_path()) or "{}")
|
||||
except ValueError:
|
||||
return None
|
||||
return {"wizards": sorted((rec.get("wizards") or {}).keys()), "completed": rec.get("completed")}
|
||||
|
||||
before = record_view()
|
||||
gate_before = fc.get("/wiz")[1].get("gate")
|
||||
try:
|
||||
archive, pub = _pack_check(work)
|
||||
shutil.copy(pub, owner_key)
|
||||
os.chmod(owner_key, 0o644)
|
||||
r = _forgeext("install", archive, "--consent-community")
|
||||
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||
x = _until(ctx, lambda: _svc(REF_ID) if _svc(REF_ID).get("state") == "running" else None, 90, poll=0.5)
|
||||
ctx.check(x, "the service is not running: %s", _svc(REF_ID))
|
||||
|
||||
e = _until(ctx, listed, 30, poll=1.0)
|
||||
ev["listed"] = e
|
||||
ctx.check(e and e.get("title") == "forgetest check" and e.get("done") is False,
|
||||
"GET /wiz does not list the package's check, not done: %s", e)
|
||||
|
||||
st, body = fc.post("/wiz/%s/start" % wid)
|
||||
ctx.check(st == 200, "POST /wiz/%s/start -> %s %s", wid, st, body)
|
||||
d = _until(ctx, lambda: dark() if (dark().get("prompt") or {}).get("id") == "ready" else None, 20, poll=0.3)
|
||||
ev["prompt"] = d
|
||||
ctx.check(d and d.get("running") and d["prompt"].get("kind") == "confirm"
|
||||
and any(l.endswith("the check begins") for l in (d.get("log") or [])) and d.get("progress") == 50,
|
||||
"the check's prompt, log, and progress: %s", d)
|
||||
lease = (fc.status().get("lease") or {}).get("holder")
|
||||
ev["lease_while_waiting"] = lease
|
||||
ctx.check(not lease, "a package's check holds the machine: %s", lease)
|
||||
st, body = fc.post("/wiz/%s/answer" % wid, data={"seq": str(d["prompt"]["seq"]), "value": "Yes"})
|
||||
ctx.check(st == 200, "the answer -> %s %s", st, body)
|
||||
end = _until(ctx, lambda: dark() if not dark().get("running") else None, 20, poll=0.3)
|
||||
ev["end"] = end
|
||||
ctx.log("the check ended: %s", end)
|
||||
ctx.check(end and not end.get("error") and (end.get("result") or {}).get("summary") == "ready",
|
||||
"the check did not end complete with the package's summary: %s", end)
|
||||
mine = own()
|
||||
ctx.check(mine.get("done") is True and ["POST", "/wizard/answer", {"id": "ready", "answer": "yes"}] in
|
||||
(mine.get("calls") or []), "the service was not answered yes: %s", mine)
|
||||
ctx.sleep(11.0) # the Setup page's list is asked again at most every 10 s
|
||||
e = listed()
|
||||
ev["listed_after"] = e
|
||||
ctx.check(e and e.get("done") is True, "GET /wiz does not list the check as done: %s", e)
|
||||
|
||||
after = record_view()
|
||||
ev["record"] = {"before": before, "after": after}
|
||||
ctx.check(after == before and not any(w.startswith("pkg:") for w in (after or {}).get("wizards", [])),
|
||||
"the machine's setup record changed: %s -> %s", before, after)
|
||||
ctx.check(fc.get("/wiz")[1].get("gate") == gate_before, "the setup gate moved")
|
||||
|
||||
# A second run, aborted from its prompt.
|
||||
st, body = fc.post("/wiz/%s/start" % wid)
|
||||
d = _until(ctx, lambda: dark() if (dark().get("prompt") or {}).get("id") == "ready" else None, 20, poll=0.3)
|
||||
ctx.check(st == 200 and d, "the second run did not reach its prompt: %s %s", st, body)
|
||||
fc.post("/wiz/%s/abort" % wid)
|
||||
end = _until(ctx, lambda: dark() if not dark().get("running") else None, 20, poll=0.3)
|
||||
ctx.check(end and end.get("error") == "aborted", "the abort: %s", end)
|
||||
ctx.check(["POST", "/wizard/abort", {}] in (own().get("calls") or []), "the service was not told of the abort")
|
||||
|
||||
st, body = fc.post("/wiz/pkg:org.forgetest.nothere/start")
|
||||
ctx.check(st == 404, "a check of a package that is not installed -> %s %s", st, body)
|
||||
finally:
|
||||
# A check that failed mid-run leaves the package's check waiting at its prompt.
|
||||
if dark().get("running") and dark().get("id") == wid:
|
||||
fc.post("/wiz/%s/abort" % wid)
|
||||
_until(ctx, lambda: not dark().get("running"), 20, poll=0.3)
|
||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||
@@ -5,13 +5,10 @@
|
||||
|
||||
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
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
|
||||
@@ -314,594 +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, with a policy that lets no frame navigate anywhere "
|
||||
"(frame-src 'none': a package's page renders in a frame of it, and a frame that "
|
||||
"could go to another address could carry what it was shown in the URL; what the "
|
||||
"header does in each browser is the frame-isolation harness's to prove, and this "
|
||||
"holds the machine's own page to carrying it), /status carries the machine "
|
||||
"telemetry the panel and "
|
||||
"the acceptance tool read (including the sys block: CPU busy percent over the "
|
||||
"interval since the previous read, memory used percent; and homed_axes, "
|
||||
"the axes that carry a reference, Z alone once the lens has taken its "
|
||||
"own at the controller's start), and /cam/status "
|
||||
"answers. In GRBL mode with a live controller, /status also echoes the "
|
||||
"controller's published state file as the grbl block (fresh age, machine "
|
||||
"state, sender session, laser window and dose model, modal report), "
|
||||
"GET /grbl/settings serves the published $$ view, and the controller's "
|
||||
"settings store is /data/forgefirm/EEPROM-glowforge.DAT with nothing of "
|
||||
"it at the top of /data.")
|
||||
def panel_serves(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
st, body = fc.get("/", raw=True)
|
||||
ev["panel_status"] = st
|
||||
ctx.log("GET / -> %s (%d bytes)", st, len(body) if body else 0)
|
||||
ctx.check(st == 200, "GET / -> %s", st)
|
||||
text = body.decode("utf-8", "replace")
|
||||
ctx.check("<html" in text.lower() and "ForgeFIRM" in text, "the panel does not look like the panel")
|
||||
ctx.check(fc.token and fc.token in text, "the panel does not embed the bearer token")
|
||||
ctx.check("<link " not in text and "<script src=" not in text,
|
||||
"the panel references an external asset (the build did not bundle src/ui/)")
|
||||
# The daemon stores the page gzipped and inflates it once at first
|
||||
# request; what it serves is the plain page with the theme attribute
|
||||
# the head script sets and the one save bar every settings tab shares.
|
||||
ctx.check("data-bs-theme" in text, "the panel lacks the theme attribute (inflate failed?)")
|
||||
ctx.check('id="savebar"' in text, "the panel lacks the save bar")
|
||||
from .setup import request
|
||||
st_h, _page, hdrs = request(fc.base, "GET", "/", headers={"Host": fc.host_header()})
|
||||
ev["page_policy"] = hdrs.get("content-security-policy")
|
||||
ctx.log("GET / Content-Security-Policy: %s", ev["page_policy"])
|
||||
ctx.check(st_h == 200 and "frame-src 'none'" in (ev["page_policy"] or ""),
|
||||
"the panel page lets a frame navigate: %s %r", st_h, ev["page_policy"])
|
||||
|
||||
s = fc.status()
|
||||
for key in ("state", "switches", "coolant", "fans"):
|
||||
ctx.check(key in s, "/status lacks %r", key)
|
||||
ev["state"] = s.get("state")
|
||||
ev["switches"] = s.get("switches")
|
||||
ctx.log("/status state=%s switches=%s", s.get("state"), s.get("switches"))
|
||||
for key in ("lid", "button", "interlock_ok", "head", "hv_enable"):
|
||||
ctx.check(key in (s.get("switches") or {}), "/status switches lacks %r", key)
|
||||
|
||||
# SoC utilization rides /status next to the temperatures. The CPU
|
||||
# number is a delta over the interval since the previous read, so
|
||||
# the read above primes it; after a beat both percents must be
|
||||
# numbers in range.
|
||||
ctx.sleep(1)
|
||||
sys_ = ctx.forgectrl.status().get("sys") or {}
|
||||
ev["sys"] = sys_
|
||||
ctx.log("/status sys=%s", sys_)
|
||||
ctx.check(isinstance(sys_.get("cpu_pct"), (int, float)) and 0.0 <= sys_["cpu_pct"] <= 100.0,
|
||||
"/status sys.cpu_pct is not a percent: %s", sys_)
|
||||
ctx.check(isinstance(sys_.get("mem_pct"), (int, float)) and 0.0 < sys_["mem_pct"] < 100.0,
|
||||
"/status sys.mem_pct is not a percent: %s", sys_)
|
||||
|
||||
# The lens takes its own reference on the hall edge at every
|
||||
# controller start, so with a controller running Z is referenced on
|
||||
# its own while X and Y wait for a home: homed_axes names the axes
|
||||
# that carry one, and Z reads inside the lens reach rather than the
|
||||
# zero an unreferenced axis would show.
|
||||
axes = s.get("homed_axes")
|
||||
ev["homed_axes"] = axes
|
||||
ctx.check(isinstance(axes, int) and 0 <= axes <= 7,
|
||||
"/status homed_axes is not an axis mask: %s", axes)
|
||||
ctx.check(bool(s.get("homed")) == (axes == 7),
|
||||
"/status homed (%s) disagrees with homed_axes (%s)",
|
||||
s.get("homed"), axes)
|
||||
st, mode0 = fc.get("/mode")
|
||||
if isinstance(mode0, dict) and mode0.get("controller") == "running":
|
||||
lens = s.get("lens") or {}
|
||||
pos = s.get("pos") or {}
|
||||
ctx.log("/status homed_axes=%s pos.z=%s reach=%s..%s", axes,
|
||||
pos.get("z"), lens.get("reach_min"), lens.get("reach_max"))
|
||||
ctx.check(axes is not None and axes & 4,
|
||||
"Z is not referenced with a controller running: %s", axes)
|
||||
ctx.check(isinstance(pos.get("z"), (int, float))
|
||||
and lens.get("reach_min") is not None
|
||||
and lens["reach_min"] <= pos["z"] <= lens["reach_max"],
|
||||
"Z %s is outside the lens reach %s..%s", pos.get("z"),
|
||||
lens.get("reach_min"), lens.get("reach_max"))
|
||||
|
||||
st, cam = fc.get("/cam/status")
|
||||
ev["cam_status"] = st
|
||||
ctx.log("GET /cam/status -> %s %s", st, cam)
|
||||
ctx.check(st == 200 and isinstance(cam, dict) and "running" in cam, "GET /cam/status -> %s", st)
|
||||
|
||||
# The controller's published state, echoed only while a live GRBL
|
||||
# controller runs (glowforge_status.c -> /run/forgefirm -> /status).
|
||||
st, mode = fc.get("/mode")
|
||||
if isinstance(mode, dict) and mode.get("mode") == "grbl" and mode.get("controller") == "running":
|
||||
g = ctx.forgectrl.status().get("grbl") or {}
|
||||
ev["grbl"] = g
|
||||
ctx.log("/status grbl=%s", g)
|
||||
ctx.check(isinstance(g.get("age_s"), (int, float)) and g["age_s"] < 30,
|
||||
"/status grbl block missing or stale: %s", g)
|
||||
rep = g.get("report") or {}
|
||||
for key in ("state", "sender", "laser", "modals"):
|
||||
ctx.check(key in rep, "/status grbl.report lacks %r", key)
|
||||
ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"),
|
||||
"grbl.report.laser carries no model: %s", rep.get("laser"))
|
||||
ctx.check((rep.get("laser") or {}).get("curve"),
|
||||
"grbl.report.laser carries no dose curve: %s", rep.get("laser"))
|
||||
st, text = fc.get("/grbl/settings", raw=True)
|
||||
ev["grbl_settings_status"] = st
|
||||
ctx.check(st == 200 and b"$35=" in (text or b""),
|
||||
"GET /grbl/settings -> %s without the $$ view", st)
|
||||
# The $-settings persist in the data directory, never loose in /data.
|
||||
nvs = "/data/forgefirm/EEPROM-glowforge.DAT"
|
||||
ev["grbl_nvs"] = os.path.isfile(nvs)
|
||||
ctx.check(os.path.isfile(nvs), "the controller's settings store is not at %s", nvs)
|
||||
ctx.check(not os.path.exists("/data/EEPROM-glowforge.DAT"),
|
||||
"a settings store remains at the top of /data")
|
||||
else:
|
||||
ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode)
|
||||
|
||||
# The dose-curve recorder's read surface answers in any mode.
|
||||
st, cs = fc.get("/curve/status")
|
||||
ev["curve_status"] = cs
|
||||
ctx.check(st == 200 and isinstance(cs, dict) and cs.get("state") in
|
||||
("idle", "waiting", "recording", "done", "failed"),
|
||||
"GET /curve/status -> %s %s", st, cs)
|
||||
st, text = fc.get("/curve/ladder.gcode", raw=True)
|
||||
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
|
||||
"GET /curve/ladder.gcode -> %s without the ladder", st)
|
||||
|
||||
|
||||
class _EventStream:
|
||||
"""GET /events over a raw socket bound to a chosen loopback source
|
||||
address. Every address in 127.0.0.0/8 is this host, so the test can be
|
||||
several peers at once, and the daemon counts streams per peer address."""
|
||||
|
||||
def __init__(self, base, source, timeout=5.0):
|
||||
u = urllib.parse.urlsplit(base)
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.sock.bind((source, 0))
|
||||
self.sock.settimeout(timeout)
|
||||
self.sock.connect((u.hostname or "127.0.0.1", u.port or 80))
|
||||
self.sock.sendall(b"GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n"
|
||||
b"Connection: close\r\n\r\n" % (u.netloc or "127.0.0.1").encode())
|
||||
self.buf = b""
|
||||
self.eof = False # the response is over: the last chunk, or the socket closed
|
||||
head = self._until(b"\r\n\r\n")
|
||||
self.status = int(head.split(b" ", 2)[1]) if head.startswith(b"HTTP/") else 0
|
||||
self.head = head.decode("latin-1", "replace")
|
||||
|
||||
def _fill(self):
|
||||
try:
|
||||
chunk = self.sock.recv(4096)
|
||||
except socket.timeout:
|
||||
return False
|
||||
if not chunk:
|
||||
self.eof = True
|
||||
return False
|
||||
self.buf += chunk
|
||||
return True
|
||||
|
||||
def _until(self, mark):
|
||||
while mark not in self.buf:
|
||||
if not self._fill():
|
||||
break
|
||||
i = self.buf.find(mark)
|
||||
if i < 0:
|
||||
out, self.buf = self.buf, b""
|
||||
return out
|
||||
out, self.buf = self.buf[:i], self.buf[i + len(mark):]
|
||||
return out
|
||||
|
||||
def text(self, seconds):
|
||||
"""Everything the stream says in the next `seconds` (chunk framing
|
||||
and all: the checks look for event names inside it)."""
|
||||
end = time.time() + seconds
|
||||
self.sock.settimeout(0.2) # the window is the caller's, not one long read
|
||||
while time.time() < end and not self.eof:
|
||||
self._fill()
|
||||
if self.buf.endswith(b"\r\n0\r\n\r\n"):
|
||||
self.eof = True
|
||||
out, self.buf = self.buf, b""
|
||||
return out.decode("utf-8", "replace")
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@test("events.stream", title="The event stream: edges arrive, and the cap holds",
|
||||
subsystem="forgectrl", kind="auto", mode="grbl", est_min=2,
|
||||
covers=[("forgectrl", "src/events.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"),
|
||||
("forgectrl", "src/grblport.*")],
|
||||
description="GET /events from three loopback source addresses: each gets 200, "
|
||||
"text/event-stream, and the hello event. A fourth address gets 503 with the "
|
||||
"reason, and GET /settings still answers while the three are held. A second "
|
||||
"stream from the first address is served, and the older one gets bye and the "
|
||||
"end of its response. An edge arrives on an open stream: POST /motion/release "
|
||||
"and /motion/energize show up as motors.released and motors.energized, with "
|
||||
"ids that count up (X and Y lose their reference, as after any release). With "
|
||||
"every stream closed, the places come back within two keep-alive intervals.")
|
||||
def events_stream(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = hw.Forgectrl()
|
||||
streams = []
|
||||
try:
|
||||
# A stream an earlier test closed keeps its place until the daemon's
|
||||
# next write to it (its keep-alive), so the three places may not be
|
||||
# free yet: that is the daemon as documented, and the last part of
|
||||
# this test measures it. The three are opened once they can be.
|
||||
deadline = time.time() + 25
|
||||
while True:
|
||||
for src in ("127.0.0.2", "127.0.0.3", "127.0.0.4"):
|
||||
streams.append(_EventStream(fc.base, src))
|
||||
if all(s.status == 200 for s in streams) or time.time() > deadline:
|
||||
break
|
||||
ctx.log("an earlier stream still holds a place (%s): waiting for it to be given back",
|
||||
[s.status for s in streams])
|
||||
for s in streams:
|
||||
s.close()
|
||||
streams = []
|
||||
ctx.sleep(3)
|
||||
for s, src in zip(streams, ("127.0.0.2", "127.0.0.3", "127.0.0.4")):
|
||||
hello = s.text(1.0)
|
||||
ctx.check(s.status == 200 and "text/event-stream" in s.head and "event: hello" in hello,
|
||||
"GET /events from %s -> %s, %r", src, s.status, hello[:120])
|
||||
fourth = _EventStream(fc.base, "127.0.0.5")
|
||||
body = fourth.text(1.0)
|
||||
fourth.close()
|
||||
ev["fourth"] = [fourth.status, body.strip()[-120:]]
|
||||
ctx.log("the fourth stream -> %s %s", fourth.status, ev["fourth"][1])
|
||||
ctx.check(fourth.status == 503 and "every event stream is taken" in body,
|
||||
"the fourth stream -> %s %r, expected 503 and the reason", fourth.status, body[:160])
|
||||
st, _body = fc.get("/settings")
|
||||
ctx.check(st == 200, "GET /settings -> %s with three event streams held", st)
|
||||
|
||||
# One per address, by replacement.
|
||||
newer = _EventStream(fc.base, "127.0.0.2")
|
||||
hello = newer.text(1.0)
|
||||
ctx.check(newer.status == 200 and "event: hello" in hello,
|
||||
"a second stream from one address -> %s %r", newer.status, hello[:120])
|
||||
bye = streams[0].text(2.0)
|
||||
ev["replaced"] = {"bye": "event: bye" in bye, "ended": streams[0].eof}
|
||||
ctx.check("event: bye" in bye and "replaced" in bye, "the older stream was not told: %r", bye[-160:])
|
||||
ctx.check(streams[0].eof, "the older stream was not ended")
|
||||
streams[0].close()
|
||||
streams[0] = newer
|
||||
|
||||
# An edge, on a stream that has been open all along.
|
||||
st, body = fc.post("/motion/release")
|
||||
ctx.check(st == 200, "POST /motion/release -> %s %s", st, body)
|
||||
ctx.sleep(1.0)
|
||||
st, body = fc.post("/motion/energize")
|
||||
ctx.check(st == 200, "POST /motion/energize -> %s %s", st, body)
|
||||
text = streams[1].text(2.0)
|
||||
ev["edges"] = [l for l in text.splitlines() if l.startswith(("id:", "event:"))]
|
||||
ctx.log("the stream carried: %s", ev["edges"])
|
||||
i_rel, i_en = text.find("event: motors.released"), text.find("event: motors.energized")
|
||||
ctx.check(0 <= i_rel < i_en, "the release and the energize did not arrive in order: %r", text[-300:])
|
||||
ids = [int(l.split(":")[1]) for l in text.splitlines() if l.startswith("id:")]
|
||||
ctx.check(ids and ids == sorted(ids) and len(set(ids)) == len(ids), "the ids do not count up: %s", ids)
|
||||
finally:
|
||||
for s in streams:
|
||||
s.close()
|
||||
if fc.status().get("motors_released"):
|
||||
fc.post("/motion/energize")
|
||||
|
||||
# A closed client is noticed at the daemon's next write to it.
|
||||
def reopened():
|
||||
s = _EventStream(fc.base, "127.0.0.5")
|
||||
try:
|
||||
return s.status == 200
|
||||
finally:
|
||||
s.close()
|
||||
took = ctx.wait_for(reopened, 25, poll=1.0)
|
||||
ev["place_back_s"] = took
|
||||
ctx.check(took is not None, "no stream could be opened 25 s after every stream was closed")
|
||||
ctx.log("PASS: three streams, the fourth refused in words with the settings route answering, a "
|
||||
"replacement told and ended, two edges in order, and a place back after %.0f s", took)
|
||||
|
||||
|
||||
def _lease(fc):
|
||||
return (fc.status().get("lease") or {})
|
||||
|
||||
|
||||
def _text(body):
|
||||
return body if isinstance(body, str) else json.dumps(body)
|
||||
|
||||
|
||||
@test("forgectrl.lease", title="The machine lease: a holder refuses everything else, by name",
|
||||
subsystem="forgectrl", kind="auto", est_min=2,
|
||||
covers=[("forgectrl", "src/lease.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"),
|
||||
("forgectrl", "src/wizdark.*"), ("forgectrl", "src/diag.*"), ("forgectrl", "src/curverec.*"),
|
||||
("forgectrl", "src/jobrun.*"), ("forgectrl", "src/update.*"), ("forgectrl", "src/logs.*"), ("forgectrl", "src/super.*"),
|
||||
("forgectrl", "src/events.*")],
|
||||
description="The switches check is started and left waiting at its first prompt: it moves "
|
||||
"nothing, and it holds the machine lease for as long as it waits. /status must "
|
||||
"name it as the holder (wizard:switches, kind hardware), the event stream must "
|
||||
"report lease.changed, and everything that asks the lease must be refused with "
|
||||
"409 and the holder's name: a diagnostic, the dose-curve recorder, a log export, a "
|
||||
"mode switch to the mode already in force, POST /controller/start, and POST "
|
||||
"/settings (with 'settings are locked'). Only requests that would do no harm if "
|
||||
"the lease failed are made: no reboot, no boot-slot change, no update job. The "
|
||||
"check is then aborted, and the lease must read free, lease.changed must say so, "
|
||||
"and POST /settings must be accepted again. Whatever a failed refusal started is "
|
||||
"stopped on the way out.")
|
||||
def lease(ctx):
|
||||
from .setup_dark import dark
|
||||
|
||||
ev = ctx.evidence
|
||||
fc = hw.Forgectrl()
|
||||
wid, owner = "switches", "wizard:switches"
|
||||
rest = _lease(fc)
|
||||
ctx.check("holder" in rest and rest["holder"] is None, "the machine is not free at the start: %s", rest)
|
||||
ctx.check(set(rest.get("observed") or {}) == {"sender", "motors_released"},
|
||||
"what is observed outside the lease is not reported: %s", rest)
|
||||
mode = (fc.get("/mode")[1] or {}).get("mode")
|
||||
units = fc.settings().get("ui_units") or "metric"
|
||||
stream = _EventStream(fc.base, "127.0.0.6")
|
||||
ctx.check(stream.status == 200, "GET /events -> %s", stream.status)
|
||||
stream.text(0.5)
|
||||
started = False
|
||||
try:
|
||||
st, body = fc.post("/wiz/%s/start" % wid)
|
||||
ctx.check(st == 200, "the %s check did not start (%s %s)", wid, st, _text(body))
|
||||
started = True
|
||||
ok = ctx.wait_for(lambda: (_lease(fc).get("holder") or {}).get("owner") == owner, 10, poll=0.2)
|
||||
held = _lease(fc).get("holder") or {}
|
||||
ev["holder"] = held
|
||||
ctx.log("the lease while the check waits: %s", held)
|
||||
ctx.check(ok is not None and held.get("kind") == "hardware" and "switches" in (held.get("words") or ""),
|
||||
"the lease does not name the running check: %s", held)
|
||||
|
||||
refused = {}
|
||||
for name, path, kw in (
|
||||
("a diagnostic", "/diag/flow-verify", {}),
|
||||
("the recorder", "/curve/record", {}),
|
||||
("a log export", "/logs/export", {}),
|
||||
("a mode switch", "/mode", {"params": {"controller": mode}}),
|
||||
("the controller's start", "/controller/start", {}),
|
||||
("a settings write", "/settings", {"params": {"ui_units": units}})):
|
||||
st, body = fc.post(path, **kw)
|
||||
refused[name] = [st, _text(body)[:160]]
|
||||
ctx.check(st == 409 and "holds the machine" in _text(body) and "switches" in _text(body),
|
||||
"%s under the wizard's hold -> %s %s", name, st, _text(body)[:160])
|
||||
ctx.check("settings are locked" in refused["a settings write"][1],
|
||||
"the settings refusal does not say they are locked: %s", refused["a settings write"])
|
||||
ev["refused"] = refused
|
||||
ctx.check((_lease(fc).get("holder") or {}).get("owner") == owner,
|
||||
"a refused request took the lease away: %s", _lease(fc))
|
||||
finally:
|
||||
if started:
|
||||
fc.post("/wiz/%s/abort" % wid)
|
||||
ctx.wait_for(lambda: not dark(fc).get("running"), 30)
|
||||
# Nothing a failed refusal may have started is left running.
|
||||
fc.post("/diag/abort")
|
||||
fc.post("/curve/stop")
|
||||
|
||||
ok = ctx.wait_for(lambda: _lease(fc).get("holder") is None, 30, poll=0.5)
|
||||
ctx.check(ok is not None, "the lease was not given back after the abort: %s", _lease(fc))
|
||||
st, body = fc.post("/settings", params={"ui_units": units})
|
||||
ctx.check(st == 200, "POST /settings with the machine free again -> %s %s", st, _text(body)[:120])
|
||||
text = stream.text(2.0)
|
||||
stream.close()
|
||||
changes = [l for l in text.splitlines() if l.startswith("data:") and "owner" in l]
|
||||
ev["lease_changed"] = changes
|
||||
ctx.log("the event stream: %s", changes)
|
||||
i_take, i_free = text.find('"owner":"%s"' % owner), text.find('"owner":null')
|
||||
ctx.check("event: lease.changed" in text and 0 <= i_take < i_free,
|
||||
"lease.changed did not report the hold and then its end: %r", text[-300:])
|
||||
ctx.log("PASS: %s held the machine, six requests were refused by name, the abort freed it, and the "
|
||||
"event stream said both", owner)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- scoped tokens
|
||||
|
||||
def _words_of(body):
|
||||
b = body if isinstance(body, (dict, str)) else ""
|
||||
return b.get("error", "") if isinstance(b, dict) else b
|
||||
|
||||
|
||||
@test("forgectrl.tokens", title="Scoped API tokens: a token reaches what it was granted, and nothing else",
|
||||
subsystem="forgectrl", kind="auto", mode="grbl", est_min=2,
|
||||
covers=_COVERS_AUTH + [("forgectrl", "src/tokens.*"), ("forgectrl", "src/grblport.*"),
|
||||
("forgectrl", "src/settings.*")],
|
||||
steps=["Bed clear; the head needs 2 mm of free travel toward +X."],
|
||||
description="Three tokens are created through the panel's route: one holding machine.read "
|
||||
"and camera.lid, one holding motion.jog, one holding camera.lid alone. Each is then "
|
||||
"used the way a client on the network uses it, over HTTPS to the machine's own LAN "
|
||||
"address, so the daemon sees a peer that is not this host. The first reads /status "
|
||||
"as a bearer token, /mode in the panel token's header, and /cam/status as a bearer "
|
||||
"token; the camera-only token reads /cam/status as ?key=. Refused, each with 403 "
|
||||
"and the reason in words: the first token in a URL (it holds more than a camera), "
|
||||
"the camera-only token on the head camera, and for the first token a jog, a "
|
||||
"settings write, a mode switch, and the token routes. The second jogs the head 1 mm with no "
|
||||
"session and no panel token (the kernel's counters are the witness) and is refused "
|
||||
"/status. A token presented over plain HTTP from the LAN is refused, and the "
|
||||
"daemon's log names it. A token that is not this machine's is refused. With "
|
||||
"panel_open_reads=0 the LAN reads /status with machine.read and with nothing else. "
|
||||
"The list names both tokens with a last-used time and carries neither a token nor "
|
||||
"a hash, and the store is mode 0600 and holds neither token. A revoked token is "
|
||||
"refused at once, with reads open again. Both tokens are revoked, the setting "
|
||||
"restored, and the head returned on the way out. No token is written to the log "
|
||||
"or the evidence.")
|
||||
def scoped_tokens(ctx):
|
||||
from .setup import request, decode
|
||||
from .motion import kernel_xy_mm, machine_idle
|
||||
|
||||
ev = ctx.evidence
|
||||
fc = hw.Forgectrl()
|
||||
ip = lan_ip()
|
||||
ctx.check(ip, "cannot determine the board's LAN address")
|
||||
tls, plain = "https://%s" % ip, "http://%s" % ip
|
||||
machine_idle(ctx)
|
||||
x0 = kernel_xy_mm(ctx)[0]
|
||||
reads_were = str(fc.settings().get("panel_open_reads", "1"))
|
||||
mode = (fc.get("/mode")[1] or {}).get("mode")
|
||||
units = fc.settings().get("ui_units") or "metric"
|
||||
made = {}
|
||||
|
||||
def create(name, caps):
|
||||
st, body = fc.post("/tokens", data={"name": name, "caps": caps})
|
||||
ctx.check(st == 200 and isinstance(body, dict) and str(body.get("token", "")).startswith("fft_")
|
||||
and len(body["token"]) == 36, "POST /tokens %s -> %s", name, st)
|
||||
made[name] = body
|
||||
return body["token"]
|
||||
|
||||
def lan(token, method, path, how="bearer", base=None, data=None):
|
||||
headers = {}
|
||||
if token and how == "bearer":
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
elif token and how == "header":
|
||||
headers["X-ForgeFIRM-Token"] = token
|
||||
elif token and how == "key":
|
||||
path += ("&" if "?" in path else "?") + "key=" + token
|
||||
st, content, _h = request(base or tls, method, path, data=data, headers=headers)
|
||||
return st, decode(content)
|
||||
|
||||
try:
|
||||
# Whatever an earlier, interrupted run left behind.
|
||||
st, listed = fc.get("/tokens")
|
||||
ctx.check(st == 200 and isinstance(listed, dict), "GET /tokens -> %s", st)
|
||||
for t in listed.get("tokens", []):
|
||||
if t.get("name", "").startswith("forgetest "):
|
||||
fc.post("/tokens/revoke", data={"id": t["id"]})
|
||||
st, body = fc.post("/tokens", data={"name": "forgetest bad", "caps": "settings.write"})
|
||||
ev["outside_the_list"] = [st, _words_of(body)[:120]]
|
||||
ctx.check(st == 400 and "not a capability" in _words_of(body),
|
||||
"a capability outside the list -> %s %s", st, _words_of(body)[:120])
|
||||
hub = create("forgetest hub", "machine.read,camera.lid")
|
||||
pend = create("forgetest jogger", "motion.jog")
|
||||
view = create("forgetest viewer", "camera.lid")
|
||||
|
||||
passed = {}
|
||||
for name, call in (("bearer GET /status", lambda: lan(hub, "GET", "/status")),
|
||||
("header GET /mode", lambda: lan(hub, "GET", "/mode", how="header")),
|
||||
("bearer GET /cam/status", lambda: lan(hub, "GET", "/cam/status")),
|
||||
("camera-only ?key= GET /cam/status",
|
||||
lambda: lan(view, "GET", "/cam/status", how="key"))):
|
||||
st, body = call()
|
||||
passed[name] = st
|
||||
ctx.check(st == 200 and isinstance(body, dict), "the hub token, %s -> %s %s", name, st,
|
||||
_words_of(body)[:120])
|
||||
ev["hub_passed"] = passed
|
||||
|
||||
refused = {}
|
||||
for name, want, call in (
|
||||
("the head camera", "does not hold camera.head",
|
||||
lambda: lan(view, "GET", "/cam/snapshot?cam=head", how="key")),
|
||||
("a token with more than a camera, in a URL", "in a URL may hold camera capabilities only",
|
||||
lambda: lan(hub, "GET", "/cam/status", how="key")),
|
||||
("a jog", "does not hold motion.jog", lambda: lan(hub, "POST", "/motion/jog", data={"x": "1"})),
|
||||
("a settings write", "no scoped token reaches", lambda: lan(hub, "POST", "/settings",
|
||||
data={"ui_units": units})),
|
||||
("a mode switch", "no scoped token reaches", lambda: lan(hub, "POST", "/mode",
|
||||
data={"controller": mode})),
|
||||
("the token list", "no scoped token reaches", lambda: lan(hub, "GET", "/tokens")),
|
||||
("a token of its own", "no scoped token reaches",
|
||||
lambda: lan(hub, "POST", "/tokens", data={"name": "forgetest child", "caps": "machine.read"})),
|
||||
("the panel's ?token= form", "authentication required",
|
||||
lambda: lan(None, "POST", "/settings?token=" + hub, data={"ui_units": units})),
|
||||
("a stranger's token", "authentication required",
|
||||
lambda: lan("fft_" + "0" * 32, "GET", "/status")),
|
||||
("the jog token on /status", "does not hold machine.read", lambda: lan(pend, "GET", "/status"))):
|
||||
st, body = call()
|
||||
refused[name] = [st, _words_of(body)[:120]]
|
||||
ctx.check(st == 403 and want in _words_of(body), "%s -> %s %s (wanted 403 %r)", name, st,
|
||||
_words_of(body)[:120], want)
|
||||
ev["refused"] = refused
|
||||
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.01, "a refused jog moved the head")
|
||||
|
||||
# A write with a scoped token and nothing else: no session, no panel token.
|
||||
st, body = lan(pend, "POST", "/motion/jog", data={"x": "1", "feed": "1200"})
|
||||
ctx.check(st == 200, "the jog token's jog -> %s %s", st, _words_of(body)[:120])
|
||||
ctx.wait_for(lambda: abs(kernel_xy_mm(ctx)[0] - x0 - 1.0) < 0.02, 10, poll=0.1)
|
||||
machine_idle(ctx)
|
||||
moved = kernel_xy_mm(ctx)[0] - x0
|
||||
ev["jog_mm"] = round(moved, 3)
|
||||
ctx.log("the jog token jogged the head %.3f mm from the LAN with no session", moved)
|
||||
ctx.check(abs(moved - 1.0) < 0.02, "the jog moved %.3f mm, not 1", moved)
|
||||
|
||||
# Plain HTTP from the LAN: refused, and the log says which token crossed in the clear.
|
||||
log_path = "/data/log/forgefirm/forgectrl/forgectrl.log"
|
||||
try:
|
||||
log_from = os.path.getsize(log_path)
|
||||
except OSError:
|
||||
log_from = 0
|
||||
st, body = lan(hub, "GET", "/status", base=plain)
|
||||
ev["plain_http"] = [st, _words_of(body)[:120]]
|
||||
ctx.check(st == 403 and "HTTPS only" in _words_of(body), "a token over plain HTTP -> %s %s", st,
|
||||
_words_of(body)[:120])
|
||||
|
||||
def logged():
|
||||
try:
|
||||
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
f.seek(log_from)
|
||||
return "scoped token %s was presented over plain HTTP" % made["forgetest hub"]["id"] in f.read()
|
||||
except OSError:
|
||||
return False
|
||||
ctx.check(ctx.wait_for(logged, 10, poll=0.5) is not None, "the log does not name the token sent in the clear")
|
||||
|
||||
# Reads closed to the LAN: machine.read opens them, nothing else does.
|
||||
st, body = fc.post("/settings", params={"panel_open_reads": "0"})
|
||||
ctx.check(st == 200, "closing the reads -> %s", st)
|
||||
closed = {"nobody": lan(None, "GET", "/status")[0], "machine.read": lan(hub, "GET", "/status")[0],
|
||||
"motion.jog": lan(pend, "GET", "/status")[0]}
|
||||
ev["reads_closed"] = closed
|
||||
ctx.check(closed == {"nobody": 403, "machine.read": 200, "motion.jog": 403},
|
||||
"with the reads closed: %s", closed)
|
||||
st, body = fc.post("/settings", params={"panel_open_reads": reads_were})
|
||||
ctx.check(st == 200, "restoring panel_open_reads=%s -> %s", reads_were, st)
|
||||
|
||||
# The list and the store: names and times, never a token or a hash.
|
||||
st, listed = fc.get("/tokens")
|
||||
mine = {t["name"]: t for t in listed.get("tokens", []) if t.get("name", "").startswith("forgetest ")}
|
||||
ev["listed"] = {n: {k: t[k] for k in ("id", "caps", "last_used")} for n, t in mine.items()}
|
||||
ctx.check(set(mine) == {"forgetest hub", "forgetest jogger", "forgetest viewer"},
|
||||
"the list: %s", sorted(mine))
|
||||
ctx.check(mine["forgetest hub"]["caps"] == ["machine.read", "camera.lid"] and
|
||||
mine["forgetest jogger"]["caps"] == ["motion.jog"], "the capabilities listed: %s", ev["listed"])
|
||||
ctx.check(all(t["last_used"] > 0 for t in mine.values()), "a used token reads as never used: %s", ev["listed"])
|
||||
text = json.dumps(listed)
|
||||
ctx.check(hub[4:] not in text and pend[4:] not in text and view[4:] not in text and "sha" not in text and
|
||||
all(set(t) == {"id", "name", "caps", "created", "last_used"} for t in mine.values()),
|
||||
"the list carries more than names, capabilities, and times")
|
||||
store = "/data/forgefirm/tokens"
|
||||
mode_bits = os.stat(store).st_mode & 0o777
|
||||
with open(store, "r", encoding="utf-8", errors="replace") as f:
|
||||
kept = f.read()
|
||||
ev["store"] = {"mode": oct(mode_bits),
|
||||
"holds_a_token": hub[4:] in kept or pend[4:] in kept or view[4:] in kept}
|
||||
ctx.check(mode_bits == 0o600 and not ev["store"]["holds_a_token"], "the store: %s", ev["store"])
|
||||
|
||||
# A revoke ends it at once, and the open reads are no way around it.
|
||||
st, body = fc.post("/tokens/revoke", data={"id": made["forgetest hub"]["id"]})
|
||||
ctx.check(st == 200, "the revoke -> %s %s", st, _words_of(body)[:120])
|
||||
st, body = lan(hub, "GET", "/status")
|
||||
ev["after_revoke"] = [st, _words_of(body)[:120]]
|
||||
ctx.check(st == 403 and "authentication required" in _words_of(body),
|
||||
"a revoked token -> %s %s", st, _words_of(body)[:120])
|
||||
ctx.check(lan(pend, "POST", "/motion/cancel")[0] == 200, "the revoke took the other token too")
|
||||
finally:
|
||||
fc.post("/settings", params={"panel_open_reads": reads_were})
|
||||
for t in made.values():
|
||||
fc.post("/tokens/revoke", data={"id": t.get("id", "")})
|
||||
back = x0 - kernel_xy_mm(ctx)[0]
|
||||
if abs(back) > 0.02:
|
||||
machine_idle(ctx)
|
||||
fc.post("/motion/jog", params={"x": "%.3f" % back, "feed": "1200"})
|
||||
ctx.wait_for(lambda: abs(kernel_xy_mm(ctx)[0] - x0) < 0.02, 10, poll=0.1)
|
||||
machine_idle(ctx)
|
||||
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.05, "the head was not returned: %.3f mm off",
|
||||
kernel_xy_mm(ctx)[0] - x0)
|
||||
st, listed = fc.get("/tokens")
|
||||
ctx.check(not [t for t in listed.get("tokens", []) if t.get("name", "").startswith("forgetest ")],
|
||||
"a test token was left on the machine")
|
||||
ctx.log("PASS: each token reached what it was granted from the LAN with no session, was refused "
|
||||
"everything else in words, was refused over plain HTTP and once revoked, and the machine "
|
||||
"keeps no token")
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""A camera home's offset pair, on the machine.
|
||||
"""The helpers for a test that lets the web service move the head.
|
||||
|
||||
Its own module, so that no other test's fingerprint moves. The homing is
|
||||
the web-service session cloud.mode-switch runs. The helpers for a test
|
||||
that lets the service move the head live here too: the motions ran whole,
|
||||
and the head goes back to where the test found it, by the travel the
|
||||
service's motions logged. No counter reading can say where that was: every
|
||||
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.
|
||||
"""
|
||||
@@ -18,13 +17,11 @@ import re
|
||||
import sys
|
||||
|
||||
from ..baseline import XY_STEPS_PER_MM, counter_frame, counter_steps_per_mm, read_position
|
||||
from ..catalog import test
|
||||
from ..runner import Failed
|
||||
from . import cloud as logs # the log paths, read at each call
|
||||
from .cloud import _HOMING_PATH, gfhome_homing, log_lines_since, log_size
|
||||
from .motion import _drop_reference, clean_slate, machine_idle, wait_idle, wait_state
|
||||
from .cloud import log_lines_since, log_size
|
||||
from .motion import _drop_reference, clean_slate, machine_idle, wait_idle
|
||||
|
||||
HOME_X, HOME_Y = 4.5, -3.25
|
||||
RETURN_MAX_MM = 600.0 # no hand-back jogs farther on an axis: the bed is smaller
|
||||
# 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
|
||||
@@ -169,73 +166,3 @@ def _jog_back(ctx, rec, travel, since):
|
||||
_drop_reference(ctx, ctx.forgectrl)
|
||||
ctx.counters_rezeroed()
|
||||
ctx.log("head returned to where the test found it (%.3f/%.3f mm)", -dx, -dy)
|
||||
|
||||
|
||||
@test("homing.cloud-offsets", title="A camera home declares its offset pair, and the envelope reaches back to a "
|
||||
"negative one",
|
||||
subsystem="homing", kind="auto", mode="grbl", est_min=7,
|
||||
covers=_HOMING_PATH + [("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||
("grblhal-glowforge", "src/glowforge_cooling.*")],
|
||||
requires=["forgectrl.auth", "motion.pacing"],
|
||||
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. The head "
|
||||
"travels to the camera home and back to where it was."],
|
||||
description="With cloud mode on, homing_mode = gfcloud and the camera-home offsets set to (4.5, -3.25), "
|
||||
"$H runs the web-service homing session. Every service motion of the session runs whole: "
|
||||
"the runner reports to the cooling engine in the controller's place, and one stopped short "
|
||||
"(the engine's stop, a fault) leaves the head short of the home and the position declared "
|
||||
"there false. When the session ends, the controller declares the position (4.5, -3.25): a "
|
||||
"camera-home offset may be negative, and the declared position is snapped to the step "
|
||||
"grid. The work envelope reaches back to the home on the negative axis, since the head "
|
||||
"stands there: a jog 2 mm out in +Y and 2 mm back ends at the home, accepted, and the same "
|
||||
"in X. The homing mode and the offsets are put back as found, the camera home is dropped, "
|
||||
"and the head is jogged back to where the test found it by the travel the session's "
|
||||
"motions logged; a travel that cannot be known fails the test and moves nothing. That an "
|
||||
"offset past the axis travel is refused, and the envelope's edge on the grid, are the "
|
||||
"driver's unit and harness cases.")
|
||||
def cloud_offsets(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
s0 = fc.settings() or {}
|
||||
ctx.check(s0.get("cloud_enabled") == "1", "cloud mode is off: a camera home needs it on")
|
||||
found = {k: s0.get(k) or "" for k in ("homing_mode", "gfcloud_home_x", "gfcloud_home_y")}
|
||||
ev["found"] = found
|
||||
session_at = None
|
||||
try:
|
||||
for k, v in (("homing_mode", "gfcloud"), ("gfcloud_home_x", str(HOME_X)), ("gfcloud_home_y", str(HOME_Y))):
|
||||
st, body = fc.post("/settings", data={k: v})
|
||||
ctx.check(st == 200, "%s=%s -> %s %s", k, v, st, body)
|
||||
with ctx.grbl() as g:
|
||||
session_at = session_mark()
|
||||
gfhome_homing(ctx, ev, g)
|
||||
judge_whole_motions(ctx, ev, session_at)
|
||||
mpos = g.status_report().get("MPos") or (None, None, None)
|
||||
ev["declared"] = mpos
|
||||
ctx.log("the camera home declared %s", mpos)
|
||||
ctx.check(mpos[0] is not None and abs(mpos[0] - HOME_X) < 0.02 and abs(mpos[1] - HOME_Y) < 0.02,
|
||||
"the camera home did not declare (%.2f, %.2f): %s", HOME_X, HOME_Y, mpos)
|
||||
jogs = {}
|
||||
for axis, home in (("Y", HOME_Y), ("X", HOME_X)):
|
||||
out = g.command("$J=G91 G21 %s2 F1000" % axis, timeout=10)
|
||||
wait_state(ctx, g, "Idle", 20)
|
||||
back = g.command("$J=G91 G21 %s-2 F1000" % axis, timeout=10)
|
||||
wait_state(ctx, g, "Idle", 20)
|
||||
at = g.status_report().get("MPos")
|
||||
jogs[axis] = {"out": out[-1:], "back": back[-1:], "at": at}
|
||||
ctx.check(out[-1:] == ["ok"] and back[-1:] == ["ok"],
|
||||
"a jog out and back to the home in %s was refused: %s", axis, jogs[axis])
|
||||
i = "XY".index(axis)
|
||||
ctx.check(at and abs(at[i] - home) < 0.02, "the jog back in %s did not end at the home: %s", axis, at)
|
||||
ev["jogs"] = jogs
|
||||
ctx.log("out and back to the home: %s", jogs)
|
||||
finally:
|
||||
# The settings are refused for a moment after the suite's Grbl client closes; each is put back when taken.
|
||||
for k, v in found.items():
|
||||
def put():
|
||||
st_, body_ = (fc.post("/settings", params={k: ""}) if v == "" else fc.post("/settings", data={k: v}))
|
||||
if st_ != 200:
|
||||
ctx.log("restore %s=%r -> %s %s", k, v, st_, body_)
|
||||
return st_ == 200 or None
|
||||
took = ctx.wait_for(put, 20, poll=0.5)
|
||||
ctx.log("restore %s=%r -> %s", k, v, "taken" if took is not None else "not taken")
|
||||
if session_at is not None:
|
||||
camera_home_return(ctx, ev, session_at)
|
||||
|
||||
@@ -112,6 +112,7 @@ def fds_of(pid):
|
||||
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
|
||||
always=True, est_min=1,
|
||||
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,12 +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 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
|
||||
@@ -260,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
|
||||
@@ -381,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
|
||||
@@ -506,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
-792
@@ -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,42 +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, machine_idle,
|
||||
_job, _job_post, _job_wait, _lease_holder, _port_state, _words)
|
||||
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.")
|
||||
@@ -405,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,
|
||||
@@ -457,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)
|
||||
@@ -475,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:
|
||||
@@ -533,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 "
|
||||
@@ -550,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 "
|
||||
@@ -563,175 +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)
|
||||
# The press waits for the button to light: pressed before the job
|
||||
# reaches its arm wait, it is lost, and the move never starts.
|
||||
ctx.arm_press()
|
||||
ctx.wait_for(w.in_state("Run"), 180)
|
||||
ctx.clear_notice()
|
||||
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")],
|
||||
@@ -844,330 +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.0 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: the gated LASER_ON output (cnc/laser_on), read about 3300 times a "
|
||||
"second, reads the laser off in every read that falls inside the hold from 0.3 s "
|
||||
"after the first Hold:0 (the pause tier's lit deceleration still playing out of "
|
||||
"the kernel's 200 ms queue) until the cut resumes, at least 500 reads, and the "
|
||||
"same reads saw the cut lit in the second before the pause. 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
|
||||
# The dark judge's witness is the gated LASER_ON output itself
|
||||
# (cnc/laser_on, 1 = on), read fast. The kernel's sampled count latches
|
||||
# once a second over the second before, so it reads 0 only once a whole
|
||||
# window has closed inside the hold, up to 2 s in, and the hold can be
|
||||
# shorter than that. About 3300 reads a second on the bench reference;
|
||||
# each read yields the CPU to anything runnable (the controller).
|
||||
FAST_S = 0.1
|
||||
# The pause tier's first deceleration is lit on purpose (a pause leaves
|
||||
# no gap in the cut), and the controller reports Hold:0 once it has
|
||||
# produced the deceleration, not once the kernel has played it: the
|
||||
# driver's queue depth (200 ms, GFSINK_DEPTH_MS's default) and its
|
||||
# producer lead (10 ms) are still to play. The dark span starts that
|
||||
# long after the first Hold:0, with margin.
|
||||
DRAIN_S = 0.3
|
||||
MIN_DARK_READS = 500
|
||||
MIN_LIT_READS = 10
|
||||
|
||||
def fast_reads(seconds, t_ref):
|
||||
"""Reads of cnc/laser_on for `seconds`: [start, end] in seconds
|
||||
from t_ref, then the reads taken, the reads that saw the laser on,
|
||||
and the reads that failed."""
|
||||
start = time.time()
|
||||
n = on = bad = 0
|
||||
while time.time() - start < seconds:
|
||||
v = hw.sysfs_int("cnc/laser_on")
|
||||
n += 1
|
||||
if v is None:
|
||||
bad += 1
|
||||
elif v:
|
||||
on += 1
|
||||
_os.sched_yield()
|
||||
return [round(start - t_ref, 3), round(time.time() - t_ref, 3), n, on, bad]
|
||||
|
||||
def dark_span(rows, drain_s):
|
||||
"""The fast reads that fell wholly inside the hold, drain_s or more
|
||||
after the first Hold:0 row: a burst counts when the rows either side
|
||||
of it both read Hold, so the hold held through it. [reads, on,
|
||||
failed, bursts, from_s, to_s], or None without a Hold:0 row. Pure:
|
||||
tests/test_laser_verdict.py runs it over synthetic trails."""
|
||||
hold0 = next((r["t"] for r in rows if r["gstate"].startswith("Hold:0")), None)
|
||||
if hold0 is None:
|
||||
return None
|
||||
reads = on = bad = bursts = 0
|
||||
first = last = None
|
||||
for a, b in zip(rows, rows[1:]):
|
||||
f = a.get("fast")
|
||||
if not f or not a["gstate"].startswith("Hold") or not b["gstate"].startswith("Hold"):
|
||||
continue
|
||||
if f[0] < hold0 + drain_s:
|
||||
continue
|
||||
reads, on, bad, bursts = reads + f[2], on + f[3], bad + f[4], bursts + 1
|
||||
first = f[0] if first is None else first
|
||||
last = f[1]
|
||||
return [reads, on, bad, bursts, first, last]
|
||||
|
||||
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"])
|
||||
# The witness's own proof: over the lit cut the same reads see it lit.
|
||||
lit_ctl = fast_reads(1.0, time.time())
|
||||
ctx.log("fast reads of cnc/laser_on over the lit cut: %d of %d on, %d failed", lit_ctl[3],
|
||||
lit_ctl[2], lit_ctl[4])
|
||||
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()})
|
||||
if st.startswith("Hold"):
|
||||
trail[-1]["fast"] = fast_reads(FAST_S, t0)
|
||||
else:
|
||||
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
|
||||
if resumed_at is None and st.startswith("Hold"):
|
||||
row["fast"] = fast_reads(FAST_S, t0)
|
||||
else:
|
||||
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 through the hold: the gate masked the stream. First the witness:
|
||||
# the reads that judge the dark saw the cut lit before the pause.
|
||||
ev["fast_lit_control"] = {"reads": lit_ctl[2], "on": lit_ctl[3], "failed": lit_ctl[4]}
|
||||
ctx.check(lit_ctl[4] == 0 and lit_ctl[3] >= MIN_LIT_READS,
|
||||
"the fast reads of cnc/laser_on did not see the lit cut before the pause (%d of %d on, "
|
||||
"%d failed): they cannot witness the dark", lit_ctl[3], lit_ctl[2], lit_ctl[4])
|
||||
dark = dark_span(trail, DRAIN_S)
|
||||
ev["fast_dark"] = (dict(zip(("reads", "on", "failed", "bursts", "from_s", "to_s"), dark))
|
||||
if dark else None)
|
||||
ctx.check(dark is not None, "no Hold:0 was seen, so the hold cannot be judged dark")
|
||||
ctx.check(dark[2] == 0, "cnc/laser_on could not be read %d times inside the hold", dark[2])
|
||||
ctx.check(dark[1] == 0, "the laser was on in %d of %d reads of cnc/laser_on inside the hold "
|
||||
"(%s s to %s s, %.1f s or more after the first Hold:0)", dark[1], dark[0], dark[4], dark[5],
|
||||
DRAIN_S)
|
||||
ctx.check(dark[0] >= MIN_DARK_READS, "only %d reads of cnc/laser_on fell inside the hold after the "
|
||||
"drain: too few to judge it dark (at least %d)", dark[0], MIN_DARK_READS)
|
||||
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, the laser off in all %d reads inside the "
|
||||
"hold (%s s to %s s), resumed lit at +%s s with no press, disarmed %.1f s after Idle",
|
||||
ev["held_at_s"], dark[0], dark[4], dark[5], 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 "
|
||||
@@ -1325,214 +863,3 @@ def pause_resume_lid_cancel(ctx):
|
||||
ctx.log("PASS: button paused the burn (emission 0, armed kept, latch unlocked) and resumed it; "
|
||||
"the lid then canceled it - emission 0 at +%s s, reset without alarm, returned (drift "
|
||||
"%.3f mm), button latch SET", zero_at, drift)
|
||||
|
||||
|
||||
def _port_jog_sampled(ctx, fc, params, samples, timeout=30.0):
|
||||
"""One jog through the controller port, sampled at 8 Hz until the port
|
||||
says it is over. Returns the HTTP status and body of the jog request."""
|
||||
st, body = fc.post("/motion/jog", params=params)
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
ctx.checkpoint()
|
||||
smp = sample(ctx)
|
||||
if smp:
|
||||
samples.append(smp)
|
||||
try:
|
||||
ps = fc.get("/motion/state")[1]
|
||||
except hw.HwError:
|
||||
ps = None
|
||||
if st != 200 or (isinstance(ps, dict) and ps.get("state") == "Idle" and not ps.get("port_jog")):
|
||||
break
|
||||
time.sleep(0.125)
|
||||
return st, body
|
||||
|
||||
|
||||
@test("laser.port-dark", title="A panel jog under an open armed window ships dark",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=3,
|
||||
covers=_LASER_COVERS + [("forgectrl", "src/grblport.*")],
|
||||
requires=["laser.emission-witness", "motion.port-jog"], 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 M3 S400/F600 with no M5 and no program end after it, so the "
|
||||
"armed window stays open with M3 modal and S above zero: the state in which an "
|
||||
"injected G1 would fire. The line itself must be witnessed lit, or the case "
|
||||
"proves nothing. Then POST /motion/jog takes the head back over the line and out "
|
||||
"again, through the controller port. The window must read armed before and "
|
||||
"after the jogs, and through both the kernel's LASER_ON sample count stays 0, "
|
||||
"the HV current stays at its idle reading, and the head's beam detector does "
|
||||
"not rise over its level before the jogs. The client then ends the job (M5, "
|
||||
"M2) and the window closes.")
|
||||
def port_dark(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
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")
|
||||
ctx.arm_press()
|
||||
jog_samples = []
|
||||
try:
|
||||
# No M5 and no M2: the window stays open, M3 S400 stays modal.
|
||||
lit = run_and_sample(ctx, g, ["G91", "G21", "M3", "S400", "G1 X20 F600"])
|
||||
peak = max((s["emission"] or 0 for s in lit), default=0)
|
||||
ctx.check(peak > 0, "no emission witnessed on the G1: the dark jogs would prove nothing")
|
||||
|
||||
# The cut is over and dark again; the window is still open.
|
||||
ok = ctx.wait_for(lambda: (sample(ctx) or {}).get("emission") == 0, 10, poll=0.125)
|
||||
ctx.check(ok is not None, "emission_samples did not return to 0 after the G1")
|
||||
before = sample(ctx)
|
||||
ctx.check(before and before["armed"], "the armed window closed before the jogs: %s",
|
||||
before and before["phase"])
|
||||
ctx.sleep(0.6) # past the port's sender-quiet interval
|
||||
|
||||
for params in ({"x": "-20", "feed": "600"}, {"x": "20", "feed": "2400"},
|
||||
{"x": "-20", "feed": "2400"}):
|
||||
st, body = _port_jog_sampled(ctx, fc, params, jog_samples)
|
||||
ctx.check(st == 200, "POST /motion/jog %s under the open window -> %s %s", params, st, body)
|
||||
ctx.sleep(0.6)
|
||||
after = sample(ctx)
|
||||
ctx.check(after and after["armed"],
|
||||
"the armed window closed during the jogs: the dark samples were not under it")
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
r = g.command("M5")
|
||||
r += g.command("M2")
|
||||
ctx.check(not any(x.startswith("error") for x in r), "the client could not end the job: %s", r)
|
||||
ev["disarm_s"] = wait_disarm(ctx, 30)
|
||||
ctx.check(ev["disarm_s"] is not None, "the armed window did not close after the program end")
|
||||
|
||||
lit_in_jog = [s for s in jog_samples if s["emission"]]
|
||||
hv_max = max((s["hv"] for s in jog_samples if s["hv"] is not None), default=0)
|
||||
beams = [s["beam"] for s in jog_samples if s.get("beam") is not None]
|
||||
beam_rise = (max(beams) - before["beam"]) if beams and before.get("beam") is not None else None
|
||||
ev.update({"emission_peak_g1": peak, "jog_samples": len(jog_samples),
|
||||
"jog_emission_samples": len(lit_in_jog), "jog_hv_max": hv_max,
|
||||
"jog_beam_rise": beam_rise, "trail": trail(jog_samples)})
|
||||
ctx.log("G1 emission peak %s; %d samples across three port jogs: %d with emission, HV max %s, "
|
||||
"beam detector rise %s", peak, len(jog_samples), len(lit_in_jog), hv_max, beam_rise)
|
||||
ctx.check(len(jog_samples) >= 16, "only %d samples across the jogs", len(jog_samples))
|
||||
ctx.check(not lit_in_jog, "a port jog fired under the open window: %d samples with emission, "
|
||||
"first emission_samples %s", len(lit_in_jog), lit_in_jog[0]["emission"] if lit_in_jog else None)
|
||||
ctx.check(hv_max <= HV_DARK_MAX, "HV current %s during a port jog (idle reads ~0)", hv_max)
|
||||
ctx.check(beam_rise is not None and beam_rise < BEAM_DELTA_MIN,
|
||||
"the head's beam detector rose %s during a port jog", beam_rise)
|
||||
ctx.log("PASS: lit on the G1 (peak %s), and three port jogs under the same open window with M3 S400 "
|
||||
"modal shipped dark by all three witnesses", peak)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the recorder, as a job of the runner's
|
||||
|
||||
def _laser_keys(fc):
|
||||
s = fc.settings()
|
||||
return {k: s.get(k) for k in ("laser_floor_density", "laser_dose_curve")}
|
||||
|
||||
|
||||
def _arming(fc):
|
||||
rep = ((fc.status().get("grbl") or {}).get("report") or {})
|
||||
return bool((rep.get("laser") or {}).get("arming"))
|
||||
|
||||
|
||||
@test("laser.recorder-dark", title="The dose-curve recorder takes the machine as a job, arms, and is stopped dark",
|
||||
subsystem="laser", kind="auto", mode="grbl", est_min=2,
|
||||
covers=_LASER_COVERS + [("forgectrl", "src/curverec.*"), ("forgectrl", "src/jobrun.*"),
|
||||
("forgectrl", "src/jobstream.*"), ("forgectrl", "src/lease.*"),
|
||||
("forgectrl", "src/settings.*")],
|
||||
requires=["motion.job"],
|
||||
steps=["Lid closed, bed clear. Nobody presses the button: the recorder is stopped before its press.",
|
||||
"The ladder begins at the controller's X0 Y0: the head goes there, and is brought back."],
|
||||
description="POST /curve/record starts the dose ladder as a job of the job runner's. While it "
|
||||
"waits for a press that never comes: /status names the recorder as the lease "
|
||||
"holder of kind sender, GET /job reports the run (owner recorder, not a posted "
|
||||
"program), the floor and the curve are overridden (0 and off), the controller "
|
||||
"reaches its arm wait, POST /job/abort refuses to stop a run it did not start, a "
|
||||
"posted job is refused in the recorder's name, and the kernel's LASER_ON sample "
|
||||
"count and the HV current stay dark. POST /curve/stop then ends it: the recorder "
|
||||
"says it was stopped before the ladder fired, the lease is free, and both laser "
|
||||
"keys read as they did before. The head is returned by a posted job.")
|
||||
def recorder_dark(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = hw.Forgectrl()
|
||||
machine_idle(ctx)
|
||||
x0, y0 = kernel_start(ctx)
|
||||
keys0 = _laser_keys(fc)
|
||||
base = sample(ctx)
|
||||
ctx.check(base and not base["emission"], "emission_samples nonzero before the recording (%s)", base)
|
||||
ctx.check(not _lease_holder(fc), "the machine is not free at the start: %s", _lease_holder(fc))
|
||||
started = False
|
||||
samples = []
|
||||
try:
|
||||
st, body = fc.post("/curve/record")
|
||||
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "waiting",
|
||||
"POST /curve/record -> %s %s", st, _words(body)[:200])
|
||||
started = True
|
||||
ok = ctx.wait_for(lambda: _lease_holder(fc).get("owner") == "recorder", 5, poll=0.1)
|
||||
held = _lease_holder(fc)
|
||||
ev["holder"] = held
|
||||
ctx.check(ok is not None and held.get("kind") == "sender", "the lease does not name the recorder: %s", held)
|
||||
rec = _job(fc)
|
||||
ev["job_record"] = rec
|
||||
ctx.check(rec["state"] == "running" and rec["owner"] == "recorder" and rec["program"] is False,
|
||||
"GET /job while the recorder runs: %s", rec)
|
||||
over = _laser_keys(fc)
|
||||
ev["override"] = over
|
||||
ctx.check(str(over["laser_dose_curve"]) == "off" and float(over["laser_floor_density"] or 0) == 0.0,
|
||||
"the floor and the curve are not overridden for the ladder: %s", over)
|
||||
|
||||
ok = ctx.wait_for(lambda: _arming(fc), 40, poll=0.25)
|
||||
ctx.check(ok is not None, "the controller never reached its arm wait: %s",
|
||||
(fc.status().get("grbl") or {}).get("report"))
|
||||
ctx.log("the recorder holds the machine as %s, and the controller waits for the press", held)
|
||||
st, body = fc.post("/job/abort")
|
||||
ev["abort_route"] = [st, _words(body)[:160]]
|
||||
ctx.check(st == 409 and "stop it where it was started" in _words(body),
|
||||
"POST /job/abort on the recorder's run -> %s %s", st, _words(body)[:160])
|
||||
st, body = _job_post(fc, "G21\n")
|
||||
ev["job_beside"] = [st, _words(body)[:160]]
|
||||
ctx.check(st == 409 and "the dose-curve recorder holds the machine" in _words(body),
|
||||
"a posted job beside the recorder -> %s %s", st, _words(body)[:160])
|
||||
for _ in range(16): # two seconds in the arm wait, by the witnesses
|
||||
samples.append(sample(ctx))
|
||||
ctx.sleep(0.125)
|
||||
ctx.check(_lease_holder(fc).get("owner") == "recorder", "a refused request took the lease away")
|
||||
finally:
|
||||
if started:
|
||||
st, body = fc.post("/curve/stop")
|
||||
ev["stopped"] = body if isinstance(body, dict) else _words(body)[:160]
|
||||
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "failed"
|
||||
and "before the ladder fired" in body.get("reason", ""), "POST /curve/stop -> %s %s",
|
||||
st, _words(body)[:200])
|
||||
ok = ctx.wait_for(lambda: not _lease_holder(fc), 10, poll=0.2)
|
||||
ctx.check(ok is not None, "the lease was not given back: %s", _lease_holder(fc))
|
||||
keys1 = _laser_keys(fc)
|
||||
ev["keys"] = {"before": keys0, "after": keys1}
|
||||
ctx.check(keys1 == keys0, "the laser keys were not restored: %s, were %s", keys1, keys0)
|
||||
|
||||
lit = [s for s in samples if s and s["emission"]]
|
||||
hv_max = max((s["hv"] for s in samples if s and s["hv"] is not None), default=0)
|
||||
ev["witnesses"] = {"samples": len(samples), "with_emission": len(lit), "hv_max": hv_max,
|
||||
"job": _job(fc)["emission"]}
|
||||
ctx.check(not lit and hv_max <= HV_DARK_MAX and _job(fc)["emission"]["laser_on_samples"] == 0,
|
||||
"the recorder's arm wait was not dark: %s", ev["witnesses"])
|
||||
|
||||
# The ladder's first move took the head to the controller's X0 Y0.
|
||||
ctx.sleep(1.0)
|
||||
dx, dy = (a - b for a, b in zip(kernel_xy_mm(ctx), (x0, y0)))
|
||||
ev["moved_mm"] = [round(dx, 3), round(dy, 3)]
|
||||
ev["controller_after_stop"] = _port_state(fc).get("state")
|
||||
ctx.log("after the stop the controller is in %s; the head is %.3f, %.3f mm from where it was found",
|
||||
ev["controller_after_stop"], dx, dy)
|
||||
if abs(dx) > 0.05 or abs(dy) > 0.05:
|
||||
st, body = _job_post(fc, "G21\nG91\nG1 X%.3f Y%.3f F2400\nG90\n" % (-dx, -dy), unlock="1")
|
||||
ctx.check(st == 200, "the returning job -> %s %s", st, _words(body)[:200])
|
||||
rec, _far = _job_wait(ctx, fc, 60)
|
||||
ctx.check(rec["state"] == "done", "the returning job: %s", rec)
|
||||
elif str(ev["controller_after_stop"]).startswith("Alarm"):
|
||||
st, body = _job_post(fc, "G21\n", unlock="1")
|
||||
rec, _far = _job_wait(ctx, fc, 20)
|
||||
ctx.check(st == 200 and rec["state"] == "done", "the unlocking job: %s %s", st, rec)
|
||||
check_kernel_returned(ctx, ev, (x0, y0), tag="recorder")
|
||||
machine_idle(ctx)
|
||||
ctx.log("PASS: the recorder ran as a job of the runner's, held the machine as a sender through its arm "
|
||||
"wait with every witness dark, and its stop freed the machine and restored the laser keys")
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
|
||||
the routing path (emitters, relays, rendered rules); the level settings."""
|
||||
import gzip
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import tarfile
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
|
||||
|
||||
def _read(path, default=None):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return default
|
||||
|
||||
|
||||
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
|
||||
("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c")]
|
||||
|
||||
LOGS_ROOT = "/data/log/forgefirm"
|
||||
# What the export test plants in the installer's directory: a line to find
|
||||
# again, and two addresses (documentation ranges) the sanitizer must take.
|
||||
_PROBE_MARK = "forgetest export probe"
|
||||
_PROBE_MAC = "02:00:5e:10:20:30"
|
||||
_PROBE_IP = "192.0.2.77"
|
||||
|
||||
|
||||
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
|
||||
kind="auto", est_min=1,
|
||||
covers=_LOG_COVERS, requires=["forgectrl.auth"],
|
||||
description="/logs lists the loggers with their levels and files, /logs/tail returns the "
|
||||
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
|
||||
"bundle that contains neither the panel token nor the camera key. The bundle "
|
||||
"also carries the installer's directory of the tree (logs/install/), which no "
|
||||
"logger feeds: a probe file planted there for the export comes back in the "
|
||||
"bundle with its addresses redacted, and an install log that the installer "
|
||||
"left is in the bundle too. system/extensions.json is in it as well: what is "
|
||||
"installed that is not the firmware, with each package's tier, whether it is "
|
||||
"enabled, and what the operator granted it, which is what a report about a "
|
||||
"machine running extensions has to be read against.")
|
||||
def tree_tail_export(ctx):
|
||||
install_dir = os.path.join(LOGS_ROOT, "install")
|
||||
probe = os.path.join(install_dir, "forgetest-probe.txt")
|
||||
made_dir = not os.path.isdir(install_dir)
|
||||
try:
|
||||
os.makedirs(install_dir, exist_ok=True)
|
||||
with open(probe, "w") as f:
|
||||
f.write("%s from %s at %s\n" % (_PROBE_MARK, _PROBE_MAC, _PROBE_IP))
|
||||
_tree_tail_export(ctx, probe)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(probe)
|
||||
except OSError:
|
||||
pass
|
||||
if made_dir:
|
||||
try:
|
||||
os.rmdir(install_dir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _tree_tail_export(ctx, probe):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
st, body = fc.get("/logs")
|
||||
ctx.log("GET /logs -> %s", st)
|
||||
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
|
||||
loggers = body.get("loggers") or []
|
||||
names = [l.get("name") for l in loggers] if isinstance(loggers, list) else list(loggers)
|
||||
ev["loggers"] = names
|
||||
ctx.log("loggers: %s", names)
|
||||
ctx.check("forgectrl" in names, "/logs lacks the forgectrl logger: %s", names)
|
||||
|
||||
st, tail = fc.get("/logs/tail", params={"name": "forgectrl", "lines": "20"})
|
||||
ctx.log("GET /logs/tail?name=forgectrl -> %s", st)
|
||||
ctx.check(st == 200, "GET /logs/tail -> %s %s", st, tail if isinstance(tail, dict) else "")
|
||||
st, tail = fc.get("/logs/tail", params={"name": "no-such-logger"})
|
||||
ctx.check(st == 404, "unknown logger -> %s, expected 404", st)
|
||||
|
||||
# the sanitizer walks every log file; a full tree takes tens of
|
||||
# seconds on the target, so this call gets its own client timeout
|
||||
slow = hw.Forgectrl(token=fc.token, timeout=300.0)
|
||||
t0 = time.time()
|
||||
st, data = slow.post("/logs/export", raw=True)
|
||||
ev["export_s"] = round(time.time() - t0, 1)
|
||||
ctx.log("POST /logs/export -> %s (%d bytes, %.1f s)", st, len(data) if data else 0, ev["export_s"])
|
||||
ctx.check(st == 200, "POST /logs/export -> %s", st)
|
||||
ev["export_bytes"] = len(data)
|
||||
try:
|
||||
raw = gzip.decompress(data)
|
||||
tf = tarfile.open(fileobj=io.BytesIO(raw))
|
||||
members = tf.getnames()
|
||||
except (OSError, tarfile.TarError, EOFError) as e:
|
||||
ctx.fail("export is not a readable tar.gz: %s", e)
|
||||
ev["members"] = len(members)
|
||||
ctx.log("bundle: %d members, e.g. %s", len(members), members[:5])
|
||||
ctx.check(members, "empty bundle")
|
||||
ctx.check(any(m.endswith("README.txt") for m in members), "sanitized bundle lacks README.txt")
|
||||
# the two secrets the sanitizer knows by value and a bundle could
|
||||
# carry: the panel token and the camera key (a URL parameter that
|
||||
# lands in sender logs)
|
||||
secrets = {}
|
||||
if fc.token:
|
||||
secrets["panel token"] = fc.token
|
||||
st, body = fc.get("/system/camera-key")
|
||||
if st == 200 and isinstance(body, dict) and body.get("key"):
|
||||
secrets["camera key"] = body["key"]
|
||||
ctx.check("camera key" in secrets, "GET /system/camera-key -> %s: no key to check for", st)
|
||||
contents = [(m.name, tf.extractfile(m).read()) for m in tf.getmembers() if m.isfile()]
|
||||
for what, value in secrets.items():
|
||||
leaked = [name for name, content in contents if value.encode() in content]
|
||||
ev[what.replace(" ", "_") + "_leaks"] = leaked
|
||||
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
|
||||
|
||||
# the installer's directory: no logger feeds it, the export carries it
|
||||
by_name = dict(contents)
|
||||
want = "logs/install/" + os.path.basename(probe)
|
||||
got = [name for name in by_name if name.endswith(want)]
|
||||
ev["install_members"] = sorted(name for name in by_name if "/logs/install/" in name)
|
||||
ctx.check(got, "the bundle lacks %s: %s", want, ev["install_members"])
|
||||
if got:
|
||||
text = by_name[got[0]].decode("utf-8", "replace")
|
||||
ctx.check(_PROBE_MARK in text, "the probe file came back without its line: %r", text)
|
||||
for what, value in (("MAC address", _PROBE_MAC), ("IPv4 address", _PROBE_IP)):
|
||||
ctx.check(value not in text, "the install directory left the sanitizer with its %s", what)
|
||||
if os.path.isfile(os.path.join(os.path.dirname(probe), "install.log")):
|
||||
ctx.check(any(name.endswith("logs/install/install.log") for name in by_name),
|
||||
"the machine has an install log and the bundle does not")
|
||||
|
||||
# What is installed that is not the firmware. A report that starts "it
|
||||
# stopped working" reads differently with packages on the machine, and
|
||||
# the bundle is where that is read.
|
||||
import json
|
||||
|
||||
ext = [name for name in by_name if name.endswith("system/extensions.json")]
|
||||
ctx.check(ext, "the bundle carries no system/extensions.json")
|
||||
if ext:
|
||||
doc = json.loads(by_name[ext[0]].decode("utf-8", "replace"))
|
||||
ev["extensions"] = {"enabled": doc.get("enabled"), "safe_mode": doc.get("safe_mode"),
|
||||
"packages": [{k: p.get(k) for k in ("id", "version", "tier", "enabled", "grants")}
|
||||
for p in doc.get("packages", [])]}
|
||||
ctx.log("the bundle's extension listing: %s", ev["extensions"])
|
||||
ctx.check(set(doc) >= {"enabled", "safe_mode", "host", "packages"},
|
||||
"the listing is not the status document: %s", sorted(doc))
|
||||
for p in doc.get("packages", []):
|
||||
ctx.check(set(p) >= {"id", "version", "tier", "enabled", "grants"},
|
||||
"a listed package does not say what it is: %s", sorted(p))
|
||||
|
||||
|
||||
# The routing test proves the whole path every logger takes: emitter (or
|
||||
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
|
||||
# logger's own file in the ff_line format. It stands for the emitters in
|
||||
# every component (fflog in forgectrl and grblHAL, the SysLogHandler in the
|
||||
# Python apps), the supervisor's per-controller relay, the daemon's fifo
|
||||
# relay in its init script, and the render step.
|
||||
_ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/super.c"),
|
||||
("forgectrl", "init/**"),
|
||||
("grblhal-glowforge", "src/fflog.*"), ("grblhal-glowforge", "src/main.c"),
|
||||
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
|
||||
("forgefirm-app", "forgefirm-app/gfhome.py")]
|
||||
|
||||
LOGGERS =("forgectrl", "grblhal", "gfcloud", "gfhome", "forgeext", "kernel", "system")
|
||||
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
|
||||
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
|
||||
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
|
||||
|
||||
|
||||
def _tail_lines(path, n=400):
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
size = f.tell()
|
||||
f.seek(max(0, size - 256 * 1024))
|
||||
data = f.read()
|
||||
except OSError:
|
||||
return []
|
||||
return data.decode("utf-8", "replace").splitlines()[-n:]
|
||||
|
||||
|
||||
def _wait_line(path, needle, timeout=6.0):
|
||||
"""Poll a log file for a line containing needle; the matched line or None."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
for line in _tail_lines(path, 200):
|
||||
if needle in line:
|
||||
return line
|
||||
time.sleep(0.25)
|
||||
return None
|
||||
|
||||
|
||||
@test("logs.routing", title="Log routing, relays, and the rendered rules", subsystem="logs",
|
||||
kind="auto", est_min=1,
|
||||
covers=_ROUTING_COVERS, requires=["logs.tree-tail-export"],
|
||||
description="rsyslog is the only logger (no busybox syslogd/klogd), the rules rendered from "
|
||||
"the settings and the effective-levels record exist and agree with /logs, every "
|
||||
"logger has its directory, the daemon's own emitter and the per-controller "
|
||||
"`logger` relay both land in the right file in the ff_line format, a stray "
|
||||
"program's line goes to system/ and nowhere else, kernel lines reach kernel/, "
|
||||
"and nothing of ours is written outside the tree.")
|
||||
def routing(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
|
||||
# 1. one logger daemon
|
||||
rs = hw.pidof("rsyslogd")
|
||||
ev["rsyslogd_pids"] = rs
|
||||
ctx.check(rs, "rsyslogd is not running")
|
||||
for legacy in ("syslogd", "klogd"):
|
||||
pids = hw.pidof(legacy)
|
||||
ctx.check(not pids, "busybox %s is running (%s) next to rsyslog", legacy, pids)
|
||||
ctx.log("rsyslogd %s; no syslogd/klogd", rs)
|
||||
|
||||
# 2. the render step: rules + effective levels, consistent with what
|
||||
# the API reports as effective
|
||||
ctx.check(os.path.isfile("/data/forgefirm/rsyslog-forgefirm.conf"),
|
||||
"rendered rules missing: /data/forgefirm/rsyslog-forgefirm.conf")
|
||||
rules = _read("/data/forgefirm/rsyslog-forgefirm.conf") or ""
|
||||
for name in LOGGERS:
|
||||
ctx.check(("# %s:" % name) in rules, "rendered rules carry no block for %s", name)
|
||||
ctx.check(os.path.isfile("/var/run/forgefirm-loglevels"), "effective-levels record missing")
|
||||
st, body = fc.get("/logs")
|
||||
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
|
||||
ctx.check(body.get("effective_known") is True, "/logs does not know the effective levels")
|
||||
eff = {}
|
||||
for l in body.get("loggers") or []:
|
||||
eff[l["name"]] = (l.get("effective_disk"), l.get("effective_remote"))
|
||||
ev["effective"] = eff
|
||||
ctx.log("effective levels: %s", eff)
|
||||
ctx.check(set(eff) == set(LOGGERS), "loggers reported: %s", sorted(eff))
|
||||
levels = _read("/var/run/forgefirm-loglevels") or ""
|
||||
for name, (disk, _remote) in eff.items():
|
||||
ctx.check(("log_%s_disk=%s" % (name, disk)) in levels,
|
||||
"effective record and /logs disagree on %s (%s)", name, disk)
|
||||
|
||||
# 3. the tree
|
||||
for name in LOGGERS:
|
||||
ctx.check(os.path.isdir(os.path.join(LOGS_ROOT, name)), "no directory for %s", name)
|
||||
|
||||
# 4. the daemon's own emitter (fflog): a line from the running pid, if
|
||||
# its level admits info
|
||||
pids = hw.pidof("forgectrl")
|
||||
ctx.check(pids, "forgectrl is not running")
|
||||
disk_rank = _SEV_RANK.get(eff.get("forgectrl", ("info", "off"))[0], 6)
|
||||
fpath = os.path.join(LOGS_ROOT, "forgectrl", "forgectrl.log")
|
||||
if disk_rank >= 6:
|
||||
own = [l for l in _tail_lines(fpath, 2000)
|
||||
if any((" forgectrl[%d] " % p) in l for p in pids)]
|
||||
ev["own_lines"] = len(own)
|
||||
ctx.log("forgectrl[%s] lines in its file: %d", pids, len(own))
|
||||
ctx.check(own, "no line from the running daemon in %s (emitter path broken?)", fpath)
|
||||
m = _LINE_RE.match(own[-1])
|
||||
ctx.check(m and m.group("prog") == "forgectrl", "not in the ff_line format: %r", own[-1])
|
||||
else:
|
||||
ctx.log("forgectrl disk level %s: own-emitter check skipped", eff["forgectrl"][0])
|
||||
|
||||
# 5. the relay path: a `logger` probe under a controller's name lands
|
||||
# in that controller's file with the ff_line format
|
||||
nonce = "forgetest-routing-%d" % int(time.time())
|
||||
for name in ("grblhal", "gfhome"):
|
||||
rank = _SEV_RANK.get(eff.get(name, ("info", "off"))[0], 6)
|
||||
if rank < 3:
|
||||
ctx.log("%s disk level off: relay probe skipped", name)
|
||||
continue
|
||||
rc, out = hw.run(["logger", "-t", name, "-p", "daemon.err", "%s %s" % (nonce, name)])
|
||||
ctx.check(rc == 0, "logger -t %s failed: %s", name, out)
|
||||
path = os.path.join(LOGS_ROOT, name, name + ".log")
|
||||
line = _wait_line(path, nonce)
|
||||
ev["probe_" + name] = line
|
||||
ctx.check(line, "%s probe did not reach %s", name, path)
|
||||
m = _LINE_RE.match(line)
|
||||
ctx.check(m and m.group("prog") == name and m.group("sev") == "ERR",
|
||||
"%s probe line not in the ff_line format: %r", name, line)
|
||||
ctx.log("%s: %s", name, line)
|
||||
# a probe under another name must NOT leak into these files
|
||||
rc, out = hw.run(["logger", "-t", "forgetest-stray", "-p", "daemon.err", "%s stray" % nonce])
|
||||
time.sleep(1.5)
|
||||
for name in ("grblhal", "gfhome", "forgectrl", "forgeext"):
|
||||
path = os.path.join(LOGS_ROOT, name, name + ".log")
|
||||
ctx.check(not any((nonce + " stray") in l for l in _tail_lines(path, 100)),
|
||||
"a stray program's line landed in %s", path)
|
||||
sys_rank = _SEV_RANK.get(eff.get("system", ("info", "off"))[0], 6)
|
||||
if sys_rank >= 3:
|
||||
line = _wait_line(os.path.join(LOGS_ROOT, "system", "system.log"), nonce + " stray")
|
||||
ctx.check(line, "the stray program's line did not reach system/")
|
||||
|
||||
# 6. relay processes: the supervisor spawns one `logger` per controller
|
||||
# (its stdin is the controller's output pipe); the daemon's init
|
||||
# script keeps one on the fifo
|
||||
st, mode = fc.get("/mode")
|
||||
relays = hw.pidof("logger")
|
||||
ev["logger_relays"] = relays
|
||||
ev["controller"] = mode.get("controller") if isinstance(mode, dict) else None
|
||||
ctx.log("logger relays: %s (controller %s)", relays, ev["controller"])
|
||||
if isinstance(mode, dict) and mode.get("controller") == "running":
|
||||
ctx.check(relays, "controller running but no `logger` relay process")
|
||||
if os.path.exists("/var/run/forgectrl.stderr"):
|
||||
ctx.check(len(relays) >= 2 or ev["controller"] != "running",
|
||||
"fifo relay present but only %s logger process(es)", relays)
|
||||
|
||||
# 7. the kernel reaches kernel/ (unless filtered off)
|
||||
if _SEV_RANK.get(eff.get("kernel", ("info", "off"))[0], 6) >= 6:
|
||||
klines = _tail_lines(os.path.join(LOGS_ROOT, "kernel", "kernel.log"), 50)
|
||||
ev["kernel_lines"] = len(klines)
|
||||
ctx.check(klines and any(" kernel[" in l for l in klines), "no kernel lines in kernel/")
|
||||
|
||||
# 8. nothing outside the tree
|
||||
for stray in ("/data/forgectrl.log", "/data/gfcloud.log", "/data/gfhome.log",
|
||||
"/data/log/gfcloud", "/data/log/gfhome", "/var/log/messages"):
|
||||
ctx.check(not os.path.exists(stray), "pre-syslog log path still present: %s", stray)
|
||||
ctx.log("no ForgeFIRM log outside %s", LOGS_ROOT)
|
||||
|
||||
|
||||
@test("logs.level-settings", title="Log level and remote-target settings", subsystem="logs",
|
||||
kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/settings.*")],
|
||||
requires=["logs.tree-tail-export", "forgectrl.settings-bounds"],
|
||||
description="A log level outside off..debug, a bad remote port, a bad protocol and a bad "
|
||||
"server name are refused (400); a valid remote level is accepted and /logs shows "
|
||||
"it as configured but not effective (pending_reboot); the setting is restored to "
|
||||
"its prior value.")
|
||||
def level_settings(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||
|
||||
for key, val in (("log_grblhal_disk", "verbose"), ("log_kernel_remote", "7"),
|
||||
("syslog_port", "70000"), ("syslog_proto", "tls"),
|
||||
("syslog_server", "bad host name")):
|
||||
st, body = fc.post("/settings", data={key: val})
|
||||
ev["refused %s=%s" % (key, val)] = st
|
||||
ctx.log("POST /settings %s=%s -> %s", key, val, st)
|
||||
ctx.check(st == 400, "%s=%s -> %s, expected 400", key, val, st)
|
||||
|
||||
st, before = fc.get("/logs")
|
||||
ctx.check(st == 200 and isinstance(before, dict), "GET /logs -> %s", st)
|
||||
prior = {l["name"]: l for l in before.get("loggers") or []}
|
||||
ctx.check("system" in prior, "/logs lacks the system logger")
|
||||
was = prior["system"].get("remote") or "off"
|
||||
was_pending = bool(before.get("pending_reboot"))
|
||||
new = "warning" if was != "warning" else "error"
|
||||
ev["system_remote_before"] = was
|
||||
ev["pending_before"] = was_pending
|
||||
try:
|
||||
st, body = fc.post("/settings", data={"log_system_remote": new})
|
||||
ctx.log("POST /settings log_system_remote=%s -> %s", new, st)
|
||||
ctx.check(st == 200, "valid level refused: %s", st)
|
||||
st, after = fc.get("/logs")
|
||||
now = {l["name"]: l for l in after.get("loggers") or []}
|
||||
ctx.check(now["system"].get("remote") == new, "configured level reads %r", now["system"].get("remote"))
|
||||
ctx.check(now["system"].get("effective_remote") == prior["system"].get("effective_remote"),
|
||||
"effective level changed without a reboot")
|
||||
ctx.check(after.get("pending_reboot") is True, "pending_reboot not raised by a level change")
|
||||
ctx.log("configured %s, effective %s, pending_reboot %s",
|
||||
now["system"].get("remote"), now["system"].get("effective_remote"), after.get("pending_reboot"))
|
||||
finally:
|
||||
# restore: a value that was stored goes back as a value; a default
|
||||
# is restored by clearing the key (a query parameter - empty
|
||||
# values never reach the form body)
|
||||
stored = fc.settings().get("log_system_remote", "")
|
||||
if was == "off" and stored in ("", None, new):
|
||||
st, body = fc.post("/settings", params={"log_system_remote": ""})
|
||||
else:
|
||||
st, body = fc.post("/settings", data={"log_system_remote": was})
|
||||
ctx.log("restore log_system_remote -> %s", st)
|
||||
st, final = fc.get("/logs")
|
||||
fin = {l["name"]: l for l in final.get("loggers") or []}
|
||||
ctx.check(fin["system"].get("remote") == was, "system remote level not restored (%r)", fin["system"].get("remote"))
|
||||
ctx.check(bool(final.get("pending_reboot")) == was_pending,
|
||||
"pending_reboot did not return to %s after the restore", was_pending)
|
||||
File diff suppressed because it is too large
Load Diff
+50
-1456
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,283 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""setup.sheet - the setup's sheet (the live wizards) in one run,
|
||||
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
|
||||
polled, the prompts answered from here, each burn armed by a press, the
|
||||
result judged, and every setting a wizard wrote put back as found. One
|
||||
piece of wood at least 200 x 150 mm (8 x 6 in) carries the placement,
|
||||
the frame, and the five cards. Each wizard re-runs on the real record,
|
||||
so a completed one completes again at the same version.
|
||||
|
||||
The operator's part is one press on the machine at the start (the
|
||||
presence check of the ready gate; with the bench actuator up it then
|
||||
makes every arm press) and nothing else. The judgments a person makes
|
||||
by eye on the sheet (the thinnest line, the faintest rung, the best
|
||||
corner, the frame's fit) are answered here with a middle option or the
|
||||
witnesses: the test proves each wizard's mechanics and the emission
|
||||
witnesses, not the operator's eye, and the settings it writes are
|
||||
restored. The page is where the real numbers go in.
|
||||
"""
|
||||
import re
|
||||
|
||||
from ..catalog import test
|
||||
from .setup_dark import run_check, Restore
|
||||
|
||||
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
||||
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
|
||||
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
||||
("forgectrl", "src/jobrun.*"), ("forgectrl", "src/lease.*"),
|
||||
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
||||
("forgectrl", "src/super.c"),
|
||||
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
||||
("forgectrl", "src/accel.*"), ("forgectrl", "src/wizcalc.*"),
|
||||
("grblhal-glowforge", "src/glowforge_laser.c"),
|
||||
("grblhal-glowforge", "src/glowforge_status.c"),
|
||||
("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||
("grblhal-glowforge", "src/glowforge_io.*")]
|
||||
|
||||
# The live wizards in the order the sheet burns them: the wizard id, the
|
||||
# answers to its prompts (a middle pick where a person would look at the
|
||||
# sheet), and the keys its result must carry. The arm press is not timed
|
||||
# from here: each card opens a press prompt of its own when the button is
|
||||
# lit and the tube still dark, and run_check presses on that.
|
||||
CARDS = [
|
||||
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"]),
|
||||
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"},
|
||||
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
|
||||
"focus_range_mm", "stops"]),
|
||||
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"]),
|
||||
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"]),
|
||||
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"]),
|
||||
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"]),
|
||||
]
|
||||
# Every setting a card writes, restored as found when the run ends.
|
||||
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
|
||||
"laser_floor_density", "laser_dose_curve", "laser_corner_gamma",
|
||||
"cool_laser_heat_density", "cool_laser_heat_cw"]
|
||||
# The evidence run_check and the witnesses write at the top level, moved
|
||||
# under the card's own key once it is done.
|
||||
CARD_EVIDENCE = ("started", "elapsed_s", "phases", "log", "error", "result",
|
||||
"preview_bytes", "program_lines", "emission")
|
||||
|
||||
|
||||
def preview_ok(ctx, wid):
|
||||
"""The page's preview and the program for the wizard are served."""
|
||||
fc = ctx.forgectrl
|
||||
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
|
||||
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
|
||||
ctx.evidence["preview_bytes"] = len(body)
|
||||
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
|
||||
# The text and the frame are M4 at the mark dose; the patterns bring
|
||||
# their own M3 or M4 lines. A program is one with a laser-on command.
|
||||
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
||||
"no program for %s (%s)", wid, st)
|
||||
ctx.evidence["program_lines"] = body.count(b"\n")
|
||||
# Every Z the program commands is a height the head reaches in the
|
||||
# window the machine holds now: the focus card's own numbers once it
|
||||
# has them, and the conservative fallback on a head whose travel is
|
||||
# not known yet. The controller refuses a Z outside it with ALARM:2,
|
||||
# and it refuses it while the body is still burning, so the card dies
|
||||
# where the parser is and not where the bad line is.
|
||||
lens = (fc.status() or {}).get("lens") or {}
|
||||
lo, hi = lens.get("reach_min"), lens.get("reach_max")
|
||||
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
|
||||
ctx.evidence.setdefault("program_z", {})[wid] = {
|
||||
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
|
||||
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
|
||||
if lo is not None and hi is not None:
|
||||
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
|
||||
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
|
||||
wid, lo, hi, out)
|
||||
|
||||
|
||||
def emission_ok(ctx, last):
|
||||
"""The three witnesses of the burn, from the wizard's result."""
|
||||
e = (last.get("result") or {}).get("emission") or {}
|
||||
ctx.evidence["emission"] = e
|
||||
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
|
||||
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
|
||||
ctx.check(e.get("thermopile_delta", 0) >= 100, "the thermopile did not see the beam (%s)",
|
||||
e.get("thermopile_delta"))
|
||||
|
||||
|
||||
def file_card(ctx, wid):
|
||||
"""Move the card's evidence under its own key."""
|
||||
ev = ctx.evidence
|
||||
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
||||
|
||||
|
||||
# The lens settings: the head's numbers, which a fresh machine does not
|
||||
# have. Cleared before the cards so the run is a fresh machine's.
|
||||
LENS_SETTINGS = CARD_SETTINGS[:3]
|
||||
FALLBACK_WINDOW = (10, 12)
|
||||
|
||||
|
||||
def fresh_lens(ctx):
|
||||
"""The lens as a fresh machine has it: the head's numbers unknown, so
|
||||
the frame runs in the fallback window and the focus card has to find
|
||||
the stops and write them before its ladder burns. The machine's own
|
||||
numbers come back with the restore at the end of the run."""
|
||||
fc = ctx.forgectrl
|
||||
for k in LENS_SETTINGS:
|
||||
st, _ = fc.post("/settings", params={k: ""})
|
||||
ctx.check(st == 200, "clearing %s -> %s", k, st)
|
||||
lens = (fc.status() or {}).get("lens") or {}
|
||||
ctx.evidence["lens_fresh"] = lens
|
||||
ctx.check(lens.get("stops_found") is False and (lens.get("below"), lens.get("above")) == FALLBACK_WINDOW,
|
||||
"the lens window is not the fallback after the clear: %s", lens)
|
||||
|
||||
|
||||
def focus_window_ok(ctx):
|
||||
"""The window the focus card burned its ladder in is the one it wrote
|
||||
before its controller started, and the one the machine holds now: the
|
||||
result's window is the stops it found (or the fallback), the settings
|
||||
hold that window, every ladder height lies in its reach, and the
|
||||
program served now agrees with /status."""
|
||||
fc = ctx.forgectrl
|
||||
r = ((ctx.evidence.get("cards") or {}).get("laser.focus") or {}).get("result") or {}
|
||||
w, stops = r.get("window") or {}, r.get("stops") or {}
|
||||
s = fc.settings() or {}
|
||||
held = (str(s.get("lens_stop_below_steps")), str(s.get("lens_stop_above_steps")))
|
||||
ctx.check(held == (str(w.get("below")), str(w.get("above"))),
|
||||
"the settings hold the window %s, the ladder ran in %s/%s", held, w.get("below"), w.get("above"))
|
||||
if stops.get("found"):
|
||||
ctx.check((w.get("below"), w.get("above")) == (stops.get("below"), stops.get("above")),
|
||||
"the ladder's window %s is not the stops found %s", w, stops)
|
||||
else:
|
||||
ctx.check((w.get("below"), w.get("above")) == FALLBACK_WINDOW,
|
||||
"stops not found (%s), but the ladder's window is %s", stops.get("why"), w)
|
||||
zs = r.get("ladder_z") or []
|
||||
lo, hi = w.get("reach_min"), w.get("reach_max")
|
||||
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
|
||||
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
|
||||
ev = ctx.evidence
|
||||
before = dict(ev.get("program_z") or {}).get("laser.focus")
|
||||
preview_ok(ctx, "laser.focus")
|
||||
ev["focus_window"] = {"window": w, "stops": stops, "ladder_z": zs,
|
||||
"settings": {k: s.get(k) for k in LENS_SETTINGS},
|
||||
"program_z_before": before,
|
||||
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
|
||||
"preview_bytes": ev.pop("preview_bytes", None),
|
||||
"program_lines": ev.pop("program_lines", None)}
|
||||
|
||||
|
||||
def thickness_answer(ctx):
|
||||
"""The thickness the placement is answered with, in the machine's
|
||||
units (the number prompts read ui_units), and the millimeters the
|
||||
record must carry for it."""
|
||||
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
|
||||
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
|
||||
|
||||
|
||||
def place(ctx):
|
||||
"""The placement: the lens reference, two jogs, the origin at the
|
||||
head's position, the full sheet, a thickness. Nothing fires."""
|
||||
jogs = ["X+10", "X-10"]
|
||||
answer, want_mm = thickness_answer(ctx)
|
||||
ctx.evidence["thickness_answer"] = answer
|
||||
|
||||
def on_prompt(p):
|
||||
# Each jog answer closes the prompt and the wizard asks again with
|
||||
# the next sequence number, so the pad is answered one move at a
|
||||
# time until the origin is set.
|
||||
if p.get("id") == "place":
|
||||
return jogs.pop(0) if jogs else "Set origin"
|
||||
if p.get("id") == "sheet-kind":
|
||||
return "Full sheet"
|
||||
if p.get("id") == "thickness":
|
||||
return answer
|
||||
return None
|
||||
|
||||
last = run_check(ctx, "sheet.place", on_prompt, 300)
|
||||
r = last.get("result") or {}
|
||||
ctx.check("origin_x" in r and "origin_y" in r, "no origin in the result: %s", r)
|
||||
ctx.check((r.get("steps_per_mm") or {}).get("x", 0) > 0, "no steps per mm: %s", r)
|
||||
ctx.check(r.get("z_referenced") is True, "the lens was not referenced")
|
||||
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
|
||||
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
|
||||
ctx.check(r.get("alone") is False, "alone %s", r.get("alone"))
|
||||
ctx.log("placed: datum at %s, %s; steps per mm %s", r.get("origin_x"), r.get("origin_y"),
|
||||
r.get("steps_per_mm"))
|
||||
file_card(ctx, "sheet.place")
|
||||
|
||||
|
||||
def burn(ctx, wid, answers, want):
|
||||
"""One live card: the preview, the burn after the press with the
|
||||
prompts answered from `answers`, the witnesses, the keys of the
|
||||
result in `want`. run_check makes the arm press when the card asks
|
||||
for it."""
|
||||
preview_ok(ctx, wid)
|
||||
|
||||
def on_prompt(p):
|
||||
return answers.get(p.get("id"))
|
||||
|
||||
try:
|
||||
last = run_check(ctx, wid, on_prompt, 900)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
emission_ok(ctx, last)
|
||||
r = last.get("result") or {}
|
||||
missing = [k for k in want if k not in r]
|
||||
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
|
||||
ctx.log("%s: %s, emission %s", wid, {k: r.get(k) for k in want}, ctx.evidence.get("emission"))
|
||||
file_card(ctx, wid)
|
||||
|
||||
|
||||
@test("setup.sheet", title="The setup sheet: the placement, the frame, and the five cards",
|
||||
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=35,
|
||||
covers=SHEET_COVERS,
|
||||
requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "cooling.flow-verify"],
|
||||
actions=["button"], hands=["scrap"],
|
||||
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
|
||||
"it goes with its top edge at the top of the cut area (the head's home corner); "
|
||||
"lid closed. Eye protection on, exhaust on, extinguisher in reach.",
|
||||
"Start the test and press the machine's button once. That press says you are at the "
|
||||
"machine, and the bench actuator makes every arm press after it, each one when the "
|
||||
"card itself asks for it. With no actuator the start is the Ready answer instead, "
|
||||
"and you press the button each time the page asks: six times, the frame and the "
|
||||
"five cards.",
|
||||
"Nothing else: the test answers every prompt itself and puts back every setting the "
|
||||
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
|
||||
"tails."],
|
||||
description="One run over the sheet's seven wizards on one piece of wood, as a fresh "
|
||||
"machine runs them: the three lens settings are cleared first, so the frame "
|
||||
"runs in the fallback window and the focus card has to find the stops and write "
|
||||
"them before its ladder burns. "
|
||||
"POST /wiz/sheet.place/start references the lens, takes the controller in "
|
||||
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
|
||||
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
|
||||
"0.125 in). Then, each after the press and each with its preview "
|
||||
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the "
|
||||
"program commands inside the reach /status reports: the frame "
|
||||
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
|
||||
"lens on its hall edge, the stops found by the head accelerometer and written "
|
||||
"as the window before the controller starts, twelve lines from the top of the "
|
||||
"window to the bottom; the pick is line 11, the thickness kept; after it the "
|
||||
"settings, the result's window, and the served program must hold the window "
|
||||
"the ladder ran in); the floor card (twelve "
|
||||
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
|
||||
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
|
||||
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
|
||||
"through M102 inside one armed job; the pick is 1.50); and the flow-load card "
|
||||
"(the coolant loop settled, the box and the patch at 60 percent, the "
|
||||
"downstream peak over 100 s of dark as the coefficient). Every burn must be "
|
||||
"seen by the three witnesses (the tube current, the kernel's LASER_ON samples, "
|
||||
"the thermopile rise); every result must carry its keys; the record must "
|
||||
"carry each wizard at version 1; the seven settings the cards write are "
|
||||
"restored at the end.")
|
||||
def sheet(ctx):
|
||||
ctx.ready("The sheet: the placement, then the frame and the five cards burn after a press "
|
||||
"each. The bench presses when the button lights.")
|
||||
place(ctx)
|
||||
with Restore(ctx, CARD_SETTINGS):
|
||||
fresh_lens(ctx)
|
||||
for wid, answers, want in CARDS:
|
||||
burn(ctx, wid, answers, want)
|
||||
if wid == "laser.focus":
|
||||
focus_window_ok(ctx)
|
||||
ctx.log("PASS: the sheet's seven wizards ran on one piece as a fresh machine's; settings restored")
|
||||
@@ -1,203 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The crumb tray's mode, on the machine.
|
||||
|
||||
Its own module, so that no other test's fingerprint moves. With the tray
|
||||
out, the GRBL controller's Z is the focal height above the floor of the cut
|
||||
area: its Z position and its Z range move up by tray_offset_mm on the lens
|
||||
step grid, and nothing moves. The mode persists as the controller's marker
|
||||
in the data directory. That it survives a controller restart is the
|
||||
driver's harness case (tray_test.py): a restart here would drop the X and
|
||||
Y reference, and the machine would not be handed back as it was found.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
from .motion import (_kernel_position, _port_state, _words, clean_slate, drain_text, machine_idle)
|
||||
|
||||
TRAY_MARKER = "/data/forgefirm/tray.out"
|
||||
LENS_SPM = 2.922 # $102: the lens screw's half-steps per mm
|
||||
SETUP_CARD = "sheet.place" # the first setup card: it asks before it moves anything
|
||||
|
||||
|
||||
def _grid_mm(offset_mm):
|
||||
"""The offset on the lens step grid, the controller's way (lroundf)."""
|
||||
return math.floor(offset_mm * LENS_SPM + 0.5) / LENS_SPM
|
||||
|
||||
|
||||
def _lens(fc):
|
||||
return fc.status().get("lens") or {}
|
||||
|
||||
|
||||
def _judge(ctx, fc, ev, key, mode, found, shift):
|
||||
"""The machine reads the tray `mode`, with Z and the reach moved by
|
||||
`shift` mm from what the test found."""
|
||||
s = fc.status()
|
||||
lens, pos = s.get("lens") or {}, s.get("pos") or {}
|
||||
port = _port_state(fc)
|
||||
rec = ev[key] = {"lens": lens, "pos_z": pos.get("z"), "port_tray": port.get("tray"),
|
||||
"port_z": port.get("mpos", [None] * 3)[2], "marker": os.path.exists(TRAY_MARKER),
|
||||
"home": [s.get("homed_axes"), s.get("home_source")]}
|
||||
ctx.log("%s: %s", key, rec)
|
||||
ctx.check(lens.get("tray") == mode and port.get("tray") == mode,
|
||||
"%s: the status says tray %s and the port %s, not %s", key, lens.get("tray"), port.get("tray"), mode)
|
||||
ctx.check(rec["marker"] == (mode == "out"), "%s: the marker %s is %s with the tray %s", key, TRAY_MARKER,
|
||||
"present" if rec["marker"] else "absent", mode)
|
||||
for k in ("reach_min", "reach_max"):
|
||||
ctx.check(lens.get(k) is not None and abs(lens[k] - (found["lens"][k] + shift)) < 0.011,
|
||||
"%s: %s reads %s, not %.2f", key, k, lens.get(k), found["lens"][k] + shift)
|
||||
ctx.check(pos.get("z") is not None and abs(pos["z"] - (found["pos_z"] + shift)) < 0.006,
|
||||
"%s: the status Z reads %s, not %.2f", key, pos.get("z"), found["pos_z"] + shift)
|
||||
ctx.check(rec["port_z"] is not None and abs(rec["port_z"] - (found["port_z"] + shift)) < 0.002,
|
||||
"%s: the controller's Z reads %s, not %.3f", key, rec["port_z"], found["port_z"] + shift)
|
||||
ctx.check(rec["home"] == found["home"], "%s: the reference changed across the switch: %s, not %s",
|
||||
key, rec["home"], found["home"])
|
||||
|
||||
|
||||
def _tray_route(fc, mode):
|
||||
st, body = fc.post("/motion/tray", params={"tray": mode})
|
||||
return st, _words(body)
|
||||
|
||||
|
||||
def _card_refused(ctx, fc, ev):
|
||||
"""A setup card is refused while the tray is out. The machine is idle a
|
||||
moment after the suite's Grbl client closes; until then the refusal is
|
||||
the idle rule's. A card that started anyway is ended at once: the first
|
||||
one asks before it moves anything."""
|
||||
path = "/wiz/%s/start" % SETUP_CARD
|
||||
deadline = time.time() + 20
|
||||
while True:
|
||||
st, body = fc.post(path)
|
||||
words = _words(body)
|
||||
if st == 200:
|
||||
fc.post("/wiz/%s/abort" % SETUP_CARD)
|
||||
ctx.wait_for(lambda: not (fc.get("/wiz/dark")[1] or {}).get("running"), 20, poll=0.5)
|
||||
if st != 409 or "not idle" not in words or time.time() > deadline:
|
||||
break
|
||||
ctx.sleep(0.5)
|
||||
ev["setup_card"] = [st, words]
|
||||
ctx.log("a setup card started with the tray out -> %s %s", st, words)
|
||||
ctx.check(st == 409 and "crumb tray" in words, "a setup card started with the tray out -> %s %s", st, words)
|
||||
|
||||
|
||||
@test("motion.tray", title="The crumb tray: tray out moves Z and its range up by the offset, and nothing moves",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=2,
|
||||
covers=[("grblhal-glowforge", "src/glowforge_tray.*"), ("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||
("grblhal-glowforge", "src/ctlport.*"), ("grblhal-glowforge", "src/glowforge_io.*"),
|
||||
("forgectrl", "src/tray.*"), ("forgectrl", "src/grblport.*"), ("forgectrl", "src/status.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/wizdark.*"), ("forgectrl", "src/ui/panel.js"),
|
||||
("forgectrl", "src/ui/index.html")],
|
||||
requires=["motion.port-jog"],
|
||||
steps=["Nothing moves; the crumb tray can stay where it is."],
|
||||
description="The tray mode is switched to the other one and back, through the panel's route and "
|
||||
"through M103 from a Grbl client, both ways. Each switch changes the numbers and "
|
||||
"moves nothing: /status says the tray, its Z and the lens reach move by the offset on "
|
||||
"the lens step grid (1.35 in is 100 half-steps, 34.22 mm), the reference and what set it "
|
||||
"stand, the controller port's state "
|
||||
"agrees, the marker in the data directory follows, the client is told [MSG:Tray in] or "
|
||||
"[MSG:Tray out] and its next line draws ok, and the kernel's position and byte counters "
|
||||
"do not move. In the other mode, a jog to the Z the lens read before the switch is "
|
||||
"refused (error:15): the two Z ranges never overlap; back in the mode found, the same "
|
||||
"jog is taken. A setup card started with the tray out is refused in the operator's "
|
||||
"words, with nothing sent. The page "
|
||||
"the panel serves carries the switch. The tray mode is put back as found in a finally. "
|
||||
"The controller is not restarted: that would drop the X and Y reference. That the mode "
|
||||
"survives a restart, the errors of M103, and its wait for the move before it are the "
|
||||
"driver's harness cases.")
|
||||
def tray(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
s0 = fc.status()
|
||||
found_mode = (s0.get("lens") or {}).get("tray")
|
||||
ctx.check(found_mode in ("in", "out"), "the status carries no tray mode: %s", s0.get("lens"))
|
||||
other = "out" if found_mode == "in" else "in"
|
||||
p_found = "P1" if found_mode == "out" else "P0"
|
||||
p_other = "P1" if other == "out" else "P0"
|
||||
shift = _grid_mm((s0.get("lens") or {}).get("tray_offset_mm") or 34.29) * (1 if other == "out" else -1)
|
||||
machine_idle(ctx)
|
||||
found = {"lens": s0.get("lens"), "pos_z": (s0.get("pos") or {}).get("z"),
|
||||
"port_z": _port_state(fc)["mpos"][2], "kernel": _kernel_position(),
|
||||
"home": [s0.get("homed_axes"), s0.get("home_source")]}
|
||||
ev["found"] = {"tray": found_mode, "shift_mm": round(shift, 4), **found}
|
||||
ctx.log("found the tray %s; switching to %s moves Z %+.3f mm", found_mode, other, shift)
|
||||
ctx.check(found["pos_z"] is not None, "the status carries no Z")
|
||||
|
||||
st, page = fc.get("/", raw=True)
|
||||
text = page.decode("utf-8", "replace") if isinstance(page, bytes) else str(page)
|
||||
for want in ('id="traybtn"', "function trayToggle", "/motion/tray?tray="):
|
||||
ctx.check(st == 200 and want in text, "the panel page carries no %s", want)
|
||||
|
||||
switched = False
|
||||
try:
|
||||
if found_mode == "out":
|
||||
_card_refused(ctx, fc, ev)
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
ctx.sleep(0.6) # past the port's sender-quiet interval
|
||||
|
||||
# Through the panel's route.
|
||||
st, words = _tray_route(fc, other)
|
||||
ctx.check(st == 200, "POST /motion/tray tray=%s -> %s %s", other, st, words)
|
||||
switched = True
|
||||
said = drain_text(g, 0.5)
|
||||
ctx.check("[MSG:Tray %s]" % other in said, "the client was not told the tray is %s: %r", other, said)
|
||||
_judge(ctx, fc, ev, "route_" + other, other, found, shift)
|
||||
r = g.command("G4 P0")
|
||||
ctx.check(r[-1:] == ["ok"], "the client's line after the switch drew %s", r)
|
||||
|
||||
# The Z the lens read before the switch is outside this mode's range.
|
||||
jog = "$J=G90 G21 Z%.3f F200" % found["port_z"]
|
||||
r = g.command(jog)
|
||||
ev["jog_" + other] = [jog, r]
|
||||
ctx.check(r[-1:] == ["error:15"], "with the tray %s, %s drew %s, not error:15", other, jog, r)
|
||||
g.command("") # the empty line that acknowledges the error
|
||||
|
||||
# Back through M103, from the client.
|
||||
r = g.command("M103 " + p_found)
|
||||
ev["m103_" + found_mode] = r
|
||||
ctx.check(r[-1:] == ["ok"] and "[MSG:Tray %s]" % found_mode in r,
|
||||
"M103 %s drew %s", p_found, r)
|
||||
switched = False
|
||||
_judge(ctx, fc, ev, "m103_" + found_mode, found_mode, found, 0.0)
|
||||
r = g.command(jog)
|
||||
ev["jog_" + found_mode] = [jog, r]
|
||||
ctx.check(r[-1:] == ["ok"], "with the tray %s again, %s drew %s", found_mode, jog, r)
|
||||
machine_idle(ctx)
|
||||
|
||||
# And the other way: M103 out, the route back.
|
||||
r = g.command("M103 " + p_other)
|
||||
ev["m103_" + other] = r
|
||||
ctx.check(r[-1:] == ["ok"] and "[MSG:Tray %s]" % other in r, "M103 %s drew %s", p_other, r)
|
||||
switched = True
|
||||
_judge(ctx, fc, ev, "m103_" + other, other, found, shift)
|
||||
|
||||
if other == "out":
|
||||
_card_refused(ctx, fc, ev)
|
||||
|
||||
st, words = _tray_route(fc, found_mode)
|
||||
ctx.check(st == 200, "POST /motion/tray tray=%s -> %s %s", found_mode, st, words)
|
||||
switched = False
|
||||
_judge(ctx, fc, ev, "route_" + found_mode, found_mode, found, 0.0)
|
||||
finally:
|
||||
if switched:
|
||||
# The route is refused for a moment after the suite's Grbl client closes.
|
||||
first = sys.exc_info()[1]
|
||||
took = ctx.wait_for(lambda: _tray_route(fc, found_mode)[0] == 200 or None, 20, poll=0.5)
|
||||
ctx.log("put the tray back %s -> %s", found_mode, "taken" if took is not None else "not taken")
|
||||
ev["put_back"] = took is not None
|
||||
if first is None:
|
||||
ctx.check(took is not None, "the tray mode is left %s, not put back %s", other, found_mode)
|
||||
machine_idle(ctx)
|
||||
k1 = _kernel_position()
|
||||
ev["kernel"] = [found["kernel"], k1]
|
||||
ctx.check(k1 == found["kernel"], "the kernel moved across the switches: %s -> %s", found["kernel"], k1)
|
||||
ctx.log("PASS: the tray mode switched both ways through the route and through M103, Z and the reach "
|
||||
"moved by %.3f mm and back, nothing moved, and it is %s as found", abs(shift), found_mode)
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
"""update.* - the A/B slot inventory and the firmware verification path."""
|
||||
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,164 +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)
|
||||
|
||||
|
||||
@test("update.product-gate", title="The firmware upload takes firmware only: an extension package is refused by name",
|
||||
subsystem="update", kind="auto", est_min=1,
|
||||
covers=_UPDATE_COVERS + [("forgectrl", "src/fwproduct.c"), ("forgectrl", "src/fwproduct.h")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="Firmware and extension packages are the same container, and a signature says who "
|
||||
"made an archive, never what it is. Archives made on the spot with the machine's own "
|
||||
"fwup go to POST /update/upload: an extension package unsigned, one signed with a "
|
||||
"throwaway key, and one that carries a task are each refused with 400 in words that "
|
||||
"say what they are, and nothing is staged; an archive of some other product is "
|
||||
"refused as not firmware; and one whose product is ForgeFIRM firmware is taken as an "
|
||||
"unsigned upload, which is what it is. Nothing is applied: the apply calls the same "
|
||||
"gate before fwup -a, and its proof is the daemon's host test, because a catalog test "
|
||||
"that could fail there would write a slot.")
|
||||
def product_gate(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
staged = "/data/forgefirm/upload.fw"
|
||||
ctx.check(not os.path.exists(staged), "an upload is already staged: %s", staged)
|
||||
work = tempfile.mkdtemp(prefix="forgetest-gate-")
|
||||
|
||||
def archive(name, product, task=False, sign=False):
|
||||
conf = os.path.join(work, name + ".conf")
|
||||
with open(conf, "w") as f:
|
||||
f.write('meta-product = "%s"\nmeta-version = "0.0.0-test"\n'
|
||||
'file-resource payload.tar.gz { host-path = "payload.tar.gz" }\n' % product)
|
||||
if task:
|
||||
f.write('task upgrade.a { on-resource payload.tar.gz { raw_write(0) } }\n')
|
||||
out = name + ".fw"
|
||||
cmd = "cd %s && fwup -c -f %s.conf -o %s" % (work, name, out)
|
||||
if sign:
|
||||
cmd += " && fwup -S -s fwup-key.priv -i %s -o %s.signed && mv %s.signed %s" % (out, out, out, out)
|
||||
rc, text = hw.run(["sh", "-c", cmd], timeout=60)
|
||||
ctx.check(rc == 0, "could not make %s (rc %s): %s", name, rc, text.strip()[:200])
|
||||
return os.path.join(work, out)
|
||||
|
||||
def upload(path):
|
||||
mark = "forgetestGateBoundary9c2"
|
||||
body = ('--%s\r\nContent-Disposition: form-data; name="file"; filename="%s"\r\n'
|
||||
'Content-Type: application/octet-stream\r\n\r\n' % (mark, os.path.basename(path))).encode()
|
||||
with open(path, "rb") as f:
|
||||
body += f.read() + ("\r\n--%s--\r\n" % mark).encode()
|
||||
return fc.post("/update/upload", data=body,
|
||||
headers={"Content-Type": "multipart/form-data; boundary=%s" % mark})
|
||||
|
||||
try:
|
||||
with open(os.path.join(work, "payload.tar.gz"), "wb") as f:
|
||||
f.write(b"forgetest product-gate drill\n" * 8)
|
||||
rc, text = hw.run(["sh", "-c", "cd %s && fwup -g" % work], timeout=60)
|
||||
ctx.check(rc == 0, "fwup -g did not make a key pair (rc %s): %s", rc, text.strip()[:200])
|
||||
seen = {}
|
||||
for name, product, task, sign in (("extension-unsigned", "ForgeFIRM extension", False, False),
|
||||
("extension-signed", "ForgeFIRM extension", False, True),
|
||||
("extension-with-task", "ForgeFIRM extension", True, False)):
|
||||
st, body = upload(archive(name, product, task, sign))
|
||||
words = body.get("error", "") if isinstance(body, dict) else str(body)
|
||||
seen[name] = [st, words]
|
||||
ctx.log("upload %s -> %s %s", name, st, words)
|
||||
ctx.check(st == 400 and "an extension package, not firmware" in words,
|
||||
"%s at the firmware upload -> %s %s", name, st, words)
|
||||
ctx.check(not os.path.exists(staged), "%s was refused and is staged all the same", name)
|
||||
st, body = upload(archive("another-product", "forgetest"))
|
||||
words = body.get("error", "") if isinstance(body, dict) else str(body)
|
||||
seen["another-product"] = [st, words]
|
||||
ctx.log("upload another-product -> %s %s", st, words)
|
||||
ctx.check(st == 400 and "not firmware" in words and not os.path.exists(staged),
|
||||
"an archive of another product -> %s %s", st, words)
|
||||
st, body = upload(archive("firmware-unsigned", "ForgeFIRM firmware"))
|
||||
seen["firmware-unsigned"] = [st, body]
|
||||
ctx.log("upload firmware-unsigned -> %s %s", st, body)
|
||||
ctx.check(st == 200 and isinstance(body, dict) and body.get("signature") == "unsigned"
|
||||
and os.path.exists(staged), "an unsigned archive that is firmware -> %s %s", st, body)
|
||||
ev["uploads"] = seen
|
||||
finally:
|
||||
try:
|
||||
os.unlink(staged)
|
||||
except OSError:
|
||||
pass
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
ctx.log("PASS: three extension packages and one foreign product refused at the firmware upload with "
|
||||
"nothing staged; firmware taken as the unsigned upload it is")
|
||||
|
||||
|
||||
_VERSION_RX = re.compile(r"^v?\d+\.\d+\.\d+")
|
||||
|
||||
|
||||
@test("update.release-check", title="Release check answers from the releases API; the alert dismissal is per release",
|
||||
subsystem="update", kind="auto", est_min=1,
|
||||
covers=_UPDATE_COVERS + [("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="GET /update/release answers the kept answer of the daily check (available, version, "
|
||||
"current, new, checked, dismissed). POST /update/check asks the releases API now and "
|
||||
"answers the same shape; a machine with no route to the API answers 502, which the drill "
|
||||
"records and steps over. A published release is a v<semver> tag with the firmware "
|
||||
"file's size and its notes, and `new` follows the version order: a development build "
|
||||
"sees every release as newer. POST /update/dismiss marks that version dismissed, an "
|
||||
"empty version undoes it, and a version that is not a tag is refused. The dismissal "
|
||||
"the machine had before the drill is put back.")
|
||||
def release_check(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
keys = ("available", "version", "current", "new", "checked", "dismissed", "detail", "bytes")
|
||||
|
||||
st, before = fc.get("/update/release")
|
||||
ctx.log("GET /update/release -> %s %s", st, {k: before.get(k) for k in keys} if isinstance(before, dict) else before)
|
||||
ctx.check(st == 200 and isinstance(before, dict) and "available" in before and "checked" in before,
|
||||
"GET /update/release -> %s", st)
|
||||
ev["before"] = {k: before.get(k) for k in keys}
|
||||
prior = before.get("version", "") if before.get("dismissed") else ""
|
||||
|
||||
st, now = fc.post("/update/check")
|
||||
ctx.log("POST /update/check -> %s %s", st, {k: now.get(k) for k in keys} if isinstance(now, dict) else now)
|
||||
if st == 502:
|
||||
ctx.log("the machine has no route to the releases API; the kept answer stands")
|
||||
now = before
|
||||
else:
|
||||
ctx.check(st == 200 and isinstance(now, dict) and "available" in now, "POST /update/check -> %s", st)
|
||||
ctx.check(isinstance(now.get("checked"), int) and now["checked"] >= before.get("checked", 0),
|
||||
"the check did not move `checked`: %s", now.get("checked"))
|
||||
ev["after_check"] = {k: now.get(k) for k in keys}
|
||||
|
||||
if now.get("available"):
|
||||
ctx.check(_VERSION_RX.match(str(now.get("version", ""))) is not None,
|
||||
"the release version is not a v<semver> tag: %r", now.get("version"))
|
||||
ctx.check(isinstance(now.get("bytes"), int) and now["bytes"] > 0,
|
||||
"the firmware file's size is missing: %r", now.get("bytes"))
|
||||
ctx.check(isinstance(now.get("notes"), str), "the notes are not a string")
|
||||
ctx.check(isinstance(now.get("new"), bool), "`new` is not a bool")
|
||||
if not _VERSION_RX.match(str(now.get("current", ""))):
|
||||
ctx.check(now.get("new") is True,
|
||||
"a development build (%r) must see release %s as newer", now.get("current"), now["version"])
|
||||
else:
|
||||
ctx.log("no release available: %s", now.get("detail"))
|
||||
ctx.check(isinstance(now.get("detail"), str) and now["detail"], "an unavailable release names no reason")
|
||||
|
||||
try:
|
||||
st, body = fc.post("/update/dismiss", params={"version": "v0.0.1; rm -rf /"})
|
||||
ctx.log("POST /update/dismiss version=<not a tag> -> %s %s", st, body)
|
||||
ctx.check(st == 400, "a version that is not a tag was not refused: %s", st)
|
||||
|
||||
tag = now["version"] if now.get("available") else "v0.0.0"
|
||||
st, body = fc.post("/update/dismiss", params={"version": tag})
|
||||
ctx.log("POST /update/dismiss version=%s -> %s %s", tag, st,
|
||||
{k: body.get(k) for k in keys} if isinstance(body, dict) else body)
|
||||
ctx.check(st == 200 and isinstance(body, dict), "POST /update/dismiss -> %s", st)
|
||||
if now.get("available"):
|
||||
ctx.check(body.get("dismissed") is True, "the dismissal of %s was not recorded: %r", tag, body.get("dismissed"))
|
||||
st, body = fc.post("/update/dismiss", params={"version": ""})
|
||||
ctx.log("POST /update/dismiss version= -> %s", st)
|
||||
ctx.check(st == 200 and isinstance(body, dict) and body.get("dismissed") is False,
|
||||
"the empty version did not undo the dismissal: %s %r", st, body.get("dismissed") if isinstance(body, dict) else body)
|
||||
finally:
|
||||
st, body = fc.post("/update/dismiss", params={"version": prior})
|
||||
ctx.log("dismissal put back to %r -> %s", prior, st)
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""An update job holds the machine, on the machine.
|
||||
|
||||
Its own module, so that no other test's fingerprint moves. The job is a
|
||||
probe download of the published release: the download job itself, run on
|
||||
the release's acceptance record instead of its firmware file, because
|
||||
GitHub counts every request of the firmware file as an install. The
|
||||
verification refuses the record and the probe keeps nothing; a download
|
||||
the machine had staged is left as it was.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from ..catalog import test
|
||||
from .motion import _job_post, _words
|
||||
|
||||
DL_FW = "/data/forgefirm/download.fw"
|
||||
DL_PROBE = "/data/forgefirm/download-probe"
|
||||
HELD = "an update job (download) holds the machine"
|
||||
|
||||
|
||||
def _as_found(path):
|
||||
try:
|
||||
st = os.stat(path)
|
||||
return [st.st_size, st.st_mtime_ns]
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
@test("update.job-locks", title="An update job holds the machine: the settings are locked while it runs",
|
||||
subsystem="update", kind="auto", est_min=2,
|
||||
covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/relcheck.*"), ("forgectrl", "src/lease.*")],
|
||||
requires=["update.release-check"],
|
||||
description="With a release published on the releases API, POST /update/download?probe=1 starts the "
|
||||
"download job (202) on the release's acceptance record in place of its firmware file, so "
|
||||
"the test adds nothing to the firmware file's download count. The machine lease is taken "
|
||||
"before the 202: a settings write and a posted job right after it are each refused in the "
|
||||
"job's name (409, an update job (download) holds the machine, the settings locked), and "
|
||||
"/status names the job (update:download, of kind system) as the holder while it runs. The "
|
||||
"job fetches the record through the download's own path and ends refused by the signature "
|
||||
"check with the record discarded. When it ends, the lease is free and the same settings "
|
||||
"write is taken. A download the machine had staged is untouched and the probe leaves no "
|
||||
"file. That a log export does not lock the settings while every other holder does is "
|
||||
"lease_test's; that a probe never names the firmware file is relcheck_test's.")
|
||||
def job_locks(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
|
||||
st, rel = fc.get("/update/release")
|
||||
if st == 200 and isinstance(rel, dict) and not rel.get("checked"):
|
||||
# the kept answer does not outlive a restart of the daemon: ask now
|
||||
st, rel = fc.post("/update/check")
|
||||
ctx.log("the release was not checked since the daemon started: POST /update/check -> %s", st)
|
||||
ev["release"] = {k: (rel or {}).get(k) for k in ("available", "version")} if isinstance(rel, dict) else rel
|
||||
ctx.check(st == 200 and isinstance(rel, dict) and rel.get("available"), "no release is published to probe: %s", rel)
|
||||
staged = _as_found(DL_FW)
|
||||
ev["staged_download"] = staged
|
||||
ctx.check(_as_found(DL_PROBE) is None, "a probe's file is left over from before: %s", DL_PROBE)
|
||||
units = fc.settings().get("ui_units") or "metric"
|
||||
|
||||
def holder():
|
||||
return ((fc.status().get("lease") or {}).get("holder") or {})
|
||||
|
||||
def job():
|
||||
st_, j = fc.get("/update/status")
|
||||
return j if isinstance(j, dict) else {}
|
||||
|
||||
try:
|
||||
# The record is small, so the job can end within a second. The
|
||||
# lease is taken before the 202, so the settings write goes first
|
||||
# and at once: it is the same value, so a probe that ended before
|
||||
# it only costs another probe. The posted job goes only while the
|
||||
# settings are refused in the job's name.
|
||||
refused = None
|
||||
for attempt in (1, 2, 3):
|
||||
st, body = fc.post("/update/download", params={"probe": "1"})
|
||||
ctx.check(st == 202, "POST /update/download probe=1 -> %s %s", st, _words(body)[:200])
|
||||
st, body = fc.post("/settings", params={"ui_units": units})
|
||||
if st == 200:
|
||||
ctx.wait_for(lambda: job().get("running") is False or None, 120, poll=0.5)
|
||||
result = job().get("result") or {}
|
||||
ctx.log("probe %d ended before the settings write was read (%s); again", attempt, result)
|
||||
ctx.check("signature verification failed" in str(result.get("error")),
|
||||
"the probe ended, and not refused by the signature check: %s", result)
|
||||
continue
|
||||
st2, body2 = _job_post(fc, "G21\n", name="beside-an-update")
|
||||
held = holder()
|
||||
refused = {"a settings write": [st, _words(body)[:200]], "a posted job": [st2, _words(body2)[:200]]}
|
||||
break
|
||||
ctx.check(refused is not None, "three probes each ended before a settings write could be refused")
|
||||
ev["refused"] = refused
|
||||
ev["holder"] = held
|
||||
ctx.log("beside the probe: %s; the lease: %s", refused, held)
|
||||
ctx.check(st == 409 and HELD in _words(body) and "settings are locked" in _words(body),
|
||||
"a settings write beside the probe -> %s %s", st, _words(body)[:200])
|
||||
ctx.check(st2 == 409 and HELD in _words(body2), "a job beside the probe -> %s %s", st2, _words(body2)[:200])
|
||||
if held:
|
||||
ctx.check(held.get("owner") == "update:download" and held.get("kind") == "system",
|
||||
"the probe does not hold the machine as the download job: %s", held)
|
||||
else:
|
||||
ctx.log("the probe had ended before /status was read; its refusals name it")
|
||||
ctx.wait_for(lambda: job().get("running") is False or None, 600, poll=1.0)
|
||||
done = job()
|
||||
ev["job"] = done
|
||||
ctx.log("the probe ended: %s", done)
|
||||
result = done.get("result") or {}
|
||||
ctx.check(result.get("ok") is False and "signature verification failed" in str(result.get("error")),
|
||||
"the probe did not end refused by the signature check (the record fetched and discarded): %s",
|
||||
result)
|
||||
ctx.check(not holder(), "the lease was not given back: %s", holder())
|
||||
st, body = fc.post("/settings", params={"ui_units": units})
|
||||
ctx.check(st == 200, "the same settings write after the job -> %s %s", st, _words(body)[:200])
|
||||
finally:
|
||||
# The job runs on after a check that failed: its end is waited for.
|
||||
ctx.wait_for(lambda: job().get("running") is False or None, 600, poll=2.0)
|
||||
ctx.check(_as_found(DL_FW) == staged, "the staged download moved: %s, and %s before", _as_found(DL_FW), staged)
|
||||
ctx.check(_as_found(DL_PROBE) is None, "the probe left its file: %s", DL_PROBE)
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
2026-08-17T09:44:00.417337+00:00 gfcloud[1927] INFO cnc:set_step_freq 28160
|
||||
2026-08-17T09:44:00.418627+00:00 gfcloud[1927] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:44:00.419858+00:00 gfcloud[1927] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:44:00.421083+00:00 gfcloud[1927] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:44:00.422535+00:00 gfcloud[1927] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:44:00.423794+00:00 gfcloud[1927] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:44:00.425014+00:00 gfcloud[1927] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:44:00.432591+00:00 gfcloud[1927] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:44:00.434005+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:44:00.438277+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:44:01.785230+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:44:01.797728+00:00 gfcloud[1927] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:44:01.800661+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:01.808504+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (actual/expected): X (-10313/-10313), Y (-649/-649), Z (0/0)
|
||||
2026-08-17T09:44:01.809326+00:00 gfcloud[1927] INFO machine:_motion_locked motion bytes actual:34952, expected: 34952
|
||||
2026-08-17T09:44:01.810066+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:44:01.811352+00:00 gfcloud[1927] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:44:01.812874+00:00 gfcloud[1927] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:44:01.820631+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (-10313, -649, 0)
|
||||
2026-08-17T09:44:01.822062+00:00 gfcloud[1927] INFO machine:_motion end motion
|
||||
2026-08-17T09:44:01.822829+00:00 gfcloud[1927] INFO basemachine:_finish_action motion [1576550614]: finished with event ":completed"
|
||||
2026-08-17T09:44:01.824200+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:07.993647+00:00 gfcloud[1927] INFO gfuiservice:run service action request: print (ready)
|
||||
2026-08-17T09:44:07.998655+00:00 gfcloud[1927] INFO machine:_motion start motion
|
||||
2026-08-17T09:44:07.999643+00:00 gfcloud[1927] INFO gfuiservice:run print (ready) service action print
|
||||
2026-08-17T09:44:14.199666+00:00 gfcloud[1927] INFO cnc:laser_latch 0
|
||||
2026-08-17T09:44:14.208291+00:00 gfcloud[1927] INFO machine:_button_wait waiting for button
|
||||
2026-08-17T09:44:21.270846+00:00 gfcloud[1927] INFO machine:_switch_event lid opened
|
||||
2026-08-17T09:44:21.275703+00:00 gfcloud[1927] WARNING machine:_button_wait button wait lid opened - relocking the laser
|
||||
2026-08-17T09:44:21.278615+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:21.288305+00:00 gfcloud[1927] INFO machine:_return_home start return home
|
||||
2026-08-17T09:44:21.291373+00:00 gfcloud[1927] INFO machine:_return_home return home: already at the job start
|
||||
2026-08-17T09:44:21.292501+00:00 gfcloud[1927] INFO machine:_motion_locked start cool down
|
||||
2026-08-17T09:44:21.305578+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:44:21.307322+00:00 gfcloud[1927] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:44:21.309185+00:00 gfcloud[1927] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:44:21.316340+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (0, 0, 0)
|
||||
2026-08-17T09:44:21.318139+00:00 gfcloud[1927] INFO machine:_motion end motion
|
||||
2026-08-17T09:44:21.318860+00:00 gfcloud[1927] INFO basemachine:_finish_action print [1576550621]: finished with event ":cancelled"
|
||||
2026-08-17T09:44:21.320214+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:34.058320+00:00 gfcloud[1927] INFO machine:_switch_event lid closed
|
||||
-207
@@ -1,207 +0,0 @@
|
||||
2026-08-17T09:44:35.789556+00:00 gfcloud[1927] INFO websocket:img_upload START
|
||||
2026-08-17T09:44:36.105516+00:00 gfcloud[1927] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:44:36.109421+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:38.461442+00:00 gfcloud[1927] INFO gfcloud:_shutdown shutdown requested
|
||||
2026-08-17T09:44:38.910494+00:00 gfcloud[1927] INFO machine:_shutdown shutting down
|
||||
2026-08-17T09:44:38.913049+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:38.950354+00:00 gfcloud[1927] INFO machine:_shutdown joining switch thread
|
||||
2026-08-17T09:44:38.956909+00:00 gfcloud[1927] INFO machine:_shutdown shut down complete
|
||||
2026-08-17T09:44:38.986287+00:00 gfcloud[1927] INFO websocket:_on_close RX-EVENT: closed (None, None)
|
||||
2026-08-17T09:44:38.989003+00:00 gfcloud[1927] INFO websocket:run CLOSING
|
||||
2026-08-17T09:44:38.992724+00:00 gfcloud[1927] INFO gfcloud:main gfcloud exit
|
||||
2026-08-17T09:45:08.449553+00:00 gfcloud[2278] INFO gfuiservice:__init__ INITIALIZED
|
||||
2026-08-17T09:45:08.451929+00:00 gfcloud[2278] INFO authentication:authenticate_machine START
|
||||
2026-08-17T09:45:08.965893+00:00 gfcloud[2278] INFO authentication:authenticate_machine SUCCESS
|
||||
2026-08-17T09:45:08.967947+00:00 gfcloud[2278] INFO websocket:ws_connect CONNECTING
|
||||
2026-08-17T09:45:09.421918+00:00 gfcloud[2278] INFO _logging:info Websocket connected
|
||||
2026-08-17T09:45:09.424224+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready
|
||||
2026-08-17T09:45:09.973615+00:00 gfcloud[2278] INFO websocket:ws_connect ESTABLISHED
|
||||
2026-08-17T09:45:09.994287+00:00 gfcloud[2278] INFO cnc:set_ignored_faults 0
|
||||
2026-08-17T09:45:09.996281+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||
2026-08-17T09:45:09.998803+00:00 gfcloud[2278] INFO cnc:set_x_mode Microstep.M_8
|
||||
2026-08-17T09:45:09.999688+00:00 gfcloud[2278] INFO cnc:set_y_mode Microstep.M_8
|
||||
2026-08-17T09:45:10.000483+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:45:10.001229+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:45:10.002000+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:45:10.007267+00:00 gfcloud[2278] INFO cnc:set_y_current 33
|
||||
2026-08-17T09:45:10.008695+00:00 gfcloud[2278] INFO z_axis:reset resetting z
|
||||
2026-08-17T09:45:10.009124+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: False, current: ZCur.LOW, mode: Microstep.HALF
|
||||
2026-08-17T09:45:10.021592+00:00 gfcloud[2278] INFO gfuiservice:run service action request: settings (ready)
|
||||
2026-08-17T09:45:10.024928+00:00 gfcloud[2278] INFO settings:send_report START
|
||||
2026-08-17T09:45:10.039856+00:00 gfcloud[2278] INFO settings:send_report COMPLETE
|
||||
2026-08-17T09:45:10.040650+00:00 gfcloud[2278] INFO gfuiservice:run settings (ready) service action dispatched
|
||||
2026-08-17T09:45:10.591813+00:00 gfcloud[2278] INFO gfuiservice:run service action request: hunt (ready)
|
||||
2026-08-17T09:45:10.596213+00:00 gfcloud[2278] INFO z_axis:home starting z homing cycle
|
||||
2026-08-17T09:45:10.599109+00:00 gfcloud[2278] INFO gfuiservice:run hunt (ready) service action hunt
|
||||
2026-08-17T09:45:10.600433+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: True, current: ZCur.HIGH, mode: Microstep.FULL
|
||||
2026-08-17T09:45:10.607559+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:11.160422+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:11.161195+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:11.714663+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:11.717907+00:00 gfcloud[2278] INFO z_axis:home pass: 1
|
||||
2026-08-17T09:45:11.719134+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:12.272747+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:12.274301+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:12.829585+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:12.831305+00:00 gfcloud[2278] INFO z_axis:home pass: 2
|
||||
2026-08-17T09:45:12.832786+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:13.385567+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:13.397106+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:13.947958+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:13.949754+00:00 gfcloud[2278] INFO z_axis:home pass: 3
|
||||
2026-08-17T09:45:13.951244+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:14.504840+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:14.506743+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:15.060854+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:15.062536+00:00 gfcloud[2278] INFO z_axis:home pass: 4
|
||||
2026-08-17T09:45:15.063941+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:15.617907+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:15.619505+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:16.175270+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:16.177421+00:00 gfcloud[2278] INFO z_axis:home pass: 5
|
||||
2026-08-17T09:45:16.179808+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:45:16.733698+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||
2026-08-17T09:45:16.735355+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:45:17.289377+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||
2026-08-17T09:45:17.291109+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: None, current: ZCur.LOW, mode: Microstep.HALF
|
||||
2026-08-17T09:45:17.296301+00:00 gfcloud[2278] INFO z_axis:home homing cycle complete: [0, 0, 0, 0, 0]
|
||||
2026-08-17T09:45:17.298209+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:17.968494+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||
2026-08-17T09:45:17.969759+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:45:17.970993+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:45:17.972200+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:45:17.973616+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:45:17.974870+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:45:17.976077+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:45:17.983820+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:45:17.985159+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:17.989360+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:45:19.630807+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:19.636283+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:45:19.637979+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:19.651159+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (0/0), Y (0/0), Z (-4/-4)
|
||||
2026-08-17T09:45:19.652103+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:15874, expected: 15874
|
||||
2026-08-17T09:45:19.652852+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:45:19.653964+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:45:19.655305+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:45:19.664032+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (0, 0, -4)
|
||||
2026-08-17T09:45:19.664853+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:45:19.665575+00:00 gfcloud[2278] INFO basemachine:_finish_action hunt [1576550660]: finished with event ":completed"
|
||||
2026-08-17T09:45:19.667114+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:20.027338+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:45:20.030024+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:45:20.040337+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:45:21.195560+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:45:21.196529+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:45:21.721382+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:45:21.725394+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:23.732857+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||
2026-08-17T09:45:23.736044+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:23.737995+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||
2026-08-17T09:45:23.738941+00:00 gfcloud[2278] INFO machine:_safe_to_move lid opened, unsafe to move
|
||||
2026-08-17T09:45:23.739814+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:45:23.740605+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550667]: finished with event ":cancelled"
|
||||
2026-08-17T09:45:23.742169+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:42.629674+00:00 gfcloud[2278] INFO machine:_switch_event lid closed
|
||||
2026-08-17T09:45:42.920194+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:45:42.924152+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:45:42.927934+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:45:43.989152+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:45:43.990022+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:45:44.312060+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:45:44.315952+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:46.652633+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||
2026-08-17T09:45:46.654950+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:46.657522+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||
2026-08-17T09:45:48.089456+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||
2026-08-17T09:45:48.090706+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:45:48.091970+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:45:48.093155+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:45:48.094587+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:45:48.095866+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:45:48.097182+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:45:48.104758+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:45:48.106128+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:48.110477+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:45:50.393091+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:50.398748+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:45:50.399629+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:50.423949+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (12939/12939), Y (7402/7402), Z (0/0)
|
||||
2026-08-17T09:45:50.425534+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:22740, expected: 22740
|
||||
2026-08-17T09:45:50.426258+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:45:50.427835+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:45:50.429387+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:45:50.437659+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (12939, 7402, 0)
|
||||
2026-08-17T09:45:50.438474+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:45:50.439167+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550679]: finished with event ":completed"
|
||||
2026-08-17T09:45:50.440481+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:50.770056+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:45:50.772800+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:45:50.782017+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:45:51.427848+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:45:51.428456+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:45:51.757479+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:45:51.760692+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:54.222123+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||
2026-08-17T09:45:54.224786+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:54.226855+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||
2026-08-17T09:45:54.557705+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||
2026-08-17T09:45:54.562210+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:45:54.571212+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:45:54.573373+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:45:54.596668+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:45:54.597612+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:45:54.598479+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:45:54.622488+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:45:54.624223+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:54.629307+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:45:54.835600+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:45:54.838848+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:45:54.839726+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:54.849011+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (159/159), Y (5/5), Z (0/0)
|
||||
2026-08-17T09:45:54.849946+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:1222, expected: 1222
|
||||
2026-08-17T09:45:54.850790+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:45:54.852317+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:45:54.854073+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:45:54.880450+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (159, 5, 0)
|
||||
2026-08-17T09:45:54.881925+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:45:54.882641+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550683]: finished with event ":completed"
|
||||
2026-08-17T09:45:54.884500+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:55.137070+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:45:55.139449+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:45:55.142346+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:45:55.809276+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:45:55.810155+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:45:56.158888+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
2026-08-17T09:45:56.162644+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:45:58.467590+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||
2026-08-17T09:45:58.469426+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||
2026-08-17T09:45:58.471091+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||
2026-08-17T09:46:00.721960+00:00 gfcloud[2278] INFO cnc:set_step_freq 28160
|
||||
2026-08-17T09:46:00.725688+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:46:00.727477+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:46:00.728738+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:46:00.730198+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:46:00.731506+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:46:00.732735+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:46:00.740459+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:46:00.741865+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:46:00.745983+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:46:02.491897+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:46:02.494137+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:46:02.495155+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:46:02.530565+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (-13096/-13096), Y (-7400/-7400), Z (0/0)
|
||||
2026-08-17T09:46:02.531413+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:47243, expected: 47243
|
||||
2026-08-17T09:46:02.538168+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:46:02.539603+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:46:02.541147+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:46:02.549578+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (-13096, -7400, 0)
|
||||
2026-08-17T09:46:02.550385+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||
2026-08-17T09:46:02.551153+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550685]: finished with event ":completed"
|
||||
2026-08-17T09:46:02.552496+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:46:03.007623+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||
2026-08-17T09:46:03.009488+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||
2026-08-17T09:46:03.037155+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||
2026-08-17T09:46:03.758520+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||
2026-08-17T09:46:03.760049+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||
2026-08-17T09:46:04.128126+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||
@@ -21,7 +21,10 @@ from forgetest import baseline
|
||||
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
|
||||
|
||||
|
||||
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, [])
|
||||
@@ -226,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()
|
||||
@@ -308,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}
|
||||
@@ -460,11 +458,7 @@ class BaselineTests(unittest.TestCase):
|
||||
os.environ.pop("FORGETEST_BOOT_ID", None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class ExtensionFreeTests(BaselineTests):
|
||||
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."""
|
||||
|
||||
@@ -529,104 +523,6 @@ class ExtensionFreeTests(BaselineTests):
|
||||
self.assertTrue(os.path.isdir(os.path.join(self.root, "pkg", "org.example.notify")))
|
||||
|
||||
|
||||
class TransientNotLeftoverTests(BaselineTests):
|
||||
"""A leftover is what a run left behind, not the machine part-way
|
||||
through its own work. Found on the bench reference, where
|
||||
laser.arm-wait-lid passed every check it makes and failed its
|
||||
hand-back on the post-job smoke clear."""
|
||||
|
||||
def test_a_fading_button_led_is_not_a_leftover(self):
|
||||
# the trigger fades brightness toward target: target 0 with
|
||||
# brightness still high is the fade from a level the machine ended
|
||||
for name in baseline.BUTTON_LEDS:
|
||||
self._led2(name, target="0", brightness="900")
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
self.assertEqual([x for x in left if x.item.startswith("leds/")], [])
|
||||
|
||||
def test_a_lit_button_led_is_a_leftover(self):
|
||||
# a target the run left standing is dirt, whatever the fade reads
|
||||
self._led2(baseline.BUTTON_LEDS[0], target="1014", brightness="0")
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
items = [x for x in left if x.item.startswith("leds/")]
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0].found, "1014")
|
||||
|
||||
|
||||
class CoolPublishTests(unittest.TestCase):
|
||||
"""The cooling engine publishes once a tick. A status read inside the
|
||||
tick after a run ended is the engine's last word on the run, not the
|
||||
machine as the run left it. Found on the bench reference: a diagnostic
|
||||
that finished clean (cooling.aa-offset-calibrate) and a fail tier that
|
||||
did its work (cooling.fail-tier-stop) both failed their hand-back on a
|
||||
hold the engine's next tick cleared."""
|
||||
|
||||
IDLE = {"phase": "idle", "armed": False, "hold": False}
|
||||
|
||||
def setUp(self):
|
||||
self.lines = []
|
||||
|
||||
def bl(self, statuses):
|
||||
"""A baseline whose /cool/status answers play in order (the last
|
||||
one repeats) and whose waits count reads, not seconds: three for
|
||||
the engine's next tick, as the real wait makes, and enough for the
|
||||
cooldown to let a slow script run out."""
|
||||
b = baseline.Baseline(self.lines.append)
|
||||
seq = list(statuses)
|
||||
b.fc_get = lambda path: (200, dict(seq.pop(0) if len(seq) > 1 else seq[0]))
|
||||
|
||||
def wait(what, pred, timeout):
|
||||
for i in range(3 if what == "cool publish" else 50):
|
||||
if pred():
|
||||
return float(i)
|
||||
return None
|
||||
b._wait = wait
|
||||
b.stood_down = []
|
||||
b.stand_down = b.stood_down.append
|
||||
return b
|
||||
|
||||
def test_a_diagnostic_hold_the_next_tick_clears_is_not_a_leftover(self):
|
||||
left = []
|
||||
b = self.bl([{"phase": "diag", "armed": False, "hold": True}, self.IDLE])
|
||||
b._cool_side(left)
|
||||
self.assertEqual(left, [])
|
||||
self.assertTrue(any("last word on the run" in ln for ln in self.lines), self.lines)
|
||||
|
||||
def test_a_fail_tier_hold_clears_into_the_engines_own_post_job_phase(self):
|
||||
left = []
|
||||
b = self.bl([{"phase": "run", "armed": False, "hold": True},
|
||||
{"phase": "smoke", "armed": False, "hold": False},
|
||||
{"phase": "smoke", "armed": False, "hold": False}, self.IDLE])
|
||||
b._cool_side(left)
|
||||
self.assertEqual(left, [])
|
||||
self.assertTrue(any("not a leftover" in ln for ln in self.lines), self.lines)
|
||||
|
||||
def test_a_hold_that_outlives_the_tick_is_the_runs_doing(self):
|
||||
left = []
|
||||
b = self.bl([{"phase": "run", "armed": True, "hold": True}])
|
||||
b._cool_side(left)
|
||||
self.assertEqual([x.item for x in left], ["cool"])
|
||||
self.assertEqual(left[0].found, "run/armed=True/hold=True")
|
||||
self.assertTrue(left[0].action.startswith("failed: still"), left[0].action)
|
||||
self.assertEqual(len(b.stood_down), 1)
|
||||
|
||||
def test_a_hold_that_clears_only_later_is_still_recorded(self):
|
||||
left = []
|
||||
held = {"phase": "run", "armed": False, "hold": True}
|
||||
b = self.bl([held] * 10 + [self.IDLE])
|
||||
b._cool_side(left)
|
||||
self.assertEqual([x.item for x in left], ["cool"])
|
||||
self.assertEqual(left[0].action, "waited")
|
||||
self.assertEqual(b.stood_down, [])
|
||||
|
||||
def test_an_idle_engine_and_a_silent_daemon_leave_nothing(self):
|
||||
left = []
|
||||
self.bl([self.IDLE])._cool_side(left)
|
||||
b = baseline.Baseline(self.lines.append)
|
||||
b.fc_get = lambda path: (None, None)
|
||||
b._cool_side(left)
|
||||
self.assertEqual(left, [])
|
||||
|
||||
|
||||
class CloudOwnedSysfsTests(unittest.TestCase):
|
||||
"""In cloud mode the cloud client configures the machine from the
|
||||
pulse header it is playing, the lens included: z_mode comes from
|
||||
@@ -651,52 +547,35 @@ class PositionDeadbandTests(unittest.TestCase):
|
||||
where motion.lid-cancel-home returned the head twice, each within
|
||||
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"
|
||||
@@ -843,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()
|
||||
|
||||
@@ -14,7 +14,6 @@ session whose one motion the cooling engine stopped short, and a whole
|
||||
three-motion one. Runs against scratch logs and fakes for the controller."""
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
@@ -130,30 +129,6 @@ class HandBackTests(unittest.TestCase):
|
||||
def expect_jog(self, dx, dy):
|
||||
self.assertEqual(self.ctx.jogs, ["$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy)])
|
||||
|
||||
# -- the travel ------------------------------------------------------
|
||||
def test_the_travel_is_the_sum_of_the_motions_ends(self):
|
||||
self.write(WHOLE)
|
||||
self.assertEqual(homeoff.session_travel(0), (4774 - 71 - 13096, 4600 - 144 - 7399))
|
||||
|
||||
def test_the_travel_starts_at_the_mark(self):
|
||||
self.write(WHOLE)
|
||||
mark = homeoff.session_mark()
|
||||
self.write(STOPPED)
|
||||
self.assertEqual(homeoff.session_travel(mark), (4390, 2682))
|
||||
|
||||
def test_the_cloud_clients_log_is_read_the_same_way(self):
|
||||
self.write(WHOLE.replace("gfhome[", "gfcloud["), self.cloudlog)
|
||||
self.assertEqual(homeoff.session_travel(0, cloud.GFCLOUD_LOG), (4774 - 71 - 13096, 4600 - 144 - 7399))
|
||||
self.assertEqual(homeoff.session_travel(0), (0, 0)) # gfhome's log is its own
|
||||
|
||||
def test_a_motion_with_no_end_leaves_the_travel_unknown(self):
|
||||
self.write(STOPPED.split("machine:_motion_locked")[0])
|
||||
self.assertIsNone(homeoff.session_travel(0))
|
||||
|
||||
def test_a_log_rotated_under_the_run_leaves_the_travel_unknown(self):
|
||||
self.write(STOPPED)
|
||||
self.assertIsNone(homeoff.session_travel(os.path.getsize(self.log) + 1))
|
||||
|
||||
# -- the stopped-short judge --------------------------------------------
|
||||
def test_a_motion_stopped_short_fails_the_judge(self):
|
||||
self.write(STOPPED)
|
||||
@@ -162,12 +137,6 @@ class HandBackTests(unittest.TestCase):
|
||||
homeoff.judge_whole_motions(self.ctx, ev, 0)
|
||||
self.assertEqual(len(ev["short_motions"]), 2)
|
||||
|
||||
def test_whole_motions_pass_the_judge(self):
|
||||
self.write(WHOLE)
|
||||
ev = {}
|
||||
homeoff.judge_whole_motions(self.ctx, ev, 0)
|
||||
self.assertEqual(ev["short_motions"], [])
|
||||
|
||||
# -- after a camera home -------------------------------------------------
|
||||
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
|
||||
@@ -228,21 +197,6 @@ class HandBackTests(unittest.TestCase):
|
||||
homeoff.cloud_mode_return(self.ctx, {}, 0)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
|
||||
# -- the first failure wins --------------------------------------------
|
||||
def test_a_hand_back_that_fails_after_the_test_failed_is_logged_not_raised(self):
|
||||
self.write(STOPPED.split("machine:_motion_locked")[0])
|
||||
ev = {"homed": True}
|
||||
with self.assertRaises(Failed) as cm:
|
||||
try:
|
||||
raise Failed("the test's own failure")
|
||||
finally:
|
||||
homeoff.camera_home_return(self.ctx, ev, 0)
|
||||
self.assertEqual(str(cm.exception), "the test's own failure")
|
||||
self.assertIn("cannot be known", ev["return"]["failed"])
|
||||
self.assertTrue(any("the head is not back" in l for l in self.ctx.lines), self.ctx.lines)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
self.assertIsNone(sys.exc_info()[1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,774 +3,16 @@
|
||||
# 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, motion
|
||||
|
||||
|
||||
class EngineLog:
|
||||
"""The engine's log lines, as the tests read them: appended to a scratch
|
||||
forgectrl log (a check reads the lines written after its mark) and to the
|
||||
fake /logs/tail."""
|
||||
|
||||
def log_setup(self):
|
||||
self.logdir = tempfile.mkdtemp(prefix="forgetest-cool-")
|
||||
self.saved_log = motion.FORGECTRL_LOG
|
||||
motion.FORGECTRL_LOG = os.path.join(self.logdir, "forgectrl.log")
|
||||
open(motion.FORGECTRL_LOG, "w").close()
|
||||
|
||||
def log_teardown(self):
|
||||
motion.FORGECTRL_LOG = self.saved_log
|
||||
shutil.rmtree(self.logdir, ignore_errors=True)
|
||||
|
||||
def emit(self, text):
|
||||
self.fc.state.setdefault("logs_tail", {"text": ""})["text"] += text
|
||||
with open(motion.FORGECTRL_LOG, "a", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
class FakeGrbl:
|
||||
"""Answers '?' with an Idle report and every line with ok."""
|
||||
|
||||
def __init__(self):
|
||||
self.sock = socket.socket()
|
||||
self.sock.bind(("127.0.0.1", 0))
|
||||
self.sock.listen(2)
|
||||
os.environ["GRBL_HOST"] = "127.0.0.1"
|
||||
os.environ["GRBL_PORT"] = str(self.sock.getsockname()[1])
|
||||
self.commands = []
|
||||
self.on_command = None
|
||||
self._stop = False
|
||||
threading.Thread(target=self._serve, daemon=True).start()
|
||||
|
||||
def _serve(self):
|
||||
self.sock.settimeout(0.2)
|
||||
while not self._stop:
|
||||
try:
|
||||
c, _ = self.sock.accept()
|
||||
except OSError:
|
||||
continue
|
||||
threading.Thread(target=self._client, args=(c,), daemon=True).start()
|
||||
|
||||
def _client(self, c):
|
||||
c.settimeout(0.1)
|
||||
buf = b""
|
||||
c.sendall(b"\r\nGrbl 1.1f ['$' for help]\r\n")
|
||||
while not self._stop:
|
||||
try:
|
||||
d = c.recv(4096)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
if not d:
|
||||
break
|
||||
for ch in d:
|
||||
if ch == 0x3F: # '?'
|
||||
c.sendall(b"<Idle|MPos:0.000,0.000,0.000|FS:0,0>\r\n")
|
||||
elif ch in (0x18, 0x85):
|
||||
pass
|
||||
else:
|
||||
buf += bytes([ch])
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
line = line.decode().strip()
|
||||
if line:
|
||||
self.commands.append(line)
|
||||
if self.on_command:
|
||||
self.on_command(line)
|
||||
c.sendall(b"ok\r\n")
|
||||
c.close()
|
||||
|
||||
def close(self):
|
||||
self._stop = True
|
||||
self.sock.close()
|
||||
for k in ("GRBL_HOST", "GRBL_PORT"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
class FansQuietTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-cool-")
|
||||
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
|
||||
os.makedirs(self.sysfs + "thermal")
|
||||
os.makedirs(self.sysfs + "cnc")
|
||||
with open(self.sysfs + "cnc/state", "w") as f:
|
||||
f.write("idle")
|
||||
os.environ["GF_SYSFS_ROOT"] = self.sysfs
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.grbl = FakeGrbl()
|
||||
self.saved = (cooling.SAMPLE_S, cooling.IDLE_REF_TIMEOUT_S, cooling.COOLDOWN_TIMEOUT_S,
|
||||
cooling.RESTART_IDLE_TIMEOUT_S, cooling.hw.initd)
|
||||
cooling.SAMPLE_S = 0.1
|
||||
cooling.IDLE_REF_TIMEOUT_S = 3
|
||||
cooling.COOLDOWN_TIMEOUT_S = 4
|
||||
cooling.RESTART_IDLE_TIMEOUT_S = 1
|
||||
# The daemon restart of the last phase, scripted: the init script
|
||||
# is a stub, and the engine that comes up applies `after_start`
|
||||
# (the idle duties by default; a daemon that keeps the busy-start
|
||||
# cooldown airflow applies those instead).
|
||||
self.initd_calls = []
|
||||
self.after_start = (0, 0)
|
||||
|
||||
def initd(service, action, timeout=60):
|
||||
self.initd_calls.append((service, action))
|
||||
if action == "start":
|
||||
self.duty(*self.after_start)
|
||||
return 0, ""
|
||||
cooling.hw.initd = initd
|
||||
self.fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
|
||||
self.duty(0, 0)
|
||||
self.fans(0, 736, 733)
|
||||
|
||||
def tearDown(self):
|
||||
(cooling.SAMPLE_S, cooling.IDLE_REF_TIMEOUT_S, cooling.COOLDOWN_TIMEOUT_S,
|
||||
cooling.RESTART_IDLE_TIMEOUT_S, cooling.hw.initd) = self.saved
|
||||
self.grbl.close()
|
||||
self.fc.stop()
|
||||
os.environ.pop("GF_SYSFS_ROOT", None)
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
# -- the machine -------------------------------------------------------
|
||||
def duty(self, exhaust, intake):
|
||||
for attr, v in (("thermal/exhaust_pwm", exhaust), ("thermal/intake_pwm", intake)):
|
||||
with open(self.sysfs + attr, "w") as f:
|
||||
f.write(str(v))
|
||||
|
||||
def fans(self, exhaust, i1, i2, air=2080):
|
||||
self.fc.state["status"] = dict(self.fc.state["status"],
|
||||
fans={"air_assist": air, "exhaust": exhaust, "intake_1": i1, "intake_2": i2})
|
||||
|
||||
def run_engine(self):
|
||||
"""M8 -> run profile; M9 -> smoke phase, then idle duty, then the
|
||||
tachs coast down (a few samples of spin-down), like forgectrl's
|
||||
engine on the bench."""
|
||||
def on_command(line):
|
||||
if line == "M8":
|
||||
self.duty(65535, 43278)
|
||||
self.fc.state["cool"]["phase"] = "run"
|
||||
self.fans(6753, 3212, 3328, air=10997)
|
||||
elif line == "M9":
|
||||
def cooldown():
|
||||
self.fc.state["cool"]["phase"] = "smoke"
|
||||
time.sleep(0.3)
|
||||
self.fc.state["cool"]["phase"] = "idle"
|
||||
self.duty(0, 0)
|
||||
for ex, i1 in ((5030, 3319), (3100, 2200), (1200, 1300), (0, 740)):
|
||||
self.fans(ex, i1, i1 + 5)
|
||||
time.sleep(0.25)
|
||||
threading.Thread(target=cooldown, daemon=True).start()
|
||||
self.grbl.on_command = on_command
|
||||
|
||||
def run_test(self):
|
||||
run = Run("test", "cooling.fans-quiet-after-motion", "t")
|
||||
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
|
||||
cooling.fans_quiet(ctx)
|
||||
return run
|
||||
|
||||
# -- cases ------------------------------------------------------------------
|
||||
def test_idle_machine_passes_and_logs_every_sample(self):
|
||||
self.run_engine()
|
||||
run = self.run_test()
|
||||
self.assertEqual(run.evidence["before"]["exhaust"], 0)
|
||||
self.assertIsNotNone(run.evidence["settle_s"])
|
||||
self.assertEqual(run.evidence["duty_after"], cooling.IDLE_DUTY)
|
||||
self.assertIn("M8", self.grbl.commands)
|
||||
self.assertIn("M9", self.grbl.commands)
|
||||
cooldown_lines = [ln for ln in run.lines if "cooldown +" in ln]
|
||||
self.assertGreaterEqual(len(cooldown_lines), 2, run.lines)
|
||||
|
||||
def test_a_spin_down_in_progress_is_not_taken_as_the_idle_reference(self):
|
||||
"""The bench case: the previous test's fans are still coasting when
|
||||
this one starts. The reference waits for them to settle, and the
|
||||
run passes instead of waiting for the fans to come back up."""
|
||||
self.run_engine()
|
||||
self.fans(5030, 3319, 3377) # coasting, engine already idle, duty 0
|
||||
|
||||
def coast():
|
||||
for ex, i1 in ((3900, 2600), (2500, 1700), (1100, 1000), (0, 736), (0, 736)):
|
||||
time.sleep(0.12)
|
||||
self.fans(ex, i1, i1 + 5)
|
||||
threading.Thread(target=coast, daemon=True).start()
|
||||
run = self.run_test()
|
||||
self.assertEqual(run.evidence["before"]["exhaust"], 0, run.evidence["before"])
|
||||
self.assertGreater(run.evidence["idle_ref_s"], 0.3)
|
||||
self.assertIsNotNone(run.evidence["settle_s"])
|
||||
self.assertTrue(any("idle ref:" in ln for ln in run.lines))
|
||||
|
||||
def test_the_restart_phase_stops_disables_starts_and_sees_idle_duty(self):
|
||||
self.run_engine()
|
||||
run = self.run_test()
|
||||
self.assertEqual(run.finished, None)
|
||||
self.assertEqual(self.initd_calls, [("forgectrl", "stop"), ("forgectrl", "start")])
|
||||
with open(self.sysfs + "cnc/disable") as f:
|
||||
self.assertEqual(f.read(), "1")
|
||||
self.assertEqual(run.evidence["duty_after_restart"], cooling.IDLE_DUTY)
|
||||
self.assertIsNotNone(run.evidence["restart_idle_s"])
|
||||
|
||||
def test_a_daemon_that_keeps_the_busy_start_airflow_fails(self):
|
||||
# The bench case: after a takeover's restart the exhaust ran at
|
||||
# cooldown duty on an idle machine for good.
|
||||
self.run_engine()
|
||||
self.after_start = (32768, 21639)
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("kept the busy-start airflow", str(cm.exception))
|
||||
self.assertIn("32768", str(cm.exception))
|
||||
|
||||
def test_fans_left_running_fail_with_the_reference_in_the_message(self):
|
||||
def on_command(line):
|
||||
if line == "M8":
|
||||
self.duty(65535, 43278)
|
||||
self.fc.state["cool"]["phase"] = "run"
|
||||
self.fans(6753, 3212, 3328)
|
||||
# M9 ignored: the run profile stays on
|
||||
self.grbl.on_command = on_command
|
||||
run = Run("test", "cooling.fans-quiet-after-motion", "t")
|
||||
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
|
||||
with self.assertRaises(Failed) as cm:
|
||||
cooling.fans_quiet(ctx)
|
||||
self.assertIn("did not return to the idle profile", str(cm.exception))
|
||||
self.assertIn("idle reference", str(cm.exception))
|
||||
self.assertIn("65535", str(cm.exception))
|
||||
|
||||
def test_a_reference_that_never_settles_fails_early_and_says_so(self):
|
||||
def churn():
|
||||
seq = (1000, 4000, 2500, 700, 3300, 1600, 4200) # aperiodic against the sampler
|
||||
i = 0
|
||||
while not self.grbl._stop:
|
||||
v = seq[i % len(seq)]
|
||||
i += 1
|
||||
self.fans(v, v, v)
|
||||
time.sleep(0.03)
|
||||
threading.Thread(target=churn, daemon=True).start()
|
||||
run = Run("test", "cooling.fans-quiet-after-motion", "t")
|
||||
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
|
||||
with self.assertRaises(Failed) as cm:
|
||||
cooling.fans_quiet(ctx)
|
||||
self.assertIn("never settled to an idle reference", str(cm.exception))
|
||||
self.assertEqual(self.grbl.commands, []) # nothing was jogged
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class GateOffTests(EngineLog, unittest.TestCase):
|
||||
"""cooling.gate-off against a scripted engine: the fake forgectrl
|
||||
re-reads the ceiling at every M8 (as the engine reloads its tunables
|
||||
at run start), trips OVERTEMP when the coolant is over it, skips the
|
||||
gate and reports gates_off when the ceiling sits at its top, and
|
||||
writes the run-start line the test looks for."""
|
||||
|
||||
TOP, BOTTOM = 60.0, 5.0
|
||||
|
||||
def setUp(self):
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.log_setup()
|
||||
self.grbl = FakeGrbl()
|
||||
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S)
|
||||
cooling.VERDICT_WAIT_S = 3
|
||||
cooling.SESSION_END_WAIT_S = 3
|
||||
self.fc.state["status"] = dict(self.fc.state["status"],
|
||||
coolant={"down_c": 22.4, "up_c": 22.3, "pump": True, "tec": False},
|
||||
temps={"chassis_c": 29.0, "supply_raw": 589, "soc_c": 42.8,
|
||||
"soc_throttle": 0},
|
||||
gates_off=[])
|
||||
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
|
||||
"gates_off": []}
|
||||
self.fc.state["settings"].update({"cool_temp_max": "", "cool_temp_resume": ""})
|
||||
self.log_line = True # the engine writes its run-start line
|
||||
self.temps_line = True # the engine writes its run-end temperature line
|
||||
self.report_off = True # the engine reports the off gate
|
||||
self.trips = True # the engine trips a low ceiling
|
||||
self.sessions = 0 # run sessions the engine saw (M8 with the phase not run)
|
||||
self._describe()
|
||||
self.grbl.on_command = self._engine
|
||||
self.fc.on_post = self._on_post
|
||||
|
||||
def tearDown(self):
|
||||
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S = self.saved
|
||||
self.grbl.close()
|
||||
self.fc.stop()
|
||||
self.log_teardown()
|
||||
|
||||
# -- the scripted machine --------------------------------------------------
|
||||
def ceiling(self):
|
||||
v = self.fc.state["settings"].get("cool_temp_max") or ""
|
||||
return float(v) if v else 33.0
|
||||
|
||||
def _describe(self):
|
||||
"""/settings carries the gates table the way forgectrl's gates.c
|
||||
publishes it, classified from the stored value."""
|
||||
v = self.ceiling()
|
||||
state = "off" if v >= self.TOP else ("ok" if 25 <= v <= 38 else "warn")
|
||||
self.fc.state["settings"]["gates"] = {
|
||||
"cool_temp_max": {"gate": "coolant_max", "def": 33, "lo": self.BOTTOM, "hi": self.TOP,
|
||||
"band": [25, 38], "off": "high", "value": v, "state": state}}
|
||||
|
||||
def _on_post(self, path, form):
|
||||
"""The real settings reply re-classifies the gates from the new values."""
|
||||
if path != "/settings":
|
||||
return None
|
||||
self.fc.state["settings"].update(form)
|
||||
self._describe()
|
||||
return (200, self.fc.state["settings"])
|
||||
|
||||
def _engine(self, line):
|
||||
"""M8 opens a run session: the engine re-reads the ceiling and
|
||||
ticks the gate. M9 ends it a report period later (the phase
|
||||
leaves run); a hold taken against the old ceiling stands until
|
||||
the next session re-reads."""
|
||||
self._describe()
|
||||
cool = self.fc.state["cool"]
|
||||
if line == "M9":
|
||||
def end():
|
||||
time.sleep(0.3)
|
||||
cool["phase"] = "smoke"
|
||||
if self.temps_line:
|
||||
self.emit(
|
||||
"Aug 22 12:00:30 forgectrl: cool: temps this job: chassis 29.0..29.4 C, "
|
||||
"soc 42.8..47.1 C, supply raw 587..592\n")
|
||||
time.sleep(0.2)
|
||||
cool["phase"] = "idle"
|
||||
threading.Thread(target=end, daemon=True).start()
|
||||
return
|
||||
if line != "M8":
|
||||
return
|
||||
self.sessions += 1
|
||||
cool["phase"] = "run"
|
||||
v = self.ceiling()
|
||||
off = v >= self.TOP
|
||||
if off and self.log_line:
|
||||
self.emit(
|
||||
"Aug 21 12:00:00 forgectrl: cool: gate coolant_max OFF: cool_temp_max = 60 "
|
||||
"(the high end of 5 to 60; recommended 25 to 38, default 33)\n")
|
||||
gates_off = ["coolant_max"] if off and self.report_off else []
|
||||
if not off and self.trips and 22.3 > v:
|
||||
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, gates_off=gates_off)
|
||||
else:
|
||||
cool.update(verdict="OK", fire_ok=True, hold=False, gates_off=gates_off)
|
||||
self.fc.state["status"]["gates_off"] = gates_off
|
||||
|
||||
def run_test(self):
|
||||
run = Run("test", "cooling.gate-off", "t")
|
||||
ctx = Context(run, None, helpers.make_test("cooling.gate-off", []))
|
||||
cooling.gate_off(ctx)
|
||||
return run
|
||||
|
||||
def settings_posts(self):
|
||||
return [f for p, f in self.fc.posts if p == "/settings"]
|
||||
|
||||
# -- cases ------------------------------------------------------------------
|
||||
def test_trip_then_off_then_restored_passes(self):
|
||||
run = self.run_test()
|
||||
self.assertEqual(run.evidence["trip"]["verdict"], "OVERTEMP")
|
||||
self.assertEqual(run.evidence["off"]["gates_off"], ["coolant_max"])
|
||||
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
|
||||
posts = self.settings_posts()
|
||||
self.assertEqual(posts[0], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0"})
|
||||
self.assertEqual(posts[1], {"cool_temp_max": "60.0", "cool_temp_resume": ""})
|
||||
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
|
||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
||||
self.assertEqual(self.grbl.commands.count("M8"), 3)
|
||||
self.assertEqual(self.grbl.commands.count("M9"), 3)
|
||||
|
||||
def test_an_engine_that_does_not_trip_fails_and_restores(self):
|
||||
self.trips = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("did not trip", str(cm.exception))
|
||||
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
|
||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
||||
# The restore cycles a run session so the engine re-reads the
|
||||
# restored values; the bench is not left holding on the test's.
|
||||
self.assertEqual(self.grbl.commands.count("M8"), 2)
|
||||
self.assertEqual(self.grbl.commands.count("M9"), 2)
|
||||
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
||||
|
||||
def test_every_session_waits_for_the_previous_one_to_end(self):
|
||||
"""Each M8 must find the engine out of phase run, or the engine
|
||||
never re-reads: the bench failure behind this case sent M9 and
|
||||
the next M8 300 ms apart and the 1 Hz report pipeline swallowed
|
||||
the session end."""
|
||||
seen = []
|
||||
inner = self._engine
|
||||
|
||||
def engine(line):
|
||||
if line == "M8":
|
||||
seen.append(self.fc.state["cool"]["phase"])
|
||||
inner(line)
|
||||
self.grbl.on_command = engine
|
||||
self.run_test()
|
||||
self.assertEqual(seen, ["idle", "idle", "idle"])
|
||||
self.assertEqual(self.sessions, 3)
|
||||
|
||||
def test_an_engine_that_hides_the_off_gate_fails(self):
|
||||
self.report_off = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("gates_off", str(cm.exception))
|
||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
||||
|
||||
def test_a_missing_run_start_log_line_fails(self):
|
||||
self.log_line = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("run-start log line", str(cm.exception))
|
||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
||||
|
||||
def test_an_earlier_runs_line_is_not_this_runs(self):
|
||||
# The off gate's line from a run before this one is in the log, and
|
||||
# the engine writes none this time: the line judged is one written
|
||||
# after the test's own mark, so the old one does not pass for it.
|
||||
self.emit("Aug 21 11:00:00 forgectrl: cool: gate coolant_max OFF: cool_temp_max = 60 "
|
||||
"(the high end of 5 to 60; recommended 25 to 38, default 33)\n")
|
||||
self.log_line = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("run-start log line", str(cm.exception))
|
||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
||||
|
||||
def test_a_missing_run_end_temperature_line_fails(self):
|
||||
self.temps_line = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("board-temperature line", str(cm.exception))
|
||||
|
||||
def test_a_custom_ceiling_is_restored_verbatim(self):
|
||||
self.fc.state["settings"].update({"cool_temp_max": "30", "cool_temp_resume": "28"})
|
||||
self._describe()
|
||||
run = self.run_test()
|
||||
self.assertEqual(run.evidence["orig"], {"cool_temp_max": "30", "cool_temp_resume": "28"})
|
||||
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "30", "cool_temp_resume": "28"})
|
||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "30")
|
||||
|
||||
|
||||
class CriticalTierTests(EngineLog, unittest.TestCase):
|
||||
"""cooling.critical-tier against a scripted engine: two tiers on the
|
||||
coolant, the fail tier winning over the pause tier in a run session
|
||||
and ending with it, the critical line off at its top, and the settings
|
||||
cross-check refusing a critical line at or below the ceiling."""
|
||||
|
||||
UP = 22.3
|
||||
ROWS = { # key: (gate, default, lo, hi, band_lo, band_hi, off)
|
||||
"cool_temp_max": ("coolant_max", 33.0, 5.0, 60.0, 25.0, 38.0, "high"),
|
||||
"cool_temp_resume": (None, 31.0, 5.0, 59.0, 20.0, 36.0, "none"),
|
||||
"cool_temp_critical_c": ("coolant_critical", 38.0, 6.0, 70.0, 36.0, 45.0, "high"),
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.log_setup()
|
||||
self.grbl = FakeGrbl()
|
||||
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S)
|
||||
cooling.VERDICT_WAIT_S = 3
|
||||
cooling.SESSION_END_WAIT_S = 3
|
||||
self.fc.state["status"] = dict(self.fc.state["status"],
|
||||
coolant={"down_c": 22.4, "up_c": self.UP, "pump": True, "tec": False},
|
||||
gates_off=[])
|
||||
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
|
||||
"gates_off": []}
|
||||
for k in self.ROWS:
|
||||
self.fc.state["settings"].setdefault(k, "")
|
||||
self.fc.state["logs_tail"] = {"text": ""}
|
||||
self.faults = True # the engine has the critical tier
|
||||
self.ends_with_session = True
|
||||
self.cross_checks = True # the settings API refuses crit <= max
|
||||
self._describe()
|
||||
self.grbl.on_command = self._engine
|
||||
self.fc.on_post = self._on_post
|
||||
|
||||
def tearDown(self):
|
||||
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S = self.saved
|
||||
self.grbl.close()
|
||||
self.fc.stop()
|
||||
self.log_teardown()
|
||||
|
||||
def setting(self, key):
|
||||
v = self.fc.state["settings"].get(key) or ""
|
||||
return float(v) if v else self.ROWS[key][1]
|
||||
|
||||
def _describe(self):
|
||||
gates = {}
|
||||
for k, (gate, d, lo, hi, blo, bhi, off) in self.ROWS.items():
|
||||
v = self.setting(k)
|
||||
state = "off" if (off == "high" and v >= hi) else ("ok" if blo <= v <= bhi else "warn")
|
||||
gates[k] = {"gate": gate, "def": d, "lo": lo, "hi": hi, "band": [blo, bhi], "off": off,
|
||||
"value": v, "state": state}
|
||||
self.fc.state["settings"]["gates"] = gates
|
||||
|
||||
def _on_post(self, path, form):
|
||||
if path != "/settings":
|
||||
return None
|
||||
if self.cross_checks:
|
||||
merged = dict(self.fc.state["settings"])
|
||||
merged.update(form)
|
||||
tmax = float(merged.get("cool_temp_max") or 33.0)
|
||||
tcrit = float(merged.get("cool_temp_critical_c") or 38.0)
|
||||
ceiling_off = tmax >= self.ROWS["cool_temp_max"][3]
|
||||
if not ceiling_off and tcrit <= tmax:
|
||||
return (400, {"error": "cool_temp_critical_c must be above cool_temp_max"})
|
||||
self.fc.state["settings"].update(form)
|
||||
self._describe()
|
||||
return (200, self.fc.state["settings"])
|
||||
|
||||
def _verdict_idle(self):
|
||||
"""Outside a run only the ceiling's pause tier stands."""
|
||||
return "OVERTEMP" if self.UP > self.setting("cool_temp_max") else "OK"
|
||||
|
||||
def _engine(self, line):
|
||||
self._describe()
|
||||
cool = self.fc.state["cool"]
|
||||
if line == "M9":
|
||||
def end():
|
||||
time.sleep(0.3)
|
||||
cool["phase"] = "idle"
|
||||
if self.ends_with_session or cool["verdict"] != "CRITICAL":
|
||||
v = self._verdict_idle()
|
||||
cool.update(verdict=v, fire_ok=v == "OK", hold=v != "OK", reason="")
|
||||
threading.Thread(target=end, daemon=True).start()
|
||||
return
|
||||
if line != "M8":
|
||||
return
|
||||
cool["phase"] = "run"
|
||||
tmax, tcrit = self.setting("cool_temp_max"), self.setting("cool_temp_critical_c")
|
||||
crit_off = tcrit >= self.ROWS["cool_temp_critical_c"][3]
|
||||
off = ["coolant_critical"] if crit_off else []
|
||||
if crit_off:
|
||||
self.emit(
|
||||
"Aug 22 12:00:00 forgectrl: cool: gate coolant_critical OFF: cool_temp_critical_c = 70 "
|
||||
"(the high end of 6 to 70; recommended 36 to 45, default 38)\n")
|
||||
if self.faults and not crit_off and self.UP >= tcrit:
|
||||
cool.update(verdict="CRITICAL", fire_ok=False, hold=True, resume_ok=False,
|
||||
reason="CRITICAL: coolant %.1f C at or over the %.0f C critical line - hold, "
|
||||
"no resume this job" % (self.UP, tcrit), gates_off=off)
|
||||
elif self.UP > tmax:
|
||||
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, resume_ok=False,
|
||||
reason="coolant over the ceiling", gates_off=off)
|
||||
else:
|
||||
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off)
|
||||
self.fc.state["status"]["gates_off"] = off
|
||||
|
||||
def run_test(self):
|
||||
run = Run("test", "cooling.critical-tier", "t")
|
||||
ctx = Context(run, None, helpers.make_test("cooling.critical-tier", []))
|
||||
cooling.critical_tier(ctx)
|
||||
return run
|
||||
|
||||
def settings_posts(self):
|
||||
return [f for p, f in self.fc.posts if p == "/settings"]
|
||||
|
||||
# -- cases ------------------------------------------------------------------
|
||||
def test_fault_then_off_then_restored_passes(self):
|
||||
run = self.run_test()
|
||||
self.assertEqual(run.evidence["cross_check"]["status"], 400)
|
||||
self.assertEqual(run.evidence["critical"]["verdict"], "CRITICAL")
|
||||
self.assertEqual(run.evidence["critical_after"]["verdict"], "OVERTEMP")
|
||||
self.assertEqual(run.evidence["critical_off"]["gates_off"], ["coolant_critical"])
|
||||
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
|
||||
self.assertEqual(run.evidence["ceiling_off_accepted"], 200)
|
||||
posts = self.settings_posts()
|
||||
# refused cross-check, the ceiling at its off end (accepted), its undo, then the low leg
|
||||
self.assertEqual(posts[1], {"cool_temp_max": "60.0"})
|
||||
self.assertEqual(posts[3], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0",
|
||||
"cool_temp_critical_c": "7.0"})
|
||||
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": "", "cool_temp_critical_c": ""})
|
||||
self.assertEqual(self.grbl.commands.count("M8"), 3)
|
||||
self.assertEqual(self.grbl.commands.count("M9"), 3)
|
||||
|
||||
def test_an_engine_without_the_tier_fails_and_restores(self):
|
||||
self.faults = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("did not fault", str(cm.exception))
|
||||
self.assertEqual(self.settings_posts()[-1],
|
||||
{"cool_temp_max": "", "cool_temp_resume": "", "cool_temp_critical_c": ""})
|
||||
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
||||
|
||||
def test_a_fault_that_outlives_its_session_fails(self):
|
||||
self.ends_with_session = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("after the faulted session", str(cm.exception))
|
||||
|
||||
def test_a_missing_cross_check_fails_before_any_session(self):
|
||||
self.cross_checks = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("was accepted", str(cm.exception))
|
||||
self.assertEqual(self.grbl.commands.count("M8"), 0)
|
||||
|
||||
|
||||
class FanGateTests(unittest.TestCase):
|
||||
"""cooling.fan-gate-trips against a scripted engine: the fake reads the
|
||||
floors at every M8 (the engine reloads at run start), holds every fan
|
||||
in grace for the configured seconds, then judges the bench readings
|
||||
(exhaust 6753, intakes 3212/3328, air assist 10997 rpm, purge 628)
|
||||
against the floors: a floor a reading cannot meet trips AIRFLOW three
|
||||
ticks after the grace, a floor of zero reads off."""
|
||||
|
||||
READINGS = {"exhaust": 6753, "intake_1": 3212, "intake_2": 3328, "air_assist": 10997, "purge": 628}
|
||||
FLOORS = {"exhaust": ("cool_tach_exhaust_min_rpm", 3700.0, 20000.0),
|
||||
"intake_1": ("cool_tach_intake_min_rpm", 1800.0, 20000.0),
|
||||
"intake_2": ("cool_tach_intake_min_rpm", 1800.0, 20000.0),
|
||||
"air_assist": ("cool_tach_air_assist_min_rpm", 6000.0, 30000.0),
|
||||
"purge": ("cool_purge_min_current", 300.0, 1023.0)}
|
||||
GATE_OF = {"exhaust": "exhaust", "intake_1": "intake", "intake_2": "intake",
|
||||
"air_assist": "air_assist", "purge": "purge"}
|
||||
|
||||
def setUp(self):
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.grbl = FakeGrbl()
|
||||
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling.FAN_TRIP_WAIT_S)
|
||||
cooling.VERDICT_WAIT_S = 4
|
||||
cooling.SESSION_END_WAIT_S = 3
|
||||
cooling.FAN_TRIP_WAIT_S = 4
|
||||
self.fc.state["status"] = dict(self.fc.state["status"], gates_off=[])
|
||||
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
|
||||
"gates_off": [], "fan_gates": {}}
|
||||
for key, _d, _h in self.FLOORS.values():
|
||||
self.fc.state["settings"].setdefault(key, "")
|
||||
self.fc.state["settings"].setdefault("cool_fan_grace_s", "")
|
||||
self.trips = True # the engine trips an unmeetable floor
|
||||
self.reports_off = True # the engine reports a zero floor as off
|
||||
self.grace_scale = 0.1 # seconds of fake grace per configured second
|
||||
self._describe()
|
||||
self.grbl.on_command = self._engine
|
||||
self.fc.on_post = self._on_post
|
||||
|
||||
def tearDown(self):
|
||||
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling.FAN_TRIP_WAIT_S = self.saved
|
||||
self.grbl.close()
|
||||
self.fc.stop()
|
||||
|
||||
def setting(self, key, default):
|
||||
v = self.fc.state["settings"].get(key) or ""
|
||||
return float(v) if v else default
|
||||
|
||||
def _describe(self):
|
||||
gates = {}
|
||||
for fan, (key, default, hi) in self.FLOORS.items():
|
||||
v = self.setting(key, default)
|
||||
gates[key] = {"gate": self.GATE_OF[fan], "def": default, "lo": 0.0, "hi": hi,
|
||||
"band": [default * 0.7, default * 1.4], "off": "low", "value": v,
|
||||
"state": "off" if v <= 0 else "ok"}
|
||||
g = self.setting("cool_fan_grace_s", 15.0)
|
||||
gates["cool_fan_grace_s"] = {"gate": None, "def": 15.0, "lo": 0.0, "hi": 120.0, "band": [5, 30],
|
||||
"off": "none", "value": g, "state": "ok"}
|
||||
self.fc.state["settings"]["gates"] = gates
|
||||
|
||||
def _on_post(self, path, form):
|
||||
if path != "/settings":
|
||||
return None
|
||||
self.fc.state["settings"].update(form)
|
||||
self._describe()
|
||||
return (200, self.fc.state["settings"])
|
||||
|
||||
def _engine(self, line):
|
||||
self._describe()
|
||||
cool = self.fc.state["cool"]
|
||||
if line == "M9":
|
||||
def end():
|
||||
time.sleep(0.3)
|
||||
cool["phase"] = "idle"
|
||||
# the fan fault is the session's: it ends with it
|
||||
cool.update(verdict="OK", fire_ok=True, hold=False, reason="")
|
||||
for g in cool.get("fan_gates", {}).values():
|
||||
g["state"] = "idle"
|
||||
threading.Thread(target=end, daemon=True).start()
|
||||
return
|
||||
if line != "M8":
|
||||
return
|
||||
cool["phase"] = "run"
|
||||
floors = {fan: self.setting(key, default) for fan, (key, default, _h) in self.FLOORS.items()}
|
||||
off = sorted({self.GATE_OF[f] for f, v in floors.items() if v <= 0}) if self.reports_off else []
|
||||
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off,
|
||||
fan_gates={f: {"reading": self.READINGS[f], "floor": floors[f],
|
||||
"state": "off" if floors[f] <= 0 else "grace"} for f in floors})
|
||||
self.fc.state["status"]["gates_off"] = off
|
||||
grace = self.setting("cool_fan_grace_s", 15.0) * self.grace_scale
|
||||
|
||||
def judge():
|
||||
time.sleep(grace)
|
||||
if cool["phase"] != "run":
|
||||
return
|
||||
for f in floors:
|
||||
if floors[f] > 0:
|
||||
cool["fan_gates"][f]["state"] = "ok" if self.READINGS[f] >= floors[f] else "under"
|
||||
time.sleep(0.3)
|
||||
if cool["phase"] != "run" or not self.trips:
|
||||
return
|
||||
for f in floors:
|
||||
if 0 < floors[f] > self.READINGS[f]:
|
||||
cool["fan_gates"][f]["state"] = "TRIPPED"
|
||||
cool.update(verdict="AIRFLOW", fire_ok=False, hold=True, resume_ok=False,
|
||||
reason="AIRFLOW: %s %d under the %d floor for 3 s - hold, no resume this job"
|
||||
% (f, self.READINGS[f], floors[f]))
|
||||
break
|
||||
threading.Thread(target=judge, daemon=True).start()
|
||||
|
||||
def run_test(self):
|
||||
run = Run("test", "cooling.fan-gate-trips", "t")
|
||||
ctx = Context(run, None, helpers.make_test("cooling.fan-gate-trips", []))
|
||||
cooling.fan_gate_trips(ctx)
|
||||
return run
|
||||
|
||||
def settings_posts(self):
|
||||
return [f for p, f in self.fc.posts if p == "/settings"]
|
||||
|
||||
def test_trip_purge_off_and_restore_pass(self):
|
||||
run = self.run_test()
|
||||
ev = run.evidence
|
||||
self.assertEqual(ev["exhaust_trip"]["cool"]["verdict"], "AIRFLOW")
|
||||
self.assertEqual(ev["exhaust_trip"]["gate"]["state"], "TRIPPED")
|
||||
self.assertEqual(ev["purge_trip"]["gate"]["state"], "TRIPPED")
|
||||
self.assertEqual(ev["exhaust_off"]["cool"]["gates_off"], ["exhaust"])
|
||||
self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values()))
|
||||
posts = self.settings_posts()
|
||||
self.assertEqual(posts[0], {"cool_tach_exhaust_min_rpm": "20000.0", "cool_fan_grace_s": "8"})
|
||||
self.assertEqual(posts[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
|
||||
"cool_fan_grace_s": ""})
|
||||
self.assertEqual(self.grbl.commands.count("M8"), 4)
|
||||
self.assertEqual(self.grbl.commands.count("M9"), 4)
|
||||
|
||||
def test_an_engine_that_does_not_trip_fails_and_restores(self):
|
||||
self.trips = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("did not trip", str(cm.exception))
|
||||
self.assertEqual(self.settings_posts()[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
|
||||
"cool_fan_grace_s": ""})
|
||||
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
||||
|
||||
def test_an_engine_that_hides_an_off_floor_fails(self):
|
||||
self.reports_off = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("lacks exhaust", str(cm.exception))
|
||||
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
|
||||
from forgetest.suite import cooling
|
||||
|
||||
|
||||
class FlowVerdictLogTests(unittest.TestCase):
|
||||
@@ -817,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()
|
||||
|
||||
+93
-230
@@ -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-")
|
||||
@@ -231,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:
|
||||
@@ -279,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")
|
||||
@@ -291,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)
|
||||
|
||||
@@ -314,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."""
|
||||
@@ -412,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)
|
||||
@@ -477,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
|
||||
@@ -545,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
|
||||
@@ -595,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."""
|
||||
@@ -671,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")
|
||||
@@ -716,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()
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The dark judge of laser.verdict-cut over synthetic trails. The judge is
|
||||
a function inside the test's own body, so a change to it moves that test's
|
||||
fingerprint and no other laser test's; it is reached here through the test
|
||||
function's code object. A trail row is a status poll; its `fast` entry is
|
||||
the burst of cnc/laser_on reads taken after that poll: [start, end, reads,
|
||||
reads that saw the laser on, reads that failed]."""
|
||||
import types
|
||||
import unittest
|
||||
|
||||
from forgetest.suite import laser
|
||||
|
||||
DRAIN_S = 0.3
|
||||
|
||||
|
||||
def dark_span():
|
||||
code = next(c for c in laser.verdict_cut.__code__.co_consts
|
||||
if isinstance(c, types.CodeType) and c.co_name == "dark_span")
|
||||
assert code.co_freevars == (), "the judge must not close over the test's variables"
|
||||
return types.FunctionType(code, vars(laser))
|
||||
|
||||
|
||||
def row(t, gstate, on=None, n=350, bad=0):
|
||||
r = {"t": t, "gstate": gstate}
|
||||
if on is not None:
|
||||
r["fast"] = [round(t + 0.01, 3), round(t + 0.11, 3), n, on, bad]
|
||||
return r
|
||||
|
||||
|
||||
class DarkSpanTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.judge = dark_span()
|
||||
|
||||
def hold(self, lit_at=(), tail_lit=0):
|
||||
"""Run, then a hold from 2.0 s (Hold:0) polled every 0.14 s with a
|
||||
burst after each poll, then the resume at 3.4 s. lit_at: the
|
||||
poll times whose burst saw the laser on; tail_lit: the burst
|
||||
after the last Hold poll, which the resume ends."""
|
||||
rows = [row(1.72, "Run"), row(1.86, "Run")]
|
||||
t = 2.0
|
||||
while t < 3.3:
|
||||
rows.append(row(round(t, 2), "Hold:0", on=5 if round(t, 2) in lit_at else 0))
|
||||
t += 0.14
|
||||
rows[-1]["fast"][3] = tail_lit
|
||||
rows.append(row(3.4, "Run"))
|
||||
return rows
|
||||
|
||||
def test_a_dark_hold_is_judged_dark(self):
|
||||
d = self.judge(self.hold(), DRAIN_S)
|
||||
reads, on, bad, bursts, first, last = d
|
||||
self.assertEqual((on, bad), (0, 0))
|
||||
self.assertGreater(bursts, 0)
|
||||
self.assertEqual(reads, bursts * 350)
|
||||
self.assertGreaterEqual(first, 2.0 + DRAIN_S)
|
||||
self.assertLessEqual(last, 3.4)
|
||||
|
||||
def test_the_lit_deceleration_inside_the_drain_is_not_judged(self):
|
||||
# the pause tier's first deceleration plays out of the kernel's
|
||||
# queue after Hold:0: lit bursts inside the drain are expected
|
||||
d = self.judge(self.hold(lit_at=(2.0, 2.14)), DRAIN_S)
|
||||
self.assertEqual(d[1], 0)
|
||||
|
||||
def test_emission_after_the_drain_is_caught(self):
|
||||
# the negative control: a lit read inside the judged span is seen
|
||||
d = self.judge(self.hold(lit_at=(2.56,)), DRAIN_S)
|
||||
self.assertEqual(d[1], 5)
|
||||
|
||||
def test_a_burst_the_resume_ends_is_not_judged(self):
|
||||
# the poll after it reads Run: the cut may have relit inside it
|
||||
d = self.judge(self.hold(tail_lit=40), DRAIN_S)
|
||||
self.assertEqual(d[1], 0)
|
||||
|
||||
def test_failed_reads_are_counted(self):
|
||||
rows = self.hold()
|
||||
rows[5]["fast"][4] = 3
|
||||
self.assertEqual(self.judge(rows, DRAIN_S)[2], 3)
|
||||
|
||||
def test_the_drain_counts_from_the_first_hold_complete(self):
|
||||
# Hold:1 (decelerating) rows do not start the span: a lit burst
|
||||
# after a Hold:1 poll but inside the drain of the first Hold:0 is
|
||||
# not judged
|
||||
rows = [row(1.8, "Run"), row(1.94, "Hold:1", on=30), row(2.08, "Hold:0", on=12),
|
||||
row(2.22, "Hold:0", on=0), row(2.36, "Hold:0", on=0), row(2.5, "Hold:0", on=0),
|
||||
row(2.64, "Hold:0", on=0), row(2.78, "Run")]
|
||||
reads, on, bad, bursts, first, last = self.judge(rows, DRAIN_S)
|
||||
self.assertEqual(on, 0)
|
||||
self.assertGreaterEqual(first, 2.08 + DRAIN_S)
|
||||
# 2.51 only: 2.37 is inside the drain, 2.65 is ended by the resume
|
||||
self.assertEqual(bursts, 1)
|
||||
|
||||
def test_no_hold_complete_cannot_be_judged(self):
|
||||
self.assertIsNone(self.judge([row(1.0, "Run"), row(1.2, "Hold:1", on=0), row(1.4, "Run")], DRAIN_S))
|
||||
|
||||
def test_a_hold_too_short_for_the_drain_leaves_nothing_to_judge(self):
|
||||
# the test then fails on too few reads, never passes on none
|
||||
rows = [row(1.8, "Run"), row(2.0, "Hold:0", on=0), row(2.14, "Hold:0", on=0), row(2.28, "Run")]
|
||||
self.assertEqual(self.judge(rows, DRAIN_S)[:4], [0, 0, 0, 0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,6 +4,7 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
@@ -122,13 +123,14 @@ class CoverageReportTests(unittest.TestCase):
|
||||
|
||||
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.
|
||||
# **/*.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]), {"forgectrl": ["docs/advisories/privacy.md"]})
|
||||
self.assertEqual(m.coverage_report(man, [rest], host=()), {"forgectrl": ["docs/advisories/privacy.md"]})
|
||||
doc = helpers.make_test("a.two", [("forgectrl", "docs/advisories/privacy.md")])
|
||||
self.assertEqual(m.coverage_report(man, [rest, doc]), {})
|
||||
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))
|
||||
@@ -169,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):
|
||||
@@ -204,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
|
||||
@@ -309,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, ms=None):
|
||||
self.done.append((channel, state) if ms is None else (channel, state, ms))
|
||||
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,37 +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.
|
||||
|
||||
Three 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 first state's cost. Hashing the implementations parsed a test's
|
||||
module and every module it imports once per test, and each poll the
|
||||
page timed out on started the same work again beside the first. Each
|
||||
module is now parsed once, and a poll waits for the work in progress.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -57,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"])
|
||||
@@ -69,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"})
|
||||
@@ -110,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)
|
||||
@@ -128,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
|
||||
@@ -142,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")
|
||||
@@ -154,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."""
|
||||
@@ -169,150 +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 ImplementationCostTests(unittest.TestCase):
|
||||
"""The first state after a start hashes every test's implementation.
|
||||
It parses each suite module once, and a poll that arrives while it
|
||||
runs waits for it: on the board a parse per test took minutes, and
|
||||
the page's timed-out polls each started the work again beside it."""
|
||||
|
||||
def test_each_suite_module_is_parsed_once(self):
|
||||
import ast
|
||||
import inspect
|
||||
reg = catalog.load_suite()
|
||||
paths = sorted({inspect.getsourcefile(t.fn) for t in catalog.all_tests(reg)})
|
||||
for p in os.listdir(catalog.suite_dir()):
|
||||
catalog.forget(os.path.join(catalog.suite_dir(), p))
|
||||
for p in paths:
|
||||
catalog.forget(p)
|
||||
real_parse, parsed = ast.parse, []
|
||||
|
||||
def counting_parse(source, *a, **kw):
|
||||
parsed.append(source[:40])
|
||||
return real_parse(source, *a, **kw)
|
||||
|
||||
ast.parse = counting_parse
|
||||
try:
|
||||
for t in catalog.all_tests(reg):
|
||||
catalog.implementation_sha(inspect.getsourcefile(t.fn), t.id)
|
||||
finally:
|
||||
ast.parse = real_parse
|
||||
modules = [p for p in os.listdir(catalog.suite_dir()) if p.endswith(".py")]
|
||||
self.assertLessEqual(len(parsed), len(modules),
|
||||
"%d parses for %d suite modules" % (len(parsed), len(modules)))
|
||||
|
||||
def test_a_concurrent_fill_waits_instead_of_repeating(self):
|
||||
import threading
|
||||
import time
|
||||
t = helpers.make_test("fake.impl", [("forgectrl", "src/ui.c")], fn=t_noop)
|
||||
real, calls = catalog.implementation_sha, []
|
||||
|
||||
def slow(path, test_id):
|
||||
calls.append(test_id)
|
||||
time.sleep(0.3)
|
||||
return real(path, test_id)
|
||||
|
||||
catalog.implementation_sha = slow
|
||||
try:
|
||||
got = []
|
||||
threads = [threading.Thread(target=lambda: got.append(t.source_sha)) for _ in range(3)]
|
||||
for th in threads:
|
||||
th.start()
|
||||
for th in threads:
|
||||
th.join(10)
|
||||
finally:
|
||||
catalog.implementation_sha = real
|
||||
self.assertEqual(calls, ["fake.impl"], "the implementation hash was computed %d times" % len(calls))
|
||||
self.assertEqual(len(set(got)), 1)
|
||||
self.assertEqual(len(got), 3)
|
||||
|
||||
|
||||
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, Failed, 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,190 +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
|
||||
self.list_lies = None # a test sets this to break the list one way
|
||||
|
||||
def on_get(path, query):
|
||||
if path != "/extensions":
|
||||
return None
|
||||
s = self.fake.state["settings"]
|
||||
on = s.get("cloud_enabled") == "1"
|
||||
roles = [{"role": role, "provider": prov, "kind": kind, "select_key": key, "fallback": back,
|
||||
"active": on and s.get(key) == prov}
|
||||
for role, prov, kind, key, back in (("homing", "gfcloud", "runner-fd", "homing_mode", "none"),
|
||||
("controller", "cloud", "supervised", "controller_mode",
|
||||
"grbl"))]
|
||||
if self.list_lies == "stale-active":
|
||||
roles[0]["active"] = True
|
||||
if self.list_lies == "always-on":
|
||||
on = True
|
||||
return 200, {"extensions": [{"id": "cloud", "name": "Glowforge cloud mode", "builtin": True,
|
||||
"enabled": on, "enable_key": "cloud_enabled", "setup_step": "cloud",
|
||||
"tab": "gfcloud", "roles": roles}]}
|
||||
self.fake.on_get = on_get
|
||||
|
||||
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_a_list_that_disagrees_with_the_settings_fails(self):
|
||||
for lie in ("always-on", "stale-active"):
|
||||
self.list_lies = lie
|
||||
self.fake.state["settings"].update({"cloud_enabled": "1", "homing_mode": "gfcloud"})
|
||||
with self.assertRaises(Failed, msg=lie):
|
||||
self.run_test()
|
||||
|
||||
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()
|
||||
|
||||
@@ -9,9 +9,9 @@ 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; a miss is a leftover
|
||||
that fails the run. Runs against the fake forgectrl, a fake /proc, and a
|
||||
fake init script that plays the client's start."""
|
||||
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
|
||||
@@ -141,38 +141,6 @@ class TakeoverClientTests(unittest.TestCase):
|
||||
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_after_the_offline_service_fails_the_run(self):
|
||||
# the client that came up is not listening: an online client, or
|
||||
# a marker the start never saw
|
||||
self._offline_client(self.OLD_PID)
|
||||
real = self._initd
|
||||
|
||||
def lost_marker(service, action, timeout=60):
|
||||
if action == "start" and os.path.exists(baseline.OFFLINE_MARKER):
|
||||
os.remove(baseline.OFFLINE_MARKER)
|
||||
return real(service, action, timeout)
|
||||
hw.initd = lost_marker
|
||||
cap, run = self.takeover()
|
||||
self.assertEqual(cap["restart_clients"],
|
||||
[{"where": "takeover end", "expected": "the offline service",
|
||||
"found": "a client that is not the offline service (pid %d)" % self.NEW_PID}])
|
||||
left = baseline.Baseline(run.log)
|
||||
items = []
|
||||
left._preserved(items, cap)
|
||||
self.assertEqual([x.item for x in items], ["cloud client at the takeover end"])
|
||||
self.assertTrue(items[0].action.startswith("not restored"), items[0].action)
|
||||
|
||||
def test_no_client_started_takes_the_markers_down(self):
|
||||
# the gate closed, or standby: nothing read the markers, and a
|
||||
# later start must not come up offline or without its hunt
|
||||
self._offline_client(self.OLD_PID)
|
||||
self.comes_up = "gated"
|
||||
cap, run = self.takeover()
|
||||
self.assertEqual(self.at_start, ["gfcloud-nohunt", "gfcloud-offline"])
|
||||
self.assertEqual(self.markers_left(), [])
|
||||
self.assertEqual(len(cap["restart_clients"]), 1)
|
||||
self.assertIn("no cloud client running", cap["restart_clients"][0]["found"])
|
||||
|
||||
def test_an_online_client_comes_back_without_the_hunt(self):
|
||||
cap, run = self.takeover()
|
||||
self.assertEqual(self.at_start, ["gfcloud-nohunt"])
|
||||
@@ -180,14 +148,6 @@ class TakeoverClientTests(unittest.TestCase):
|
||||
self.assertNotIn("restart_clients", cap)
|
||||
self.assertEqual(self.markers_left(), [])
|
||||
|
||||
def test_a_stale_offline_socket_file_is_not_the_offline_service(self):
|
||||
# a socket another process left at the path: the online client
|
||||
# found stays online
|
||||
self._socket(99, 7002, baseline.OFFLINE_SOCKET)
|
||||
cap, run = self.takeover()
|
||||
self.assertEqual(self.at_start, ["gfcloud-nohunt"])
|
||||
self.assertNotIn("restart_clients", cap)
|
||||
|
||||
def test_grbl_mode_starts_under_no_marker(self):
|
||||
self.fc.state["mode"] = {"mode": "grbl", "controller": "running", "pid": self.OLD_PID,
|
||||
"motion": "verified"}
|
||||
|
||||
@@ -136,51 +136,8 @@ class TakeoverPositionTests(unittest.TestCase):
|
||||
self.start(t)
|
||||
self.assertNotIn("restart_positions", cap)
|
||||
|
||||
def test_an_undeclared_frame_change_is_not_judged(self):
|
||||
cap = self.cap()
|
||||
self._new_anchor(2) # a controller start the test did not declare
|
||||
self._pos(self.mm(10), 0, 0)
|
||||
t, run = self.takeover(cap)
|
||||
self.start(t)
|
||||
self.assertNotIn("restart_positions", cap)
|
||||
self.assertTrue(any("not in the frame the run began in" in l for l in run.lines), run.lines)
|
||||
|
||||
def test_cloud_mode_is_not_judged(self):
|
||||
cap = self.cap()
|
||||
self._pos(self.mm(10), 0, 0)
|
||||
t, run = self.takeover(cap)
|
||||
t.cloud = True
|
||||
t.position_at_start()
|
||||
self._pos(self.mm(20), 0, 0)
|
||||
t.position_at_end()
|
||||
self.assertNotIn("restart_positions", cap)
|
||||
|
||||
def test_a_bench_tool_is_not_judged(self):
|
||||
t = runner.Takeover(lambda s: None, "bench:tool")
|
||||
self._pos(self.mm(10), 0, 0)
|
||||
t.position_at_start()
|
||||
t.position_at_end() # no run: nothing to judge against, nothing raised
|
||||
|
||||
def test_a_drill_that_leaves_the_head_out_is_recorded_at_the_end(self):
|
||||
cap = self.cap()
|
||||
t, run = self.takeover(cap)
|
||||
self.start(t)
|
||||
self._pos(self.mm(5), 0, 0) # the drill moved the head and did not bring it back
|
||||
t.position_at_end()
|
||||
self.assertEqual([r["where"] for r in cap["restart_positions"]], ["takeover end"])
|
||||
self.assertEqual(cap["restart_positions"][0]["expected"], [0, 0, 0])
|
||||
|
||||
def test_a_changed_microstep_mode_is_not_judged_at_the_end(self):
|
||||
cap = self.cap()
|
||||
t, run = self.takeover(cap)
|
||||
self.start(t)
|
||||
self._attr("cnc/x_mode", "8" if hw.sysfs_read("cnc/x_mode") != "8" else "16")
|
||||
self._pos(self.mm(5), 0, 0)
|
||||
t.position_at_end()
|
||||
self.assertNotIn("restart_positions", cap)
|
||||
|
||||
def test_the_post_pass_fails_the_run_and_moves_nothing(self):
|
||||
# setup.check-envelope's case: a declared camera home, the head
|
||||
# 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()
|
||||
@@ -228,17 +185,6 @@ class TakeoverPositionTests(unittest.TestCase):
|
||||
self.assertEqual(f.read().strip(), "1")
|
||||
self.assertTrue(any("could not be judged" in l for l in run.lines), run.lines)
|
||||
|
||||
def test_a_declared_rezero_records_the_frame_in_force(self):
|
||||
cap = self.cap()
|
||||
self._new_anchor(5) # the camera home
|
||||
stub = type("Ctx", (), {})()
|
||||
stub.run = FakeRun(cap)
|
||||
stub.log = stub.run.log
|
||||
runner.Context.counters_rezeroed(stub)
|
||||
self.assertEqual(cap["position"], [0, 0, 0])
|
||||
self.assertTrue(cap["rezero_declared"])
|
||||
self.assertEqual(cap["frame"], baseline.counter_frame())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -11,4 +11,4 @@ overrides:
|
||||
meta-freescale-distro:
|
||||
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
||||
meta-openglow:
|
||||
commit: f012d8e82aa0dbfa511214e46a1e13559b1a0d74
|
||||
commit: b7626be45c7708a8e71bd03af61ca968796fc8f7
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||
# layer content hash because the component entry already identifies the
|
||||
# pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "4b3966396b19a333076a9b2b23cade833afd4bb0"
|
||||
PV = "0.1.29"
|
||||
SRCREV = "22a2cfc04242ddc50ed0a54e967a59634f3f4d6d"
|
||||
PV = "0.1.32"
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||
# layer content hash because the component entry already identifies the
|
||||
# pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "2d0391e7f51922848bc2ac30240ac9f5dd65af20"
|
||||
PV = "0.1.1"
|
||||
SRCREV = "31cdb4005668286c9faa5b4b834f0cbc3fdbbe9b"
|
||||
PV = "0.1.2"
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
# because the component entry already identifies the pinned source
|
||||
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
||||
# meta-glowforge-bsp pins the same repository; move both together.
|
||||
SRCREV = "021323ddfbdfcd131831f916ad3c0ef1d7a59071"
|
||||
SRCREV = "0f59c547b6e8418712ab9e000c58eb1975c29d79"
|
||||
|
||||
# Bump PV with every SRCREV move: the hash-derived package version is not
|
||||
# monotonic on its own and buildhistory QA fails the build when it sorts
|
||||
# backwards.
|
||||
PV = "0.1.31+git"
|
||||
PV = "0.1.32+git"
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# changes; keep only SRCREV and PV here - the image manifest leaves
|
||||
# *-pin.inc out of the layer content hash because the component entry
|
||||
# already identifies the pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "b34636f5fbce586e375c7d4c4d474ee20c3c69b7"
|
||||
PV = "0.1.20"
|
||||
SRCREV = "badc8323463c059e418bdf0e4a161ed73823bfc1"
|
||||
PV = "0.1.21"
|
||||
|
||||
@@ -36,6 +36,9 @@ IMAGE_FEATURES += " \
|
||||
tools-debug \
|
||||
debug-tweaks \
|
||||
"
|
||||
# The rootfs lint holds a dev image to the dev policy: root and an empty
|
||||
# password over SSH are what debug-tweaks turns on.
|
||||
FORGEFIRM_IMAGE_KIND = "dev"
|
||||
|
||||
# Dev images boot from SD, never from a 200 MiB eMMC slot: lift the slot
|
||||
# ceiling and give the filesystem generous working space instead.
|
||||
|
||||
@@ -216,18 +216,34 @@ ROOTFS_POSTPROCESS_COMMAND += "write_forgefirm_version "
|
||||
# control panel serves the bundle and its manifest (GET /system/licenses,
|
||||
# GET /system/licenses/manifest). The license class runs first
|
||||
# (license_create_manifest is prepended to this list); this step is
|
||||
# appended, so it runs after.
|
||||
# appended, so it runs after. pack-licenses.sh, next to this recipe, is
|
||||
# the step: it refuses a tree with no manifest, and a manifest that does
|
||||
# not name forgectrl, grblhal-glowforge and kernel-module-glowforge.
|
||||
COPY_LIC_MANIFEST = "1"
|
||||
COPY_LIC_DIRS = "1"
|
||||
|
||||
PACK_LICENSES := "${THISDIR}/pack-licenses.sh"
|
||||
do_rootfs[file-checksums] += "${PACK_LICENSES}:True"
|
||||
|
||||
pack_licenses() {
|
||||
d="${IMAGE_ROOTFS}${datadir}/common-licenses"
|
||||
[ -d "$d" ] || bbfatal "pack_licenses: $d is missing (COPY_LIC_DIRS off?)"
|
||||
[ -f "$d/license.manifest" ] || bbfatal "pack_licenses: no license.manifest in $d"
|
||||
install -d "${IMAGE_ROOTFS}${datadir}/forgefirm"
|
||||
tar -C "${IMAGE_ROOTFS}${datadir}" --sort=name --mtime=@0 --owner=0 --group=0 \
|
||||
--numeric-owner -cf - common-licenses | gzip -9 -n \
|
||||
> "${IMAGE_ROOTFS}${datadir}/forgefirm/licenses.tar.gz"
|
||||
rm -rf "$d"
|
||||
sh "${PACK_LICENSES}" "${IMAGE_ROOTFS}${datadir}/common-licenses" \
|
||||
"${IMAGE_ROOTFS}${datadir}/forgefirm/licenses.tar.gz" \
|
||||
|| bbfatal "pack_licenses: pack-licenses.sh refused the license tree (its message is above)"
|
||||
}
|
||||
ROOTFS_POSTPROCESS_COMMAND += "pack_licenses "
|
||||
|
||||
# The rootfs lint: the image content fixed at build time, checked on the
|
||||
# tree as it is imaged, so a drift fails the build of the release image
|
||||
# too, which no campaign runs. rootfs-lint.sh, next to this recipe, holds
|
||||
# the rules. It runs after every other rootfs step (an append is applied
|
||||
# after every +=, the dev recipe's and debug-tweaks' included), and
|
||||
# FORGEFIRM_IMAGE_KIND tells it which image's policy applies.
|
||||
ROOTFS_LINT := "${THISDIR}/rootfs-lint.sh"
|
||||
do_rootfs[file-checksums] += "${ROOTFS_LINT}:True"
|
||||
FORGEFIRM_IMAGE_KIND ?= "release"
|
||||
|
||||
rootfs_lint() {
|
||||
sh "${ROOTFS_LINT}" "${IMAGE_ROOTFS}" "${FORGEFIRM_IMAGE_KIND}" \
|
||||
|| bbfatal "rootfs_lint: rootfs-lint.sh refused the rootfs (its messages are above)"
|
||||
}
|
||||
ROOTFS_POSTPROCESS_COMMAND:append = " rootfs_lint"
|
||||
|
||||
@@ -17,4 +17,4 @@
|
||||
# it to equal the rootfs stamp, the .fw meta-version and the release tag,
|
||||
# and the acceptance test image.health compares the stamp on the running
|
||||
# machine with the manifest's.
|
||||
FORGEFIRM_RELEASE ?= "0.0.7"
|
||||
FORGEFIRM_RELEASE ?= "0.0.8"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# pack-licenses.sh <common-licenses directory> <tar.gz>
|
||||
#
|
||||
# Packs the image's license tree into one reproducible tar.gz (sorted
|
||||
# names, no timestamps, no owners), its members under the tree's own
|
||||
# name, and removes the tree. It refuses, before it writes the tar or
|
||||
# removes anything, a tree that is missing, a tree with no
|
||||
# license.manifest, and a manifest that does not name the project's own
|
||||
# recipes: forgectrl, grblhal-glowforge and kernel-module-glowforge.
|
||||
# forgefirm-image.bb's pack_licenses runs it; forgetest's test_image.py
|
||||
# runs it on a fixture.
|
||||
|
||||
dir=${1:?common-licenses directory}
|
||||
out=${2:?output tar.gz}
|
||||
fail() { echo "pack-licenses: $*" >&2; exit 1; }
|
||||
|
||||
[ -d "$dir" ] || fail "$dir is missing (COPY_LIC_DIRS off?)"
|
||||
manifest=$dir/license.manifest
|
||||
[ -f "$manifest" ] || fail "no license.manifest in $dir"
|
||||
# A line counts with its newline: an unterminated last line is not read.
|
||||
lines=$(wc -l < "$manifest")
|
||||
for name in forgectrl grblhal-glowforge kernel-module-glowforge; do
|
||||
head -n "$lines" "$manifest" | grep -Fxq "RECIPE NAME: $name" \
|
||||
|| fail "$manifest does not name the recipe $name"
|
||||
done
|
||||
|
||||
base=$(basename "$dir")
|
||||
mkdir -p "$(dirname "$out")" || fail "cannot make the directory of $out"
|
||||
tar -C "$(dirname "$dir")" --sort=name --mtime=@0 --owner=0 --group=0 \
|
||||
--numeric-owner -cf - "$base" | gzip -9 -n > "$out" || fail "cannot write $out"
|
||||
# the tree goes only once the bundle holds its manifest
|
||||
tar -tzf "$out" "$base/license.manifest" > /dev/null 2>&1 \
|
||||
|| fail "$out holds no $base/license.manifest"
|
||||
rm -rf "$dir"
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# rootfs-lint.sh <rootfs> <release|dev>
|
||||
#
|
||||
# The image content that is fixed at build time, checked on the tree as it
|
||||
# is imaged: a failure here fails the build, on the release image too,
|
||||
# which no campaign runs. forgefirm-image.bb's rootfs_lint runs it last of
|
||||
# the rootfs steps; forgetest's test_image.py runs it on fixture trees.
|
||||
# Every rule is checked and every failure printed, one line each, before
|
||||
# it exits 1. The rules:
|
||||
#
|
||||
# syslog rsyslog is the only logger: /usr/sbin/rsyslogd is on the
|
||||
# image, the syslog init script starts it and runlevel 5
|
||||
# starts that script; no syslogd or klogd is linked in a bin
|
||||
# directory, and no init script runs one.
|
||||
# sshd every sshd configuration the image carries (sshd_config,
|
||||
# and sshd_config_readonly, which the read-only rootfs runs)
|
||||
# takes passwords (PasswordAuthentication yes) and sends no
|
||||
# pre-authentication banner; on a release image it refuses
|
||||
# root (PermitRootLogin no) and an empty password
|
||||
# (PermitEmptyPasswords no). Nothing in sshd_config.d sets
|
||||
# these, since an included value comes first.
|
||||
# dhcp-name the wlan0 DHCP stanza sends the machine's name:
|
||||
# udhcpc_opts carries -x hostname:$(hostname).
|
||||
# mdns no mDNS responder: no /etc/avahi, no avahi-daemon, no
|
||||
# libnss_mdns, and the hosts line of nsswitch.conf names no
|
||||
# mdns source.
|
||||
|
||||
root=${1:?rootfs}
|
||||
kind=${2:?release or dev}
|
||||
case "$kind" in
|
||||
release|dev) ;;
|
||||
*) echo "rootfs-lint: the image kind is release or dev, not $kind" >&2; exit 2 ;;
|
||||
esac
|
||||
[ -d "$root" ] || { echo "rootfs-lint: $root is not a directory" >&2; exit 2; }
|
||||
|
||||
failed=0
|
||||
fail() { echo "rootfs-lint: $1: $2" >&2; failed=1; }
|
||||
|
||||
# The active lines of a file: no comment, no blank line.
|
||||
active() { grep -v -E '^[[:space:]]*(#|$)' "$1" 2>/dev/null; }
|
||||
|
||||
# ---------------------------------------------------------------- syslog
|
||||
[ -x "$root/usr/sbin/rsyslogd" ] || fail syslog "/usr/sbin/rsyslogd is not on the image"
|
||||
if [ -f "$root/etc/init.d/syslog" ]; then
|
||||
active "$root/etc/init.d/syslog" | grep -q '/usr/sbin/rsyslogd' \
|
||||
|| fail syslog "/etc/init.d/syslog does not start /usr/sbin/rsyslogd"
|
||||
else
|
||||
fail syslog "there is no /etc/init.d/syslog"
|
||||
fi
|
||||
started=
|
||||
for l in "$root"/etc/rc5.d/S*; do
|
||||
[ -L "$l" ] && [ "$(readlink "$l")" = "../init.d/syslog" ] && started=$l
|
||||
done
|
||||
[ -n "$started" ] || fail syslog "runlevel 5 does not start /etc/init.d/syslog"
|
||||
for d in bin sbin usr/bin usr/sbin; do
|
||||
for p in syslogd klogd; do
|
||||
if [ -e "$root/$d/$p" ] || [ -L "$root/$d/$p" ]; then
|
||||
fail syslog "/$d/$p is on the image beside rsyslog"
|
||||
fi
|
||||
done
|
||||
done
|
||||
for s in "$root"/etc/init.d/*; do
|
||||
[ -f "$s" ] || continue
|
||||
# syslogd or klogd as a word of its own: rsyslogd is not one
|
||||
if active "$s" | grep -q -E '(^|[^[:alnum:]_.-])(syslogd|klogd)([^[:alnum:]_.-]|$)'; then
|
||||
fail syslog "/etc/init.d/${s##*/} runs syslogd or klogd"
|
||||
fi
|
||||
done
|
||||
|
||||
# ------------------------------------------------------------------ sshd
|
||||
# The first value a configuration sets for a keyword before any Match
|
||||
# block: sshd takes the first one it reads. Keywords are case-insensitive,
|
||||
# and a value follows spaces or an equals sign.
|
||||
sshd_value() {
|
||||
awk -v k="$2" '
|
||||
{ line = $0; sub(/^[ \t]+/, "", line) }
|
||||
line == "" || line ~ /^#/ { next }
|
||||
{ n = split(line, f, /[ \t=]+/); key = tolower(f[1]) }
|
||||
key == "match" { exit }
|
||||
key == tolower(k) { print (n > 1 ? f[2] : ""); exit }' "$1"
|
||||
}
|
||||
configs=
|
||||
for c in sshd_config sshd_config_readonly; do
|
||||
[ -f "$root/etc/ssh/$c" ] && configs="$configs $c"
|
||||
done
|
||||
[ -n "$configs" ] || fail sshd "the image carries no /etc/ssh/sshd_config"
|
||||
for c in $configs; do
|
||||
f=$root/etc/ssh/$c
|
||||
v=$(sshd_value "$f" PasswordAuthentication)
|
||||
[ "$v" = yes ] || fail sshd "$c: PasswordAuthentication is '${v:-unset}', not yes"
|
||||
v=$(sshd_value "$f" Banner)
|
||||
[ -z "$v" ] || [ "$v" = none ] || fail sshd "$c: Banner $v sends a pre-authentication banner"
|
||||
if [ "$kind" = release ]; then
|
||||
v=$(sshd_value "$f" PermitRootLogin)
|
||||
[ "$v" = no ] || fail sshd "$c: PermitRootLogin is '${v:-unset}', not no"
|
||||
v=$(sshd_value "$f" PermitEmptyPasswords)
|
||||
[ "$v" = no ] || fail sshd "$c: PermitEmptyPasswords is '${v:-unset}', not no"
|
||||
fi
|
||||
done
|
||||
for f in "$root"/etc/ssh/sshd_config.d/*.conf; do
|
||||
[ -f "$f" ] || continue
|
||||
for k in PasswordAuthentication Banner PermitRootLogin PermitEmptyPasswords; do
|
||||
v=$(sshd_value "$f" "$k")
|
||||
[ -z "$v" ] || fail sshd "sshd_config.d/${f##*/} sets $k $v ahead of sshd_config"
|
||||
done
|
||||
done
|
||||
|
||||
# ------------------------------------------------------------- dhcp-name
|
||||
ifaces=$root/etc/network/interfaces
|
||||
if [ -f "$ifaces" ]; then
|
||||
# the udhcpc_opts lines of the wlan0 dhcp stanza, which runs to the
|
||||
# next stanza
|
||||
opts=$(awk '
|
||||
/^[ \t]*#/ { next }
|
||||
/^(iface|auto|allow-|mapping|source)/ { in_w = ($1 == "iface" && $2 == "wlan0" && $4 == "dhcp"); next }
|
||||
in_w && $1 == "udhcpc_opts" { sub(/^[ \t]*udhcpc_opts[ \t]+/, ""); print }' "$ifaces")
|
||||
case " $opts " in
|
||||
*' -x hostname:$(hostname) '*) ;;
|
||||
*) fail dhcp-name "the wlan0 dhcp stanza does not send -x hostname:\$(hostname) (udhcpc_opts: '${opts}')" ;;
|
||||
esac
|
||||
else
|
||||
fail dhcp-name "there is no /etc/network/interfaces"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ mdns
|
||||
[ -e "$root/etc/avahi" ] && fail mdns "/etc/avahi is on the image"
|
||||
for d in sbin usr/sbin; do
|
||||
[ -e "$root/$d/avahi-daemon" ] && fail mdns "/$d/avahi-daemon is on the image"
|
||||
done
|
||||
for d in lib usr/lib; do
|
||||
for l in "$root/$d"/libnss_mdns*; do
|
||||
[ -e "$l" ] && fail mdns "/$d/${l##*/} is on the image"
|
||||
done
|
||||
done
|
||||
if active "$root/etc/nsswitch.conf" | grep -E '^[[:space:]]*hosts:' | grep -q 'mdns'; then
|
||||
fail mdns "nsswitch.conf resolves hosts through mdns"
|
||||
fi
|
||||
|
||||
exit $failed
|
||||
@@ -1,14 +1,14 @@
|
||||
ForgeFIRM v0.0.7 is a beta release.
|
||||
|
||||
It adds extension packages: software that is not part of ForgeFIRM, which you install from the control panel and which runs in a sandbox with only what you grant it. Extensions are off until you turn them on. You can upload a package, or get one from OpenGlow's catalog, which lists Visual alignment. A package's own page is on the new Extensions tab.
|
||||
ForgeFIRM is now extensible. New capabilities and features can be created as extensions without requiring major changes to the core firmware. This is intended to promote 3rd-party feature development while allowing the core firmware to remain light and stable. Each extension runs in a sandbox with only the permissions the user grants it. Packages can be uploaded or installed from a new catalog.
|
||||
|
||||
Also new:
|
||||
|
||||
- Manual homing. Release the motors, push the head against the gantry stops by hand, and set home there or at an offset you choose. It needs no account, no service, and no camera.
|
||||
- The crumb tray. Take the tray out for taller work and switch the tray mode on the Lens card: Z is then the height above the floor of the machine.
|
||||
- API tokens let your own programs read the status, follow the machine's events, see a camera, jog the head with the laser off, or run jobs. A token reaches only what you tick when you make it, and a job it runs still waits for the button on the machine.
|
||||
- The Setup page can measure the bed size on a machine with the gantry stops.
|
||||
- Live camera video is smoother and has less delay, and the control panel is faster over HTTPS.
|
||||
- Manual homing: Allows you to home the machine by moving the gantry and head to their home positions against gantry stops. Cheap, repeatable and effective.
|
||||
- Crumb Tray Removal: Toggling whether or not the crumb tray is installed will adjust the Z height for accurate, calculation-free focus.
|
||||
- A new API interface lets your own programs read the status, follow the machine's events, see a camera, jog the head with the laser off, or run jobs (operator button press still required).
|
||||
- Improved the video pipeline and enabled hardware-accelerated TLS encryption to reduce CPU overhead.
|
||||
- New Extension: Adjust the laser start point using the head camera for precision alignment.
|
||||
|
||||
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
# ForgeFIRM acceptance - 20261010224118 (dev)
|
||||
|
||||
- Image: `20261010224118 (dev)` (forgefirm-image-dev)
|
||||
- Manifest identity: `c25844db9b022c976fbc02f5db9dc46bc2cde776cd6a9c90e7d789214ebe257e`
|
||||
- Catalog: `ee8534fed1b8e932ad5a9367f1b38844c9bdce1abe625ba9346bc042a5bd7b1d`
|
||||
- Campaign: `c-20261010224522-439d` opened 2026-10-10T22:45:22Z
|
||||
- Exported: 2026-10-10T23:40:44Z
|
||||
- **Release authorized: YES**
|
||||
- Tests: 128 total, 128 satisfied (80 inherited), 0 required
|
||||
|
||||
| Test | Kind | Result | Run at | Campaign | Inherited |
|
||||
|---|---|---|---|---|---|
|
||||
| `image.health` | auto, core | PASS | 2026-10-10T22:45:22Z | `c-20261010224522-439d` | no |
|
||||
| `image.license-bundle` | auto | PASS | 2026-10-10T21:42:55Z | `c-20261010214232-4790` | yes |
|
||||
| `image.network-boot` | auto | PASS | 2026-09-28T16:23:38Z | `c-20260928162331-3969` | yes |
|
||||
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-10-10T22:45:23Z | `c-20261010224522-439d` | no |
|
||||
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-10-10T22:45:45Z | `c-20261010224522-439d` | no |
|
||||
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-10-10T22:45:55Z | `c-20261010224522-439d` | no |
|
||||
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-10-10T22:46:15Z | `c-20261010224522-439d` | no |
|
||||
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-10-10T22:46:59Z | `c-20261010224522-439d` | no |
|
||||
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-28T16:25:37Z | `c-20260928162331-3969` | yes |
|
||||
| `kernel.pic-soc-load` | auto | PASS | 2026-09-28T16:25:41Z | `c-20260928162331-3969` | yes |
|
||||
| `forgectrl.auth` | auto | PASS | 2026-10-10T21:45:06Z | `c-20261010214232-4790` | yes |
|
||||
| `forgectrl.settings-bounds` | auto | PASS | 2026-10-10T21:45:13Z | `c-20261010214232-4790` | yes |
|
||||
| `forgectrl.panel-serves` | auto | PASS | 2026-10-10T21:42:46Z | `c-20261010214232-4790` | yes |
|
||||
| `events.stream` | auto | PASS | 2026-10-10T21:45:29Z | `c-20261010214232-4790` | yes |
|
||||
| `forgectrl.lease` | auto | PASS | 2026-10-10T21:45:39Z | `c-20261010214232-4790` | yes |
|
||||
| `forgectrl.tokens` | auto | PASS | 2026-10-10T21:45:48Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.gate-blocks-controllers` | auto, takeover | PASS | 2026-10-10T21:46:11Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.override-until-reboot` | auto | PASS | 2026-10-10T21:46:18Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.advisories-rehash` | auto, takeover | PASS | 2026-10-10T21:46:33Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.account-login` | auto, takeover | PASS | 2026-10-10T21:47:28Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.https-only-writes` | auto | PASS | 2026-10-10T21:47:35Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.ssh-until-reboot` | auto | PASS | 2026-10-10T21:47:42Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.cloud-disabled-surface` | auto | PASS | 2026-10-10T21:47:49Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.extensions-consent` | auto, takeover | PASS | 2026-10-10T21:48:04Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.factory-return` | auto | PASS | 2026-10-10T21:48:11Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.machine-name` | auto | PASS | 2026-09-28T16:27:36Z | `c-20260928162331-3969` | yes |
|
||||
| `setup.first-run-flow` | operator, takeover | PASS | 2026-10-10T22:47:21Z | `c-20261010224522-439d` | no |
|
||||
| `setup.first-run-page` | operator, takeover | PASS | 2026-09-28T18:52:23Z | `c-20260928183105-64ee` | yes |
|
||||
| `setup.cert-page` | auto | PASS | 2026-10-10T21:48:17Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.what-changed` | auto, takeover | PASS | 2026-10-10T21:48:33Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.record-export` | auto | PASS | 2026-10-10T21:50:38Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.mirror` | auto, takeover | PASS | 2026-10-10T21:51:21Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.check-switches` | operator | PASS | 2026-10-10T22:47:26Z | `c-20261010224522-439d` | no |
|
||||
| `setup.check-sensors` | auto | PASS | 2026-10-10T21:50:55Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.check-airflow` | auto, takeover | PASS | 2026-10-10T21:53:11Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.check-cameras` | operator | PASS | 2026-10-10T22:47:39Z | `c-20261010224522-439d` | no |
|
||||
| `setup.check-motion` | auto, takeover | PASS | 2026-10-10T21:53:38Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.check-flow-verify` | auto, takeover | PASS | 2026-10-10T22:01:19Z | `c-20261010214232-4790` | yes |
|
||||
| `setup.cloud-header-capture` | operator, takeover | PASS | 2026-10-10T23:04:58Z | `c-20261010224522-439d` | no |
|
||||
| `setup.sheet` | live, takeover | PASS | 2026-10-10T23:18:06Z | `c-20261010224522-439d` | no |
|
||||
| `logs.tree-tail-export` | auto | PASS | 2026-10-10T21:49:36Z | `c-20261010214232-4790` | yes |
|
||||
| `logs.routing` | auto | PASS | 2026-09-28T16:40:28Z | `c-20260928162331-3969` | yes |
|
||||
| `logs.level-settings` | auto | PASS | 2026-10-10T22:01:26Z | `c-20261010214232-4790` | yes |
|
||||
| `motion.pacing` | auto | PASS | 2026-09-28T16:32:45Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.jog-roundtrip` | auto | PASS | 2026-09-28T16:40:44Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.microstep-modes` | auto | PASS | 2026-10-10T22:01:57Z | `c-20261010214232-4790` | yes |
|
||||
| `motion.liveness-probe` | auto | PASS | 2026-09-28T16:41:18Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.gate-waits-for-lid` | operator | PASS | 2026-10-10T22:47:56Z | `c-20261010224522-439d` | no |
|
||||
| `motion.cancel-abort` | auto | PASS | 2026-09-28T16:41:41Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.deadman` | auto | PASS | 2026-10-10T22:02:49Z | `c-20261010214232-4790` | yes |
|
||||
| `motion.respawn-gate` | operator | PASS | 2026-09-28T16:42:35Z | `c-20260928162331-3969` | yes |
|
||||
| `motion.soft-limits` | auto | PASS | 2026-09-28T16:53:34Z | `c-20260928165212-b92e` | yes |
|
||||
| `motion.button-hold-resume` | operator | PASS | 2026-09-28T17:12:49Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.lid-cancel-home` | operator | PASS | 2026-09-28T17:13:04Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-28T17:13:12Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.lid-policy-hold` | operator | PASS | 2026-09-28T17:13:26Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.step-timing-under-load` | auto | PASS | 2026-09-28T17:13:56Z | `c-20260928171100-3ff5` | yes |
|
||||
| `motion.release` | auto, takeover | PASS | 2026-10-10T22:03:22Z | `c-20261010214232-4790` | yes |
|
||||
| `homing.manual` | auto | PASS | 2026-10-10T22:03:39Z | `c-20261010214232-4790` | yes |
|
||||
| `motion.port-jog` | auto | PASS | 2026-10-10T22:04:05Z | `c-20261010214232-4790` | yes |
|
||||
| `motion.job` | auto | PASS | 2026-10-10T22:04:21Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-10-10T21:55:16Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.flow-verify` | auto | PASS | 2026-10-10T21:58:05Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-10-10T22:05:23Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.gate-off` | auto | PASS | 2026-10-10T21:51:35Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.floor-and-warm-up` | auto | PASS | 2026-10-10T22:06:55Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.tec-drive` | auto | PASS | 2026-10-10T22:07:10Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.fire-watch-tiers` | auto | PASS | 2026-10-10T22:07:31Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-10-10T22:07:44Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.critical-tier` | auto | PASS | 2026-10-10T22:07:58Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.fan-gate-trips` | auto | PASS | 2026-10-10T21:52:25Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.flow-under-load` | live | PASS | 2026-10-10T23:20:48Z | `c-20261010224522-439d` | no |
|
||||
| `cooling.fan-duty-readback` | auto | PASS | 2026-10-10T22:08:19Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.fail-tier-stop` | operator | PASS | 2026-10-10T22:48:15Z | `c-20261010224522-439d` | no |
|
||||
| `cooling.report-channel` | auto | PASS | 2026-10-10T22:08:36Z | `c-20261010214232-4790` | yes |
|
||||
| `cooling.intake-one-fan` | auto | PASS | 2026-10-10T22:09:20Z | `c-20261010214232-4790` | yes |
|
||||
| `laser.power-floor` | auto | PASS | 2026-10-10T22:09:27Z | `c-20261010214232-4790` | yes |
|
||||
| `laser.emission-witness` | live, core | PASS | 2026-10-10T23:05:49Z | `c-20261010224522-439d` | no |
|
||||
| `laser.m5-rapid-dark` | live | PASS | 2026-10-10T23:21:48Z | `c-20261010224522-439d` | no |
|
||||
| `laser.disarm-in-hold` | live | PASS | 2026-10-10T23:23:20Z | `c-20261010224522-439d` | no |
|
||||
| `laser.armed-kill` | live | PASS | 2026-10-10T23:24:03Z | `c-20261010224522-439d` | no |
|
||||
| `laser.verdict-cut` | live | PASS | 2026-10-10T23:25:09Z | `c-20261010224522-439d` | no |
|
||||
| `laser.arm-wait-lid` | operator | PASS | 2026-10-10T22:48:33Z | `c-20261010224522-439d` | no |
|
||||
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-10-10T23:26:26Z | `c-20261010224522-439d` | no |
|
||||
| `laser.port-dark` | live | PASS | 2026-10-10T23:27:10Z | `c-20261010224522-439d` | no |
|
||||
| `laser.recorder-dark` | auto | PASS | 2026-10-10T22:09:54Z | `c-20261010214232-4790` | yes |
|
||||
| `camera.snapshot` | auto | PASS | 2026-10-10T22:10:16Z | `c-20261010214232-4790` | yes |
|
||||
| `camera.sensor-profile` | auto | PASS | 2026-10-10T22:10:36Z | `c-20261010214232-4790` | yes |
|
||||
| `camera.h264-stream` | auto | PASS | 2026-10-10T22:10:55Z | `c-20261010214232-4790` | yes |
|
||||
| `camera.frame-health` | auto | PASS | 2026-10-10T22:11:19Z | `c-20261010214232-4790` | yes |
|
||||
| `camera.lid-privacy` | operator | PASS | 2026-10-10T22:48:49Z | `c-20261010224522-439d` | no |
|
||||
| `camera.key-read` | auto | PASS | 2026-10-10T22:11:26Z | `c-20261010214232-4790` | yes |
|
||||
| `update.slots-and-signature` | auto | PASS | 2026-09-28T16:27:34Z | `c-20260928162331-3969` | yes |
|
||||
| `update.product-gate` | auto | PASS | 2026-09-28T17:20:19Z | `c-20260928171100-3ff5` | yes |
|
||||
| `update.release-check` | auto | PASS | 2026-10-10T22:11:33Z | `c-20261010214232-4790` | yes |
|
||||
| `cloud.mode-switch` | operator | PASS | 2026-10-10T22:51:05Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.service-protocol` | operator | PASS | 2026-10-10T23:28:24Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.lid-interlock-abort` | live | PASS | 2026-10-10T23:30:40Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.lid-during-button-wait` | operator | PASS | 2026-10-10T22:52:29Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.dark-print` | operator | PASS | 2026-10-10T22:53:25Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.verdict-refuse` | operator | PASS | 2026-10-10T22:54:47Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.pause-resume` | live | PASS | 2026-10-10T23:34:32Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.oversize-stream` | live | PASS | 2026-10-10T23:38:02Z | `c-20261010224522-439d` | no |
|
||||
| `cloud.paused-lid-cancel` | live | PASS | 2026-10-10T23:38:55Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.platform` | auto | PASS | 2026-09-28T18:05:37Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.service` | auto, takeover | PASS | 2026-10-10T22:12:20Z | `c-20261010214232-4790` | yes |
|
||||
| `exthost.armed-freeze` | operator, takeover | PASS | 2026-10-10T22:56:02Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.hold-pause-tier` | auto, takeover | PASS | 2026-10-10T22:14:00Z | `c-20261010214232-4790` | yes |
|
||||
| `exthost.events` | operator, takeover | PASS | 2026-10-10T22:56:23Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.motion-jog` | auto, takeover | PASS | 2026-10-10T22:56:43Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.ui-delivery` | auto | PASS | 2026-10-10T22:56:52Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.motion-job` | auto, takeover | PASS | 2026-09-28T18:11:06Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.package-routes` | auto | PASS | 2026-10-10T22:56:54Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.panel-install` | operator | PASS | 2026-10-10T22:56:58Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.core-range` | auto | PASS | 2026-09-28T18:11:13Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.page-call` | auto | PASS | 2026-10-10T22:57:14Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.operator-destinations` | auto | PASS | 2026-10-10T22:57:36Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.lifecycle` | auto | PASS | 2026-09-28T18:12:06Z | `c-20260928180313-75fb` | yes |
|
||||
| `events.button-telemetry` | operator | PASS | 2026-10-10T22:57:48Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.catalog` | auto | PASS | 2026-10-10T22:57:50Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.mcode` | auto | PASS | 2026-09-28T18:12:39Z | `c-20260928180313-75fb` | yes |
|
||||
| `exthost.wizard` | auto | PASS | 2026-10-10T22:58:18Z | `c-20261010224522-439d` | no |
|
||||
| `update.job-locks` | auto | PASS | 2026-09-28T18:13:11Z | `c-20260928180313-75fb` | yes |
|
||||
| `homing.cloud-offsets` | auto | PASS | 2026-10-10T22:59:11Z | `c-20261010224522-439d` | no |
|
||||
| `setup.check-envelope` | auto | PASS | 2026-10-10T23:00:57Z | `c-20261010224522-439d` | no |
|
||||
| `exthost.sender-keep-out` | auto | PASS | 2026-10-10T23:01:30Z | `c-20261010224522-439d` | no |
|
||||
| `motion.tray` | auto | PASS | 2026-10-10T23:01:33Z | `c-20261010224522-439d` | no |
|
||||
| `forgectrl.tls-records` | auto | PASS | 2026-10-10T23:01:37Z | `c-20261010224522-439d` | no |
|
||||
|
||||
Artifact sha256: `c8b5e4c24a59e92337e0ec9607343b64b0d8193919efca7cd0b3d18b3c4d31f7`
|
||||
@@ -0,0 +1,7 @@
|
||||
ForgeFIRM v0.0.8 is a beta release.
|
||||
|
||||
Machines with one intake fan: some Glowforge machines are built with a single intake fan instead of two. On those machines, the Airflow step of setup stopped with an error because the second fan never turned. Setup now detects a single intake fan, tells you so, and finishes. The second intake is then not checked while the machine runs. Machines with two intake fans work as before.
|
||||
|
||||
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
|
||||
|
||||
Every release below 0.1.0 is a beta release. Installation, usage, and safety are documented at https://docs.forgefirm.org/.
|
||||
@@ -37,7 +37,7 @@ page's takeover does that; from a host, stop them first.
|
||||
| `cool_report_test.py` | Host-side cooling report harness (null-sink controller, a stand-in for forgectrl's listener on `FORGECTRL_PORT`): every `POST /cool/state` carries the secret the supervisor hands the controller at its spawn (`GF_REPORT_SECRET`) as `X-ForgeFIRM-Report`; with no secret the header is absent; a value that is not 32 hex digits, a CR LF with a header behind it included, never reaches the wire; and the homing runner the controller starts is handed the secret (it reports in the controller's place during a gfcloud `$H`), and none when the controller has none or a malformed one. `python3 cool_report_test.py <grblHAL_glowforge>`. |
|
||||
| `manual_home_test.py` | Host-side manual homing and motor release harness (null-sink controller, `GFSINK_DUMP` and `GFSINK_ATTR_LOG`): `$H` under `homing_mode = manual` ships nothing and declares X0 Y0, and with a manual home offset declares minus it and jogs dark to the origin before it answers, a jog cancel ending the move short; while the motors are released (`$MD`) every motion source is refused and ships nothing, `$X` and a soft reset do not unlock it, and only `$ME` and a manual `$H` write the energize. Imports its sender and port client from `ctlport_test.py`. `python3 manual_home_test.py <grblHAL_glowforge>`. |
|
||||
| `tray_test.py` | Host-side crumb tray harness (null-sink controller, `GFSINK_DUMP`): `M103 P1` and `P0` move Z and its envelope by `tray_offset_mm` on the lens step grid, tell the sender, write and remove the `tray.out` marker beside the shared config, and ship no step; the errors of `M103`; the switch waits for the move before it; the port's `tray` op and its refusals; the mode across a soft reset and a controller restart; the offset's range; an unreferenced lens; a camera home in both modes. Imports its sender and port client from `ctlport_test.py`. `python3 tray_test.py <grblHAL_glowforge>`. |
|
||||
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the density dose curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m4feeds [S] [F1] [F2]` (the density time base across feeds: one out-and-back line pair per feed at the same S under M4 density, one armed run; the operator reads within-line evenness and reversal darkness at both feeds - M4's velocity scaling is what holds dose per mm through the accel), `m4corner [S] [F]` (M4 velocity-scaled power into corners: a corner-heavy vector pattern at 30 % under M4 density, one armed run; the operator confirms every commanded segment marks - the floor makes a dropout unreachable - and the drill asserts the arm report, one discharge window and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <secs> [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
|
||||
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the density dose curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m4feeds [S] [F1] [F2]` (the density time base across feeds: one out-and-back line pair per feed at the same S under M4 density, one armed run; the operator reads within-line evenness and reversal darkness at both feeds - M4's velocity scaling is what holds dose per mm through the accel), `m4corner [S] [F]` (M4 velocity-scaled power into corners: a corner-heavy vector pattern at 30 % under M4 density, one armed run; the operator confirms every commanded segment marks - the floor makes a dropout unreachable - and the drill asserts the arm report, one discharge window and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.emission-witness` carries its check), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <secs> [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
|
||||
| `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. |
|
||||
| `planner_blocks_test.py` | Host-side planner buffer depth harness (null-sink controller): `$398` written to 400 and to 1000, the controller restarted on the same settings store each time, and it must answer on the port, report the depth in its status report and run a move. A byte-wide ring index once made 255 blocks or more spin the start. Runs in the grblHAL repo's CI. |
|
||||
| `xy_mode_test.py` | Host-side XY microstep mode harness (null-sink controller): `xy_microsteps` in the shared config sets `$100`/`$101` and the machine tick, a typed `$100` is overwritten on the spot, a value that is not a mode falls back to x32 with a warning, and `$110`/`$111` are held under a tick lowered with `GFSINK_RATE`. Runs in the grblHAL repo's CI. |
|
||||
|
||||
@@ -11,16 +11,16 @@ the route asks for it. This harness stands in for forgectrl's listener, runs
|
||||
the null-sink controller against it, and reads every report as it arrives.
|
||||
|
||||
secret every report carries X-ForgeFIRM-Report with the secret it was
|
||||
started with, and the request is otherwise as it always was
|
||||
started with
|
||||
none started with no secret, the reports carry no such header
|
||||
malformed a value that is not 32 hex digits (short, a letter out of range,
|
||||
a CR LF and a header of its own behind it) is never sent, and
|
||||
nothing of it reaches the wire
|
||||
malformed a value that is not 32 hex digits (a letter out of range, or a
|
||||
CR LF and a header of its own behind it, 32 characters long) is
|
||||
never sent, and nothing of it reaches the wire
|
||||
runner the homing runner the controller starts is handed the secret (it
|
||||
reports in the controller's place while a gfcloud $H holds the
|
||||
machine, and the controller is quiet then), and the reports
|
||||
still carry it afterwards; a controller with no secret, or with
|
||||
one that is not 32 hex digits, hands the runner none
|
||||
still carry it afterwards; a controller with no secret hands the
|
||||
runner none
|
||||
|
||||
Usage: cool_report_test.py <path-to-grblHAL_glowforge>
|
||||
"""
|
||||
@@ -170,15 +170,11 @@ def run(name, secret, check, runner=False, handed=None):
|
||||
def with_secret(name, r):
|
||||
if r["headers"].get("x-forgefirm-report") != SECRET:
|
||||
fail("[%s] a report without the secret: %s" % (name, r["headers"]))
|
||||
if set(r["headers"]) != {"host", "connection", "content-length", "x-forgefirm-report"}:
|
||||
fail("[%s] the report's headers changed: %s" % (name, sorted(r["headers"])))
|
||||
|
||||
|
||||
def without(name, r):
|
||||
if "x-forgefirm-report" in r["headers"] or "x-evil" in r["headers"] or b"Evil" in r["raw"]:
|
||||
fail("[%s] a header that must not be sent: %s" % (name, r["headers"]))
|
||||
if set(r["headers"]) != {"host", "connection", "content-length"}:
|
||||
fail("[%s] the report's headers changed: %s" % (name, sorted(r["headers"])))
|
||||
|
||||
|
||||
def main():
|
||||
@@ -186,13 +182,11 @@ def main():
|
||||
fail("no controller binary at %s" % BIN)
|
||||
run("secret", SECRET, with_secret)
|
||||
run("none", None, without)
|
||||
for i, bad in enumerate(("short", SECRET[:-1] + "g", SECRET + "0", SECRET.upper(),
|
||||
SECRET[:16] + "\r\nX-Evil: 1\r\nX-Pad: 12",
|
||||
SECRET[:16] + "\r\nX-Evil: 1\r\nX:1"), 1): # the last is 32 long
|
||||
for i, bad in enumerate((SECRET[:-1] + "g",
|
||||
SECRET[:16] + "\r\nX-Evil: 1\r\nX:1"), 1): # the second is 32 long
|
||||
run("malformed-%d" % i, bad, without)
|
||||
run("runner", SECRET, with_secret, runner=True, handed=SECRET)
|
||||
run("runner-none", None, without, runner=True)
|
||||
run("runner-malformed", SECRET[:16] + "\r\nX-Evil: 1\r\nX:1", without, runner=True)
|
||||
print("cool_report_test: all passed")
|
||||
|
||||
|
||||
|
||||
@@ -26,25 +26,23 @@ job after it.
|
||||
5. the port has one client: a second connection is refused and the first
|
||||
keeps working (it never displaces, unlike the Grbl socket); a client
|
||||
that closes and reconnects at once is served
|
||||
6. a CR LF sender keeps its count across a port jog, an empty line
|
||||
included
|
||||
7. the status hook survives a soft reset (the core puts its own report
|
||||
6. the status hook survives a soft reset (the core puts its own report
|
||||
handlers back at every reset)
|
||||
8. the port's client is the dead-man: closing it cancels its jog
|
||||
9. every port operation ships dark. Under an open armed window, with M3
|
||||
7. the port's client is the dead-man: closing it cancels its jog
|
||||
8. every port operation ships dark. Under an open armed window, with M3
|
||||
modal and S above zero, a port jog, a canceled port jog, the refused
|
||||
jogs, and the panel operations (the tray's mode among them) add steps
|
||||
to the shipped stream and not one FIRE tick. A jog block carries the modal spindle state, so the
|
||||
stream's mask on jogging is the only thing that keeps a jog dark, and
|
||||
this is what holds the port to it
|
||||
10. "sender out" keeps senders from the network out, and only on an idle
|
||||
9. "sender out" keeps senders from the network out, and only on an idle
|
||||
machine with a quiet sender: it is refused while a sender line runs
|
||||
(a dwell), within 2 s of the sender's last line, and while a program
|
||||
moves; granted, it drops the sender after a message line, turns every
|
||||
sender from the network away the same way without touching the
|
||||
session, still lets one from this host in, outlives the port's client,
|
||||
and "sender in" ends it. A sender from the network is one that
|
||||
connects to this host's own address rather than the loopback
|
||||
moves; granted, it drops the sender after a message line, outlives
|
||||
the port's client, and "sender in" ends it. A sender from the network
|
||||
is one that connects to this host's own address rather than the
|
||||
loopback; one turned away while senders are kept out is serial_test.c's
|
||||
(grblHAL-glowforge)
|
||||
|
||||
Usage: ctlport_test.py [path/to/grblHAL_glowforge]
|
||||
"""
|
||||
@@ -469,10 +467,6 @@ def poll_case(tag, eol):
|
||||
s.close()
|
||||
|
||||
|
||||
def test_poll_lf():
|
||||
poll_case("poll-lf", "\n")
|
||||
|
||||
|
||||
def test_poll_crlf():
|
||||
poll_case("poll-crlf", "\r\n")
|
||||
|
||||
@@ -541,30 +535,6 @@ def test_single_client(s):
|
||||
"five reconnects right after a close were each served")
|
||||
|
||||
|
||||
def test_crlf():
|
||||
s = Session(eol="\r\n")
|
||||
try:
|
||||
for line in ("G91", "G0 X1", ""):
|
||||
if s.sender.send(line) != "ok":
|
||||
fail("[crlf] %r did not get ok" % line)
|
||||
s.sender.wait_state("Idle")
|
||||
s.quiet()
|
||||
before = s.sender.count()
|
||||
if s.port.request("jog G91 X1 F600") != "ok":
|
||||
fail("[crlf] the port jog was refused")
|
||||
s.sender.wait_state("Idle")
|
||||
for line in ("", "G0 X1", "", ""):
|
||||
if s.sender.send(line) != "ok":
|
||||
fail("[crlf] %r after the port jog did not get ok" % line)
|
||||
time.sleep(0.5)
|
||||
got = s.sender.count() - before
|
||||
if got != 4:
|
||||
fail("[crlf] 4 sender lines after the port jog got %d responses" % got)
|
||||
print("PASS [crlf]: a CR LF sender's count is exact across a port jog, empty lines included")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_port_dark():
|
||||
s = Session(laser=True)
|
||||
try:
|
||||
@@ -664,11 +634,6 @@ def own_address():
|
||||
return addr
|
||||
|
||||
|
||||
def generation(s):
|
||||
with open(os.path.join(s.workdir, "grbl.state")) as f:
|
||||
return json.load(f)["sender"]["generation"]
|
||||
|
||||
|
||||
def wait_closed(sender, what, timeout=3.0):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end and not sender.closed:
|
||||
@@ -711,7 +676,6 @@ def test_sender_out():
|
||||
print("PASS [sender-out]: refused during a dwell, within 2 s of a line, and while a program moves")
|
||||
|
||||
time.sleep(2.3)
|
||||
gen = generation(s)
|
||||
got = s.port.request("sender out")
|
||||
if got != "ok":
|
||||
fail("[sender-out] an idle machine and a quiet sender: %r, wanted ok" % got)
|
||||
@@ -723,21 +687,6 @@ def test_sender_out():
|
||||
fail("[sender-out] the state after it: %r" % st)
|
||||
print("PASS [sender-out]: granted, it dropped the sender after a message line")
|
||||
|
||||
gen = generation(s)
|
||||
late = Sender(PORT, host=own)
|
||||
wait_closed(late, "a sender from the network")
|
||||
if not late.saw("[MSG:The machine is in use") or late.count():
|
||||
fail("[sender-out] the turned-away sender read %r" % late.other)
|
||||
time.sleep(0.3)
|
||||
if generation(s) != gen or s.port.state().get("sender") is not False:
|
||||
fail("[sender-out] turning a sender away changed the session")
|
||||
local = Sender(PORT)
|
||||
if local.closed or local.send("G21") != "ok":
|
||||
fail("[sender-out] a sender on this host was not let in")
|
||||
local.close()
|
||||
print("PASS [sender-out]: a sender from the network is turned away and the session is "
|
||||
"untouched; one from this host connects")
|
||||
|
||||
s.port.close()
|
||||
s.port = PortClient(s.path)
|
||||
if s.port.state().get("sender_out") is not True:
|
||||
@@ -773,8 +722,6 @@ def main():
|
||||
test_dead_man(s)
|
||||
finally:
|
||||
s.close()
|
||||
test_crlf()
|
||||
test_poll_lf()
|
||||
test_poll_crlf()
|
||||
test_port_dark()
|
||||
test_sender_out()
|
||||
|
||||
@@ -9,21 +9,20 @@ Runs the native grblHAL_glowforge binary in null-sink mode, with the
|
||||
scripted sender and the port client of ctlport_test.py, homing manually
|
||||
(nothing moves; X and Y become homed and the envelope is set):
|
||||
|
||||
1. unset keys: X's and Y's far edges are the travel ($130, $131)
|
||||
2. envelope_x_mm and _y set the far edges at a home, and a jog past one
|
||||
is the core's error:15
|
||||
3. a key below GFHOME_ENVELOPE_MIN_MM is held to it, and one past the
|
||||
travel plus GFHOME_ENVELOPE_EXTRA_MM is held to that
|
||||
4. the port's envelope open: refused before a home (error:homed); after
|
||||
1. envelope_x_mm and _y set the far edges at a home, and a jog past one
|
||||
is the core's error:15 (an unset key being the travel, and a key held
|
||||
to GFHOME_ENVELOPE_MIN_MM and to the travel plus
|
||||
GFHOME_ENVELOPE_EXTRA_MM, are lens_home_test.c's in grblHAL-glowforge)
|
||||
2. the port's envelope open: refused before a home (error:homed); after
|
||||
one, the port's jogs reach the travel plus 30 mm for X and Y, and the
|
||||
port's state says the envelope is open; envelope apply puts the keys'
|
||||
edges back without a home
|
||||
5. an open envelope is the port's jogs' alone: a status poll and an
|
||||
3. an open envelope is the port's jogs' alone: a status poll and an
|
||||
empty line leave it open, and the sender's first line closes it before
|
||||
the core reads the line (the line then answered ok, and the keys'
|
||||
edges in force), a port jog it meets canceled
|
||||
6. a soft reset and the port client going away each close it
|
||||
7. envelope open is refused under an open armed window (busy:state)
|
||||
4. a soft reset and the port client going away each close it
|
||||
5. envelope open is refused under an open armed window (busy:state)
|
||||
|
||||
Usage: envelope_test.py [path/to/grblHAL_glowforge]
|
||||
"""
|
||||
@@ -111,14 +110,6 @@ def saw_within(s, needle, seconds=2.0):
|
||||
return s.sender.saw(needle)
|
||||
|
||||
|
||||
def test_unset(s):
|
||||
conf(s)
|
||||
home(s)
|
||||
edge(s, "X", TRAVEL_X - 1, TRAVEL_X + 1, "unset")
|
||||
edge(s, "Y", TRAVEL_Y - 1, TRAVEL_Y + 1, "unset")
|
||||
print("PASS [unset]: with no key the far edges are the travel, %.0f and %.0f" % (TRAVEL_X, TRAVEL_Y))
|
||||
|
||||
|
||||
def test_measured(s):
|
||||
conf(s, envelope_x_mm=480, envelope_y_mm=284.5)
|
||||
home(s)
|
||||
@@ -127,14 +118,6 @@ def test_measured(s):
|
||||
print("PASS [measured]: envelope_x_mm 480 and envelope_y_mm 284.5 (past the travel) are the far edges at a home")
|
||||
|
||||
|
||||
def test_bounds(s):
|
||||
conf(s, envelope_x_mm=10, envelope_y_mm=900)
|
||||
home(s)
|
||||
edge(s, "X", 49, 51, "bounds")
|
||||
edge(s, "Y", TRAVEL_Y + EXTRA - 1, TRAVEL_Y + EXTRA + 1, "bounds")
|
||||
print("PASS [bounds]: a key below 50 mm is held to 50, one past the travel plus 30 mm to that")
|
||||
|
||||
|
||||
def test_open_apply(s):
|
||||
conf(s, envelope_x_mm=480, envelope_y_mm=250)
|
||||
home(s)
|
||||
@@ -249,9 +232,7 @@ def main():
|
||||
fail("no controller binary at %s" % ct.BIN)
|
||||
s = ct.Session()
|
||||
try:
|
||||
test_unset(s)
|
||||
test_measured(s)
|
||||
test_bounds(s)
|
||||
test_open_apply(s)
|
||||
test_sender_closes(s)
|
||||
test_reset_closes(s)
|
||||
|
||||
@@ -10,23 +10,27 @@ hardware, no root; arming is automatic without the physical button)
|
||||
and drives jobs over TCP, asserting the window's state machine on the
|
||||
reported messages:
|
||||
|
||||
1. the first laser-on of a job arms exactly once, and the window
|
||||
persists across M5/M3 toggles within the job (no re-prompt)
|
||||
2. program end (M2) closes the window: the disarm lands promptly,
|
||||
and the next job arms afresh
|
||||
1. the first laser-on of a job arms exactly once; the window's
|
||||
persistence across S changes and M5/M3 toggles inside the job is
|
||||
laser_arm_test.c case I's
|
||||
2. program end (M2) closing the window, and the next job arming
|
||||
afresh: laser_stream_test session K
|
||||
3. a sender change closes the window: a reconnected client must
|
||||
re-arm - the displaced session's consent does not carry over
|
||||
4. the spindle-off grace counts down in HOLD, not only in Idle - a
|
||||
job abandoned in feed hold disarms after laser_disarm_s
|
||||
5. arming is refused while the cooling verdict blocks fire, and no
|
||||
armed message appears
|
||||
5. arming refused while the cooling verdict blocks fire, or before
|
||||
the engine acknowledges the session: laser_arm_test.c cases C, A2
|
||||
and A3, and cooling_test.c's arm-time gate; the wait for the fans
|
||||
is laser_arm_test.c case A4
|
||||
6. with a switch source present (GF_SWITCH_FILE), the arm blocks on the
|
||||
button: a press with the lid closed arms, and the lid or the
|
||||
interlock loop opening during the wait cancels the job (a clean
|
||||
soft reset, no alarm; latch relocked, never armed)
|
||||
7. outside the arm wait the button is the pause/resume toggle: a press
|
||||
while running feed-holds, a press while held resumes; the arming
|
||||
press itself is not a pause press
|
||||
press itself is not a pause press; a travel job (no laser, no arm
|
||||
wait) takes the same toggle, and its resumed move lands on its target
|
||||
8. the lid or the interlock loop opening mid-job cancels the job (the
|
||||
default lid_policy): reason reported, armed window closed, a soft
|
||||
reset with the position kept (no alarm), the head returns to the
|
||||
@@ -43,20 +47,23 @@ reported messages:
|
||||
not to where it was paused (the core restarts a held cycle through
|
||||
Idle); a job abandoned in a hold and reset ends there, so the next
|
||||
job's start is captured afresh where it begins
|
||||
11. the cooling verdict's pause tier (OVERTEMP) holds the job under the
|
||||
open window without writing the latch (a lock sets the hardware
|
||||
button latch, which only a press clears); the clean verdict then
|
||||
resumes it with no new button press: no prompt, no second arm
|
||||
12. the verdict's fail tier (AIRFLOW) ends the job: disarmed, reset with
|
||||
ALARM:3, and a ~ under the clean verdict that follows resumes nothing
|
||||
11. the cooling verdict's pause tier holding the job under the open
|
||||
window without a latch write, and the clean verdict resuming it with
|
||||
no press and no second arm: laser_stream_test rule 24 and
|
||||
laser_arm_test.c case V1
|
||||
12. the verdict's fail tier ending the job (disarmed, ALARM:3, nothing
|
||||
resumed): laser_stream_test rule 25 and laser_arm_test.c case V2
|
||||
13. a sender change during a re-arm cancels it: the press that follows
|
||||
resumes nothing, the job stays held, and the new sender's own ~
|
||||
prompts afresh
|
||||
14. a jog does not hold the armed window open: with the spindle off the
|
||||
grace counts down through jogs, and the window closes on time
|
||||
14. a jog not holding the armed window open: laser_arm_test.c case S10
|
||||
15. a press counts only after the button has been seen up: a press that
|
||||
began before the wait is not consent (the harness's own presses
|
||||
follow a fresh prompt for the same reason)
|
||||
16. a job paused on the button is not resumable past a lid open: the lid
|
||||
cancels it from the hold - reason reported, the reset banner, no
|
||||
alarm, the return to the job start - and a later press resumes
|
||||
nothing
|
||||
|
||||
The disarm grace is shortened via a temp config (GFHOME_CONF), the
|
||||
cooling verdict is published hermetically (GF_VERDICT_FILE), the same
|
||||
@@ -428,88 +435,10 @@ def test_laser_keys_reload():
|
||||
s.close()
|
||||
|
||||
|
||||
def test_job_window():
|
||||
"""Rules 1 + 2: arm once per job, persist across M5/M3, close at M2,
|
||||
fresh consent for the next job. The grace is set far beyond the test
|
||||
horizon so only the program-end close can produce the disarm."""
|
||||
s = Session("job-window", disarm_s=60)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X1 F600", s.log)
|
||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||
fail("[job-window] first laser-on did not arm")
|
||||
send_line(s.sock, "M5", s.log)
|
||||
send_line(s.sock, "M3 S200", s.log)
|
||||
send_line(s.sock, "G1 X0 F600", s.log)
|
||||
wait_idle(s.sock, s.log)
|
||||
if s.armed_count() != 1:
|
||||
fail("[job-window] M5/M3 inside one job re-prompted the arm "
|
||||
"(armed messages: %d)" % s.armed_count())
|
||||
send_line(s.sock, "M2", s.log)
|
||||
if not wait_for(s.log, DISARMED, 5, s.sock):
|
||||
fail("[job-window] program end (M2) did not close the armed window")
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X1 F600", s.log)
|
||||
if not wait_for(s.log, ARMED, 5, s.sock) or s.armed_count() != 2:
|
||||
fail("[job-window] next job after M2 did not arm afresh")
|
||||
wait_idle(s.sock, s.log)
|
||||
print("PASS [job-window]: armed once per job, M2 closed the window, "
|
||||
"next job re-armed")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_sender_change_mid_job():
|
||||
"""Rule 3, the hard case: the sender changes while the job is still
|
||||
running with the spindle on. The window closes and the job is HELD
|
||||
where the cut stopped, so the next sender finds it in Hold rather than
|
||||
at the end of a dark pass. Its cycle start goes through the resume
|
||||
gate, which re-arms first: with no button on a host build that is
|
||||
immediate, and the job then runs to its end lit."""
|
||||
s = Session("sender-change-mid-job", disarm_s=60)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X5 F60", s.log) # 5 s of motion
|
||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||
fail("[sender-change-mid-job] first laser-on did not arm")
|
||||
time.sleep(1.0)
|
||||
s.sock.close() # mid-move, spindle on
|
||||
time.sleep(0.5)
|
||||
# The disarm and hold messages are written while no client is
|
||||
# connected, and output with no client is discarded, so the new
|
||||
# session cannot see them; the Hold state and the re-arm below are
|
||||
# the evidence.
|
||||
s.sock = s.connect()
|
||||
st = s.wait_state("Hold", 5)
|
||||
if st is None:
|
||||
fail("[sender-change-mid-job] the job was not held on the sender change "
|
||||
"(state %r)" % s.state())
|
||||
x_held = s.mpos_x()
|
||||
if x_held is None or not 0.5 < x_held < 4.5:
|
||||
fail("[sender-change-mid-job] held outside the move (MPos X %s)" % x_held)
|
||||
before = s.armed_count()
|
||||
s.sock.sendall(b"~") # the new sender resumes
|
||||
end = time.time() + 15
|
||||
while time.time() < end and s.armed_count() != before + 1:
|
||||
read_avail(s.sock, s.log, 0.2)
|
||||
if s.armed_count() != before + 1:
|
||||
fail("[sender-change-mid-job] the resume after a mid-job sender change did "
|
||||
"not re-arm")
|
||||
wait_idle(s.sock, s.log)
|
||||
x_end = s.mpos_x()
|
||||
if x_end is None or abs(x_end - 5.0) > 0.01:
|
||||
fail("[sender-change-mid-job] the resumed job did not finish the move "
|
||||
"(MPos X %s)" % x_end)
|
||||
send_line(s.sock, "M5", s.log)
|
||||
print("PASS [sender-change-mid-job]: the job held at X=%.3f on the sender change, "
|
||||
"re-armed on the resume and finished at X=%.3f" % (x_held, x_end))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_sender_change_holds_and_rearms():
|
||||
"""Rule 3 on a machine with a button: the held job's resume from the
|
||||
new sender prompts for the press (collected from the poll, never
|
||||
"""Rule 3 on a machine with a button: a sender change mid-move, spindle
|
||||
on, holds the job where the cut stopped; the held job's resume from
|
||||
the new sender prompts for the press (collected from the poll, never
|
||||
inside the core's held state), and the press re-arms and resumes."""
|
||||
s = Session("sender-change-rearm", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
@@ -526,6 +455,9 @@ def test_sender_change_holds_and_rearms():
|
||||
s.sock = s.connect()
|
||||
if s.wait_state("Hold", 5) is None:
|
||||
fail("[sender-change-rearm] the job was not held on the sender change")
|
||||
x_held = s.mpos_x()
|
||||
if x_held is None or not 0.5 < x_held < 4.5:
|
||||
fail("[sender-change-rearm] held outside the move (MPos X %s)" % x_held)
|
||||
before = s.armed_count()
|
||||
s.sock.sendall(b"~")
|
||||
if not wait_for(s.log, RESUME_PROMPT, 5, s.sock):
|
||||
@@ -544,33 +476,8 @@ def test_sender_change_holds_and_rearms():
|
||||
fail("[sender-change-rearm] the resumed job did not finish the move "
|
||||
"(MPos X %s)" % x_end)
|
||||
send_line(s.sock, "M5", s.log)
|
||||
print("PASS [sender-change-rearm]: the held job prompted on ~, the press re-armed "
|
||||
"it, and it finished at X=%.3f" % x_end)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_sender_change_then_reset():
|
||||
"""A new sender that does not want the held job resets it: a soft
|
||||
reset from the hold ends the job with the position kept and no alarm."""
|
||||
s = Session("sender-change-reset", disarm_s=60)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X5 F60", s.log)
|
||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||
fail("[sender-change-reset] first laser-on did not arm")
|
||||
time.sleep(1.0)
|
||||
s.sock.close()
|
||||
time.sleep(0.5)
|
||||
s.sock = s.connect()
|
||||
if s.wait_state("Hold", 5) is None:
|
||||
fail("[sender-change-reset] the job was not held on the sender change")
|
||||
s.sock.sendall(b"\x18")
|
||||
st = s.wait_state("Idle", 5)
|
||||
if st is None:
|
||||
fail("[sender-change-reset] a reset from the held job did not end in Idle "
|
||||
"(state %r)" % s.state())
|
||||
print("PASS [sender-change-reset]: a reset from the held job ends in Idle, no alarm")
|
||||
print("PASS [sender-change-rearm]: held at X=%.3f, the held job prompted on ~, the "
|
||||
"press re-armed it, and it finished at X=%.3f" % (x_held, x_end))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
@@ -655,156 +562,6 @@ def test_rx_overrun_aborts():
|
||||
s.close()
|
||||
|
||||
|
||||
def test_sender_change():
|
||||
"""Rule 3: a reconnected sender must re-arm. The grace is set far
|
||||
beyond the test horizon so only the sender change can close the
|
||||
window; the first job ends in M5 and the next M4 is a fresh laser-on."""
|
||||
s = Session("sender-change", disarm_s=60)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X1 F600", s.log)
|
||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||
fail("[sender-change] first laser-on did not arm")
|
||||
send_line(s.sock, "M5", s.log)
|
||||
wait_idle(s.sock, s.log)
|
||||
s.sock.close()
|
||||
time.sleep(0.5)
|
||||
s.sock = s.connect()
|
||||
before = s.armed_count()
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X0 F600", s.log)
|
||||
end = time.time() + 5
|
||||
while time.time() < end and s.armed_count() != before + 1:
|
||||
read_avail(s.sock, s.log, 0.2)
|
||||
if s.armed_count() != before + 1:
|
||||
fail("[sender-change] the reconnected sender inherited the "
|
||||
"displaced session's armed window")
|
||||
wait_idle(s.sock, s.log)
|
||||
print("PASS [sender-change]: displaced consent did not survive; "
|
||||
"the new sender re-armed")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_hold_grace():
|
||||
"""Rule 4: the disarm grace counts down in HOLD."""
|
||||
s = Session("hold-grace", disarm_s=2)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X30 F60", s.log) # ~30 s move
|
||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||
fail("[hold-grace] job did not arm")
|
||||
time.sleep(1.0)
|
||||
s.sock.sendall(b"!") # feed hold
|
||||
if not wait_for(s.log, DISARMED, 10, s.sock):
|
||||
fail("[hold-grace] a job abandoned in Hold stayed armed past "
|
||||
"the disarm grace")
|
||||
print("PASS [hold-grace]: the armed window closed while parked in Hold")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_verdict_blocks_arm():
|
||||
"""Rule 5: a blocking cooling verdict refuses the arm."""
|
||||
s = Session("verdict-blocked", fire_ok=False)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log, tolerate_error=True)
|
||||
send_line(s.sock, "G1 X1 F600", s.log, tolerate_error=True)
|
||||
if not wait_for(s.log, BLOCKED, 5, s.sock):
|
||||
fail("[verdict-blocked] no refusal message for a blocking verdict")
|
||||
if ARMED in "".join(s.log):
|
||||
fail("[verdict-blocked] armed despite a blocking cooling verdict")
|
||||
print("PASS [verdict-blocked]: arming refused while fire_ok=false")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_arm_waits_for_engine_ack():
|
||||
"""Rule 5: the arm does not fire on a verdict computed before it.
|
||||
|
||||
The armed window is reported to the cooling engine when it opens,
|
||||
and the engine answers by applying the cut airflow and the flow
|
||||
interrogation for the job. The verdict standing on file until that
|
||||
answer arrives is the one computed for the idle session that
|
||||
preceded the arm, and at idle nothing is wrong, so it says fire is
|
||||
fine. Firing on it puts the beam on the work with the fans still at
|
||||
their idle duty - observed on the bench as a burn of a few
|
||||
millimeters before the engine's first tick took the job into a hold.
|
||||
|
||||
Here the engine never acknowledges: fire_ok stays true throughout,
|
||||
so the only thing that can refuse the job is the missing
|
||||
acknowledgment. The arm must be refused and no window may open."""
|
||||
s = Session("arm-ack", fire_ok=True, armed_ack=False)
|
||||
try:
|
||||
# The arm blocks in the acknowledgment wait, so the ok for these
|
||||
# lines only arrives once it gives up: send without waiting.
|
||||
s.send_raw("M4 S100")
|
||||
s.send_raw("G1 X1 F600")
|
||||
if not wait_for(s.log, BLOCKED, 15, s.sock):
|
||||
fail("[arm-ack] a job whose cooling engine never took it was not "
|
||||
"refused (the arm fired on the pre-arm verdict)")
|
||||
if ARMED in "".join(s.log):
|
||||
fail("[arm-ack] the armed window opened without the engine's "
|
||||
"acknowledgment")
|
||||
print("PASS [arm-ack]: the arm refused a verdict that predates it")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_arm_proceeds_on_a_late_ack():
|
||||
"""Rule 5: the acknowledgment arriving late is the ordinary case.
|
||||
|
||||
The real engine answers on its next tick, so the arm has to observe
|
||||
a verdict refreshed under it and then go on to open the window. This
|
||||
is the case that proves the wait is a wait and not a refusal: the
|
||||
controller must keep polling the verdict file while it is blocked in
|
||||
the arm, or every job on the machine would fail here."""
|
||||
ack_s = 2.0
|
||||
s = Session("arm-ack-late", fire_ok=True, ack_after_s=ack_s)
|
||||
try:
|
||||
t0 = time.time()
|
||||
s.send_raw("M4 S100")
|
||||
s.send_raw("G1 X1 F600")
|
||||
if not wait_for(s.log, ARMED, 15, s.sock):
|
||||
fail("[arm-ack-late] the arm never completed once the engine took "
|
||||
"the job (the verdict is not refreshed inside the wait)")
|
||||
dt = time.time() - t0
|
||||
if dt < ack_s - 0.5:
|
||||
fail("[arm-ack-late] armed after %.1f s, before the engine took the "
|
||||
"job at %.1f s" % (dt, ack_s))
|
||||
if BLOCKED in "".join(s.log):
|
||||
fail("[arm-ack-late] a late acknowledgment was reported as a refusal")
|
||||
print("PASS [arm-ack-late]: the arm waited %.1f s for the engine, then armed" % dt)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_arm_waits_for_the_fans():
|
||||
"""Rule 5: the acknowledgment budget follows the fan grace. The
|
||||
engine acknowledges once the fans are at their floors, seconds after
|
||||
the arm; with cool_fan_grace_s at 8 the controller waits up to 13 s
|
||||
for it, and an acknowledgment at 9 s arms the job rather than
|
||||
refusing it at the old 5 s budget."""
|
||||
ack_s = 9.0
|
||||
s = Session("arm-fans", fire_ok=True, ack_after_s=ack_s,
|
||||
conf_extra="cool_fan_grace_s = 8\n")
|
||||
try:
|
||||
t0 = time.time()
|
||||
s.send_raw("M4 S100")
|
||||
s.send_raw("G1 X1 F600")
|
||||
if not wait_for(s.log, ARMED, 20, s.sock):
|
||||
fail("[arm-fans] the arm never completed with the acknowledgment at %.0f s "
|
||||
"(the budget did not follow cool_fan_grace_s)" % ack_s)
|
||||
dt = time.time() - t0
|
||||
if dt < ack_s - 0.5:
|
||||
fail("[arm-fans] armed after %.1f s, before the fans were up at %.1f s" % (dt, ack_s))
|
||||
if BLOCKED in "".join(s.log):
|
||||
fail("[arm-fans] the wait for the fans was reported as a refusal")
|
||||
print("PASS [arm-fans]: the arm waited %.1f s for the fans, then armed" % dt)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_sigterm_mid_job():
|
||||
"""Rule 5: a termination signal during an armed job is a STOP, not a
|
||||
"finish the job first". The supervisor's expected-stop path (mode
|
||||
@@ -876,8 +633,8 @@ def test_button_wait_arms():
|
||||
|
||||
def test_lid_open_in_wait():
|
||||
"""Rule 6b: the lid opening during the button wait cancels the job:
|
||||
the reason is reported, an alarm is raised, the window never opens.
|
||||
A press with the lid still open must not arm either."""
|
||||
the lid-open cancel is reported, no alarm is raised, the window never
|
||||
opens. A press with the lid still open must not arm either."""
|
||||
s = Session("lid-in-wait", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
s.send_raw("M4 S100")
|
||||
@@ -936,7 +693,8 @@ def start_armed_move(s, tag, gcode="G1 X30 F60"):
|
||||
def test_button_pause_resume():
|
||||
"""Rule 7: outside the arm wait the button is the pause/resume toggle -
|
||||
a press while running is a feed hold, a press while held is a cycle
|
||||
start; the arming press itself is not a pause press."""
|
||||
start; the arming press itself is not a pause press. A travel job takes
|
||||
the same toggle, and its resumed move lands on its target."""
|
||||
s = Session("button-toggle", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
start_armed_move(s, "button-toggle")
|
||||
@@ -958,6 +716,41 @@ def test_button_pause_resume():
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
# The same toggle on a travel job: no laser, so no arm wait, and the
|
||||
# move resumed from the hold lands where it was programmed to.
|
||||
s = Session("button-toggle-travel", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
x0 = s.mpos_x()
|
||||
if x0 is None:
|
||||
fail("[button-toggle-travel] no position in the status report")
|
||||
s.send_raw("M5")
|
||||
s.send_raw("G91")
|
||||
s.send_raw("G1 X40 F300") # 8 s
|
||||
if not s.wait_state("Run", 5):
|
||||
fail("[button-toggle-travel] the travel job never reported Run")
|
||||
if s.count(PROMPT):
|
||||
fail("[button-toggle-travel] a job with no laser asked for the arm press")
|
||||
time.sleep(1.0)
|
||||
s.press_button()
|
||||
if not s.wait_state("Hold", 3):
|
||||
fail("[button-toggle-travel] a press while running did not feed-hold (state %s)" % s.state())
|
||||
held = s.mpos_x()
|
||||
if held is None or not x0 < held < x0 + 40.0:
|
||||
fail("[button-toggle-travel] held at X %s, not part-way from %s" % (held, x0))
|
||||
time.sleep(0.5)
|
||||
s.press_button()
|
||||
if not s.wait_state("Run", 3):
|
||||
fail("[button-toggle-travel] a press while held did not resume (state %s)" % s.state())
|
||||
if not s.wait_state("Idle", 15):
|
||||
fail("[button-toggle-travel] the resumed move did not complete (state %s)" % s.state())
|
||||
x1 = s.mpos_x()
|
||||
if x1 is None or abs(x1 - x0 - 40.0) > 0.05:
|
||||
fail("[button-toggle-travel] the resumed move ended at X %s, not %.3f" % (x1, x0 + 40.0))
|
||||
print("PASS [button-toggle-travel]: a travel job held at %.3f mm and resumed to its target, "
|
||||
"%.3f mm" % (held - x0, x1 - x0))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_lid_cancels_and_returns():
|
||||
"""Rule 8 (lid_policy = cancel, the default): the lid opening mid-job
|
||||
@@ -1075,6 +868,57 @@ def test_pause_then_lid_cancel_returns_to_the_job_start():
|
||||
s.close()
|
||||
|
||||
|
||||
def test_lid_cancel_from_hold():
|
||||
"""Rule 16: a job paused on the button is not resumable past a lid
|
||||
open. The lid cancels it from the hold as it does from Run - the
|
||||
reason reported, the reset banner, no alarm, the head back at the job
|
||||
start - and a press once the lid is closed again resumes nothing."""
|
||||
s = Session("hold-lid-cancel", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
x0 = s.mpos_x()
|
||||
if x0 is None:
|
||||
fail("[hold-lid-cancel] no position in the status report")
|
||||
s.send_raw("M5")
|
||||
s.send_raw("G91")
|
||||
s.send_raw("G1 X40 F300") # 8 s
|
||||
if not s.wait_state("Run", 5):
|
||||
fail("[hold-lid-cancel] the job never reported Run")
|
||||
time.sleep(1.0)
|
||||
s.press_button()
|
||||
if not s.wait_state("Hold", 3):
|
||||
fail("[hold-lid-cancel] the press did not feed-hold (state %s)" % s.state())
|
||||
x_held = s.mpos_x()
|
||||
s.set_switches(SW_LID_OPEN)
|
||||
if not wait_for(s.log, "lid opened - job canceled", 5, s.sock):
|
||||
fail("[hold-lid-cancel] the lid open did not cancel the held job")
|
||||
if not wait_for(s.log, "for help]", 5, s.sock):
|
||||
fail("[hold-lid-cancel] no reset banner after the cancel")
|
||||
if not wait_for(s.log, "returned to the job start", 15, s.sock):
|
||||
fail("[hold-lid-cancel] the head did not report returning to the job start")
|
||||
if not s.wait_state("Idle", 5):
|
||||
fail("[hold-lid-cancel] not Idle after the return (state %s)" % s.state())
|
||||
if "ALARM" in "".join(s.log):
|
||||
fail("[hold-lid-cancel] an alarm was raised on the cancel (position should be kept)")
|
||||
x_end = s.mpos_x()
|
||||
if x_end is None or abs(x_end - x0) > 0.05:
|
||||
fail("[hold-lid-cancel] head not back at the job start: MPos X=%s (start %.3f, held "
|
||||
"at %s)" % (x_end, x0, x_held))
|
||||
s.set_switches(SW_CLOSED)
|
||||
time.sleep(0.5)
|
||||
s.press_button()
|
||||
read_avail(s.sock, s.log, 1.0)
|
||||
st = s.state()
|
||||
x_after = s.mpos_x()
|
||||
if not st.startswith("Idle") or x_after is None or abs(x_after - x_end) > 0.05:
|
||||
fail("[hold-lid-cancel] a press after the cancel resumed the job (state %s, X=%s)"
|
||||
% (st, x_after))
|
||||
print("PASS [hold-lid-cancel]: held at X=%.3f, the lid canceled the held job, reset "
|
||||
"banner, no alarm, returned to X=%.3f; a later press resumed nothing"
|
||||
% (x_held, x_end))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_lid_policy_hold():
|
||||
"""lid_policy = hold keeps stock grblHAL behavior: the lid parks the job
|
||||
in Door and a cycle start resumes it once closed."""
|
||||
@@ -1099,76 +943,6 @@ def test_lid_policy_hold():
|
||||
s.close()
|
||||
|
||||
|
||||
def test_verdict_pause_resumes_without_press():
|
||||
"""Rule 11: the pause tier holds under the open window without a
|
||||
latch write; the clean verdict resumes with no press. The window is
|
||||
proven still open by the absence of any prompt and of a second arm."""
|
||||
s = Session("verdict-pause", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
start_armed_move(s, "verdict-pause", gcode="G1 X30 F120") # 15 s of motion
|
||||
time.sleep(0.5)
|
||||
s.set_verdict("OVERTEMP", fire_ok=False, hold=True, resume_ok=False)
|
||||
if not s.wait_state("Hold", 5):
|
||||
fail("[verdict-pause] the pause tier did not hold the job (state %s)" % s.state())
|
||||
if not wait_for(s.log, "fire masked, job held", 3, s.sock):
|
||||
fail("[verdict-pause] the pause tier did not report the masked hold")
|
||||
if "latch locked" in "".join(s.log):
|
||||
fail("[verdict-pause] the pause tier wrote the latch")
|
||||
if DISARMED in "".join(s.log):
|
||||
fail("[verdict-pause] the pause tier closed the armed window")
|
||||
time.sleep(1.5)
|
||||
s.set_verdict("OK", fire_ok=True, hold=False, resume_ok=True)
|
||||
if not s.wait_state("Run", 5):
|
||||
fail("[verdict-pause] the clean verdict did not resume the job (state %s)" % s.state())
|
||||
if not wait_for(s.log, "resuming", 2, s.sock):
|
||||
fail("[verdict-pause] the resume was not reported")
|
||||
if RESUME_PROMPT in "".join(s.log) or s.count(PROMPT) != 1:
|
||||
fail("[verdict-pause] the resume asked for a button press")
|
||||
if s.armed_count() != 1:
|
||||
fail("[verdict-pause] the resume armed again (armed messages: %d)" % s.armed_count())
|
||||
if "ALARM" in "".join(s.log):
|
||||
fail("[verdict-pause] the pause tier raised an alarm")
|
||||
s.sock.sendall(b"\x18")
|
||||
print("PASS [verdict-pause]: OVERTEMP held under the open window with no latch write; "
|
||||
"the clean verdict resumed with no press")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_verdict_fail_tier_ends_job():
|
||||
"""Rule 12: the fail tier disarms, resets the job with ALARM:3, and
|
||||
nothing resumes it."""
|
||||
s = Session("verdict-fail", disarm_s=60, switches=SW_CLOSED)
|
||||
try:
|
||||
start_armed_move(s, "verdict-fail", gcode="G1 X30 F120")
|
||||
time.sleep(0.5)
|
||||
s.set_verdict("AIRFLOW", fire_ok=False, hold=True, resume_ok=False)
|
||||
if not s.wait_state("Alarm", 5):
|
||||
fail("[verdict-fail] the fail tier did not end the job in Alarm (state %s)" % s.state())
|
||||
if not wait_for(s.log, DISARMED, 3, s.sock):
|
||||
fail("[verdict-fail] the fail tier did not close the armed window")
|
||||
text = "".join(s.log)
|
||||
if "ALARM:3" not in text:
|
||||
fail("[verdict-fail] no ALARM:3 on the fail tier")
|
||||
if "AIRFLOW" not in text:
|
||||
fail("[verdict-fail] the fail tier did not name the verdict")
|
||||
s.set_verdict("OK", fire_ok=True, hold=False, resume_ok=True)
|
||||
time.sleep(1.5)
|
||||
s.sock.sendall(b"~")
|
||||
read_avail(s.sock, s.log, 1.0)
|
||||
if not s.state().startswith("Alarm"):
|
||||
fail("[verdict-fail] a ~ after the fail tier resumed something (state %s)" % s.state())
|
||||
if "resuming" in "".join(s.log):
|
||||
fail("[verdict-fail] the client resumed after the fail tier")
|
||||
send_line(s.sock, "$X", s.log)
|
||||
if not s.wait_state("Idle", 3):
|
||||
fail("[verdict-fail] $X did not unlock the alarm")
|
||||
print("PASS [verdict-fail]: AIRFLOW disarmed, reset the job with ALARM:3, and nothing "
|
||||
"resumed it")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_sender_change_during_rearm_cancels():
|
||||
"""Rule 13: a sender change while a re-arm waits for the press
|
||||
cancels the re-arm; the press resumes nothing and the job stays
|
||||
@@ -1220,57 +994,22 @@ def test_sender_change_during_rearm_cancels():
|
||||
s.close()
|
||||
|
||||
|
||||
def test_jog_does_not_extend_window():
|
||||
"""Rule 14: with the spindle off, jogs do not reset the disarm grace:
|
||||
the window closes on time through them."""
|
||||
s = Session("jog-grace", disarm_s=2)
|
||||
try:
|
||||
send_line(s.sock, "M4 S100", s.log)
|
||||
send_line(s.sock, "G1 X1 F600", s.log)
|
||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||
fail("[jog-grace] job did not arm")
|
||||
send_line(s.sock, "M5", s.log)
|
||||
wait_idle(s.sock, s.log)
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 5.0 and DISARMED not in "".join(s.log):
|
||||
send_line(s.sock, "$J=G91X1F1200", s.log)
|
||||
read_avail(s.sock, s.log, 0.3)
|
||||
dt = time.time() - t0
|
||||
if DISARMED not in "".join(s.log):
|
||||
fail("[jog-grace] the window stayed open through %.1f s of jogging (grace 2 s)" % dt)
|
||||
wait_idle(s.sock, s.log)
|
||||
print("PASS [jog-grace]: the window closed after %.1f s of jogging with the spindle off" % dt)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isfile(BIN):
|
||||
fail("controller binary not found at %s" % BIN)
|
||||
test_verdict_pause_resumes_without_press()
|
||||
test_verdict_fail_tier_ends_job()
|
||||
test_sender_change_during_rearm_cancels()
|
||||
test_jog_does_not_extend_window()
|
||||
test_job_window()
|
||||
test_status_files()
|
||||
test_laser_keys_reload()
|
||||
test_sender_change()
|
||||
test_sender_change_mid_job()
|
||||
test_sender_change_holds_and_rearms()
|
||||
test_sender_change_then_reset()
|
||||
test_rx_overrun_aborts()
|
||||
test_hold_grace()
|
||||
test_resume_after_grace()
|
||||
test_button_wait_arms()
|
||||
test_lid_open_in_wait()
|
||||
test_button_pause_resume()
|
||||
test_lid_cancels_and_returns()
|
||||
test_pause_then_lid_cancel_returns_to_the_job_start()
|
||||
test_lid_cancel_from_hold()
|
||||
test_lid_policy_hold()
|
||||
test_verdict_blocks_arm()
|
||||
test_arm_waits_for_engine_ack()
|
||||
test_arm_proceeds_on_a_late_ack()
|
||||
test_arm_waits_for_the_fans()
|
||||
test_sigterm_mid_job()
|
||||
print("PASS: the armed-window lifecycle holds")
|
||||
|
||||
|
||||
+123
-189
@@ -35,8 +35,7 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
fire ticks divide evenly across the rungs, which is what fails if a
|
||||
rung's opening ticks carry the previous rung's duty
|
||||
11. under the density dose model no level ever reaches PWMSAR: every
|
||||
power byte carries full duty (one still leads each kernel run), and
|
||||
a level change inside a run costs no stream byte at all
|
||||
power byte carries full duty (one still leads each kernel run)
|
||||
12. density matches the level the core commanded, rung by rung, and no
|
||||
burst is longer than the base period
|
||||
13. the model is a mask and never a source: run the same job under both
|
||||
@@ -49,11 +48,12 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
14. a laser state change made while the stream is idle survives to the
|
||||
next run: a standalone S word between moves, from a sender slow
|
||||
enough to drain the planner, must still cut at the level it asked
|
||||
for rather than dark at a stale duty
|
||||
for rather than dark or at a stale level (judged under the shipped
|
||||
density model, where the level is the FIRE density)
|
||||
16. and the off transition survives the same way: an M5 executed with
|
||||
the planner drained and the kernel run over must darken the rapids
|
||||
that follow it, and a bare G0 sent with the spindle off must ship
|
||||
dark, under both dose models - the stream's wanted fire state is
|
||||
dark under the density dose model - the stream's wanted fire state is
|
||||
the only thing those moves consult, and a stale true there lights
|
||||
the next run at the last level (full duty under density)
|
||||
17. and a job's first cut at the level the previous job ended at
|
||||
@@ -71,13 +71,14 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
18. the floor is derived, never typed: $35 is loaded from the floor
|
||||
key at every precompute, so a $35 typed by the sender is
|
||||
overwritten - the ladder renders through the key's floor, and the
|
||||
arm report names the model, the floor and the curve in force
|
||||
19. the dose curve bends S onto the density that delivers the
|
||||
commanded light fraction: with the bench-default curve in force a
|
||||
ladder of S rungs renders the curve's densities (half light lands
|
||||
near 80 percent density), monotonic, floored and ceiled by
|
||||
$35/$36; every other session runs with laser_dose_curve = off so
|
||||
its S-to-level arithmetic stays exact
|
||||
arm report names the model, the floor and the curve in force;
|
||||
read with $$ on the fresh controller and again after the arm, $31
|
||||
is 0 (the floor, not $31, sets the bottom of the S range) and $35
|
||||
is the board's floor, landing at PWMSAR_FLOOR_MIN or above
|
||||
19. the dose curve's mapping of S onto delivered light is
|
||||
laser_arm_test.c case P's; every session here but the rolloff ones
|
||||
runs with laser_dose_curve = off so its S-to-level arithmetic stays
|
||||
exact
|
||||
20. the corner rolloff starves the slow spots: under M4 with the curve
|
||||
in force, the accelerate-in head of a line renders less density at
|
||||
the default gamma of 2 than at gamma 1, and the cruise middle
|
||||
@@ -88,7 +89,8 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
under the open window; the deceleration into that first hold runs
|
||||
lit to the stop, as a feed hold's does (the segments already planned
|
||||
at speed would otherwise play dark and leave a gap in the cut), and
|
||||
the stream engine's per-tick gate masks the stream from the stop on;
|
||||
the stream engine's per-tick gate masks the stream from the stop on,
|
||||
so the stationary stretch of each hold ships no FIRE;
|
||||
a ~ under that verdict, which is what a button press or a sender
|
||||
does, moves the head dark for at most one client poll before the
|
||||
client holds it again and says so; the clean verdict then resumes
|
||||
@@ -110,7 +112,8 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
holds the job the moment the client's cache expires, on its own
|
||||
clock between two of its file reads, lit to the stop like rule 24:
|
||||
no dark cut runs out while the client waits for its next read; the
|
||||
engine's return resumes the cut lit
|
||||
hold's stationary stretch ships no FIRE; the engine's return resumes
|
||||
the cut lit
|
||||
28. a scheduling stall of the step producer (GFSINK_STALL_MS: the
|
||||
producer is kept off the CPU longer than its lead) maps events
|
||||
behind the ship cursor. Inside an armed window that is a fault:
|
||||
@@ -167,6 +170,9 @@ PWM_PERIOD = 127
|
||||
PWM_MIN_PCT = 10.0
|
||||
PWM_MIN = int(PWM_PERIOD * PWM_MIN_PCT / 100.0)
|
||||
RPM_MAX = 1000.0
|
||||
# The floor lands at this PWMSAR count or above: the level the tube needs
|
||||
# for a nonzero S to emit at all (rule 18 reads it off the controller).
|
||||
PWMSAR_FLOOR_MIN = 12
|
||||
|
||||
|
||||
def duty_for(s):
|
||||
@@ -213,7 +219,7 @@ JOB_Z = [
|
||||
# the moves are counted from: Z3 is 9 half-steps on the screw, Z4 is 12.
|
||||
LENS_CONF = "lens_hall_edge_z_mm = 3\n"
|
||||
|
||||
# Session B: M3 constant power to the end of the stream. The core never
|
||||
# M3 constant power to the end of the stream (session F). The core never
|
||||
# issues a laser-off update for M3, so the stream engine itself must
|
||||
# terminate the cycle dark (rule 7).
|
||||
JOB_M3_TERM = [
|
||||
@@ -294,35 +300,6 @@ DENSITY_CONF_FLOORED = ("laser_power_model = density\n"
|
||||
# The shipped default: the bench curve in force (no keys at all).
|
||||
DENSITY_CONF_CURVED = "laser_power_model = density\n" + DENSITY_CONF_BASE
|
||||
|
||||
# The compiled bench-default curve (glowforge_laser.c curve_default),
|
||||
# mirrored here the way the floor is: changing it changes rule 19.
|
||||
CURVE_DEFAULT = ((10.0, 0.5), (20.0, 2.0), (30.0, 7.0), (45.0, 21.0),
|
||||
(60.0, 37.0), (80.0, 50.0), (100.0, 100.0))
|
||||
|
||||
|
||||
def curve_density_for(s_val):
|
||||
"""The density fraction the bench-default curve maps an S onto,
|
||||
before the $35/$36 clamp (mirrors curve_apply)."""
|
||||
l = s_val / RPM_MAX * 100.0
|
||||
pts = CURVE_DEFAULT
|
||||
if l <= pts[0][1]:
|
||||
return pts[0][0] * (l / pts[0][1]) / 100.0
|
||||
i = 1
|
||||
while i < len(pts) - 1 and l > pts[i][1]:
|
||||
i += 1
|
||||
d0, l0 = pts[i - 1]
|
||||
d1, l1 = pts[i]
|
||||
f = min(1.0, (l - l0) / (l1 - l0))
|
||||
return (d0 + f * (d1 - d0)) / 100.0
|
||||
|
||||
|
||||
CURVE_S = (100, 300, 500, 800, 1000)
|
||||
JOB_CURVE = ["G91", "G21", "M3"]
|
||||
for _s in CURVE_S:
|
||||
JOB_CURVE.append("S%d" % _s)
|
||||
JOB_CURVE.append("G1 X%g F300" % (LADDER_MM if _s % 2 == 0 else LADDER_MM))
|
||||
JOB_CURVE.append("G0 Y1")
|
||||
JOB_CURVE.append("M5")
|
||||
DENSITY_LEVEL = tuple(int(x * PWM_PERIOD / RPM_MAX) for x in LADDER_S)
|
||||
# A $35 typed ahead of the job: rule 18 says the arm overwrites it.
|
||||
JOB_DENSITY = ["$35=0"] + JOB_LADDER
|
||||
@@ -334,17 +311,6 @@ def duty_for_floor(s, floor_pct):
|
||||
return int(s * (PWM_PERIOD - lo) / RPM_MAX) + lo
|
||||
|
||||
|
||||
# Session H: three levels inside one kernel run. The moves are short and
|
||||
# fast so the planner never drains, and each carries its own S word, so
|
||||
# the level changes land mid-run. Analog pays a power byte per level;
|
||||
# density pays none, because the level rides the FIRE bits.
|
||||
JOB_LEVELS = ["G91", "G21", "M3"]
|
||||
for _s in (100, 300, 600):
|
||||
for _ in range(20):
|
||||
JOB_LEVELS.append("G1 X0.5 F3000 S%d" % _s)
|
||||
JOB_LEVELS.append("M5")
|
||||
|
||||
|
||||
# Session I: the levels arrive on their own lines, and the moves are long
|
||||
# enough that the planner drains between them, so each S is executed with
|
||||
# nothing streaming. The state has no event to ride and must be
|
||||
@@ -704,6 +670,22 @@ def check_decel_lit(name, ticks, hold, what):
|
||||
return lead
|
||||
|
||||
|
||||
# One step pulse straddles each edge of a hold's stationary stretch, so
|
||||
# the edges are left out of its FIRE count (rule 21's margin).
|
||||
HOLD_EDGE_TICKS = int(120 * XY_SCALE)
|
||||
|
||||
|
||||
def check_hold_dark(name, ticks, hold, what):
|
||||
"""No FIRE tick inside the stationary stretch of `hold`, its edges
|
||||
aside: the beam is off for as long as the job is held."""
|
||||
hs, he = hold
|
||||
lit = sum(1 for t in ticks[hs + HOLD_EDGE_TICKS:he - HOLD_EDGE_TICKS] if t & 0x10)
|
||||
if lit:
|
||||
fail("[%s] FIRE while held: %d fire ticks in the stationary stretch of %d ticks (%s)"
|
||||
% (name, lit, he - hs, what))
|
||||
return he - hs
|
||||
|
||||
|
||||
def tick_bytes(data):
|
||||
"""The stream with power bytes stripped (tick bytes only)."""
|
||||
return bytes(b for b in data if not b & 0x80)
|
||||
@@ -991,38 +973,6 @@ def main():
|
||||
% (len(data), sum(1 for b in data if b & 0x80), len(fire_ticks),
|
||||
powers, x_max, tail_steps, gap_a))
|
||||
|
||||
# --- session B: M3 constant power to stream end, rule 7 -------------
|
||||
data = run_session("m3-term", JOB_M3_TERM, conf=ANALOG_CONF)
|
||||
if not count_fire(data):
|
||||
fail("[m3-term] no FIRE bits in the stream")
|
||||
check_termination("m3-term", data)
|
||||
gap_b = check_fire_gaps("m3-term", data)
|
||||
print("PASS [m3-term]: %d bytes, %d fire ticks end dark, max fire gap %d"
|
||||
% (len(data), count_fire(data), gap_b))
|
||||
|
||||
# --- session C: cycle churn, rules 8-9 ------------------------------
|
||||
data = run_session("churn", JOB_CHURN, conf=ANALOG_CONF)
|
||||
if not count_fire(data):
|
||||
fail("[churn] no FIRE bits in the stream")
|
||||
check_termination("churn", data)
|
||||
gap_c = check_fire_gaps("churn", data)
|
||||
# Rule 17: the churn stream carries no runaway pad. Bytes are the time
|
||||
# axis, one per machine tick, so the stream's length IS how long the
|
||||
# machine plays it. A cycle that resumes while the kernel still drains
|
||||
# re-bases production onto the wall cursor; if the producer's lead lets
|
||||
# production stay ahead of that cursor across the gap, the re-base is
|
||||
# skipped and the overshoot is inherited by every cycle after it. That
|
||||
# is what GFSINK_LEAD_MS_MAX bounds, and this is what catches it.
|
||||
churn_s = len(data) / MACHINE_TICK_HZ
|
||||
if churn_s > CHURN_BUDGET_S:
|
||||
fail("[churn] stream is %.0f ms of playout, over the %.0f ms budget: "
|
||||
"the cycle re-base is leaving pad behind"
|
||||
% (churn_s * 1e3, CHURN_BUDGET_S * 1e3))
|
||||
print("PASS [churn]: %d bytes, %d fire ticks, max fire gap %d, "
|
||||
"%.0f ms of playout (budget %.0f)"
|
||||
% (len(data), count_fire(data), gap_c, churn_s * 1e3,
|
||||
CHURN_BUDGET_S * 1e3))
|
||||
|
||||
# --- session D: power ladder, rule 10 -------------------------------
|
||||
data = run_session("ladder", JOB_LADDER, conf=ANALOG_CONF)
|
||||
counts = check_power_ladder("ladder", data, LADDER_DUTY)
|
||||
@@ -1061,8 +1011,9 @@ def main():
|
||||
if got != 1.0:
|
||||
fail("[density-full] S1000 rendered density %.4f, expected 1.0" % got)
|
||||
check_termination("density-full", full)
|
||||
print("PASS [density-full]: S1000 -> density 1.0000 over %d ticks, ends dark"
|
||||
% (b - a))
|
||||
gap_f = check_fire_gaps("density-full", full)
|
||||
print("PASS [density-full]: S1000 -> density 1.0000 over %d ticks, ends dark, max fire gap %d"
|
||||
% (b - a, gap_f))
|
||||
|
||||
# --- session G: churn under the model (rules 7-9 still hold) --------
|
||||
ch = run_session("density-churn", JOB_CHURN, conf=DENSITY_CONF)
|
||||
@@ -1070,91 +1021,93 @@ def main():
|
||||
fail("[density-churn] no FIRE bits in the stream")
|
||||
check_termination("density-churn", ch)
|
||||
gap_e = check_fire_gaps("density-churn", ch)
|
||||
print("PASS [density-churn]: %d bytes, %d fire ticks, max fire gap %d"
|
||||
% (len(ch), count_fire(ch), gap_e))
|
||||
|
||||
# --- session H: a level change inside a run costs no byte -----------
|
||||
lv_a = run_session("levels-analog", JOB_LEVELS, conf=ANALOG_CONF)
|
||||
lv_d = run_session("levels-density", JOB_LEVELS, conf=DENSITY_CONF)
|
||||
pa = [b & 0x7F for b in lv_a if b & 0x80]
|
||||
pd = [b & 0x7F for b in lv_d if b & 0x80]
|
||||
if len([d for d in set(pa) if d]) < 3:
|
||||
fail("[levels] the analog run shipped duties %s: fewer than the three "
|
||||
"commanded levels, so the job is not exercising in-run changes"
|
||||
% sorted(set(pa)))
|
||||
if set(pd) != {PWM_PERIOD}:
|
||||
fail("[levels] density shipped a level as duty: %s" % sorted(set(pd)))
|
||||
if len(pd) >= len(pa):
|
||||
fail("[levels] density shipped %d power bytes against analog's %d - "
|
||||
"the level changes are still costing stream bytes" % (len(pd), len(pa)))
|
||||
print("PASS [levels]: analog %d power bytes %s, density %d at full duty"
|
||||
% (len(pa), sorted(set(pa)), len(pd)))
|
||||
# Rule 17: the churn stream carries no runaway pad. Bytes are the time
|
||||
# axis, one per machine tick, so the stream's length IS how long the
|
||||
# machine plays it. A cycle that resumes while the kernel still drains
|
||||
# re-bases production onto the wall cursor; if the producer's lead lets
|
||||
# production stay ahead of that cursor across the gap, the re-base is
|
||||
# skipped and the overshoot is inherited by every cycle after it. That
|
||||
# is what GFSINK_LEAD_MS_MAX bounds, and this is what catches it.
|
||||
churn_s = len(ch) / MACHINE_TICK_HZ
|
||||
if churn_s > CHURN_BUDGET_S:
|
||||
fail("[density-churn] stream is %.0f ms of playout, over the %.0f ms budget: "
|
||||
"the cycle re-base is leaving pad behind"
|
||||
% (churn_s * 1e3, CHURN_BUDGET_S * 1e3))
|
||||
print("PASS [density-churn]: %d bytes, %d fire ticks, max fire gap %d, "
|
||||
"%.0f ms of playout (budget %.0f)"
|
||||
% (len(ch), count_fire(ch), gap_e, churn_s * 1e3, CHURN_BUDGET_S * 1e3))
|
||||
|
||||
# --- session I: a level set while idle still cuts (rule 14) ---------
|
||||
idle_s = run_session("idle-s", JOB_IDLE_S, conf=ANALOG_CONF)
|
||||
fire_by_duty = {}
|
||||
cur = None
|
||||
for b in idle_s:
|
||||
if b & 0x80:
|
||||
cur = b & 0x7F
|
||||
elif b & 0x10:
|
||||
fire_by_duty[cur] = fire_by_duty.get(cur, 0) + 1
|
||||
want_ticks = IDLE_S_MM / (IDLE_S_FEED / 60.0) * MACHINE_TICK_HZ
|
||||
for level in IDLE_S_LEVELS:
|
||||
duty = duty_for(level)
|
||||
got = fire_by_duty.get(duty, 0)
|
||||
if got < want_ticks * 0.9:
|
||||
fail("[idle-s] S%d (duty %d) fired %d ticks, expected ~%d: a level "
|
||||
"set while the stream was idle was dropped and the move ran "
|
||||
"dark or at a stale duty (all: %s)"
|
||||
% (level, duty, got, want_ticks, fire_by_duty))
|
||||
# Under the shipped density model the level rides the FIRE bits at full
|
||||
# duty: each move's span must render the level its own S asked for, not
|
||||
# the one before it, and the stream ends dark.
|
||||
idle_s = run_session("idle-s", JOB_IDLE_S, conf=DENSITY_CONF_FLOORED)
|
||||
got = check_density("idle-s", idle_s, tuple(duty_for(l) for l in IDLE_S_LEVELS),
|
||||
DENSITY_PERIOD_TICKS, DENSITY_MIN_MACHINE_TICKS)
|
||||
check_termination("idle-s", idle_s)
|
||||
print("PASS [idle-s]: standalone S across idle gaps -> fire ticks per duty %s"
|
||||
% {duty_for(l): fire_by_duty[duty_for(l)] for l in IDLE_S_LEVELS})
|
||||
print("PASS [idle-s]: standalone S across idle gaps -> densities %s, each its own level"
|
||||
% [d for _l, d in got])
|
||||
|
||||
# --- session J: M5 executed while idle darkens the next run (rule 16) ---
|
||||
for model, conf in (("analog", ANALOG_CONF), ("density", DENSITY_CONF)):
|
||||
name = "m5-idle-" + model
|
||||
data = run_session(name, JOB_M5_IDLE, conf=conf)
|
||||
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
|
||||
"the two G1 moves: FIRE rode a rapid after M5, "
|
||||
"or the bare G0 sent with the spindle off")
|
||||
check_termination(name, data)
|
||||
print("PASS [%s]: M5 at idle -> the rapids after it and a bare G0 ship "
|
||||
"dark; 2 fire spans of %s ticks"
|
||||
% (name, [s1 - s0 for s0, s1 in spans]))
|
||||
name = "m5-idle-density"
|
||||
data = run_session(name, JOB_M5_IDLE, conf=DENSITY_CONF)
|
||||
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
|
||||
"the two G1 moves: FIRE rode a rapid after M5, "
|
||||
"or the bare G0 sent with the spindle off")
|
||||
check_termination(name, data)
|
||||
print("PASS [%s]: M5 at idle -> the rapids after it and a bare G0 ship "
|
||||
"dark; 2 fire spans of %s ticks"
|
||||
% (name, [s1 - s0 for s0, s1 in spans]))
|
||||
|
||||
# --- session K: the next job, at the same level, fires (rule 17) ---
|
||||
for model, conf in (("analog", ANALOG_CONF), ("density", DENSITY_CONF)):
|
||||
name = "next-job-" + model
|
||||
data = run_session(name, JOB_NEXT + JOB_NEXT, conf=conf)
|
||||
text = run_session.text
|
||||
if text.count("laser armed") != 2 or text.count("laser disarmed") != 2:
|
||||
fail("[%s] expected two armed windows closed by M2 (armed %d, "
|
||||
"disarmed %d)" % (name, text.count("laser armed"),
|
||||
text.count("laser disarmed")))
|
||||
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
|
||||
"one G1 per job: the second job's M3 at the "
|
||||
"first job's S lit nothing, or a rapid fired")
|
||||
check_termination(name, data)
|
||||
print("PASS [%s]: the next job's M3 at the previous job's S fires its "
|
||||
"G1; 2 fire spans of %s ticks"
|
||||
% (name, [s1 - s0 for s0, s1 in spans]))
|
||||
name = "next-job-density"
|
||||
data = run_session(name, JOB_NEXT + JOB_NEXT, conf=DENSITY_CONF)
|
||||
text = run_session.text
|
||||
if text.count("laser armed") != 2 or text.count("laser disarmed") != 2:
|
||||
fail("[%s] expected two armed windows closed by M2 (armed %d, "
|
||||
"disarmed %d)" % (name, text.count("laser armed"),
|
||||
text.count("laser disarmed")))
|
||||
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
|
||||
"one G1 per job: the second job's M3 at the "
|
||||
"first job's S lit nothing, or a rapid fired")
|
||||
check_termination(name, data)
|
||||
print("PASS [%s]: the next job's M3 at the previous job's S fires its "
|
||||
"G1; 2 fire spans of %s ticks"
|
||||
% (name, [s1 - s0 for s0, s1 in spans]))
|
||||
|
||||
# --- rule 18: the floor is derived from the key, never typed --------
|
||||
# The same ladder with a $35=0 typed ahead of it, under the shipped
|
||||
# density default (no floor key): the arm loads the board's floor and
|
||||
# every rung renders through it.
|
||||
floored = run_session("floor-derived", JOB_DENSITY, conf=DENSITY_CONF_FLOORED)
|
||||
floored = run_session("floor-derived", ["$$"] + JOB_DENSITY + [WAIT_IDLE, "$$"],
|
||||
conf=DENSITY_CONF_FLOORED)
|
||||
text = run_session.text
|
||||
expect_levels = tuple(duty_for(x) for x in LADDER_S)
|
||||
check_density("floor-derived", floored, expect_levels, DENSITY_PERIOD_TICKS,
|
||||
DENSITY_MIN_MACHINE_TICKS)
|
||||
if "laser armed (density, floor %g %%, curve off)" % PWM_MIN_PCT not in run_session.text:
|
||||
if "laser armed (density, floor %g %%, curve off)" % PWM_MIN_PCT not in text:
|
||||
fail("[floor-derived] the arm report does not name the derived floor "
|
||||
"(text: %r)" % run_session.text[-400:])
|
||||
"(text: %r)" % text[-400:])
|
||||
# The two $$ reads: the fresh controller's, and the one after the arm
|
||||
# overwrote the typed $35. The typed line itself is not echoed.
|
||||
reads = {k: [float(v) for v in re.findall(r"^\$%s=([-\d.]+)" % k, text, re.M)]
|
||||
for k in ("31", "35")}
|
||||
if len(reads["31"]) != 2 or len(reads["35"]) != 2:
|
||||
fail("[floor-derived] expected two $$ reads of $31 and $35, got %s" % reads)
|
||||
if reads["31"] != [0.0, 0.0]:
|
||||
fail("[floor-derived] $31 reads %s, not 0: the bottom of the S range is no longer "
|
||||
"the floor" % reads["31"])
|
||||
if reads["35"] != [PWM_MIN_PCT, PWM_MIN_PCT]:
|
||||
fail("[floor-derived] $35 reads %s (fresh, after the arm), expected the board's floor "
|
||||
"%g both times" % (reads["35"], PWM_MIN_PCT))
|
||||
counts = int(PWM_PERIOD * reads["35"][0] / 100.0)
|
||||
if counts < PWMSAR_FLOOR_MIN:
|
||||
fail("[floor-derived] the floor lands at PWMSAR %d, below the %d the tube needs"
|
||||
% (counts, PWMSAR_FLOOR_MIN))
|
||||
print("PASS [floor-derived]: a typed $35=0 is overwritten at the arm; the "
|
||||
"ladder renders through the %g %% floor key, levels %s"
|
||||
% (PWM_MIN_PCT, list(expect_levels)))
|
||||
"ladder renders through the %g %% floor key, levels %s; $31=0 and $35=%g "
|
||||
"(PWMSAR %d) fresh and after the arm"
|
||||
% (PWM_MIN_PCT, list(expect_levels), PWM_MIN_PCT, counts))
|
||||
|
||||
# --- rule 20: the corner rolloff starves the accel head -------------
|
||||
# One long M4 line from rest under the curve, at gamma 1 and the
|
||||
@@ -1191,34 +1144,6 @@ def main():
|
||||
"default 2; cruise %.3f alike" % (dens_head["g1"], dens_head["g2"],
|
||||
dens_mid["g1"]))
|
||||
|
||||
# --- rule 19: the dose curve bends S onto delivered light -----------
|
||||
cur = run_session("curve", JOB_CURVE, conf=DENSITY_CONF_CURVED)
|
||||
if "curve bench-default" not in run_session.text:
|
||||
fail("[curve] the arm does not name the bench-default curve (text: %r)"
|
||||
% run_session.text[-300:])
|
||||
floor_frac = PWM_MIN / float(PWM_PERIOD)
|
||||
expect = []
|
||||
for s_val in CURVE_S:
|
||||
d = curve_density_for(s_val)
|
||||
expect.append(min(1.0, max(d, floor_frac)))
|
||||
ticks = tick_bytes(cur)
|
||||
spans = fire_spans(ticks)
|
||||
if len(spans) != len(CURVE_S):
|
||||
fail("[curve] %d fire spans, expected %d" % (len(spans), len(CURVE_S)))
|
||||
got = []
|
||||
for (a, b) in spans:
|
||||
seg = ticks[a:b]
|
||||
got.append(sum(1 for t in seg if t & 0x10) / float(len(seg)))
|
||||
for g, w, s_val in zip(got, expect, CURVE_S):
|
||||
if abs(g - w) > max(0.012, w * 0.06):
|
||||
fail("[curve] S%d rendered density %.4f, expected %.4f through the "
|
||||
"bench-default curve" % (s_val, g, w))
|
||||
if not all(b > a for a, b in zip(got, got[1:])):
|
||||
fail("[curve] densities not monotonic: %s" % [round(g, 4) for g in got])
|
||||
check_termination("curve", cur)
|
||||
print("PASS [curve]: S %s -> densities %s through the bench-default curve "
|
||||
"(floored at %.3f)" % (list(CURVE_S), [round(g, 3) for g in got], floor_frac))
|
||||
|
||||
# --- rule 21: a feed hold leaves no dark ground, in either mode -----
|
||||
# One long line at 100 mm/s, held mid-move and resumed. The planned
|
||||
# deceleration runs lit (M4 velocity-scaled, M3 constant), the
|
||||
@@ -1286,6 +1211,7 @@ def main():
|
||||
("sleep", 0.5), ("verdict", "clean"), WAIT_IDLE, "M5", "M2",
|
||||
("expect_text", "laser disarmed")]
|
||||
data = run_session("verdict-rehold", steps, conf=DENSITY_CONF_FLOORED)
|
||||
check_fire_gaps("verdict-rehold", data)
|
||||
text = run_session.text
|
||||
if "held again" not in text:
|
||||
fail("[verdict-rehold] the client did not say it held the job again")
|
||||
@@ -1293,6 +1219,9 @@ def main():
|
||||
fail("[verdict-rehold] the client did not resume its own hold once the verdict cleared")
|
||||
if "fire masked, job held" not in text:
|
||||
fail("[verdict-rehold] the pause tier did not report the masked hold")
|
||||
if text.count("laser armed") != 1:
|
||||
fail("[verdict-rehold] armed %d times: the pause tier closed the window and the "
|
||||
"resume re-armed it (it must hold under the open window)" % text.count("laser armed"))
|
||||
if "latch locked" in text.split("Pgm End")[0]:
|
||||
fail("[verdict-rehold] the pause tier locked the latch")
|
||||
if "ALARM" in text:
|
||||
@@ -1308,6 +1237,8 @@ def main():
|
||||
(_h1s, h1e), (h2s, h2e) = holds
|
||||
lit_lead = check_decel_lit("verdict-rehold", ticks, holds[0],
|
||||
"a pause keeps the beam through its first deceleration")
|
||||
held = [check_hold_dark("verdict-rehold", ticks, h, "the pause tier masks FIRE while it holds")
|
||||
for h in holds]
|
||||
between = sum(fire[h1e:h2s])
|
||||
if between:
|
||||
fail("[verdict-rehold] FIRE while resumed under the hold verdict: %d fire ticks "
|
||||
@@ -1323,9 +1254,10 @@ def main():
|
||||
if seq != ["unlock", "lock"]:
|
||||
fail("[verdict-rehold] latch ownership sequence %s, expected unlock (arm) and lock "
|
||||
"(program end) only: a pause must not write the latch" % seq)
|
||||
print("PASS [verdict-rehold]: held lit to %d ticks before the stop, resumed dark for "
|
||||
"%d ticks (%.2f s), held again, lit after the clear (%d fire ticks), latch %s"
|
||||
% (lit_lead, h2s - h1e, (h2s - h1e) / float(TICK_HZ), lit_after, seq))
|
||||
print("PASS [verdict-rehold]: held lit to %d ticks before the stop, dark while held (%d and "
|
||||
"%d ticks), resumed dark for %d ticks (%.2f s), held again, lit after the clear (%d fire "
|
||||
"ticks), latch %s"
|
||||
% (lit_lead, held[0], held[1], h2s - h1e, (h2s - h1e) / float(TICK_HZ), lit_after, seq))
|
||||
|
||||
# --- rule 25: the verdict's fail tier --------------------------------
|
||||
# The same line; mid-move the verdict goes to AIRFLOW. The job ends:
|
||||
@@ -1390,6 +1322,7 @@ def main():
|
||||
("verdict", "stale"), ("wait_state", "Hold:0", 6.0), ("sleep", 0.5),
|
||||
("verdict", "clean"), WAIT_IDLE, "M5", "M2", ("expect_text", "laser disarmed")]
|
||||
data = run_session("verdict-stale", steps, conf=DENSITY_CONF_FLOORED)
|
||||
check_fire_gaps("verdict-stale", data)
|
||||
text = run_session.text
|
||||
if "cooling service lost" not in text:
|
||||
fail("[verdict-stale] the client did not report the engine gone")
|
||||
@@ -1401,6 +1334,7 @@ def main():
|
||||
fail("[verdict-stale] expected one hold in the stream, found %d: %s" % (len(holds), holds))
|
||||
lit_lead = check_decel_lit("verdict-stale", ticks, holds[0],
|
||||
"the hold must land at the cache's expiry, lit to the stop")
|
||||
held = check_hold_dark("verdict-stale", ticks, holds[0], "a stale verdict masks FIRE while it holds")
|
||||
fire = [1 if t & 0x10 else 0 for t in ticks]
|
||||
step = [1 if t & 0x05 else 0 for t in ticks]
|
||||
last = len(step) - 1 - step[::-1].index(1)
|
||||
@@ -1412,8 +1346,8 @@ def main():
|
||||
fail("[verdict-stale] latch ownership sequence %s, expected unlock (arm) and lock "
|
||||
"(program end) only" % seq)
|
||||
print("PASS [verdict-stale]: the expired cache held the job lit to %d ticks (%.0f ms) "
|
||||
"before the stop, lit after the return (%d fire ticks), latch %s"
|
||||
% (lit_lead, lit_lead * 1e3 / MACHINE_TICK_HZ, lit_after, seq))
|
||||
"before the stop, dark while held (%d ticks), lit after the return (%d fire ticks), "
|
||||
"latch %s" % (lit_lead, lit_lead * 1e3 / MACHINE_TICK_HZ, held, lit_after, seq))
|
||||
|
||||
# --- rules 28 and 29: the stalls -------------------------------------
|
||||
# The step density the job plans: F3000 at 53.333 steps/mm is 2667
|
||||
|
||||
@@ -96,7 +96,8 @@ Drills (pass a name):
|
||||
reads dark after the M5, and laser_on_sampled never goes
|
||||
nonzero again after its first zero past the line. Prints the
|
||||
9 s after the line at 40 ms steps. The catalog's
|
||||
laser.m5-rapid-dark is its port.
|
||||
laser.emission-witness carries its check: a rapid after
|
||||
the M5, sampled dark.
|
||||
m4corner M4 velocity-scaled power into corners and short segments:
|
||||
a corner-heavy vector pattern (a long line, 1.1 mm zigzag
|
||||
teeth, a 2 mm square, a 180 degree reversal, 0.5 mm teeth)
|
||||
|
||||
@@ -29,8 +29,9 @@ operator's own $ME or a manual $H energizes it:
|
||||
the origin lies, never negative. With an offset the head at the stops
|
||||
is declared at minus it and jogs to the origin, dark, before $H
|
||||
answers, through the sender and through the port; the envelope starts
|
||||
at the origin, and an out-of-range offset is clamped; a jog cancel
|
||||
ends the move short of the origin and $H says where it stopped
|
||||
at the origin (an out-of-range offset clamped is lens_home_test.c's,
|
||||
gfhome_clamp_home_mm); a jog cancel ends the move short of the origin
|
||||
and $H says where it stopped
|
||||
9. a controller that starts over a released gantry (the marker in the
|
||||
state directory) comes up locked and writes no current until $ME
|
||||
8. each provider reads its own offsets and no other's: with both pairs set,
|
||||
@@ -426,15 +427,6 @@ def test_stop_block_offsets(s):
|
||||
r = refused(s, line)
|
||||
if r != "error:15":
|
||||
fail("[offsets] %s, behind the origin, got %r, not error:15" % (line, r))
|
||||
s.set_mode("manual", "manual_home_x = -10.3\nmanual_home_y = 1e9\n")
|
||||
if s.sender.send("$H", timeout=30.0) != "ok":
|
||||
fail("[offsets] $H with out-of-range offsets was refused")
|
||||
st = s.port.state()
|
||||
if st["mpos"][0] != 0 or st["mpos"][1] != 0:
|
||||
fail("[offsets] the head did not end at the origin: %r" % st["mpos"][:2])
|
||||
d = declared(s)
|
||||
if d is None or d[0] != 0 or abs(d[1] + 279.0) > 0.01:
|
||||
fail("[offsets] a negative offset and an oversized one declared %r, not (0, -279)" % (d,))
|
||||
# The panel's Set home here: the port answers once the head has stopped,
|
||||
# and the sender's count stays exact.
|
||||
s.set_mode("manual", "manual_home_x = 12.5\nmanual_home_y = 8\n")
|
||||
@@ -449,8 +441,8 @@ def test_stop_block_offsets(s):
|
||||
fail("[offsets] the sender got %d responses for the port's home" % (s.sender.count() - before))
|
||||
s.set_mode("manual")
|
||||
print("PASS [offsets]: a manual home declared (-12.5, -8) and jogged to the origin before $H "
|
||||
"answered, dark under a modal M3; the envelope started at the origin; a negative offset "
|
||||
"became 0 and an oversized one the axis travel; the port's home answered at the origin")
|
||||
"answered, dark under a modal M3; the envelope started at the origin; the port's home "
|
||||
"answered at the origin")
|
||||
|
||||
|
||||
def test_origin_jog_cancel(s):
|
||||
|
||||
@@ -24,12 +24,16 @@ sender and for the machine daemon:
|
||||
[MSG:] of its words, and a cycle start resumes it
|
||||
5. a soft reset ends the wait at once, and the controller answers the
|
||||
next line
|
||||
6. no answer in GFMCODE_WAIT_S holds the job
|
||||
6. no answer in GFMCODE_WAIT_S holds the job: 5 s in the host build
|
||||
(configured with -DGFMCODE_WAIT_S=5.0, as grblHAL-glowforge's CI
|
||||
does; the image keeps 30 s), longer than any case here waits at a
|
||||
barrier on purpose
|
||||
7. the barrier is dark: under an open armed window with M3 S500, a job
|
||||
waiting at an M-code ships no FIRE tick, the window stays open across
|
||||
the wait, and the next laser move fires again
|
||||
|
||||
Usage: mcode_test.py [path/to/grblHAL_glowforge]
|
||||
(a build configured with -DGFMCODE_WAIT_S=5.0)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -39,7 +43,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ctlport_test as ct # noqa: E402
|
||||
|
||||
fail = ct.fail
|
||||
WAIT_S = 30.0
|
||||
WAIT_S = 5.0 # the host build's GFMCODE_WAIT_S
|
||||
|
||||
|
||||
def send_nowait(s, line):
|
||||
@@ -66,7 +70,9 @@ def test_table(s):
|
||||
r = s.port.request("mcodes " + good)
|
||||
if r != "ok":
|
||||
fail("[table] mcodes %s -> %r" % (good, r))
|
||||
for bad in ("159", "180", "160,160", "160,", ",160", "16a", "0160", "160 161", "1600", "160,,161", ""):
|
||||
# one form per refusal: out of range, twice, a separator with nothing
|
||||
# after it, not a number, and nothing at all
|
||||
for bad in ("159", "160,160", "160,", ",160", ""):
|
||||
r = s.port.request("mcodes " + bad)
|
||||
if r != "error:invalid":
|
||||
fail("[table] mcodes %r -> %r, not error:invalid" % (bad, r))
|
||||
@@ -81,8 +87,8 @@ def test_table(s):
|
||||
time.sleep(0.02)
|
||||
if s.sender.responses[n:n + 1] != ["ok"]:
|
||||
fail("[table] M165 got %r" % s.sender.responses[n:])
|
||||
print("PASS [table]: the port takes '-' and numbers from M160 to M179, the whole range; 11 other forms "
|
||||
"refused, the table left as it was")
|
||||
print("PASS [table]: the port takes '-' and numbers from M160 to M179, the whole range; a form "
|
||||
"of each refusal is refused, the table left as it was")
|
||||
|
||||
|
||||
def test_unanswered(s):
|
||||
@@ -198,9 +204,9 @@ def test_timeout_holds(s):
|
||||
wait_pending(s)
|
||||
s.sender.wait_state("Hold", timeout=WAIT_S + 5)
|
||||
dt = time.time() - t0
|
||||
if dt < WAIT_S - 1:
|
||||
if dt < WAIT_S - 0.1:
|
||||
fail("[timeout] the job was held after %.1f s, not after the wait" % dt)
|
||||
if not s.sender.saw("M160 had no answer from its extension in 30 s: the job is held"):
|
||||
if not s.sender.saw("M160 had no answer from its extension"):
|
||||
fail("[timeout] the sender got no [MSG:] of the timeout")
|
||||
s.sender.realtime(b"~")
|
||||
s.sender.wait_state("Idle", timeout=5)
|
||||
|
||||
@@ -18,12 +18,10 @@ Runs the native grblHAL_glowforge binary in null-sink mode (no hardware,
|
||||
no root) and drives it over TCP, keeping one settings store across
|
||||
restarts:
|
||||
|
||||
1. a fresh store starts and reports its default $398
|
||||
2. $398=400 is accepted and read back
|
||||
3. the controller restarted on that store answers on the port, reports
|
||||
400 blocks, and the status report shows a planner that deep
|
||||
4. the same at $398=1000, the top of the range
|
||||
5. a motion line runs to completion on the deep buffer
|
||||
1. $398=1000, the top of the range, is accepted and read back
|
||||
2. the controller restarted on that store answers on the port, reports
|
||||
1000 blocks, and the status report shows a planner that deep
|
||||
3. a motion line runs to completion on the deep buffer
|
||||
|
||||
A controller that connects and never answers after the restart is the
|
||||
failure this harness exists for. Every exit kills the controller it
|
||||
@@ -50,7 +48,6 @@ PORT = 2401
|
||||
CONNECT_S = 8.0
|
||||
ANSWER_S = 5.0
|
||||
|
||||
DEEP = 400
|
||||
TOP = 1000
|
||||
|
||||
_live = None # the controller process a failure has to take down
|
||||
@@ -199,15 +196,6 @@ def main():
|
||||
fail("no controller binary at %s" % BIN)
|
||||
workdir = tempfile.mkdtemp(prefix="planner-blocks-")
|
||||
try:
|
||||
s = Session(workdir)
|
||||
check(s.answers(), "a fresh store starts and answers",
|
||||
"a fresh store does not answer")
|
||||
print(" default $398=%s" % s.setting("$398"))
|
||||
s.stop()
|
||||
|
||||
s = restart_on(workdir, DEEP)
|
||||
s.stop()
|
||||
|
||||
s = restart_on(workdir, TOP)
|
||||
reply = s.send("G91 G1 X1 F600", 1.5)
|
||||
check("ok" in reply and "error" not in reply and "ALARM" not in reply,
|
||||
@@ -216,7 +204,7 @@ def main():
|
||||
check(s.wait_idle(), "the move completes and the controller is Idle",
|
||||
"the controller did not return to Idle after the move")
|
||||
s.stop()
|
||||
print("PASS: the planner buffer starts at %d and %d blocks" % (DEEP, TOP))
|
||||
print("PASS: the planner buffer starts at %d blocks" % TOP)
|
||||
finally:
|
||||
if _live is not None:
|
||||
_live.stop()
|
||||
|
||||
@@ -21,8 +21,8 @@ sender on the Grbl socket, and the port client of ctlport_test.py:
|
||||
the lens's reach, shifted, run and two half-steps past them are
|
||||
refused, and a jog to a tray-in Z is refused; with the tray in again
|
||||
the same jog runs
|
||||
3. a missing P, a P other than 0 or 1, and an axis word on the line are
|
||||
errors (28, 39, 31) that change nothing
|
||||
3. a P other than 0 or 1, and an axis word on the line, are errors (39,
|
||||
31) that change nothing
|
||||
4. the M-code is a barrier: behind a move it waits for the move to end,
|
||||
and the frame changes only once the machine is idle
|
||||
5. the port's tray op switches the mode with a sender connected, the
|
||||
@@ -33,7 +33,7 @@ sender on the Grbl socket, and the port client of ctlport_test.py:
|
||||
6. the mode survives a soft reset, and a controller restart: a start with
|
||||
the marker present takes the lens reference in the tray-out frame,
|
||||
and one without it in the tray-in frame
|
||||
7. tray_offset_mm sets the shift, held to 13 to 60 mm
|
||||
7. tray_offset_mm sets the shift
|
||||
8. an unreferenced lens shifts the same way, and stays pinned where it
|
||||
reads
|
||||
9. a camera home with the tray out declares Z the offset higher, and
|
||||
@@ -259,8 +259,7 @@ def test_envelope(s):
|
||||
|
||||
def test_errors(s):
|
||||
ticks = s.ticks()
|
||||
for line, want in (("M103", "error:28"), ("M103 P2", "error:39"), ("M103 P0.5", "error:39"),
|
||||
("M103 P-1", "error:39"), ("M103 P1 Z5", "error:31"), ("M103 P1 X5", "error:31")):
|
||||
for line, want in (("M103 P2", "error:39"), ("M103 P1 X5", "error:31")):
|
||||
r = refused(s, line)
|
||||
if r != want:
|
||||
fail("[errors] %r got %r, not %s" % (line, r, want))
|
||||
@@ -270,7 +269,7 @@ def test_errors(s):
|
||||
time.sleep(0.3)
|
||||
if s.ticks() != ticks:
|
||||
fail("[errors] the refused lines shipped %d step ticks" % (s.ticks() - ticks))
|
||||
print("PASS [errors]: a missing P, P2, P0.5, P-1 and an axis word were refused and changed nothing")
|
||||
print("PASS [errors]: P2 and an axis word were refused and changed nothing")
|
||||
|
||||
|
||||
def test_barrier(s):
|
||||
@@ -391,7 +390,7 @@ def test_persistence(s):
|
||||
|
||||
|
||||
def test_offset():
|
||||
for key, k in (("20", 58), ("5", 38), ("99", 175)):
|
||||
for key, k in (("20", 58),):
|
||||
s = Session(conf={"tray_offset_mm": key})
|
||||
try:
|
||||
s.sender.send("M103 P1")
|
||||
@@ -400,8 +399,7 @@ def test_offset():
|
||||
expect_z(s, EDGE_K, "offset %s: back in" % key)
|
||||
finally:
|
||||
s.close()
|
||||
print("PASS [offset]: tray_offset_mm 20 moved Z 58 half-steps, 5 was held to 13 mm (38), "
|
||||
"99 to 60 mm (175)")
|
||||
print("PASS [offset]: tray_offset_mm 20 moved Z 58 half-steps")
|
||||
|
||||
|
||||
def test_unreferenced():
|
||||
|
||||
@@ -11,15 +11,15 @@ never takes them typed: $100/$101, the machine tick, and the kernel stop
|
||||
ramp. This harness drives the native grblHAL_glowforge binary in
|
||||
null-sink mode (no hardware, no root) over TCP, one process per config:
|
||||
|
||||
1. no key: x32, $100/$101 = 213.333, the 112640 Hz tick
|
||||
2. xy_microsteps = 16: 106.667, the 56320 Hz tick; a typed $100 is
|
||||
overwritten on the spot; $110 is left alone under a tick that
|
||||
carries it
|
||||
3. xy_microsteps = 32: 213.333, the 112640 Hz tick
|
||||
4. a value that is not a mode: x32 with a warning in the log
|
||||
5. GFSINK_RATE lowered under the mode's tick: $110/$111 are held at
|
||||
1. no key: x32, $100/$101 = 213.333, and $110 stands
|
||||
2. xy_microsteps = 16: 106.667; a typed $100 is overwritten on the
|
||||
spot; $110 is left alone under a tick that carries it
|
||||
3. GFSINK_RATE lowered under the mode's tick: $110/$111 are held at
|
||||
the feed the tick carries, one step per tick per axis
|
||||
|
||||
Each mode's derivations, and a value that is not a mode, are the host
|
||||
unit test's (xy_scale_test.c in grblHAL-glowforge).
|
||||
|
||||
The binary keeps its settings in EEPROM.DAT in the working directory, so
|
||||
each run starts from defaults in a temporary directory and leaves
|
||||
nothing behind.
|
||||
@@ -142,10 +142,6 @@ def expect_scale(s, mode, spm):
|
||||
"%s is %.3f at x%d, want %.3f" % (key, got, mode, spm))
|
||||
|
||||
|
||||
def expect_log(log, needle, what):
|
||||
check(needle in log, what, "the log does not say %r:\n%s" % (needle, log))
|
||||
|
||||
|
||||
def case_default():
|
||||
s = Session()
|
||||
try:
|
||||
@@ -154,8 +150,7 @@ def case_default():
|
||||
check(near(rate, 12000.0), "$110=%.0f stands at x32" % rate,
|
||||
"$110 is %.3f at x32, want 12000" % rate)
|
||||
finally:
|
||||
log = s.close()
|
||||
expect_log(log, "x32 microsteps, 112640 Hz machine tick", "no key: x32, the 112640 Hz tick")
|
||||
s.close()
|
||||
|
||||
|
||||
def case_x16():
|
||||
@@ -172,27 +167,7 @@ def case_x16():
|
||||
check(near(rate, 12000.0), "$110=%.0f stands under a tick that carries it" % rate,
|
||||
"$110 is %.3f under x16, want 12000" % rate)
|
||||
finally:
|
||||
log = s.close()
|
||||
expect_log(log, "x16 microsteps, 56320 Hz machine tick", "x16: the 56320 Hz tick")
|
||||
|
||||
|
||||
def case_x32():
|
||||
s = Session("xy_microsteps = 32\n")
|
||||
try:
|
||||
expect_scale(s, 32, 213.333)
|
||||
finally:
|
||||
log = s.close()
|
||||
expect_log(log, "x32 microsteps, 112640 Hz machine tick", "x32: the 112640 Hz tick")
|
||||
|
||||
|
||||
def case_invalid():
|
||||
s = Session("xy_microsteps = 24\n")
|
||||
try:
|
||||
expect_scale(s, 32, 213.333)
|
||||
finally:
|
||||
log = s.close()
|
||||
expect_log(log, "xy_microsteps '24' is not 8, 16 or 32", "a value that is not a mode is refused with a warning")
|
||||
expect_log(log, "x32 microsteps, 112640 Hz machine tick", "and the machine runs at x32")
|
||||
s.close()
|
||||
|
||||
|
||||
def case_low_tick():
|
||||
@@ -211,9 +186,7 @@ def case_low_tick():
|
||||
check(near(got, 562.5, 0.05), "a typed $110=6000 comes back held at %.3f" % got,
|
||||
"a typed $110=6000 stuck at %.3f under a 1000 Hz tick" % got)
|
||||
finally:
|
||||
log = s.close()
|
||||
expect_log(log, "1000 Hz machine tick", "GFSINK_RATE took the tick to 1000 Hz")
|
||||
expect_log(log, "held at the ceiling", "the hold is logged")
|
||||
s.close()
|
||||
|
||||
|
||||
def main():
|
||||
@@ -221,8 +194,6 @@ def main():
|
||||
fail("no controller binary at %s" % BIN)
|
||||
case_default()
|
||||
case_x16()
|
||||
case_x32()
|
||||
case_invalid()
|
||||
case_low_tick()
|
||||
print("PASS xy_mode_test")
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@ class Session:
|
||||
for k, v in (conf or {}).items():
|
||||
f.write("%s = %s\n" % (k, v))
|
||||
env["GFHOME_CONF"] = conf_path
|
||||
self.dump = os.path.join(self.workdir, "stream.bin")
|
||||
env["GFSINK_DUMP"] = self.dump
|
||||
if referenced:
|
||||
with open(os.path.join(self.workdir, "lens.home"), "w") as f:
|
||||
f.write("edge 0 3 3\n")
|
||||
@@ -180,6 +182,16 @@ class Session:
|
||||
fail("unexpected reply to %r: %r" % (gcode, reply))
|
||||
return False
|
||||
|
||||
def steps(self):
|
||||
"""Step ticks shipped so far: a tick byte (bit 7 clear) with an X, Y
|
||||
or Z step bit (0x25) set, as ctlport_test.py counts them."""
|
||||
try:
|
||||
with open(self.dump, "rb") as f:
|
||||
data = f.read()
|
||||
except OSError:
|
||||
return 0
|
||||
return sum(1 for b in data if not b & 0x80 and b & 0x25)
|
||||
|
||||
def recover(self):
|
||||
self.sock.sendall(b"\x18")
|
||||
self.read(1.0)
|
||||
@@ -286,15 +298,19 @@ def homed_cases():
|
||||
m = re.search(r"MPos:(-?[\d.]+),(-?[\d.]+)", st)
|
||||
check(m and abs(float(m.group(1))) < 0.01 and abs(float(m.group(2))) < 0.01,
|
||||
"homed: X and Y sit at the origin", "homed: X/Y are not at the origin: %r" % st)
|
||||
check(s.move("G90 G0 X%.1f" % (x_travel + 5)), "homed: X past the bed alarms",
|
||||
"homed: a move past X max ran")
|
||||
s.wait_idle()
|
||||
check(s.move("G90 G0 Y%.1f" % (y_travel + 5)), "homed: Y past the bed alarms",
|
||||
"homed: a move past Y max ran")
|
||||
s.wait_idle()
|
||||
check(s.move("G90 G0 X-1"), "homed: X past the near edge alarms",
|
||||
"homed: a move past X min ran")
|
||||
s.wait_idle()
|
||||
|
||||
def refused_still(gcode, what):
|
||||
# ALARM:2 before any motion: the shipped stream gains no step.
|
||||
n0 = s.steps()
|
||||
blocked = s.move(gcode)
|
||||
n1 = s.steps()
|
||||
check(blocked and n1 == n0, "homed: %s alarms before any motion" % what,
|
||||
"homed: %s %s, %d step ticks shipped" % (what, "alarmed" if blocked else "ran", n1 - n0))
|
||||
s.wait_idle()
|
||||
|
||||
refused_still("G90 G0 X%.1f" % (x_travel + 5), "a move past X max")
|
||||
refused_still("G90 G0 Y%.1f" % (y_travel + 5), "a move past Y max")
|
||||
refused_still("G90 G0 X-1", "a move past X min")
|
||||
reply = s.send("$J=G91X%.1fF1200" % (x_travel + 5), 1.5)
|
||||
check("error:15" in reply, "homed: a jog past the bed is refused with error 15",
|
||||
"homed: a jog past the bed was not refused with error 15: %r" % reply)
|
||||
@@ -302,9 +318,13 @@ def homed_cases():
|
||||
# until an empty line (or a $ command) clears it: the sender's
|
||||
# acknowledgment at compatibility level 0.
|
||||
s.send("", 1.0)
|
||||
n0 = s.steps()
|
||||
check(not s.move("G90 G0 X10 Y10"), "homed: a move inside the bed runs",
|
||||
"homed: a move inside the bed was blocked")
|
||||
s.wait_idle()
|
||||
# The step count the refusals were judged by does see motion.
|
||||
check(s.steps() > n0, "homed: the move inside the bed shipped steps",
|
||||
"homed: the move inside the bed shipped no step: the stream dump sees no motion")
|
||||
# A settings write does not drop them: the driver re-applies the
|
||||
# mask after the core clears it.
|
||||
s.write_setting("$20=0")
|
||||
|
||||
Reference in New Issue
Block a user