mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
93
Commits
v0.0.6
..
dfd32d5fd1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfd32d5fd1 | ||
|
|
35221de791 | ||
|
|
46b6df1c2d | ||
|
|
16a8ae9ba3 | ||
|
|
72907f311a | ||
|
|
3873013383 | ||
|
|
2ea501f975 | ||
|
|
1bc15fe70a | ||
|
|
66b39c6f2b | ||
|
|
83686fc712 | ||
|
|
f47ddad839 | ||
|
|
eb9e61d3c6 | ||
|
|
55e789bc34 | ||
|
|
11031ad87f | ||
|
|
cd84786fc5 | ||
|
|
6e71ac86f0 | ||
|
|
1727b819cb | ||
|
|
b494bbecb5 | ||
|
|
b2e0f91342 | ||
|
|
a05a226305 | ||
|
|
9f7744b459 | ||
|
|
8dc7c0bc56 | ||
|
|
0eb0a16452 | ||
|
|
bfcfd7a376 | ||
|
|
d2307a899e | ||
|
|
8570c0141e | ||
|
|
800863fb09 | ||
|
|
cfef6a1b84 | ||
|
|
d40ebb090e | ||
|
|
ef2390f7d4 | ||
|
|
82869cc45c | ||
|
|
b7dd23d539 | ||
|
|
308a033146 | ||
|
|
dc9bede034 | ||
|
|
56af6eaab2 | ||
|
|
c95b5aa771 | ||
|
|
d45520c218 | ||
|
|
2870daa2aa | ||
|
|
82c354d70e | ||
|
|
853f862f03 | ||
|
|
ac2456261e | ||
|
|
8fc07c47ee | ||
|
|
69b56ec1ff | ||
|
|
99d9830991 | ||
|
|
4e47b7fbf1 | ||
|
|
675fc11cb1 | ||
|
|
71b44598eb | ||
|
|
fab8920cd9 | ||
|
|
861f5597e6 | ||
|
|
b7e7c0fb2c | ||
|
|
2f51e38178 | ||
|
|
d9cc61f198 | ||
|
|
1bdc11f6fe | ||
|
|
625d0acec7 | ||
|
|
d410c6f696 | ||
|
|
a552541acd | ||
|
|
5915408147 | ||
|
|
59887ac742 | ||
|
|
4388d69816 | ||
|
|
77e4c03369 | ||
|
|
0ba0fb112b | ||
|
|
f74382984c | ||
|
|
75ef8bbc95 | ||
|
|
2216e5d3f2 | ||
|
|
bb28c7855a | ||
|
|
447f90205d | ||
|
|
952e7dc32d | ||
|
|
eb0a1e7838 | ||
|
|
963bded9b3 | ||
|
|
9c36df9ee9 | ||
|
|
121d7d3f81 | ||
|
|
68fd35b8b7 | ||
|
|
ee89b31c07 | ||
|
|
9ee277f977 | ||
|
|
800d6890a4 | ||
|
|
1a306d5d8d | ||
|
|
1e98b37e45 | ||
|
|
2894269115 | ||
|
|
5ad7ee6faa | ||
|
|
9128f0e98e | ||
|
|
619414ed2b | ||
|
|
474bf9db09 | ||
|
|
d627ee32f1 | ||
|
|
965f7c3ca9 | ||
|
|
19ce4d78d2 | ||
|
|
ebc4222182 | ||
|
|
f0ba4b9249 | ||
|
|
888b47abe8 | ||
|
|
520bf6023d | ||
|
|
25cc42b879 | ||
|
|
8856fb0099 | ||
|
|
b29bb9e023 | ||
|
|
11f4c42705 |
@@ -0,0 +1,39 @@
|
||||
# The extension sandbox's deny rules, proven on real traffic.
|
||||
#
|
||||
# scripts/sandbox-rules-test.py loads meta-forgefirm's ffx.nft into a network
|
||||
# namespace of its own, joins a second namespace to it with a veth pair (a
|
||||
# peer that is not the machine), and sends from real uids: a pool uid is
|
||||
# refused on loopback, at its own LAN address, and at the peer (TCP at once,
|
||||
# UDP with EPERM), the uids on either side of the pool and root are not
|
||||
# touched, an allowlist chain opens one port of the peer to one uid, the
|
||||
# machine's own addresses stay shut even on an allowlist, and loading the
|
||||
# file again takes the allowlist away. The
|
||||
# runner's nft is the version the image carries or newer; the kernel side is
|
||||
# the image's own business and is proven on the machine (exthost.platform).
|
||||
|
||||
name: sandbox-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'meta-forgefirm/recipes-forgefirm/forgefirm-sandbox/**'
|
||||
- 'scripts/sandbox-rules-test.py'
|
||||
- '.github/workflows/sandbox-ci.yml'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
rules:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: nft
|
||||
run: sudo apt-get update && sudo apt-get install -y nftables
|
||||
|
||||
- name: The deny rules on real traffic
|
||||
run: |
|
||||
# 77 is the script's "could not run here": on this runner that is a failure.
|
||||
sudo python3 -B scripts/sandbox-rules-test.py
|
||||
@@ -3,7 +3,7 @@
|
||||
> ### BETA
|
||||
>
|
||||
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
|
||||
> Expect problems, and expect frequent updates. Upgrade whenever a newer
|
||||
> It's stable, but you may still hit the occasional bug, and expect frequent updates. Upgrade whenever a newer
|
||||
> release is available, and report what you find on the
|
||||
> [community forum](https://community.openglow.org).
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"seq":268,"ts_mono":1268.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":true,"reason":"","down_c":22.30,"up_c":22.30}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
255
|
||||
@@ -0,0 +1 @@
|
||||
idle
|
||||
@@ -0,0 +1 @@
|
||||
1023
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -0,0 +1 @@
|
||||
629
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
700
|
||||
@@ -0,0 +1 @@
|
||||
700
|
||||
@@ -0,0 +1 @@
|
||||
65535
|
||||
@@ -0,0 +1 @@
|
||||
26214
|
||||
@@ -0,0 +1 @@
|
||||
43278
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -0,0 +1 @@
|
||||
{"seq":1517,"ts_mono":3127.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":false,"reason":"HEAD CRASH SIGNAL (axes x) - motion stopped, laser locked","down_c":22.30,"up_c":22.30}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
idle
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1 @@
|
||||
204
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -0,0 +1 @@
|
||||
629
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
100
|
||||
@@ -0,0 +1 @@
|
||||
100
|
||||
@@ -0,0 +1 @@
|
||||
100
|
||||
@@ -0,0 +1 @@
|
||||
100
|
||||
@@ -0,0 +1 @@
|
||||
700
|
||||
@@ -0,0 +1 @@
|
||||
700
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -0,0 +1 @@
|
||||
300
|
||||
@@ -41,6 +41,7 @@ that made it, not a reason to hand the dirt on.
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from . import hw
|
||||
@@ -116,6 +117,12 @@ UNPRESERVED_SETTINGS = ("controller_mode",)
|
||||
# service starts its own client with the hunt.
|
||||
NOHUNT_MARKER = os.environ.get("FORGETEST_NOHUNT_MARKER", "/run/gfcloud-nohunt")
|
||||
|
||||
# The offline service: gfcloud's one-start offline marker and the socket
|
||||
# the service listens on while it runs. A takeover that finds it running
|
||||
# makes forgectrl's start at its end under the marker again (runner.Takeover).
|
||||
OFFLINE_MARKER = os.environ.get("FORGETEST_OFFLINE_MARKER", "/run/gfcloud-offline")
|
||||
OFFLINE_SOCKET = os.environ.get("FORGETEST_OFFLINE_SOCKET", "/run/gfcloud-offline.sock")
|
||||
|
||||
# Read-only readbacks with their idle values (no direct restore: the state
|
||||
# comes right through forgectrl - see restore_forgectrl - or is fatal).
|
||||
IDLE_READBACKS = [
|
||||
@@ -214,6 +221,23 @@ def write_led(name, value):
|
||||
f.write(str(value))
|
||||
|
||||
|
||||
# The GRBL controller zeroes the kernel's step counters at every start (the
|
||||
# lens reference) and at every home, and rewrites its home anchor each time.
|
||||
# The anchor's identity is therefore the counters' frame: if it changed
|
||||
# during a run, the counters at the end are not in the frame of the counters
|
||||
# at the start, and their difference is not a distance the head traveled.
|
||||
ANCHOR_PATH = os.environ.get("FORGETEST_ANCHOR", "/run/grblhal.homed")
|
||||
|
||||
|
||||
def counter_frame():
|
||||
"""What identifies the frame the step counters are in, or None."""
|
||||
try:
|
||||
st = os.stat(ANCHOR_PATH)
|
||||
return [st.st_ino, st.st_mtime_ns]
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def read_position():
|
||||
"""(x, y, z) step counters, or None when unreadable."""
|
||||
try:
|
||||
@@ -272,6 +296,11 @@ def read_program_total():
|
||||
return None
|
||||
|
||||
|
||||
# The extension host stops a removed package's service on its next
|
||||
# one-second turn; its group is killed and waited for inside that turn.
|
||||
EXT_STOP_S = 8
|
||||
|
||||
|
||||
class Leftover:
|
||||
def __init__(self, item, found, expected, action):
|
||||
self.item = item
|
||||
@@ -445,7 +474,8 @@ class Baseline:
|
||||
# -- capture -------------------------------------------------------
|
||||
def capture(self):
|
||||
"""Record the preserved state before a run."""
|
||||
cap = {"sysfs": {}, "position": read_position(), "settings": None, "mode": None}
|
||||
cap = {"sysfs": {}, "position": read_position(), "frame": counter_frame(),
|
||||
"settings": None, "mode": None}
|
||||
for attr in PRESERVED_SYSFS:
|
||||
cap["sysfs"][attr] = hw.sysfs_read(attr)
|
||||
st, body = self.fc_get("/settings")
|
||||
@@ -471,6 +501,7 @@ class Baseline:
|
||||
self._forgectrl_side(left, captured)
|
||||
self._kernel_side(left)
|
||||
self._lamp_side(left)
|
||||
self._ext_side(left)
|
||||
self._preserved(left, captured)
|
||||
if left:
|
||||
self.log("%s: %d leftover(s): %s" % (phase, len(left), "; ".join(str(x) for x in left)))
|
||||
@@ -669,6 +700,37 @@ class Baseline:
|
||||
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
||||
act if w is not None else "failed: still %s" % found))
|
||||
|
||||
def _ext_side(self, left):
|
||||
"""A campaign's machine is extension-free. What a test made and
|
||||
left behind (a package under the tests' own prefix, the owner key
|
||||
it was signed with) is removed, and the host stops its service.
|
||||
The operator's own packages are theirs: running ones are reported
|
||||
and not touched."""
|
||||
for id_ in [p for p in hw.ext_packages() if p.startswith(hw.EXT_TEST_PREFIX)]:
|
||||
try:
|
||||
p = subprocess.run([hw.FORGEEXT, "remove", id_], capture_output=True, text=True, timeout=120)
|
||||
ok = p.returncode == 0 and id_ not in hw.ext_packages()
|
||||
act = "restored" if ok else "failed: %s" % (p.stdout.strip()[-120:] or p.returncode)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
act = "failed: %s" % e
|
||||
left.append(Leftover("ext.package", id_, "not installed", act))
|
||||
keys = hw.EXT_ROOT + "/keys"
|
||||
for name in sorted(os.listdir(keys)) if os.path.isdir(keys) else []:
|
||||
if not name.startswith("forgetest-"):
|
||||
continue
|
||||
try:
|
||||
os.remove(os.path.join(keys, name))
|
||||
act = "restored"
|
||||
except OSError as e:
|
||||
act = "failed: %s" % e
|
||||
left.append(Leftover("ext.owner_key", name, "absent", act))
|
||||
# the host stops a removed package's service on its next turn
|
||||
running = self._wait("no test package running", lambda: not hw.pool_pids() or None, EXT_STOP_S) is None \
|
||||
and hw.pool_pids()
|
||||
if running:
|
||||
left.append(Leftover("ext.running", "%d process(es) under pool accounts, packages %s"
|
||||
% (len(running), hw.ext_packages()), "an extension-free machine", "unrestorable"))
|
||||
|
||||
def _lamp_side(self, left):
|
||||
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
|
||||
In cloud mode the cloud client owns the lamp (its lid-image level)."""
|
||||
@@ -829,7 +891,19 @@ class Baseline:
|
||||
# action re-zeroes them at its start, so they preserve nothing.
|
||||
was = captured.get("position")
|
||||
now = read_position()
|
||||
if was is not None and now is not None and now != was and not self.cloud_mode():
|
||||
# A controller start or a home during the run re-zeroed the counters
|
||||
# wherever the head then stood. Unless the test said where that was
|
||||
# (ctx.counters_rezeroed), the two readings share no frame: on the
|
||||
# bench reference a head that had not moved was "returned" 30 mm into
|
||||
# the gantry stops this way, twice. Nothing is moved on a guess.
|
||||
frame_lost = ("frame" in captured and captured["frame"] != counter_frame()
|
||||
and not captured.get("rezero_declared"))
|
||||
if was is not None and now is not None and now != was and frame_lost and not self.cloud_mode():
|
||||
self.log("position: %s, and %s when the run began, but the controller re-zeroed its "
|
||||
"counters during the run (a controller start or a home): the two are not in "
|
||||
"one frame, so the head's position cannot be judged and the head is not moved"
|
||||
% (now, was))
|
||||
elif was is not None and now is not None and now != was and not self.cloud_mode():
|
||||
spm = self._counter_spm
|
||||
act = self._return_head(was, now, spm)
|
||||
if position_quantized(was, now, spm):
|
||||
@@ -837,6 +911,21 @@ class Baseline:
|
||||
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
|
||||
else:
|
||||
left.append(Leftover("position", now, was, act))
|
||||
# A controller restart during the run (a takeover's) re-zeroed the
|
||||
# counters wherever the head then stood, so a head left out before
|
||||
# it reads as home from here. The takeover judged it on the spot
|
||||
# (runner.Takeover); what it recorded is a leftover, never moved.
|
||||
for r in captured.get("restart_positions") or []:
|
||||
left.append(Leftover("position at the %s" % r["where"], r["found"], r["expected"],
|
||||
"unrestorable: a controller restart re-zeroed the counters there, "
|
||||
"so the head is not moved"))
|
||||
# The cloud client forgectrl's start brought back at the end of a
|
||||
# takeover, when it is not the one the takeover found (the same
|
||||
# judge, runner.Takeover). The client that came up is left as it
|
||||
# is: the cloud test that needs a client starts its own.
|
||||
for r in captured.get("restart_clients") or []:
|
||||
left.append(Leftover("cloud client at the %s" % r["where"], r["found"], r["expected"],
|
||||
"not restored: a cloud test starts the client it needs"))
|
||||
was = captured.get("settings")
|
||||
if was:
|
||||
st, body = self.fc_get("/settings")
|
||||
|
||||
@@ -257,6 +257,64 @@ TOOLS = [
|
||||
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
|
||||
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
|
||||
"bench-page tool."},
|
||||
{"id": "ctlport-test", "title": "Controller port and line multiplexer harness", "script": "ctlport_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "A scripted sender that counts every response, beside the controller port's one client, on the "
|
||||
"null-sink controller: a port jog's status goes to the port and never to the sender, a port "
|
||||
"error does not reach the sender's parser, a sender line cancels a port jog and gets its own ok "
|
||||
"(a line queued right behind the port's included), the refusals, the single client, a CR LF "
|
||||
"sender, a soft reset, and the client as the dead-man. A CI harness (the grblHAL repo): needs "
|
||||
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "cool-report-test", "title": "Cooling report secret harness", "script": "cool_report_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The controller's cooling reports on the null-sink controller, read by a stand-in for "
|
||||
"forgectrl's listener: every report carries the secret the supervisor hands the controller at "
|
||||
"its spawn (GF_REPORT_SECRET), none does when there is none, a value that is not 32 hex digits "
|
||||
"(a CR LF with a header behind it included) never reaches the wire, and the homing runner the "
|
||||
"controller starts is handed it (it reports in the controller's place during a gfcloud $H), "
|
||||
"and none when the controller has none. A CI harness (the grblHAL repo): needs the host-built "
|
||||
"null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "mcode-test", "title": "Package M-code barrier harness", "script": "mcode_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The M-codes extension packages answer (M160 to M179), on the null-sink controller with a "
|
||||
"scripted sender and a stand-in for the machine daemon on the port: the table and its refusals, "
|
||||
"a number nothing answers is error:20 where it is parsed, an answered one waits with the head "
|
||||
"still and a port jog refused while the port's state names it, the answer's words, an answer "
|
||||
"that the work was not done and no answer in time each hold the job, a soft reset ends the "
|
||||
"wait, and a wait under an open armed window with M3 S500 ships no FIRE tick. Imports its "
|
||||
"sender and port client from ctlport_test.py. A CI harness (the grblHAL repo): needs the "
|
||||
"host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "envelope-test", "title": "Measured envelope harness", "script": "envelope_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The measured work envelope and the bed check's port op, on the null-sink controller with a "
|
||||
"scripted sender and a port client: unset keys are the axis travel, envelope_x_mm and _y are the "
|
||||
"far edges at a home and are held to 50 mm up to the travel plus 30 mm, the port's envelope open "
|
||||
"is refused before a home and under an open armed window and lets the port's jogs reach the "
|
||||
"travel plus 30 mm, envelope apply puts the keys' edges back, and the sender's first line (never "
|
||||
"a status poll or an empty line), a soft reset, and the port client going away each close an "
|
||||
"open envelope, the sender's line only after it closed. Imports its sender and port client from "
|
||||
"ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink controller, "
|
||||
"not the machine, so it is not a bench-page tool."},
|
||||
{"id": "manual-home-test", "title": "Manual home and motor release harness", "script": "manual_home_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The manual homing provider and the motor release on the null-sink controller: $H under manual "
|
||||
"ships nothing and declares X0 Y0 with the soft limits on and Z kept; while the motors are "
|
||||
"released every motion source is refused and ships nothing, $X and a soft reset do not unlock "
|
||||
"it, and only $ME and a manual $H write the energize, once each; the port's panel operations "
|
||||
"do the same with the sender's count exact. Imports its sender and port client from "
|
||||
"ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink controller, "
|
||||
"not the machine, so it is not a bench-page tool."},
|
||||
{"id": "tray-test", "title": "Crumb tray mode harness", "script": "tray_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The crumb tray's mode on the null-sink controller: M103 P1 and P0 move Z and its envelope by "
|
||||
"tray_offset_mm on the lens step grid, tell the sender, write and remove the marker beside the "
|
||||
"shared config, and ship no step; a missing P, a P other than 0 or 1 and an axis word are "
|
||||
"errors; the switch waits for the move before it; the port's tray op, and its refusals during "
|
||||
"a program and at a package's M-code; the mode survives a soft reset and a restart, and a start "
|
||||
"over the marker references Z in the tray-out frame; the offset's range; an unreferenced lens; "
|
||||
"and a camera home in both modes hands the runner the same park. Imports its sender and port "
|
||||
"client from ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink "
|
||||
"controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
|
||||
|
||||
@@ -75,8 +75,12 @@ class Test:
|
||||
invalidates the tests of that module. A test the module does not
|
||||
define in the @test form hashes its whole file."""
|
||||
if self._source_sha is None:
|
||||
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
|
||||
self._source_sha = implementation_sha(path, self.id)
|
||||
# One filler at a time: a second poll that arrives while the
|
||||
# first computes waits for it rather than repeating the work.
|
||||
with _IMPL_LOCK:
|
||||
if self._source_sha is None:
|
||||
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
|
||||
self._source_sha = implementation_sha(path, self.id)
|
||||
return self._source_sha
|
||||
|
||||
def fingerprint(self, manifest):
|
||||
@@ -140,8 +144,38 @@ def source_file_sha(path):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
_PARTS = {} # path -> (shared sha, {test id: own sha}); the files never change under a run
|
||||
_PARTS_LOCK = threading.Lock()
|
||||
# The files never change under a run, so each is read and parsed once:
|
||||
# path -> (text, tree or None), path -> (shared sha, {test id: own sha}),
|
||||
# path -> the sibling modules it imports itself.
|
||||
_PARSED = {}
|
||||
_PARTS = {}
|
||||
_SIBLINGS = {}
|
||||
_PARTS_LOCK = threading.RLock()
|
||||
_IMPL_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def forget(path):
|
||||
"""Drop what is known about a module: its file changed (the unit tests
|
||||
edit their modules between readings)."""
|
||||
with _PARTS_LOCK:
|
||||
for cache in (_PARSED, _PARTS, _SIBLINGS):
|
||||
cache.pop(path, None)
|
||||
|
||||
|
||||
def _parsed(path):
|
||||
"""(text, tree) of a suite module, line endings normalized; the tree
|
||||
is None when the file does not parse."""
|
||||
with _PARTS_LOCK:
|
||||
hit = _PARSED.get(path)
|
||||
if hit is None:
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
tree = None
|
||||
hit = _PARSED[path] = (text, tree)
|
||||
return hit
|
||||
|
||||
|
||||
def module_parts(path):
|
||||
@@ -154,14 +188,9 @@ def module_parts(path):
|
||||
hit = _PARTS.get(path)
|
||||
if hit is not None:
|
||||
return hit
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
||||
text, tree = _parsed(path)
|
||||
lines = text.split("\n")
|
||||
spans = {}
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
tree = None
|
||||
for node in (tree.body if tree is not None else []):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
@@ -188,22 +217,30 @@ def sibling_imports(path, seen=None):
|
||||
test's fingerprint."""
|
||||
if seen is None:
|
||||
seen = []
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
return seen
|
||||
here = os.path.dirname(path)
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
|
||||
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
|
||||
if os.path.isfile(sib) and sib not in seen and sib != path:
|
||||
seen.append(sib)
|
||||
sibling_imports(sib, seen)
|
||||
for sib in _direct_siblings(path):
|
||||
if sib not in seen and sib != path:
|
||||
seen.append(sib)
|
||||
sibling_imports(sib, seen)
|
||||
return seen
|
||||
|
||||
|
||||
def _direct_siblings(path):
|
||||
"""The suite modules one module imports itself, in import order."""
|
||||
with _PARTS_LOCK:
|
||||
hit = _SIBLINGS.get(path)
|
||||
if hit is None:
|
||||
_text, tree = _parsed(path)
|
||||
here = os.path.dirname(path)
|
||||
hit = []
|
||||
for node in (tree.body if tree is not None else []):
|
||||
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
|
||||
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
|
||||
if os.path.isfile(sib):
|
||||
hit.append(sib)
|
||||
_SIBLINGS[path] = hit
|
||||
return hit
|
||||
|
||||
|
||||
def implementation_sha(path, test_id):
|
||||
"""The implementation hash of one test (see Test.source_sha): the
|
||||
test's own text, the shared text of its module, and the shared text
|
||||
|
||||
@@ -297,11 +297,11 @@ class Fixture:
|
||||
return bool(st and st.get("button_enabled"))
|
||||
return True
|
||||
|
||||
def act(self, channel, state):
|
||||
def act(self, channel, state, ms=None):
|
||||
if channel == "button":
|
||||
if state != "press":
|
||||
raise FixtureError("the button is only ever pressed")
|
||||
st, body = self._press()
|
||||
st, body = self._press(ms)
|
||||
else:
|
||||
if state not in ("open", "close"):
|
||||
raise FixtureError("%s: unknown state %r" % (channel, state))
|
||||
@@ -312,8 +312,11 @@ class Fixture:
|
||||
self.last_state = body
|
||||
return body
|
||||
|
||||
def _press(self):
|
||||
"""One press, spaced from the last one. The controller detects
|
||||
def _press(self, ms=None):
|
||||
"""One press, spaced from the last one. `ms` is how long the box
|
||||
holds it (the firmware clamps it into 20 to 500, and takes 200 when
|
||||
it is not said): a check that must reach the machine *while* the
|
||||
button is down asks for the longest press the box can make. The controller detects
|
||||
the button's rising edge, so a press must follow a release it has
|
||||
seen: the next press waits for the last pulse to end (the
|
||||
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
|
||||
@@ -322,7 +325,7 @@ class Fixture:
|
||||
wait = self._pulse_end + BUTTON_GAP_S - time.time()
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
st, body = self._request("POST", "/button", {})
|
||||
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
|
||||
if st == 409 and "in progress" in str(body.get("error", "")):
|
||||
deadline = time.time() + PULSE_WAIT_S
|
||||
while time.time() < deadline:
|
||||
@@ -331,7 +334,7 @@ class Fixture:
|
||||
if s == 200 and not b.get("button_pulsing"):
|
||||
break
|
||||
time.sleep(BUTTON_GAP_S)
|
||||
st, body = self._request("POST", "/button", {})
|
||||
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
|
||||
if st == 200:
|
||||
self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0
|
||||
return st, body
|
||||
|
||||
@@ -324,6 +324,40 @@ def initd(service, action, timeout=60):
|
||||
return p.returncode, p.stdout.decode("utf-8", "replace")
|
||||
|
||||
|
||||
# The extension host's side of the machine (forgefirm-sandbox, forgeext).
|
||||
EXT_POOL_FIRST, EXT_POOL_LAST = 800, 831
|
||||
EXT_ROOT = "/data/forgefirm/ext"
|
||||
EXT_TEST_PREFIX = "org.forgetest." # a package a test made; every other one is the operator's
|
||||
FORGEEXT = "/usr/bin/forgeext"
|
||||
|
||||
|
||||
def pool_pids():
|
||||
"""{pid: uid} of every process that runs under an extension pool account."""
|
||||
out = {}
|
||||
try:
|
||||
pids = [p for p in os.listdir("/proc") if p.isdigit()]
|
||||
except OSError:
|
||||
return out
|
||||
for pid in pids:
|
||||
try:
|
||||
with open("/proc/%s/status" % pid) as f:
|
||||
line = next((x for x in f if x.startswith("Uid:")), "")
|
||||
uid = int(line.split()[1]) # the real uid; a service has all four alike
|
||||
except (OSError, IndexError, ValueError):
|
||||
continue
|
||||
if EXT_POOL_FIRST <= uid <= EXT_POOL_LAST:
|
||||
out[int(pid)] = uid
|
||||
return out
|
||||
|
||||
|
||||
def ext_packages():
|
||||
"""The ids of the installed extension packages."""
|
||||
try:
|
||||
return sorted(d for d in os.listdir(EXT_ROOT + "/pkg") if os.path.isdir(os.path.join(EXT_ROOT, "pkg", d)))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def pidof(comm):
|
||||
"""PIDs whose /proc/<pid>/comm equals comm (15-char kernel limit applies)."""
|
||||
out = []
|
||||
@@ -361,6 +395,44 @@ def pgrep_f(needle):
|
||||
return out
|
||||
|
||||
|
||||
def listens_on(pid, path):
|
||||
"""Process pid holds a listening UNIX socket bound at path: one of its
|
||||
own descriptors is that socket, so a socket file another process left
|
||||
behind does not count. Reads /proc (FORGETEST_PROC_ROOT for tests)."""
|
||||
if not pid:
|
||||
return False
|
||||
proc = os.environ.get("FORGETEST_PROC_ROOT") or "/proc"
|
||||
inodes = set()
|
||||
try:
|
||||
with open(os.path.join(proc, "net", "unix")) as f:
|
||||
next(f, None) # the header line
|
||||
for ln in f:
|
||||
# Num RefCount Protocol Flags Type St Inode Path
|
||||
parts = ln.split()
|
||||
try:
|
||||
if len(parts) >= 8 and parts[7] == path and int(parts[3], 16) & 0x10000:
|
||||
inodes.add(parts[6]) # __SO_ACCEPTCON: a listener
|
||||
except ValueError:
|
||||
continue
|
||||
except OSError:
|
||||
return False
|
||||
if not inodes:
|
||||
return False
|
||||
fd_dir = os.path.join(proc, str(pid), "fd")
|
||||
try:
|
||||
fds = os.listdir(fd_dir)
|
||||
except OSError:
|
||||
return False
|
||||
for fd in fds:
|
||||
try:
|
||||
link = os.readlink(os.path.join(fd_dir, fd))
|
||||
except OSError:
|
||||
continue
|
||||
if link.startswith("socket:[") and link[8:-1] in inodes:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def run(cmd, timeout=60):
|
||||
"""Run a command list; returns (rc, combined output)."""
|
||||
try:
|
||||
|
||||
@@ -93,10 +93,27 @@ NON_BEHAVIORAL = [
|
||||
("*", ".vscode/**"),
|
||||
("*", ".env.example"),
|
||||
("forgectrl", "tools/**"), # host-side dev tools (panel dev server)
|
||||
("forgectrl", "examples/**"), # clients of the remote API, run on another computer
|
||||
# forgeext's recipe installs the binary and its init script, and nothing of these: the author's kit
|
||||
# and the host tools run off the image (OpenGlow's own packages have repositories of their own).
|
||||
("forgeext", "sdk/**"),
|
||||
("forgeext", "template/**"),
|
||||
("forgeext", "tools/**"),
|
||||
]
|
||||
|
||||
|
||||
# Paths the list above would take out that are behavior all the same: forgectrl embeds its advisory
|
||||
# documents in the binary, serves them, and records the operator's consent to a document by its hash, so
|
||||
# an edited advisory is a changed consent and must move every fingerprint that covers it.
|
||||
BEHAVIORAL = [
|
||||
("forgectrl", "docs/advisories/**"),
|
||||
]
|
||||
|
||||
|
||||
def non_behavioral(comp, path, allow=NON_BEHAVIORAL):
|
||||
for c, pat in BEHAVIORAL:
|
||||
if c == comp and glob_to_regex(pat).match(path):
|
||||
return False
|
||||
for c, pat in allow:
|
||||
if c in ("*", comp) and glob_to_regex(pat).match(path):
|
||||
return True
|
||||
@@ -203,7 +220,8 @@ def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
|
||||
"""Which manifest paths no test covers.
|
||||
|
||||
tests: iterable with .covers. allow: iterable of (component, glob)
|
||||
that need no coverage (docs, CI, licenses...). Returns
|
||||
that need no coverage (docs, CI, licenses...); a BEHAVIORAL path is
|
||||
never allowed away, as it is never taken out of a fingerprint. Returns
|
||||
{component: [uncovered paths]} for the non-dev-only components.
|
||||
"""
|
||||
covered = {}
|
||||
@@ -217,11 +235,14 @@ def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
|
||||
for comp in manifest.component_names():
|
||||
if comp in DEV_ONLY_COMPONENTS:
|
||||
continue
|
||||
rxs = covered.get(comp, []) + allowed.get(comp, [])
|
||||
star = allowed.get("*", [])
|
||||
rxs = covered.get(comp, [])
|
||||
free = allowed.get(comp, []) + allowed.get("*", [])
|
||||
behavior = [glob_to_regex(pat) for c, pat in BEHAVIORAL if c == comp]
|
||||
missing = []
|
||||
for p, _b in manifest.files(comp):
|
||||
if any(rx.match(p) for rx in rxs) or any(rx.match(p) for rx in star):
|
||||
if any(rx.match(p) for rx in rxs):
|
||||
continue
|
||||
if any(rx.match(p) for rx in free) and not any(rx.match(p) for rx in behavior):
|
||||
continue
|
||||
missing.append(p)
|
||||
if missing:
|
||||
|
||||
@@ -366,7 +366,7 @@ class Context:
|
||||
"""One of forgectrl's switch readings (lid, interlock_ok, ...)."""
|
||||
return (self.forgectrl.status().get("switches") or {}).get(name)
|
||||
|
||||
def act(self, channel, state, until=None, timeout=ACT_TIMEOUT_S, text="", fail=True):
|
||||
def act(self, channel, state, until=None, timeout=ACT_TIMEOUT_S, text="", fail=True, ms=None):
|
||||
"""A machine action by name: ("lid", "open"|"close"),
|
||||
("interlock", "open"|"close"), ("button", "press"). The bench's
|
||||
actuator performs it when one covers the channel (the runner's
|
||||
@@ -405,7 +405,7 @@ class Context:
|
||||
self.log("ACT %s %s (fixture)", channel, state)
|
||||
rec["by"] = "fixture"
|
||||
try:
|
||||
fixture.act(channel, state)
|
||||
fixture.act(channel, state, ms)
|
||||
except _fixture.FixtureError as e:
|
||||
rec["fixture_error"] = str(e)
|
||||
if self.run.unattended:
|
||||
@@ -539,7 +539,7 @@ class Context:
|
||||
return hw.Grbl()
|
||||
|
||||
def takeover(self):
|
||||
return Takeover(self.run.log, self.test.id)
|
||||
return Takeover(self.run.log, self.test.id, run=self.run)
|
||||
|
||||
def counters_rezeroed(self):
|
||||
"""Tell the baseline the kernel position counters were re-zeroed
|
||||
@@ -549,6 +549,10 @@ class Context:
|
||||
cap = self.run.baseline_captured
|
||||
if cap and cap.get("position") is not None:
|
||||
cap["position"] = [0, 0, 0]
|
||||
cap["rezero_declared"] = True # the test vouches for the new frame
|
||||
# ... and it is the frame now in force, so a takeover later in
|
||||
# the run can still judge where the head stands against it
|
||||
cap["frame"] = _baseline.counter_frame()
|
||||
self.log("position counters re-zeroed at the starting position; the baseline "
|
||||
"expects (0,0,0) at the end")
|
||||
|
||||
@@ -578,11 +582,16 @@ class Takeover:
|
||||
"cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
|
||||
"pic/x_step_current", "pic/y_step_current")
|
||||
|
||||
def __init__(self, log, who):
|
||||
def __init__(self, log, who, run=None):
|
||||
self.log = log # callable(str)
|
||||
self.who = who
|
||||
self.marker = marker_path()
|
||||
self.saved = {}
|
||||
self.run = run # the test's run, whose baseline the head is judged against
|
||||
self.cloud = False
|
||||
self.offline = False # the cloud client found is the offline service
|
||||
self.frame = None # the counters' frame, read before the controller is stopped
|
||||
self.found = None # (counters, microstep mode) once the controller is gone
|
||||
|
||||
def wait_settled(self):
|
||||
return _baseline.Baseline(self.log).wait_settled()
|
||||
@@ -609,6 +618,18 @@ class Takeover:
|
||||
self.saved[attr] = v
|
||||
if self.saved:
|
||||
log("takeover: preserving %s" % ", ".join("%s=%s" % kv for kv in self.saved.items()))
|
||||
try:
|
||||
st, mode = hw.Forgectrl().get("/mode")
|
||||
self.cloud = st == 200 and isinstance(mode, dict) and mode.get("mode") == "cloud"
|
||||
self.offline = self.cloud and hw.listens_on(mode.get("pid"), _baseline.OFFLINE_SOCKET)
|
||||
except hw.HwError:
|
||||
pass
|
||||
if self.offline:
|
||||
log("takeover: the cloud client is the offline service (pid %s); forgectrl's start "
|
||||
"brings it back offline" % mode.get("pid"))
|
||||
# The frame is read while the controller runs: its exit takes the
|
||||
# anchor with it (forgectrl unlinks it), though not the counters.
|
||||
self.frame = _baseline.counter_frame()
|
||||
log("takeover: stopping the controller through forgectrl")
|
||||
try:
|
||||
st, body = hw.Forgectrl().post("/controller/stop")
|
||||
@@ -631,10 +652,146 @@ class Takeover:
|
||||
self.__exit__(None, None, None)
|
||||
raise Failed("takeover: processes still alive after stop: %s" % left)
|
||||
log("takeover: pulse device free")
|
||||
try:
|
||||
self.position_at_start()
|
||||
except Exception as e: # noqa: BLE001 - a reading never keeps forgectrl down
|
||||
log("takeover: WARNING the head's position could not be judged: %s: %s" % (type(e).__name__, e))
|
||||
return self
|
||||
|
||||
# A controller start re-zeroes the step counters where the head then
|
||||
# stands, and forgectrl's start after a takeover is one, so the
|
||||
# baseline's check after the run cannot see a head left out before it.
|
||||
# The takeover judges it at the two moments it can: as the controller
|
||||
# goes away (against where the run expects the head) and before it
|
||||
# comes back (against where the takeover found it). A miss is recorded
|
||||
# for the baseline's post pass, which fails the run; nothing is moved,
|
||||
# because a jog sized from these counters is only as good as the frame
|
||||
# they are in. Cloud mode is not judged: its counters are the cloud
|
||||
# client's, as in the baseline.
|
||||
|
||||
def _record(self, where, found, expected):
|
||||
cap = self.run.baseline_captured if self.run is not None else None
|
||||
if cap is not None:
|
||||
cap.setdefault("restart_positions", []).append(
|
||||
{"where": where, "found": found, "expected": expected})
|
||||
self.log("takeover: the head is not where it belongs at the %s: counters %s, expected %s; "
|
||||
"recorded as a leftover, nothing moved" % (where, found, expected))
|
||||
|
||||
def position_at_start(self):
|
||||
cap = self.run.baseline_captured if self.run is not None else None
|
||||
if not cap or self.cloud:
|
||||
return
|
||||
now = _baseline.read_position()
|
||||
self.found = (now, hw.sysfs_read("cnc/x_mode"))
|
||||
was = cap.get("position")
|
||||
if now is None or was is None:
|
||||
return
|
||||
if self.frame is None or cap.get("frame") != self.frame:
|
||||
self.log("takeover: the counters are not in the frame the run began in (a controller "
|
||||
"start or a home the test did not declare), so the head is not judged here")
|
||||
return
|
||||
if now != was and not _baseline.position_quantized(was, now, _baseline.counter_steps_per_mm()):
|
||||
self._record("takeover start", now, was)
|
||||
|
||||
def position_at_end(self):
|
||||
if self.found is None:
|
||||
return
|
||||
was, mode = self.found
|
||||
now = _baseline.read_position()
|
||||
if was is None or now is None:
|
||||
return
|
||||
if hw.sysfs_read("cnc/x_mode") != mode:
|
||||
self.log("takeover: the microstep mode changed under the takeover, so the counters "
|
||||
"before and after it are not one scale; the head is not judged here")
|
||||
return
|
||||
if now != was and not _baseline.position_quantized(was, now, _baseline.counter_steps_per_mm()):
|
||||
self._record("takeover end", now, was)
|
||||
|
||||
# In cloud mode forgectrl's start at the end starts a cloud client, and
|
||||
# what that client is comes only from the one-start markers gfcloud
|
||||
# reads and takes down as it starts. Started bare, it is the online
|
||||
# client with the service's connect-time hunt, and the moves the
|
||||
# service sends after the hunt are still running as the run ends and
|
||||
# the next one begins, whatever the takeover found. The start is made
|
||||
# under the no-hunt marker, as every client start the runner makes is,
|
||||
# and under the offline marker as well when the takeover found the
|
||||
# offline service; that client is then proven to be the offline
|
||||
# service, and a miss is recorded for the baseline's post pass, which
|
||||
# fails the run.
|
||||
OFFLINE_BACK_S = 60 # the start to the offline service's listener
|
||||
|
||||
def client_markers(self):
|
||||
"""(path, name) of the markers forgectrl's start is made under."""
|
||||
if not self.cloud:
|
||||
return []
|
||||
marks = [(_baseline.NOHUNT_MARKER, "no-hunt")]
|
||||
if self.offline:
|
||||
marks.append((_baseline.OFFLINE_MARKER, "offline"))
|
||||
return marks
|
||||
|
||||
def markers_on(self):
|
||||
for path, name in self.client_markers():
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
f.write("forgetest takeover %s\n" % self.who)
|
||||
except OSError as e:
|
||||
self.log("takeover: WARNING the %s marker could not be written: %s" % (name, e))
|
||||
|
||||
def wait_offline(self):
|
||||
"""The cloud client's pid once it listens on the offline socket, or
|
||||
None after OFFLINE_BACK_S."""
|
||||
deadline = time.time() + self.OFFLINE_BACK_S
|
||||
while True:
|
||||
try:
|
||||
st, m = hw.Forgectrl().get("/mode")
|
||||
except hw.HwError:
|
||||
st, m = None, None
|
||||
pid = m.get("pid") if st == 200 and isinstance(m, dict) else None
|
||||
if hw.listens_on(pid, _baseline.OFFLINE_SOCKET):
|
||||
return pid
|
||||
if time.time() >= deadline:
|
||||
return None
|
||||
time.sleep(0.5)
|
||||
|
||||
def client_back(self, mode):
|
||||
"""After forgectrl's start: a marker no client read is taken down
|
||||
(no client started - the gate, standby, a fault - and a later
|
||||
start must not come up under it), and the offline service found is
|
||||
judged against the client that came up."""
|
||||
if not self.cloud:
|
||||
return
|
||||
mode = mode if isinstance(mode, dict) else {}
|
||||
running = mode.get("mode") == "cloud" and mode.get("controller") == "running"
|
||||
pid = self.wait_offline() if self.offline and running else None
|
||||
for path, name in self.client_markers():
|
||||
if (not running or name == "offline") and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
self.log("takeover: the %s marker was never read - taken down" % name)
|
||||
except OSError:
|
||||
pass
|
||||
if not self.offline:
|
||||
return
|
||||
if pid:
|
||||
self.log("takeover: the cloud client is back as the offline service (pid %s)" % pid)
|
||||
return
|
||||
found = ("a client that is not the offline service (pid %s)" % mode.get("pid") if running
|
||||
else "no cloud client running (mode %s, controller %s)"
|
||||
% (mode.get("mode"), mode.get("controller")))
|
||||
cap = self.run.baseline_captured if self.run is not None else None
|
||||
if cap is not None:
|
||||
cap.setdefault("restart_clients", []).append(
|
||||
{"where": "takeover end", "found": found, "expected": "the offline service"})
|
||||
self.log("takeover: the offline service did not come back: %s; recorded as a leftover" % found)
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
try:
|
||||
self.position_at_end()
|
||||
except Exception as e: # noqa: BLE001 - a reading never keeps forgectrl down
|
||||
self.log("takeover: WARNING the head's position could not be judged: %s: %s"
|
||||
% (type(e).__name__, e))
|
||||
self.restore_attrs()
|
||||
self.markers_on()
|
||||
rc, out = hw.initd("forgectrl", "start")
|
||||
self.log("takeover: forgectrl start -> rc %s" % rc)
|
||||
try:
|
||||
@@ -643,7 +800,12 @@ class Takeover:
|
||||
pass
|
||||
# leave the machine settled for whatever runs next: the probe
|
||||
# move done, the controller back (or the ladder's verdict logged)
|
||||
self.wait_settled()
|
||||
mode = self.wait_settled()
|
||||
try:
|
||||
self.client_back(mode)
|
||||
except Exception as e: # noqa: BLE001 - the judge never breaks the exit path
|
||||
self.log("takeover: WARNING the cloud client could not be judged: %s: %s"
|
||||
% (type(e).__name__, e))
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,24 @@ from . import setup_sheet # noqa: F401,E402
|
||||
from . import logs # noqa: F401,E402
|
||||
from . import motion # noqa: F401,E402
|
||||
from . import cooling # noqa: F401,E402
|
||||
from . import cooling_intake # noqa: F401,E402
|
||||
from . import laser # noqa: F401,E402
|
||||
from . import camera # noqa: F401,E402
|
||||
from . import update # noqa: F401,E402
|
||||
from . import cloud # noqa: F401,E402
|
||||
# Last: it stands on the setup record's consent and on the cloud suite's dark print.
|
||||
from . import exthost # noqa: F401,E402
|
||||
from . import 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
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# 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)
|
||||
@@ -63,7 +63,10 @@ def _jpeg_size(data):
|
||||
"far smaller), a second snapshot with the lid lamp switched off differs from it "
|
||||
"and is smaller (the capture is live, not a frame the pipeline is re-serving, "
|
||||
"and the lamp lights what the camera sees), and the MJPEG stream starts and "
|
||||
"stops.")
|
||||
"stops. A capture marked background=1 yields to a viewer: while the stream is "
|
||||
"open it is refused in words, the operator's own unmarked capture is still "
|
||||
"served, and once the viewer stops the background capture is served again. A "
|
||||
"background value that is neither 0 nor 1 is refused.")
|
||||
def snapshot(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
@@ -116,6 +119,48 @@ def snapshot(ctx):
|
||||
ev["stream_ctype"] = ctype
|
||||
ctx.log("stream: %s, first %d bytes", ctype, len(chunk))
|
||||
ctx.check("multipart" in ctype and b"\xff\xd8" in chunk, "stream is not an MJPEG multipart")
|
||||
|
||||
# A capture nobody is waiting for yields to one somebody is: a
|
||||
# snapshot borrows the mux and stutters a running stream, which is a
|
||||
# price worth paying for an operator and not for a program. The
|
||||
# stream above is closed by now, so one is opened and held here.
|
||||
bad = fc.get("/cam/snapshot", params={"cam": "lid", "background": "maybe"})
|
||||
ev["background_bad_value"] = bad
|
||||
ctx.check(bad[0] == 400, "background=maybe -> %s", bad)
|
||||
|
||||
held = urllib.request.urlopen(
|
||||
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}), timeout=10)
|
||||
try:
|
||||
held.read(8192)
|
||||
watching = None
|
||||
for _ in range(40):
|
||||
st_, body_ = fc.get("/cam/status")
|
||||
if isinstance(body_, dict) and body_.get("clients"):
|
||||
watching = body_
|
||||
break
|
||||
ctx.sleep(0.25)
|
||||
ev["clients_while_watching"] = (watching or {}).get("clients")
|
||||
ctx.check(watching, "no viewer was counted while the stream was held: %s", body_)
|
||||
st_, why = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half", "background": "1"})
|
||||
ev["background_while_watched"] = [st_, why]
|
||||
ctx.log("background capture while a viewer watches -> %s %s", st_, why)
|
||||
ctx.check(st_ == 409, "a background capture did not yield to a viewer: %s %s", st_, why)
|
||||
st_, data_ = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
|
||||
ev["operator_while_watched"] = [st_, len(data_ or b"")]
|
||||
ctx.check(st_ == 200 and data_ and data_[:2] == b"\xff\xd8",
|
||||
"the operator's own capture was refused while watching: %s", st_)
|
||||
finally:
|
||||
held.close()
|
||||
for _ in range(40):
|
||||
st_, body_ = fc.get("/cam/status")
|
||||
if isinstance(body_, dict) and not body_.get("clients"):
|
||||
break
|
||||
ctx.sleep(0.25)
|
||||
st_, data_ = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half", "background": "1"}, raw=True)
|
||||
ev["background_after_watching"] = [st_, len(data_ or b"")]
|
||||
ctx.check(st_ == 200 and data_ and data_[:2] == b"\xff\xd8",
|
||||
"a background capture was still refused after the viewer stopped: %s", st_)
|
||||
|
||||
ctx.sleep(2)
|
||||
st, body = fc.get("/cam/status")
|
||||
ev["cam_status_after"] = body
|
||||
@@ -234,10 +279,12 @@ def h264_stream(ctx):
|
||||
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
||||
description="The capture queue flags a frame errored when it is short, torn, or arrived "
|
||||
"after the CSI-2 receiver lost sync; forgectrl drops those rather than "
|
||||
"demosaicing them, and restarts the stream if they persist. A healthy machine "
|
||||
"captures a burst with none of them. A nonzero corrupt count here is a real "
|
||||
"signal - a marginal camera ribbon, a mistimed D-PHY - even though the frames "
|
||||
"themselves never reach a client.")
|
||||
"demosaicing them, restarts the stream if they persist, and withholds the "
|
||||
"stream frames on either side of one. A healthy machine captures a burst of "
|
||||
"snapshots and a few seconds of stream with none of them flagged and none "
|
||||
"withheld, and reports the stream's per-stage timing while a viewer watches. "
|
||||
"A nonzero corrupt count here is a real signal - a marginal camera ribbon, a "
|
||||
"mistimed D-PHY - even though the frames themselves never reach a client.")
|
||||
def frame_health(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
@@ -274,18 +321,63 @@ def frame_health(ctx):
|
||||
ctx.check(corrupt == 0, "%d of %d captured frames came back errored", corrupt, captured)
|
||||
ctx.check(restarts == 0, "the capture stream was restarted %d time(s)", restarts)
|
||||
|
||||
# The stream path: five seconds of MJPEG, so the frame gate judges a
|
||||
# few dozen frames and the timing covers two whole windows. A frame is
|
||||
# withheld only next to one the receiver flagged, so a healthy machine
|
||||
# withholds none, and the per-stage timing is reported while the
|
||||
# viewer watches.
|
||||
import time
|
||||
import urllib.request
|
||||
s0 = health()
|
||||
stream = urllib.request.urlopen(
|
||||
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}),
|
||||
timeout=10)
|
||||
try:
|
||||
got = 0
|
||||
t_end = time.monotonic() + 5.0
|
||||
while time.monotonic() < t_end:
|
||||
got += len(stream.read(65536))
|
||||
st, body = fc.get("/cam/status")
|
||||
timing = body.get("timing") if isinstance(body, dict) else None
|
||||
finally:
|
||||
stream.close()
|
||||
s1 = health()
|
||||
ev["stream_bytes"] = got
|
||||
ev["stream_timing"] = timing
|
||||
ev["stream_health"] = [s0, s1]
|
||||
ctx.log("stream: %d bytes in 5 s, timing %s", got, timing)
|
||||
s_captured = (s1.get("captured") or 0) - (s0.get("captured") or 0)
|
||||
s_corrupt = (s1.get("corrupt") or 0) - (s0.get("corrupt") or 0)
|
||||
s_withheld = (s1.get("withheld") or 0) - (s0.get("withheld") or 0)
|
||||
ev["stream_captured"] = s_captured
|
||||
ev["stream_withheld"] = s_withheld
|
||||
ctx.check(s_captured >= 30, "only %d frames were dequeued in 5 s of stream", s_captured)
|
||||
ctx.check(s_corrupt == 0, "%d stream frames came back errored", s_corrupt)
|
||||
ctx.check("withheld" in s1, "/cam/status health carries no withheld count: %s", s1)
|
||||
ctx.check(s_withheld == 0, "%d stream frames were withheld with none flagged", s_withheld)
|
||||
ctx.check(isinstance(timing, dict) and
|
||||
all(k in timing for k in ("latency_ms", "convert_ms", "copy_ms", "encode_ms")),
|
||||
"/cam/status carries no timing block while a viewer watches: %s", body)
|
||||
latency = (timing or {}).get("latency_ms") or 0
|
||||
ctx.check(0 < latency < 250, "capture to publication took %.1f ms while streaming", latency)
|
||||
|
||||
@test("camera.lid-privacy", title="Cameras capture only with the lid closed", subsystem="camera",
|
||||
|
||||
@test("camera.lid-privacy", title="Cameras see the room only with the lid closed", subsystem="camera",
|
||||
kind="operator", est_min=3,
|
||||
covers=_PRIVACY_COVERS, requires=["forgectrl.panel-serves"], actions=["lid"],
|
||||
covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")], requires=["forgectrl.panel-serves"],
|
||||
actions=["lid"],
|
||||
steps=["Start with the lid closed; you will be told to open it, then close it again.",
|
||||
"Nothing moves and the laser is not involved."],
|
||||
description="The privacy gate: with the lid open neither camera captures. A running stream "
|
||||
"stops within a frame or so of the lid opening, /cam/status reports capture as "
|
||||
"not allowed, and both the snapshot and the stream are refused with 409 and a "
|
||||
"reason naming the lid. Closing the lid restores all of it. This is what stops "
|
||||
"the machine - and in cloud mode the Glowforge service - from imaging the room "
|
||||
"through an open lid.")
|
||||
description="The privacy gate: with the lid open the lid camera does not capture, and the head "
|
||||
"camera captures only for a local viewer. A running lid-camera stream stops within a "
|
||||
"frame or so of the lid opening, /cam/status reports capture as not allowed, and the "
|
||||
"lid camera's snapshot and stream are refused with 409 and a reason naming the lid, "
|
||||
"to the panel too. The head camera, which looks down at the bed, still answers the "
|
||||
"panel (its token) and the extension host (its client header from this host), while "
|
||||
"the same snapshot and stream asked without either - the cloud client's form - are "
|
||||
"refused for the lid. Closing the lid restores all of it. This is what stops the "
|
||||
"machine - and in cloud mode the Glowforge service - from imaging the room through "
|
||||
"an open lid.")
|
||||
def lid_privacy(ctx):
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -366,9 +458,26 @@ def lid_privacy(ctx):
|
||||
ctx.check(b"lid" in data.lower(), "the refusal should name the lid: %r", data[:120])
|
||||
ctx.check(data[:2] != b"\xff\xd8", "a JPEG was returned with the lid open")
|
||||
|
||||
# The head camera looks at the bed: a local viewer still has it, the cloud client's form does not.
|
||||
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True)
|
||||
ctx.log("lid open: head snapshot -> %s", st)
|
||||
ctx.check(st == 409, "the head camera should be refused too, got %s", st)
|
||||
ctx.log("lid open: the panel's head snapshot -> %s", st)
|
||||
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "the panel's head snapshot with the lid open -> %s", st)
|
||||
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True, auth=False,
|
||||
headers={"X-ForgeFIRM-Client": "extension-host"})
|
||||
ctx.log("lid open: the extension host's head snapshot -> %s", st)
|
||||
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "the extension host's head snapshot with the lid open -> %s", st)
|
||||
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True, auth=False)
|
||||
ev["head_cloud_form_lid_open"] = [st, (data or b"")[:120].decode("utf-8", "replace")]
|
||||
ctx.log("lid open: head snapshot without a token or the header -> %s %s", st, (data or b"")[:120])
|
||||
ctx.check(st == 409 and b"lid" in (data or b"").lower(),
|
||||
"the head camera without a token or the header should be refused for the lid, got %s", st)
|
||||
try:
|
||||
hreq = urllib.request.Request(fc.base + "/cam/stream?cam=head", headers={"Host": fc.host_header()})
|
||||
with urllib.request.urlopen(hreq, timeout=10) as r:
|
||||
ctx.fail("the head stream opened with the lid open, for no local viewer (%s)", r.status)
|
||||
except urllib.error.HTTPError as e:
|
||||
ctx.log("lid open: head stream for no local viewer -> %s", e.code)
|
||||
ctx.check(e.code == 409, "the head stream for no local viewer should be refused with 409, got %s", e.code)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
|
||||
@@ -95,7 +95,6 @@ SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
|
||||
# The service's connect-time hunt, as the client logs its request: a few
|
||||
# seconds after the controller starts, right behind the session.
|
||||
HUNT_REQUEST = "service action request: hunt"
|
||||
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
|
||||
|
||||
|
||||
def log_size(path):
|
||||
@@ -120,39 +119,6 @@ def session_established(lines):
|
||||
return all(any(m in ln for ln in lines) for m in SESSION_MARKS)
|
||||
|
||||
|
||||
def return_head(ctx, feed=2400):
|
||||
"""Jog the head back to where the run found it: cloud mode re-zeroed the
|
||||
kernel counters at the starting position, so the counters now read the
|
||||
displacement (the home corner). Ends on the machine idle."""
|
||||
fc = ctx.forgectrl
|
||||
pos = fc.status().get("pos") or {}
|
||||
x, y = float(pos.get("x", 0.0)), float(pos.get("y", 0.0))
|
||||
ctx.log("head displacement since the switch: X %.3f Y %.3f mm", x, y)
|
||||
ctx.check(abs(x) <= RETURN_MAX_MM and abs(y) <= RETURN_MAX_MM,
|
||||
"displacement %.1f/%.1f mm exceeds %.0f mm - not jogging back", x, y, RETURN_MAX_MM)
|
||||
if abs(x) < 0.05 and abs(y) < 0.05:
|
||||
return
|
||||
with ctx.grbl() as g:
|
||||
st = g.status_report()["state"]
|
||||
if st.startswith("Alarm"):
|
||||
g.command("$X")
|
||||
r = g.command("$J=G91X%.3fY%.3fF%d" % (-x, -y, feed))
|
||||
ctx.check(not any(k.startswith("error") for k in r), "return jog refused: %s", r)
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 120:
|
||||
ctx.checkpoint()
|
||||
st = g.status_report()["state"]
|
||||
if st.startswith("Idle") and time.time() - t0 > 0.5:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
g.command("G90")
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the return jog")
|
||||
pos = fc.status().get("pos") or {}
|
||||
ctx.log("head returned: counters X %.3f Y %.3f mm", float(pos.get("x", 0)), float(pos.get("y", 0)))
|
||||
ctx.check(abs(float(pos.get("x", 0))) < 0.1 and abs(float(pos.get("y", 0))) < 0.1,
|
||||
"head not back at the start after the return jog: %s", pos)
|
||||
|
||||
|
||||
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90, poll=1.0):
|
||||
t0 = time.time()
|
||||
last = None
|
||||
@@ -254,8 +220,8 @@ def gfhome_homing(ctx, ev, g):
|
||||
"Open the lid the moment you are told, with a hand ready on it: the cloud client's "
|
||||
"first hunt begins a few seconds after its controller starts and must find the lid "
|
||||
"open. Leave it open through the connect and the hunt; close it when told. Nothing "
|
||||
"else: the switch back and the $H homing run on their own, and the head ends parked "
|
||||
"at the home corner."],
|
||||
"else: the switch back and the $H homing run on their own, and the head ends where the "
|
||||
"test found it."],
|
||||
description="One round trip with the two service-driven motions on it. POST /mode switches "
|
||||
"to the cloud controller with the lid closed (no controller starts with the "
|
||||
"enclosure open): gfcloud comes up under supervision, authenticates and "
|
||||
@@ -268,11 +234,18 @@ def gfhome_homing(ctx, ev, g):
|
||||
"is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row "
|
||||
"reads unjudged); the camera service survives the switch. The lid closed, the "
|
||||
"service's re-hunt is waited out; switching back brings grblHAL up with the Grbl "
|
||||
"port open and Idle, and the head returns to its start. Then $H with "
|
||||
"port open and Idle, and the head returns to its start by the travel the "
|
||||
"client logged for the service's motions. Then $H with "
|
||||
"homing_mode = gfcloud runs gfhome, the web-service homing session with the "
|
||||
"head-accelerometer motion witness: the controller returns to Idle with "
|
||||
"homed:true and gfhome reports the homing complete with the motion it saw.")
|
||||
"homed:true, gfhome reports the homing complete with the motion it saw, and "
|
||||
"every service motion of the session ran whole. The camera home is then "
|
||||
"dropped and the head goes back to where the test found it, by the travel the "
|
||||
"session's motions logged.")
|
||||
def mode_switch(ctx):
|
||||
# The hand-back helpers, imported here so that no other cloud test's
|
||||
# fingerprint moves.
|
||||
from .homeoff import camera_home_return, cloud_mode_return, judge_whole_motions, session_mark
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
st, m0 = fc.get("/mode")
|
||||
@@ -383,10 +356,9 @@ def mode_switch(ctx):
|
||||
st, cam2 = fc.get("/cam/status")
|
||||
ev["cam_after"] = cam2
|
||||
ctx.check(st == 200, "camera status lost after the switch back")
|
||||
# cloud mode's connect cleared the kernel counters at the starting position
|
||||
# and its hunt homed the head: bring it back
|
||||
ctx.counters_rezeroed()
|
||||
return_head(ctx)
|
||||
# The service moved the head in cloud mode, and the controller's start
|
||||
# zeroed the counters where it left it: the client's record brings it back.
|
||||
cloud_mode_return(ctx, ev, log_offset)
|
||||
# cloud mode sets its own lid-lamp level (LLvl) and leaves it: hand back the level found
|
||||
lamp1 = hw.sysfs_read("pic/lid_led")
|
||||
ev["lid_lamp"] = {"before": lamp0, "after_cloud": lamp1}
|
||||
@@ -400,9 +372,11 @@ def mode_switch(ctx):
|
||||
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
|
||||
ctx.log("homing_mode=gfcloud for the homing -> %s %s", st, body if isinstance(body, str) else "")
|
||||
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
|
||||
session_at = session_mark()
|
||||
try:
|
||||
with ctx.grbl() as g:
|
||||
gfhome_homing(ctx, ev, g)
|
||||
judge_whole_motions(ctx, ev, session_at)
|
||||
finally:
|
||||
if ev["homing_mode"] != "gfcloud":
|
||||
# Back to exactly what the machine had: unset is the empty
|
||||
@@ -413,6 +387,7 @@ def mode_switch(ctx):
|
||||
if not ev["homing_mode"]
|
||||
else fc.post("/settings", data={"homing_mode": ev["homing_mode"]}))
|
||||
ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st)
|
||||
camera_home_return(ctx, ev, session_at)
|
||||
ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> "
|
||||
"grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
|
||||
|
||||
@@ -527,9 +502,18 @@ NOHUNT_MARK = "NO-HUNT:"
|
||||
NOHUNT_MARKER = "/run/gfcloud-nohunt"
|
||||
HUNT_DONE = "hunt ["
|
||||
WS_MARKS = ("RX-EVENT: ready", "RX-EVENT: closed", "RECONNECTING", "CLOSING", OFFLINE_MARK)
|
||||
ACTIVITY_MARKS = ("start motion", "start return home", "starting run", "starting z homing cycle")
|
||||
# The service at work: every line of a service action, the requests, the image
|
||||
# uploads and the ends included. Between a lid image and its next move the
|
||||
# service is thinking and the log is silent, so the ends count: the quiet is
|
||||
# measured from the last line of any action.
|
||||
ACTIVITY_MARKS = ("service action request:", "img_upload COMPLETE", "finished with event",
|
||||
"start motion", "end motion", "start return home", "return home complete",
|
||||
"starting run", "finished run", "starting z homing cycle")
|
||||
LOG_TAIL_BYTES = 4 << 20
|
||||
QUIET_S = 8 # the re-hunt's motions are ~4 s apart (a lid image between them)
|
||||
# The service's think time after an image, on the bench reference: over 1901
|
||||
# motion, lid-image and hunt requests, half came within 1.6 s of the line before,
|
||||
# 99 percent within 12.8 s, and two after more than 30 s (32.8 and 62.2 s).
|
||||
QUIET_S = 30
|
||||
QUIET_TIMEOUT_S = 180
|
||||
HUNT_TIMEOUT_S = 180
|
||||
|
||||
@@ -586,6 +570,42 @@ def wait_log(ctx, offset, needles, timeout, poll=0.5):
|
||||
return found
|
||||
|
||||
|
||||
# What the client logs when it turns a print away before the button wait
|
||||
# (machine._safe_to_move): the lid or the interlock, a machine that is not
|
||||
# idle, the coolant above the start ceiling of its own configuration
|
||||
# (THERMAL.max_start_temp), a coolant sensor that reads nothing.
|
||||
REFUSED_BEFORE_THE_BUTTON = ("unsafe to move", "machine is not idle", "machine temp is too high",
|
||||
"coolant sensor reads invalid")
|
||||
|
||||
|
||||
def wait_button_wait(ctx, offset, timeout, also=()):
|
||||
"""Wait for the client's button wait (and the `also` needles). A print
|
||||
the client turns away never gets there: it logs why and finishes at
|
||||
once, so the wait ends on that finish line too and the failure carries
|
||||
the client's own reason, not two minutes of silence. Returns wait_log's
|
||||
result."""
|
||||
needles = list(also) + ["waiting for button"]
|
||||
found = {n: None for n in needles}
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
ctx.checkpoint()
|
||||
lines = log_lines_since(GFCLOUD_LOG, offset)
|
||||
for ln in lines:
|
||||
for n in needles:
|
||||
if found[n] is None and n in ln:
|
||||
found[n] = ln
|
||||
if all(found.values()):
|
||||
break
|
||||
if found["waiting for button"] is None and action_finish_index(lines, "print") is not None:
|
||||
why = [message(ln) for ln in lines if any(r in ln for r in REFUSED_BEFORE_THE_BUTTON)]
|
||||
fin = lines[action_finish_index(lines, "print")]
|
||||
ctx.fail("the client turned the print away before the button wait: %s (%s)",
|
||||
"; ".join(why[-3:]) or "it logged no reason", message(fin))
|
||||
time.sleep(0.5)
|
||||
ctx.check(found["waiting for button"], "the print never reached the button wait")
|
||||
return found
|
||||
|
||||
|
||||
def log_has(offset, needle):
|
||||
"""True when the gfcloud log carries needle since offset (one read;
|
||||
the condition an `act` waits on)."""
|
||||
@@ -718,9 +738,9 @@ def client_offline(pid):
|
||||
|
||||
|
||||
def wait_quiet(ctx, offset, quiet_s=None, timeout=None):
|
||||
"""The service's moves are over: the machine idle and no new service
|
||||
activity in the log (a motion, a park, a run, a lens homing) for
|
||||
quiet_s (default QUIET_S; timeout QUIET_TIMEOUT_S). False on timeout."""
|
||||
"""The service's moves are over: the machine idle and no new line of any
|
||||
service action in the log (ACTIVITY_MARKS) for quiet_s (default QUIET_S;
|
||||
timeout QUIET_TIMEOUT_S). False on timeout."""
|
||||
quiet_s = QUIET_S if quiet_s is None else quiet_s
|
||||
timeout = QUIET_TIMEOUT_S if timeout is None else timeout
|
||||
fc = ctx.forgectrl
|
||||
@@ -1043,9 +1063,7 @@ def offline_start_print(ctx, off, action_id, path, offset, settings=None):
|
||||
client loads the job, lights the button and waits; the operator's
|
||||
press arms it and the run starts. Returns the 'starting run' line."""
|
||||
off.print_ready(action_id, path, settings)
|
||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||
ctx.check(got["waiting for button"], "the offline print never reached the button wait (refused, "
|
||||
"or the job did not load)")
|
||||
wait_button_wait(ctx, offset, 120)
|
||||
ctx.act("button", "press", text="The button is lit white: the press arms the print and the "
|
||||
"head starts to move. Nothing fires: the job commands no laser.",
|
||||
until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=180)
|
||||
@@ -1272,10 +1290,9 @@ def lid_during_button_wait(ctx):
|
||||
|
||||
def lid_during_button_wait_body(ctx, ev, off, job, offset):
|
||||
off.print_ready(9003, job)
|
||||
got = wait_log(ctx, offset, ["job is longer than the ring", "waiting for button"], 180)
|
||||
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.check(got["waiting for button"], "the print never reached the button wait")
|
||||
ctx.act("lid", "open", text="The button is lit: do NOT press it.", timeout=120)
|
||||
relock = "button wait lid opened - relocking the laser"
|
||||
got = wait_log(ctx, offset, [relock], 60)
|
||||
@@ -1365,8 +1382,7 @@ def dark_print(ctx):
|
||||
|
||||
def dark_print_body(ctx, ev, off, job, offset, fc):
|
||||
off.print_ready(9005, job)
|
||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
||||
wait_button_wait(ctx, offset, 120)
|
||||
ev["latch_locked_at_button"] = latch_locked()
|
||||
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
|
||||
ctx.act("button", "press", text="The button is lit: press it. The print runs dark and finishes.",
|
||||
@@ -1434,8 +1450,7 @@ def verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig):
|
||||
gate, HOLD_REFUSE_S, st, body)
|
||||
ev["gate"] = gate
|
||||
off.print_ready(9006, job)
|
||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
||||
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 "
|
||||
|
||||
@@ -375,6 +375,7 @@ def _tail_has(fc, needle):
|
||||
"board temperatures (chassis and SoC die in degrees, supply as a raw count, the "
|
||||
"CPU unthrottled) and every run session ends with them ranged into one log line.")
|
||||
def gate_off(ctx):
|
||||
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings()
|
||||
@@ -428,6 +429,7 @@ def gate_off(ctx):
|
||||
|
||||
# Leg 2: the ceiling at its top. Off by value: no gate, verdict
|
||||
# back to OK at the next run start, and said out loud.
|
||||
at_off = _log_offset(FORGECTRL_LOG) # the off gate's line is one written after this
|
||||
_set_gates(ctx, fc, {"cool_temp_max": str(top), "cool_temp_resume": orig["cool_temp_resume"]})
|
||||
state, val = _gate_state(fc, "cool_temp_max")
|
||||
ev["off_state"] = state
|
||||
@@ -439,13 +441,17 @@ def gate_off(ctx):
|
||||
c.get("gates_off"))
|
||||
s_off = fc.status().get("gates_off")
|
||||
ctx.check(s_off == ["coolant_max"], "/status gates_off %s, expected [coolant_max]", s_off)
|
||||
ctx.check(_tail_has(fc, "gate coolant_max OFF: cool_temp_max = %g" % top),
|
||||
# A log line reaches the file a moment after the engine acts.
|
||||
line = "gate coolant_max OFF: cool_temp_max = %g" % top
|
||||
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_off, line)), 5, poll=0.25)
|
||||
ctx.check(_log_lines(FORGECTRL_LOG, at_off, line),
|
||||
"the run-start log line for the off gate is missing from the forgectrl log")
|
||||
|
||||
# Restore, and prove the restore: the next run start reloads.
|
||||
_set_gates(ctx, fc, orig)
|
||||
restored = True
|
||||
state, val = _gate_state(fc, "cool_temp_max")
|
||||
at_restore = _log_offset(FORGECTRL_LOG) # the session's run-end line is one written after this
|
||||
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and not c.get("gates_off"),
|
||||
"restored")
|
||||
ev["restored"] = c
|
||||
@@ -454,7 +460,10 @@ def gate_off(ctx):
|
||||
ctx.log("restored ceiling %s reports state %s", val, state)
|
||||
# Every run session ends with the board temperatures ranged
|
||||
# into one line.
|
||||
ctx.check(_tail_has(fc, "temps this job: chassis ") and _tail_has(fc, ", soc "),
|
||||
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_restore, "temps this job: chassis ")), 5, poll=0.25)
|
||||
temps = _log_lines(FORGECTRL_LOG, at_restore, "temps this job: chassis ")
|
||||
ev["temps_line"] = temps[-1] if temps else None
|
||||
ctx.check(temps and ", soc " in temps[-1],
|
||||
"the run-end board-temperature line (chassis, soc, supply) is missing from the "
|
||||
"forgectrl log")
|
||||
finally:
|
||||
@@ -519,6 +528,7 @@ def _hold_session(ctx, g, fc, until, what, wait):
|
||||
"run-start log line, and the session reads OK. Restored, everything reads as "
|
||||
"before.")
|
||||
def floor_and_warm_up(ctx):
|
||||
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings()
|
||||
@@ -544,6 +554,7 @@ def floor_and_warm_up(ctx):
|
||||
# Leg 1: the warm-up hold and its release. The gate sits just
|
||||
# above the loop, so the heater reaches it in a few minutes.
|
||||
gate = round(up + WARMUP_ABOVE_C, 1)
|
||||
at_warmup = _log_offset(FORGECTRL_LOG) # the release line is one written after this
|
||||
_set_gates(ctx, fc, {"cool_temp_start": str(gate), "cool_temp_min": orig["cool_temp_min"]})
|
||||
c = _hold_session(ctx, grbl, fc, lambda c: c.get("verdict") == "WARMUP", "warm-up hold", VERDICT_WAIT_S)
|
||||
ev["warmup_hold"] = c
|
||||
@@ -582,7 +593,11 @@ def floor_and_warm_up(ctx):
|
||||
ev["run_duty"] = duty
|
||||
ctx.check(heater == 0, "the loop heater stayed on after the release (%s=%s)", HEATER_PWM, heater)
|
||||
ctx.check(duty != IDLE_DUTY, "the run fans did not come up at the release: %s", duty)
|
||||
ctx.check(_tail_has(fc, "warm-up complete: coolant "), "the warm-up release line is missing from the forgectrl log")
|
||||
# A log line reaches the file a moment after the engine acts.
|
||||
line = "warm-up complete: coolant "
|
||||
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_warmup, line)), 5, poll=0.25)
|
||||
ctx.check(_log_lines(FORGECTRL_LOG, at_warmup, line),
|
||||
"the warm-up release line is missing from the forgectrl log")
|
||||
grbl.command("M9")
|
||||
_session_ended(ctx, fc, "warm-up")
|
||||
|
||||
@@ -600,6 +615,7 @@ def floor_and_warm_up(ctx):
|
||||
c.get("gates_off"))
|
||||
|
||||
# Leg 3: both at zero: off, said so, and the session reads OK.
|
||||
at_min_off = _log_offset(FORGECTRL_LOG) # the floor's off line is one written after this
|
||||
_set_gates(ctx, fc, {"cool_temp_start": "0", "cool_temp_min": "0"})
|
||||
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and len(c.get("gates_off") or []) == 2,
|
||||
"both off")
|
||||
@@ -607,7 +623,9 @@ def floor_and_warm_up(ctx):
|
||||
ctx.check(c.get("verdict") == "OK", "gates at 0 did not clear the hold: %s", c)
|
||||
ctx.check(sorted(c.get("gates_off") or []) == ["coolant_min", "warm_up"],
|
||||
"gates_off %s, expected [coolant_min, warm_up]", c.get("gates_off"))
|
||||
ctx.check(_tail_has(fc, "gate coolant_min OFF: cool_temp_min = 0"),
|
||||
line = "gate coolant_min OFF: cool_temp_min = 0"
|
||||
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_min_off, line)), 5, poll=0.25)
|
||||
ctx.check(_log_lines(FORGECTRL_LOG, at_min_off, line),
|
||||
"the run-start log line for the floor off is missing from the forgectrl log")
|
||||
|
||||
# Restore, and prove the restore at the next run start.
|
||||
@@ -656,6 +674,7 @@ TEC_WAIT_S = 20 # settings re-read at run start, 1 Hz ticks
|
||||
"The cross-check keeps off under on. The line has no readback, so the last written value is "
|
||||
"what the test reads; the drive rule is the engine's, never the cloud's.")
|
||||
def tec_drive(ctx):
|
||||
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings()
|
||||
@@ -681,6 +700,7 @@ def tec_drive(ctx):
|
||||
_set_gates(ctx, fc, {"cool_tec_present": "1", "cool_tec_on_c": str(on_c),
|
||||
"cool_tec_off_c": str(off_c)})
|
||||
ctx.check(_cool(fc).get("phase") != "run", "a run session is already open")
|
||||
off = _log_offset(FORGECTRL_LOG) # the TEC-on line is one written after this
|
||||
grbl.command("M8")
|
||||
t0 = time.time()
|
||||
tec = 0
|
||||
@@ -692,7 +712,11 @@ def tec_drive(ctx):
|
||||
ev["tec_on_after_s"] = round(time.time() - t0)
|
||||
ctx.log("fitted, in session: %s=%s after %s s", TEC_ATTR, tec, ev["tec_on_after_s"])
|
||||
ctx.check(tec == 1, "the TEC did not come on in a run session with its threshold under the loop")
|
||||
ctx.check(_tail_has(fc, "TEC on: coolant "), "the TEC-on line is missing from the forgectrl log")
|
||||
# The engine writes the line first and logs it after, and the log
|
||||
# line reaches the file a moment later still.
|
||||
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, off, "TEC on: coolant ")), 5, poll=0.25)
|
||||
ev["tec_on_line"] = (_log_lines(FORGECTRL_LOG, off, "TEC on: coolant ") or [None])[0]
|
||||
ctx.check(ev["tec_on_line"], "the TEC-on line is missing from the forgectrl log")
|
||||
grbl.command("M9")
|
||||
_session_ended(ctx, fc, "tec leg 1")
|
||||
t0 = time.time()
|
||||
@@ -1002,6 +1026,7 @@ def crash_watch_plumbing(ctx):
|
||||
"value (gates_off names it) and the ceiling alone pauses; restored, the next "
|
||||
"session runs OK with nothing off.")
|
||||
def critical_tier(ctx):
|
||||
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings()
|
||||
@@ -1078,6 +1103,7 @@ def critical_tier(ctx):
|
||||
|
||||
# Leg 2: the critical line at its top is the gate off; the
|
||||
# ceiling alone pauses, and gates_off says so.
|
||||
at_off = _log_offset(FORGECTRL_LOG) # the off gate's line is one written after this
|
||||
_set_gates(ctx, fc, {"cool_temp_critical_c": str(top)})
|
||||
c = _run_session(ctx, grbl, fc,
|
||||
lambda c: c.get("verdict") == "OVERTEMP" and "coolant_critical" in (c.get("gates_off") or []),
|
||||
@@ -1087,7 +1113,10 @@ def critical_tier(ctx):
|
||||
"with the critical line off the ceiling did not pause (verdict %s): %s", c.get("verdict"), c)
|
||||
ctx.check("coolant_critical" in (c.get("gates_off") or []),
|
||||
"gates_off %s lacks coolant_critical", c.get("gates_off"))
|
||||
ctx.check(_tail_has(fc, "gate coolant_critical OFF: cool_temp_critical_c = %g" % top),
|
||||
# A log line reaches the file a moment after the engine acts.
|
||||
line = "gate coolant_critical OFF: cool_temp_critical_c = %g" % top
|
||||
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_off, line)), 5, poll=0.25)
|
||||
ctx.check(_log_lines(FORGECTRL_LOG, at_off, line),
|
||||
"the run-start log line for the off gate is missing from the forgectrl log")
|
||||
|
||||
# Restore, and prove it: OK, nothing off.
|
||||
@@ -1386,6 +1415,7 @@ def flow_under_load(ctx):
|
||||
"loss and its put-back, and the session stays OK with no airflow fault (a fan put "
|
||||
"back gets its spin-up grace). Then M9 and the idle duties.")
|
||||
def fan_duty_readback(ctx):
|
||||
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
run = {"head/air_assist_pwm": 1023, "thermal/exhaust_pwm": 65535, "thermal/intake_pwm": 43278}
|
||||
@@ -1399,6 +1429,7 @@ def fan_duty_readback(ctx):
|
||||
ev["run_duties"] = before
|
||||
ctx.check(all(before.get(k) == v for k, v in run.items()),
|
||||
"not every fan reads its run duty: %s", before)
|
||||
at_loss = _log_offset(FORGECTRL_LOG) # the put-back lines are ones written after this
|
||||
hw.sysfs_write("head/air_assist_pwm", 204)
|
||||
hw.sysfs_write("thermal/exhaust_pwm", 0)
|
||||
lost = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
|
||||
@@ -1416,9 +1447,13 @@ def fan_duty_readback(ctx):
|
||||
ctx.check(back.get("head/air_assist_pwm") == 1023, "the air-assist duty was not put back: %s", back)
|
||||
ctx.check(back.get("thermal/exhaust_pwm") == 65535, "the exhaust duty was not put back: %s", back)
|
||||
ctx.sleep(2)
|
||||
ctx.check(_tail_has(fc, "head/air_assist_pwm read 204 with 1023 commanded: put back"),
|
||||
line = "head/air_assist_pwm read 204 with 1023 commanded: put back"
|
||||
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_loss, line)), 5, poll=0.25)
|
||||
ctx.check(_log_lines(FORGECTRL_LOG, at_loss, line),
|
||||
"the air-assist loss is not named in the log")
|
||||
ctx.check(_tail_has(fc, "thermal/exhaust_pwm read 0 with 65535 commanded: put back"),
|
||||
line = "thermal/exhaust_pwm read 0 with 65535 commanded: put back"
|
||||
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_loss, line)), 5, poll=0.25)
|
||||
ctx.check(_log_lines(FORGECTRL_LOG, at_loss, line),
|
||||
"the exhaust loss is not named in the log")
|
||||
for _ in range(8):
|
||||
ctx.sleep(1)
|
||||
@@ -1532,3 +1567,60 @@ def fail_tier_stop(ctx):
|
||||
"thresholds not restored: %s", {k: after.get(k) for k in CRASH_KEYS})
|
||||
ctx.log("PASS: the crash tier stopped the job, locked the latch, and the supervisor restarted "
|
||||
"the controller (pid %s -> %s)", pid0, m1 and m1.get("pid"))
|
||||
|
||||
|
||||
@test("cooling.report-channel", title="A forged cooling report inside a run session is refused and changes nothing",
|
||||
subsystem="cooling", kind="auto", mode="grbl", est_min=2,
|
||||
covers=_COOL_COVERS + [("forgectrl", "src/super.*"), ("forgectrl", "src/main.c"),
|
||||
("forgectrl", "src/auth.*")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="The report channel (POST /cool/state) is what tells the engine a job is running: a "
|
||||
"forged idle report would stand the fans down under a cut. M8 opens a run session "
|
||||
"from the suite's Grbl client, and once the engine is in phase run with its run fan "
|
||||
"profile commanded, a process on this host that is not the controller reports "
|
||||
"mode=idle, armed=0: with no secret, with a made-up secret, and with a made-up "
|
||||
"secret and a forged Host header. Each is refused (403). Over the next five seconds "
|
||||
"the engine stays in phase run and no commanded fan duty drops. M9 "
|
||||
"ends the session, and the engine leaves phase run on the controller's own report.")
|
||||
def report_channel(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
forged = {"mode": "idle", "armed": "0"}
|
||||
|
||||
with ctx.grbl() as g:
|
||||
def in_run(c):
|
||||
return c.get("phase") == "run"
|
||||
ctx.check(_cool(fc).get("phase") != "run", "a run session is already open")
|
||||
g.command("M8")
|
||||
try:
|
||||
ok = ctx.wait_for(lambda: in_run(_cool(fc)), VERDICT_WAIT_S, poll=0.5)
|
||||
ctx.check(ok is not None, "M8 did not open a run session: %s", _cool(fc).get("phase"))
|
||||
ctx.sleep(2) # the run fan profile is commanded
|
||||
before = _cool(fc)
|
||||
duties0 = _duties()
|
||||
ev["before"] = {"phase": before.get("phase"), "duties": duties0}
|
||||
refused = {}
|
||||
for name, headers in (("no secret", {}),
|
||||
("a made-up secret", {"X-ForgeFIRM-Report": "0123456789abcdef" * 2}),
|
||||
("a made-up secret and a forged Host",
|
||||
{"X-ForgeFIRM-Report": "f" * 32, "Host": "localhost"})):
|
||||
st, body = fc.post("/cool/state", params=forged, headers=headers, auth=False)
|
||||
refused[name] = [st, str(body)[:100]]
|
||||
ctx.check(st == 403, "a forged report (%s) -> %s %s", name, st, str(body)[:100])
|
||||
ev["refused"] = refused
|
||||
held = []
|
||||
for _ in range(5):
|
||||
ctx.sleep(1)
|
||||
c = _cool(fc)
|
||||
held.append([c.get("phase"), _duties()])
|
||||
ev["after"] = held
|
||||
ctx.log("phase and duties over 5 s after three forged idle reports: %s", held)
|
||||
ctx.check(all(p == "run" for p, _d in held), "the engine left phase run: %s", [p for p, _d in held])
|
||||
ctx.check(all(d[k] >= duties0[k] for _p, d in held for k in duties0),
|
||||
"a commanded fan duty dropped under the forged reports: %s -> %s", duties0, held)
|
||||
finally:
|
||||
g.command("M9")
|
||||
_session_ended(ctx, fc, "report channel")
|
||||
ctx.check(_cool(fc).get("phase") != "run", "the session did not end on the controller's own report")
|
||||
ctx.log("PASS: three forged idle reports from this host were refused inside a run session, and the "
|
||||
"engine held its phase and its fan duties")
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""cooling.intake-one-fan - a machine built with one intake fan
|
||||
(cool_intake_fans 1, which the setup's airflow check writes): the second
|
||||
intake tach is published as not fitted and never judged, and intake 1
|
||||
still holds the intake floor. On a machine with two intake fans the
|
||||
setting alone proves both halves: intake 2 turns and is not judged, and
|
||||
an intake floor no fan can meet trips with intake 2 still out of it."""
|
||||
from ..catalog import test
|
||||
from .cooling import _COOL_COVERS, _after_session, _cool, _fan_gate, _run_session, _set_gates
|
||||
|
||||
INTAKE_KEYS = ("cool_intake_fans", "cool_tach_intake_min_rpm", "cool_fan_grace_s")
|
||||
INTAKE_GRACE_S = "8" # past the intakes' 7 s to 90 percent, so only the leg's floor trips
|
||||
INTAKE_TRIP_WAIT_S = 20 # grace + three ticks, with slack for the 1 Hz pipeline
|
||||
|
||||
|
||||
@test("cooling.intake-one-fan", title="With one intake fan set, intake 2 is not judged and intake 1 still is",
|
||||
subsystem="cooling", kind="auto", mode="grbl", est_min=3,
|
||||
covers=_COOL_COVERS + [("forgectrl", "src/main.c")], requires=["cooling.fan-gate-trips"],
|
||||
steps=["Machine idle, fans quiet. The test sets one intake fan, the intake floor and the spin-up "
|
||||
"grace and restores all three; three short M8/M9 cycles spin the fans."],
|
||||
description="cool_intake_fans 1 must take intake 2 out of the airflow gates and leave intake 1 in "
|
||||
"them. With it set, a session at the cut fan profile must publish intake_2 as not fitted "
|
||||
"with no floor while it reads its real speed, run OK, and judge intake_1 ok at the intake "
|
||||
"floor; an intake floor at its legal maximum must then trip AIRFLOW (hold, fire blocked) "
|
||||
"with intake_1 TRIPPED and intake_2 still not fitted, and the fault must end with the "
|
||||
"session; restored, the next session judges intake_2 again and every fan reads at or "
|
||||
"above its floor.")
|
||||
def intake_one_fan(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings()
|
||||
orig = {k: before.get(k, "") for k in INTAKE_KEYS}
|
||||
ev["orig"] = orig
|
||||
ctx.log("original: %s", orig)
|
||||
inl = (before.get("gates") or {}).get("cool_tach_intake_min_rpm") or {}
|
||||
ctx.check(inl.get("gate") == "intake" and inl.get("off") == "low",
|
||||
"settings reply does not describe the intake floor as the intake gate, off at zero: %s", inl)
|
||||
ctx.check(orig["cool_intake_fans"] != "1",
|
||||
"cool_intake_fans is already 1: the test starts from a machine with both intakes judged")
|
||||
c0 = _cool(fc)
|
||||
ctx.check(c0.get("verdict") == "OK" and c0.get("gates_off") == [],
|
||||
"engine is not at OK with every gate on before the test: %s", c0)
|
||||
|
||||
restored = False
|
||||
with ctx.grbl() as grbl:
|
||||
st = grbl.status_report()
|
||||
ctx.check(st["state"].startswith("Idle"), "controller is %s", st["state"])
|
||||
try:
|
||||
# Leg 1: one intake fan. Intake 2 still turns on this machine; it
|
||||
# is measured and published not fitted, and nothing judges it.
|
||||
_set_gates(ctx, fc, {"cool_intake_fans": "1", "cool_fan_grace_s": INTAKE_GRACE_S})
|
||||
c = _run_session(ctx, grbl, fc,
|
||||
lambda c: c.get("verdict") == "OK" and _fan_gate(c, "intake_1").get("state") == "ok",
|
||||
"one intake fan leg", wait=INTAKE_TRIP_WAIT_S + 10)
|
||||
g1, g2 = _fan_gate(c, "intake_1"), _fan_gate(c, "intake_2")
|
||||
ev["one_fan"] = {"cool": c, "intake_1": g1, "intake_2": g2}
|
||||
ctx.check(g2.get("state") == "not fitted" and (g2.get("floor") or 0) == 0,
|
||||
"intake_2 is not published not fitted with no floor: %s", g2)
|
||||
ctx.check((g2.get("reading") or 0) > 0,
|
||||
"intake_2 reads %s: the test needs a machine whose second intake turns", g2.get("reading"))
|
||||
ctx.check(c.get("verdict") == "OK", "the one intake fan session is not OK: %s", c)
|
||||
ctx.check(g1.get("state") == "ok" and (g1.get("floor") or 0) > 0,
|
||||
"intake_1 is not judged ok at the intake floor: %s", g1)
|
||||
|
||||
# Leg 2: the intake floor at its legal maximum. Intake 1 is the
|
||||
# one intake left in the gates; intake 2 stays out of them.
|
||||
_set_gates(ctx, fc, {"cool_tach_intake_min_rpm": str(inl.get("hi"))})
|
||||
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "AIRFLOW", "intake 1 trip leg",
|
||||
wait=INTAKE_TRIP_WAIT_S)
|
||||
g1, g2 = _fan_gate(c, "intake_1"), _fan_gate(c, "intake_2")
|
||||
ev["intake_1_trip"] = {"cool": c, "intake_1": g1, "intake_2": g2}
|
||||
ctx.check(c.get("verdict") == "AIRFLOW", "an intake floor of %s rpm did not trip: %s", inl.get("hi"), c)
|
||||
ctx.check(c.get("fire_ok") is False and c.get("hold") is True,
|
||||
"AIRFLOW without fire blocked and a hold: %s", c)
|
||||
ctx.check(g1.get("state") == "TRIPPED", "intake_1 does not read TRIPPED: %s", g1)
|
||||
ctx.check(g2.get("state") == "not fitted", "intake_2 was judged with one intake fan set: %s", g2)
|
||||
ctx.check("intake_1" in (c.get("reason") or ""), "the reason does not name intake_1: %r", c.get("reason"))
|
||||
c = _after_session(ctx, fc)
|
||||
ev["intake_1_trip_after"] = c
|
||||
ctx.check(c.get("verdict") == "OK" and c.get("hold") is False,
|
||||
"the fan fault outlived its run session: %s", c)
|
||||
|
||||
# Restore, and prove it: intake 2 judged again, every fan at or
|
||||
# above its floor. The restored grace is the shipped one.
|
||||
_set_gates(ctx, fc, orig)
|
||||
restored = True
|
||||
c = _run_session(ctx, grbl, fc,
|
||||
lambda c: c.get("verdict") == "OK" and not c.get("gates_off")
|
||||
and all(g.get("state") == "ok" for g in (c.get("fan_gates") or {}).values()),
|
||||
"restored", wait=INTAKE_TRIP_WAIT_S + 20)
|
||||
ev["restored"] = c.get("fan_gates")
|
||||
ctx.check(c.get("verdict") == "OK" and c.get("gates_off") == [],
|
||||
"engine did not return to OK with every gate on after the restore: %s", c)
|
||||
bad = {k: g for k, g in (c.get("fan_gates") or {}).items() if g.get("state") != "ok"}
|
||||
ctx.check(not bad, "fans not at or above their floors after the restore: %s", bad)
|
||||
finally:
|
||||
if not restored:
|
||||
st, body = fc.post("/settings", params=orig)
|
||||
ctx.log("restore on failure: POST /settings %s -> %s", orig, st)
|
||||
try:
|
||||
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and not c.get("gates_off"),
|
||||
"restore on failure")
|
||||
ctx.log("restore on failure: engine %s gates_off %s", c.get("verdict"), c.get("gates_off"))
|
||||
except Exception as e: # the original failure is the one to report
|
||||
ctx.log("restore on failure: run session did not complete (%s)", e)
|
||||
after = fc.settings()
|
||||
ctx.check(all(after.get(k, "") == orig[k] for k in INTAKE_KEYS),
|
||||
"settings not restored: %s", {k: after.get(k) for k in INTAKE_KEYS})
|
||||
ctx.check(fc.wait_idle(60, abort=ctx.aborted), "machine did not return to idle")
|
||||
@@ -0,0 +1,103 @@
|
||||
# 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="operator", mode="grbl", est_min=2,
|
||||
covers=[("forgectrl", "src/events.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*")],
|
||||
actions=["button"],
|
||||
steps=["The machine idle; you will be told to press the button once.",
|
||||
"Nothing moves and nothing fires: nothing is waiting for the press."],
|
||||
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)
|
||||
@@ -0,0 +1,265 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,188 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,237 @@
|
||||
# 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
@@ -0,0 +1,154 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,238 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,286 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,224 @@
|
||||
# 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)
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
@@ -142,14 +143,39 @@ def auth(ctx):
|
||||
"GET /fuse-identity with the token but no button -> %s %r (expected the two-factor refusal)",
|
||||
st, body)
|
||||
|
||||
# the cooling report channel: the loopback peer is accepted. An idle
|
||||
# report is what the controller sends every period; the engine is idle
|
||||
# here, so it changes nothing. A dual-stack listener reports this peer
|
||||
# as ::ffff:127.0.0.1, which the check must recognize in full.
|
||||
st, body = fc.post("/cool/state", params={"mode": "idle", "armed": "0"})
|
||||
# the cooling report channel is the running controller's alone. A
|
||||
# loopback peer is not enough: anything on this host is one. The
|
||||
# supervisor hands the controller a secret at its spawn, and the route
|
||||
# asks for it. An idle report is what the controller sends every
|
||||
# period; the engine is idle here, so the accepted one changes nothing.
|
||||
# A dual-stack listener reports this peer as ::ffff:127.0.0.1, which
|
||||
# the loopback check must recognize in full.
|
||||
report = {"mode": "idle", "armed": "0"}
|
||||
st, body = fc.post("/cool/state", params=report)
|
||||
ev["cool_state_from_loopback_no_secret"] = st
|
||||
ctx.log("POST /cool/state from loopback, no secret -> %s %s", st, body if isinstance(body, str) else "")
|
||||
ctx.check(st == 403 and "controller's alone" in str(body),
|
||||
"/cool/state from loopback without the secret -> %s %r", st, body)
|
||||
st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": "0" * 32})
|
||||
ctx.check(st == 403, "/cool/state with a secret that is not the controller's -> %s %r", st, body)
|
||||
# The secret itself, as only root on this host can read it: out of
|
||||
# the running controller's environment. It is never logged.
|
||||
secret = None
|
||||
pid = (fc.get("/mode")[1] or {}).get("pid")
|
||||
if pid:
|
||||
try:
|
||||
with open("/proc/%d/environ" % int(pid), "rb") as f:
|
||||
for item in f.read().split(b"\0"):
|
||||
if item.startswith(b"GF_REPORT_SECRET="):
|
||||
secret = item.split(b"=", 1)[1].decode("ascii", "replace")
|
||||
except OSError:
|
||||
pass
|
||||
ev["controller_has_a_secret"] = bool(secret)
|
||||
ctx.check(secret and len(secret) == 32, "the running controller (pid %s) was handed no report secret", pid)
|
||||
st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": secret})
|
||||
ev["cool_state_from_loopback"] = st
|
||||
ctx.log("POST /cool/state from loopback -> %s %s", st, body if isinstance(body, dict) else "")
|
||||
ctx.check(st == 200, "/cool/state refused the loopback peer (%s %r): the controller's "
|
||||
ctx.log("POST /cool/state from loopback with the controller's secret -> %s", st)
|
||||
ctx.check(st == 200, "/cool/state refused the controller's own secret (%s %r): the controller's "
|
||||
"reports never reach the engine", st, body)
|
||||
|
||||
# ...and a non-loopback peer is refused, even with a token. Over HTTP
|
||||
@@ -161,8 +187,7 @@ def auth(ctx):
|
||||
ip = lan_ip()
|
||||
ev["lan_ip"] = ip
|
||||
ctx.check(ip, "cannot determine the board's LAN address")
|
||||
token = {"X-ForgeFIRM-Token": fc.token}
|
||||
report = {"mode": "idle", "armed": "0"}
|
||||
token = {"X-ForgeFIRM-Token": fc.token, "X-ForgeFIRM-Report": secret}
|
||||
st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token)
|
||||
loc = hdrs.get("location", "")
|
||||
ev["cool_state_from_lan_http"] = {"status": st, "location": loc}
|
||||
@@ -297,7 +322,12 @@ def settings_bounds(ctx):
|
||||
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
|
||||
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
|
||||
("forgectrl", "src/curverec.*")],
|
||||
description="The panel page is served, /status carries the machine telemetry the panel and "
|
||||
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 "
|
||||
@@ -325,6 +355,12 @@ def panel_serves(ctx):
|
||||
# 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"):
|
||||
@@ -417,3 +453,455 @@ def panel_serves(ctx):
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""A camera home's offset pair, on the machine.
|
||||
|
||||
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
|
||||
service motion zeroes the step counters at its start, and a home and every
|
||||
controller start zero them again.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
from ..baseline import XY_STEPS_PER_MM, counter_frame, counter_steps_per_mm, read_position
|
||||
from ..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
|
||||
|
||||
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
|
||||
# motion is not this one). A print's park keeps the counters and drives
|
||||
# them back, so the line after it is the print's net travel.
|
||||
MOTION_END = re.compile(r"end positions \((-?\d+), (-?\d+), (-?\d+)\)")
|
||||
|
||||
|
||||
def session_mark(path=None):
|
||||
"""Where a client's log ends now (gfhome's by default): the start of
|
||||
the next session's record."""
|
||||
return log_size(path or logs.GFHOME_LOG)
|
||||
|
||||
|
||||
def session_travel(offset, path=None):
|
||||
"""The X/Y travel of the service motions logged in a client's log
|
||||
(gfhome's by default) since offset, in steps, or None when it cannot be
|
||||
known. Every service motion zeroes the kernel counters at its start and
|
||||
logs where they ended, so the travel is the sum of those ends. A service
|
||||
motion runs at x8 or not at all (the client refuses any other mode), so
|
||||
the steps are x8 steps. A motion refused before it moved logs no end
|
||||
and moved nothing; a motion with no "end motion" (one that raised after
|
||||
it moved, or a client killed inside it) leaves the travel unknown, and
|
||||
so does a log rotated under the run."""
|
||||
path = path or logs.GFHOME_LOG
|
||||
if log_size(path) < offset:
|
||||
return None
|
||||
x = y = 0
|
||||
inside, end = False, None
|
||||
for ln in log_lines_since(path, offset):
|
||||
ln = ln.rstrip()
|
||||
m = MOTION_END.search(ln)
|
||||
if ln.endswith(" start motion"):
|
||||
if inside:
|
||||
return None
|
||||
inside, end = True, None
|
||||
elif m and inside:
|
||||
end = (int(m.group(1)), int(m.group(2)))
|
||||
elif ln.endswith(" end motion") and inside:
|
||||
if end:
|
||||
x, y = x + end[0], y + end[1]
|
||||
inside = False
|
||||
return None if inside else (x, y)
|
||||
|
||||
|
||||
def judge_whole_motions(ctx, ev, offset):
|
||||
"""Every service motion of the homing session since offset ran whole.
|
||||
One that stopped short (the cooling engine's stop, a fault) left the
|
||||
head short of the camera home, and the position declared there is
|
||||
false."""
|
||||
short = [ln.strip()[:160] for ln in log_lines_since(logs.GFHOME_LOG, offset)
|
||||
if "run stopped short" in ln
|
||||
or ("_finish_action motion [" in ln and 'event ":completed"' not in ln)]
|
||||
ev["short_motions"] = short
|
||||
ctx.check(not short, "a service motion of the homing did not run whole, so the head stopped short of "
|
||||
"the camera home and the declared position is false: %s", short)
|
||||
|
||||
|
||||
def camera_home_return(ctx, ev, offset):
|
||||
"""Hand the head back to where the test found it after gfhome_homing
|
||||
ran from gfhome's log offset, whatever the session decided. The head is
|
||||
the session's logged travel from there, plus, once homed, whatever the
|
||||
counters read since: the home zeroed them where the session left the
|
||||
head and wrote the anchor, and a controller start since would have
|
||||
removed it. Unhomed, the counters still hold the last motion's end,
|
||||
which the travel already counts. The camera home is dropped first,
|
||||
since its envelope need not hold the spot the head came from."""
|
||||
def back():
|
||||
now = [0, 0, 0]
|
||||
if ev.get("homed"):
|
||||
now = read_position() if counter_frame() is not None else None
|
||||
_drop_reference(ctx, ctx.forgectrl)
|
||||
# Read after the stop: a runner the stop ended has written its last line.
|
||||
travel = session_travel(offset)
|
||||
rec = ev["return"] = {"session_travel_steps": travel, "counters_since_home": now}
|
||||
ctx.check(travel is not None and now is not None,
|
||||
"the homing session's travel cannot be known (gfhome log %s, counters since the home %s): "
|
||||
"the head is left where it stands and the hand-back moves nothing", travel, now)
|
||||
_jog_back(ctx, rec, travel, now)
|
||||
_hand_back(ctx, ev, "return", back)
|
||||
|
||||
|
||||
def cloud_mode_return(ctx, ev, offset):
|
||||
"""Hand the head back to where the test found it after a stay in cloud
|
||||
mode that began at the cloud client's log offset, on the GRBL
|
||||
controller the switch back started: that start zeroed the counters
|
||||
where the service left the head, and they hold whatever moved since.
|
||||
The client's record of its motions is the rest (a hunt's lens travel is
|
||||
Z, which a hand-back never touches)."""
|
||||
def back():
|
||||
travel = session_travel(offset, logs.GFCLOUD_LOG)
|
||||
now = read_position()
|
||||
rec = ev["cloud_return"] = {"session_travel_steps": travel, "counters_since_start": now}
|
||||
ctx.check(travel is not None and now is not None,
|
||||
"the travel in cloud mode cannot be known (gfcloud log %s, counters %s): the head is left "
|
||||
"where it stands and the hand-back moves nothing", travel, now)
|
||||
_jog_back(ctx, rec, travel, now)
|
||||
_hand_back(ctx, ev, "cloud_return", back)
|
||||
|
||||
|
||||
def _hand_back(ctx, ev, key, back):
|
||||
"""Run a hand-back from a test's finally. The first failure wins: when
|
||||
the test has failed already (the exception in flight), a hand-back that
|
||||
fails as well is logged and recorded, and the test's own failure is the
|
||||
one reported."""
|
||||
first = sys.exc_info()[1]
|
||||
try:
|
||||
back()
|
||||
except Failed as e:
|
||||
if first is None:
|
||||
raise
|
||||
ev.setdefault(key, {})["failed"] = str(e)
|
||||
ctx.log("the head is not back, and the run has failed already: %s", e)
|
||||
|
||||
|
||||
def _jog_back(ctx, rec, travel, since):
|
||||
"""Jog the head back by the service's travel (x8 steps) and what the
|
||||
counters read since (the controller's own steps), on a controller
|
||||
without a home. One more start then zeroes the counters where the head
|
||||
began, which the rest of the catalog counts on."""
|
||||
spm = counter_steps_per_mm()
|
||||
dx = travel[0] / XY_STEPS_PER_MM + since[0] / spm
|
||||
dy = travel[1] / XY_STEPS_PER_MM + since[1] / spm
|
||||
rec["mm"] = [round(dx, 3), round(dy, 3)]
|
||||
ctx.log("the head is %.3f/%.3f mm from where the test found it", dx, dy)
|
||||
ctx.check(abs(dx) <= RETURN_MAX_MM and abs(dy) <= RETURN_MAX_MM,
|
||||
"a travel of %.1f/%.1f mm exceeds %.0f mm - not jogging back", dx, dy, RETURN_MAX_MM)
|
||||
if abs(dx) >= 0.05 or abs(dy) >= 0.05:
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
start = read_position()
|
||||
r = g.command("$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy))
|
||||
ctx.check(not any(x.startswith("error") for x in r), "the return jog was refused: %s", r)
|
||||
states = wait_idle(ctx, g, timeout=60)[1]
|
||||
ctx.check(states[-1:] != ["TIMEOUT"], "the return jog did not end: %s", states)
|
||||
machine_idle(ctx)
|
||||
end = read_position()
|
||||
rec["counters_across_jog"] = [start, end]
|
||||
ctx.check(start is not None and end is not None and abs((end[0] - start[0]) / spm + dx) < 0.1
|
||||
and abs((end[1] - start[1]) / spm + dy) < 0.1,
|
||||
"the return jog did not travel %.3f/%.3f mm: counters %s -> %s", -dx, -dy, start, end)
|
||||
_drop_reference(ctx, ctx.forgectrl)
|
||||
ctx.counters_rezeroed()
|
||||
ctx.log("head returned to where the test found it (%.3f/%.3f mm)", -dx, -dy)
|
||||
|
||||
|
||||
@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)
|
||||
@@ -111,7 +111,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/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
|
||||
covers=[("forgectrl", "init/**"), ("forgeext", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
|
||||
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
|
||||
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
|
||||
("linux-fslc", "**")],
|
||||
@@ -120,7 +120,9 @@ def fds_of(pid):
|
||||
"console banner and the motd, "
|
||||
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
|
||||
"the daemon ownership, "
|
||||
"the init ordering, the file modes the release depends on, and the mounts: the "
|
||||
"the 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.")
|
||||
def image_health(ctx):
|
||||
@@ -267,6 +269,29 @@ def image_health(ctx):
|
||||
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links")
|
||||
ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[0])
|
||||
|
||||
# 5b. the extension host: one process, up after forgectrl and down
|
||||
# before it, and a campaign's machine is extension-free
|
||||
host = []
|
||||
for pid in hw.pidof("forgeext"):
|
||||
argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
|
||||
if argv[:2] == [hw.FORGEEXT, "run"]:
|
||||
host.append(pid)
|
||||
ev["extension_host_pids"] = host
|
||||
ctx.check(len(host) == 1, "the extension host is not one running process: %s", host)
|
||||
s5 = sorted(os.path.basename(x) for x in glob.glob("/etc/rc5.d/S*"))
|
||||
sx = [x for x in s5 if x.endswith("forgeext")]
|
||||
sf = [x for x in s5 if x.endswith("forgectrl")]
|
||||
kx = [x for x in k if x.endswith("forgeext")]
|
||||
ev["forgeext_links"] = {"start": sx, "kill": kx}
|
||||
ctx.check(sx and sf and sf[0] < sx[0], "the extension host must start after forgectrl: %s, %s", sf, sx)
|
||||
ctx.check(kx and kx[0] < kf[0], "the extension host must stop before forgectrl: %s, %s", kx, kf)
|
||||
ev["extension_packages"] = hw.ext_packages()
|
||||
ev["pool_processes"] = len(hw.pool_pids())
|
||||
ctx.log("extension host pid %s, packages %s, pool processes %d", host, ev["extension_packages"], ev["pool_processes"])
|
||||
ctx.check(not ev["extension_packages"], "a campaign wants an extension-free machine: installed %s",
|
||||
ev["extension_packages"])
|
||||
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"])
|
||||
|
||||
# 6. logging lever present, no userspace watchdog daemon
|
||||
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
|
||||
ev["logging_init"] = logging
|
||||
@@ -284,6 +309,14 @@ def image_health(ctx):
|
||||
ev[path] = "%o" % m
|
||||
ctx.log("%s mode %o", path, m)
|
||||
ctx.check(m == 0o600, "%s mode %o, expected 600", path, m)
|
||||
# an operator account walks through the data directory to its home, and a
|
||||
# package's account to its files: forgefirm-users opens it for search at
|
||||
# every render, whoever made it and under whatever umask
|
||||
if os.path.exists("/data/forgefirm/users"):
|
||||
m = stat.S_IMODE(os.stat("/data/forgefirm").st_mode)
|
||||
ev["/data/forgefirm"] = "%o" % m
|
||||
ctx.log("/data/forgefirm mode %o", m)
|
||||
ctx.check(m & 0o011 == 0o011, "/data/forgefirm mode %o: no account can walk through it to its home", m)
|
||||
if os.path.isdir("/data"):
|
||||
s = os.statvfs("/data")
|
||||
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
|
||||
|
||||
@@ -30,7 +30,8 @@ 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)
|
||||
wait_state_text, wait_left_state, wait_idle, drain_text, Watch, machine_idle,
|
||||
_job, _job_post, _job_wait, _lease_holder, _port_state, _words)
|
||||
|
||||
_LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
|
||||
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"),
|
||||
@@ -670,8 +671,11 @@ def disarm_in_hold(ctx):
|
||||
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
|
||||
ctx.log("armed; waiting for motion to start (arm + your button press)...")
|
||||
w = Watch(g)
|
||||
ctx.act("button", "press", text="The button is lit white: the press arms the job and the "
|
||||
"move starts.", until=w.in_state("Run"), timeout=180, fail=False)
|
||||
# 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)
|
||||
@@ -849,11 +853,15 @@ def armed_kill(ctx):
|
||||
"Press the physical button when it lights white (the arm). Nothing else: the test "
|
||||
"takes the cooling verdict away itself and gives it back."],
|
||||
description="A 40 mm line at constant power (M3 S400/F300, 8 s). About 1.5 s in, the test "
|
||||
"pauses the machine-services daemon for 3.5 s, so the cooling verdict the "
|
||||
"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, so the kernel's LASER_ON sample count reads 0 before the cut "
|
||||
"resumes, and it must not write the latch: a lock sets the hardware button "
|
||||
"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 "
|
||||
@@ -876,6 +884,61 @@ def verdict_cut(ctx):
|
||||
# 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
|
||||
@@ -908,7 +971,10 @@ def verdict_cut(ctx):
|
||||
smp = arm_and_fire(ctx, g, room="40 mm +X", job=job)
|
||||
beams = [(smp.get("beam"), smp.get("beam_d"))]
|
||||
ctx.log("emission live (%s); pausing the daemon in 1 s", smp["emission"])
|
||||
ctx.sleep(1.0)
|
||||
# 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:
|
||||
@@ -924,7 +990,10 @@ def verdict_cut(ctx):
|
||||
trail.append({"t": round(time.time() - t0, 2), "gstate": st, "il": il,
|
||||
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
|
||||
"armed": '"armed":true' in grbl_state_file()})
|
||||
time.sleep(0.12)
|
||||
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)
|
||||
@@ -950,7 +1019,10 @@ def verdict_cut(ctx):
|
||||
resumed_at = time.time() - t0
|
||||
if st.startswith("Idle") and resumed_at is not None and time.time() - t0 > resumed_at + 3.0:
|
||||
break
|
||||
time.sleep(0.12)
|
||||
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
|
||||
@@ -985,13 +1057,22 @@ def verdict_cut(ctx):
|
||||
"the armed window closed during the pause")
|
||||
ctx.check("press the button" not in text, "the resume asked for a button press")
|
||||
ctx.check(DISARMED_MSG not in text.split("resuming")[0], "the pause tier disarmed the job")
|
||||
# Dark from the hold until the resume: the gate masked the stream.
|
||||
span = [r for r in trail if held and r["t"] >= held[0]["t"]
|
||||
and (resumed_at is None or r["t"] < resumed_at)]
|
||||
zero = next((r for r in span if r["emission"] == 0), None)
|
||||
ev["emission_zero_after_hold_s"] = round(zero["t"] - held[0]["t"], 2) if zero else None
|
||||
ctx.check(zero is not None and zero["t"] - held[0]["t"] <= 2.5,
|
||||
"emission did not read 0 within 2.5 s of the hold (before the resume)")
|
||||
# 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]
|
||||
@@ -1001,9 +1082,9 @@ def verdict_cut(ctx):
|
||||
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base)
|
||||
judge_beam(ctx, ev["beam"], "the cut")
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.log("PASS: held at +%s s with the latch untouched, emission 0 %s s after the hold, resumed "
|
||||
"lit at +%s s with no press, disarmed %.1f s after Idle", ev["held_at_s"],
|
||||
ev["emission_zero_after_hold_s"], ev["resumed_at_s"], dt)
|
||||
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"
|
||||
@@ -1244,3 +1325,214 @@ 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")
|
||||
|
||||
@@ -25,7 +25,7 @@ def _read(path, default=None):
|
||||
|
||||
|
||||
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
|
||||
("forgectrl", "src/main.c")]
|
||||
("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
|
||||
@@ -44,7 +44,10 @@ _PROBE_IP = "192.0.2.77"
|
||||
"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.")
|
||||
"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")
|
||||
@@ -134,6 +137,25 @@ def _tree_tail_export(ctx, probe):
|
||||
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
|
||||
@@ -147,7 +169,7 @@ _ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("
|
||||
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
|
||||
("forgefirm-app", "forgefirm-app/gfhome.py")]
|
||||
|
||||
LOGGERS =("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
|
||||
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}
|
||||
@@ -262,7 +284,7 @@ def routing(ctx):
|
||||
# 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"):
|
||||
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)
|
||||
|
||||
@@ -10,7 +10,9 @@ Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
|
||||
and round-trip; the laser stays latched (the tests never touch it); the
|
||||
suite is the only Grbl client while a test runs.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
@@ -1200,72 +1202,132 @@ def respawn_gate(ctx):
|
||||
|
||||
|
||||
@test("motion.soft-limits", title="After a home the bed is the X/Y envelope",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=3,
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=5,
|
||||
covers=_MOTION_COVERS, requires=["motion.jog-roundtrip", "cloud.mode-switch"],
|
||||
steps=["Bed clear, lid closed. The test homes the machine through the web service if it is "
|
||||
"not homed (about a minute), then sends moves the controller must refuse."],
|
||||
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. The test "
|
||||
"homes the machine through the web service (about a minute), sends moves the controller "
|
||||
"must refuse, and hands the head back where it found it."],
|
||||
description="The machine has no limit switches, so the core's $20 cannot be turned on and "
|
||||
"the X/Y soft limits are the driver's: off while the position is not trusted, on "
|
||||
"after a home, when the envelope is the bed ($130 by $131 from the home corner). "
|
||||
"Homed, a program move 5 mm past X max or Y max, or past the near edge, raises "
|
||||
"ALARM:2 before any motion (the kernel counters do not move), a jog past the bed "
|
||||
"is refused with error 15, and a move inside the bed runs. The Z envelope is the "
|
||||
"lens window, as before.")
|
||||
"after a home, when the envelope is the bed from the home corner: the far edges the "
|
||||
"bed check measured (envelope_x_mm, envelope_y_mm, held to 50 mm up to the travel "
|
||||
"plus 30 mm), or $130 by $131 on a machine that was never measured. "
|
||||
"With homing_mode = gfcloud and the camera-home offsets unset for the run, $H runs "
|
||||
"the web-service homing session, every motion of it run whole. Homed, a program "
|
||||
"move 5 mm past the far edge in X or in Y, or past the near edge, raises ALARM:2 "
|
||||
"before any motion (the kernel counters do not move), a jog past the far edge is "
|
||||
"refused with error 15, and a move inside the bed runs. A move or jog that is not "
|
||||
"refused is stopped before the test fails. The Z envelope is the lens window, as "
|
||||
"before. The settings 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.")
|
||||
def soft_limits(ctx):
|
||||
# The camera-home helpers, imported here: homeoff imports this module.
|
||||
from .cloud import gfhome_homing
|
||||
from .homeoff import camera_home_return, judge_whole_motions, session_mark
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
if not fc.status().get("homed"):
|
||||
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
|
||||
|
||||
def put(k, v):
|
||||
# The controller refuses a setting for a moment after a Grbl client
|
||||
# closes: each is written when it is taken. An empty value unsets it.
|
||||
def write():
|
||||
st_, body_ = fc.post("/settings", params={k: ""}) if v == "" else fc.post("/settings", data={k: v})
|
||||
if st_ != 200:
|
||||
ctx.log("%s=%r -> %s %s", k, v, st_, body_)
|
||||
return st_ == 200 or None
|
||||
return ctx.wait_for(write, 20, poll=0.5) is not None
|
||||
|
||||
try:
|
||||
# A camera home at the origin: the envelope is the bed from the home corner.
|
||||
for k, v in (("homing_mode", "gfcloud"), ("gfcloud_home_x", ""), ("gfcloud_home_y", "")):
|
||||
ctx.check(put(k, v), "%s=%r was not taken", k, v)
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
session_at = session_mark()
|
||||
gfhome_homing(ctx, ev, g)
|
||||
ctx.check(fc.status().get("homed"), "the machine is not homed")
|
||||
x_travel = float(grbl_setting(g, "$130"))
|
||||
y_travel = float(grbl_setting(g, "$131"))
|
||||
ev["travel"] = {"x": x_travel, "y": y_travel}
|
||||
k0 = kernel_xy_mm(ctx)
|
||||
judge_whole_motions(ctx, ev, session_at)
|
||||
ctx.check(fc.status().get("homed"), "the machine is not homed")
|
||||
x_travel = float(grbl_setting(g, "$130"))
|
||||
y_travel = float(grbl_setting(g, "$131"))
|
||||
ev["travel"] = {"x": x_travel, "y": y_travel}
|
||||
|
||||
def refused(cmd):
|
||||
lines = g.command(cmd, timeout=2)
|
||||
ctx.sleep(0.5)
|
||||
text = "\n".join(lines) + g.drain()
|
||||
k1 = kernel_xy_mm(ctx)
|
||||
moved = max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1]))
|
||||
st = g.status_report()["state"]
|
||||
rec = {"cmd": cmd, "reply": lines, "alarm": "ALARM:2" in text, "moved_mm": round(moved, 3), "state": st}
|
||||
ctx.log("%s", rec)
|
||||
ctx.check(rec["alarm"], "%s was not refused with ALARM:2: %s", cmd, lines)
|
||||
ctx.check(moved < 0.05, "%s moved the kernel %.3f mm before the alarm", cmd, moved)
|
||||
g.realtime(0x18)
|
||||
ctx.sleep(1.5)
|
||||
g.drain()
|
||||
unlock = g.command("$X")
|
||||
ctx.check(unlock and unlock[-1] == "ok", "$X after the soft-limit alarm: %s", unlock)
|
||||
st = g.status_report()["state"]
|
||||
ctx.check(st.startswith("Idle"), "controller is %s after the recovery", st)
|
||||
return rec
|
||||
# The far edges the home armed, as the driver takes them
|
||||
# (gfhome_clamp_envelope_mm): the measured key held to 50 mm up
|
||||
# to the travel plus 30 mm, or the travel when it is unset.
|
||||
def far_edge(key, travel):
|
||||
try:
|
||||
mm = float(s0.get(key) or 0)
|
||||
except ValueError:
|
||||
mm = 0.0
|
||||
if not mm > 0:
|
||||
return travel
|
||||
return min(max(mm, 50.0), travel + 30.0)
|
||||
|
||||
ev["refused"] = [refused("G90 G1 X%.1f F600" % (x_travel + 5)),
|
||||
refused("G90 G1 Y%.1f F600" % (y_travel + 5)),
|
||||
refused("G90 G1 X-1 F600")]
|
||||
# Homed still: the soft-limit alarm and its reset keep the reference.
|
||||
ctx.check(fc.status().get("homed"), "the soft-limit alarm and the reset un-homed the machine")
|
||||
jog = g.command("$J=G91X%.1fF1200" % (x_travel + 5))
|
||||
ev["jog"] = jog
|
||||
ctx.check(any(l.startswith("error:15") for l in jog), "a jog past the bed was not refused with error 15: %s", jog)
|
||||
# The core answers the line after an error with that error again
|
||||
# until an empty line clears it (the sender's acknowledgment).
|
||||
g.command("")
|
||||
g.command("G90 G1 X10 Y10 F600", timeout=0.5)
|
||||
peak, states, st = wait_idle(ctx, g, 20)
|
||||
ev["inside"] = states
|
||||
ctx.check("TIMEOUT" not in states, "a move inside the bed did not run")
|
||||
g.command("G90 G1 X0 Y0 F600", timeout=0.5)
|
||||
wait_idle(ctx, g, 20)
|
||||
k1 = kernel_start(ctx) # at rest: grbl's Idle leads the kernel's tail
|
||||
ev["kernel_back_mm"] = [round(k1[0] - k0[0], 3), round(k1[1] - k0[1], 3)]
|
||||
ctx.check(max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1])) < 0.1, "the head did not come back to the corner: %s",
|
||||
ev["kernel_back_mm"])
|
||||
x_edge = far_edge("envelope_x_mm", x_travel)
|
||||
y_edge = far_edge("envelope_y_mm", y_travel)
|
||||
ev["envelope"] = {"x": x_edge, "y": y_edge}
|
||||
k0 = kernel_xy_mm(ctx)
|
||||
|
||||
def refused(cmd):
|
||||
lines = g.command(cmd, timeout=2)
|
||||
ctx.sleep(0.5)
|
||||
text = "\n".join(lines) + g.drain()
|
||||
k1 = kernel_xy_mm(ctx)
|
||||
moved = max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1]))
|
||||
st = g.status_report()["state"]
|
||||
rec = {"cmd": cmd, "reply": lines, "alarm": "ALARM:2" in text, "moved_mm": round(moved, 3),
|
||||
"state": st}
|
||||
ctx.log("%s", rec)
|
||||
# Stopped first: a move that was not refused must not run on
|
||||
# to its target while the test fails.
|
||||
g.realtime(0x18)
|
||||
ctx.sleep(1.5)
|
||||
g.drain()
|
||||
ctx.check(rec["alarm"], "%s was not refused with ALARM:2: %s", cmd, lines)
|
||||
ctx.check(moved < 0.05, "%s moved the kernel %.3f mm before the alarm", cmd, moved)
|
||||
unlock = g.command("$X")
|
||||
ctx.check(unlock and unlock[-1] == "ok", "$X after the soft-limit alarm: %s", unlock)
|
||||
st = g.status_report()["state"]
|
||||
ctx.check(st.startswith("Idle"), "controller is %s after the recovery", st)
|
||||
return rec
|
||||
|
||||
ev["refused"] = [refused("G90 G1 X%.1f F600" % (x_edge + 5)),
|
||||
refused("G90 G1 Y%.1f F600" % (y_edge + 5)),
|
||||
refused("G90 G1 X-1 F600")]
|
||||
# Homed still: the soft-limit alarm and its reset keep the reference.
|
||||
ctx.check(fc.status().get("homed"), "the soft-limit alarm and the reset un-homed the machine")
|
||||
jog = g.command("$J=G91X%.1fF1200" % (x_edge + 5))
|
||||
ev["jog"] = jog
|
||||
if not any(l.startswith("error:15") for l in jog):
|
||||
g.realtime(0x85) # the jog cancel, before the test fails
|
||||
ctx.sleep(1.5)
|
||||
g.drain()
|
||||
ctx.check(any(l.startswith("error:15") for l in jog), "a jog past the bed was not refused with error 15: %s",
|
||||
jog)
|
||||
# The core answers the line after an error with that error again
|
||||
# until an empty line clears it (the sender's acknowledgment).
|
||||
g.command("")
|
||||
g.command("G90 G1 X10 Y10 F600", timeout=0.5)
|
||||
peak, states, st = wait_idle(ctx, g, 20)
|
||||
ev["inside"] = states
|
||||
ctx.check("TIMEOUT" not in states, "a move inside the bed did not run")
|
||||
g.command("G90 G1 X0 Y0 F600", timeout=0.5)
|
||||
wait_idle(ctx, g, 20)
|
||||
k1 = kernel_start(ctx) # at rest: grbl's Idle leads the kernel's tail
|
||||
ev["kernel_back_mm"] = [round(k1[0] - k0[0], 3), round(k1[1] - k0[1], 3)]
|
||||
ctx.check(max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1])) < 0.1, "the head did not come back to the corner: %s",
|
||||
ev["kernel_back_mm"])
|
||||
finally:
|
||||
for k, v in found.items():
|
||||
ctx.log("restore %s=%r -> %s", k, v, "taken" if put(k, v) else "not taken")
|
||||
if session_at is not None:
|
||||
camera_home_return(ctx, ev, session_at)
|
||||
ctx.log("PASS: X max, Y max and X min refused with ALARM:2 and no motion, the jog refused with "
|
||||
"error 15, a move inside the bed ran")
|
||||
|
||||
@@ -1778,3 +1840,690 @@ def step_timing_under_load(ctx):
|
||||
"; ".join(clamped))
|
||||
ctx.log("PASS: %d legs at 2000 mm/min against a nice-5 CPU hog, no clamped events",
|
||||
ev["legs"])
|
||||
|
||||
|
||||
# ------------------------------------------------- manual home, motor release
|
||||
|
||||
HOLD_CURRENTS = ("33", "5") # pic/x_step_current, pic/y_step_current at idle
|
||||
MANUAL_NOTICE = "Manual home: position set where the head was placed"
|
||||
|
||||
|
||||
def _step_currents():
|
||||
return (hw.sysfs_read("pic/x_step_current"), hw.sysfs_read("pic/y_step_current"))
|
||||
|
||||
|
||||
def _rail_lines():
|
||||
"""How many times the kernel has logged the 40 V supply going on or off."""
|
||||
rc, out = hw.run(["dmesg"])
|
||||
if rc != 0:
|
||||
return -1
|
||||
return sum(1 for line in out.splitlines() if "40V on" in line or "40V off" in line)
|
||||
|
||||
|
||||
def _kernel_position():
|
||||
"""The kernel's position record: (x, y, z) in steps, then the bytes it
|
||||
has played and the bytes it was handed. Nothing moves without the last
|
||||
two moving, which makes them the witness for "nothing was shipped": the
|
||||
head accelerometer cannot say it, since the rotors relax off their held
|
||||
microstep when the drivers let go, when the currents drop to hold, and at
|
||||
a reset, and the head feels each of those."""
|
||||
with open(hw.sysfs_root() + "cnc/position", "rb") as f:
|
||||
raw = f.read(32)
|
||||
return struct.unpack_from("<3i", raw, 0) + struct.unpack_from("<2I", raw, 12)
|
||||
|
||||
|
||||
def _refused(ctx, g, line):
|
||||
"""Send a line that must be refused; acknowledge the parser error it
|
||||
leaves (clear_error has the why) and return the error."""
|
||||
r = g.command(line)
|
||||
err = next((x for x in r if x.startswith("error")), None)
|
||||
if err is not None:
|
||||
g.command("")
|
||||
return err
|
||||
|
||||
|
||||
def _drop_reference(ctx, fc):
|
||||
"""A false home is not left behind: a fresh controller starts unhomed."""
|
||||
fc.post("/controller/stop")
|
||||
_wait_controller(ctx, fc, ("stopped", "standby"), 30)
|
||||
fc.post("/controller/start")
|
||||
m = _wait_controller(ctx, fc, ("running",), 120, motion="verified")
|
||||
ctx.check(m.get("controller") == "running", "the controller did not come back: %s", m)
|
||||
|
||||
|
||||
@test("motion.release", title="Motor release: nothing moves a released gantry, only the operator energizes it",
|
||||
subsystem="motion", kind="auto", hardware="takeover", mode="grbl", est_min=8,
|
||||
covers=_MOTION_COVERS + [("forgectrl", "src/status.*"), ("forgectrl", "src/wizdark.*")],
|
||||
requires=["motion.jog-roundtrip"],
|
||||
steps=["Bed clear; the head needs 50 mm of free travel each way on X and Y. Nobody touches the gantry."],
|
||||
description="$MD releases X and Y by taking their step currents to 0, with the 40 V rail "
|
||||
"untouched (no supply line in the kernel log, cnc/state idle, no fault), drops the "
|
||||
"X and Y reference and locks the machine in alarm. While released a jog, a G0 and "
|
||||
"$X are refused, a soft reset does not unlock it, and forgectrl refuses a switch "
|
||||
"to cloud mode; the kernel's position record "
|
||||
"is the witness that nothing was shipped (no step and no byte played), and the head "
|
||||
"accelerometer stays under the moving threshold in the window after the release. "
|
||||
"$ME energizes with no fault. The drivers are then proven alive the way the machine "
|
||||
"proves it to itself: the Setup motion check runs the liveness probe on the held "
|
||||
"device and the witnessed 50 mm jogs.")
|
||||
def motor_release(ctx):
|
||||
from .setup_dark import run_check
|
||||
|
||||
ev = ctx.evidence
|
||||
fc = hw.Forgectrl()
|
||||
accel = hw.AccelSampler()
|
||||
ctx.check(accel.available, "no head accelerometer found (i2c %s): the motion witness is missing",
|
||||
hw.HEAD_ACCEL_I2C)
|
||||
rail0 = _rail_lines()
|
||||
ctx.check(rail0 >= 0, "the kernel log is unreadable: the rail witness is missing")
|
||||
with ctx.grbl() as g, accel:
|
||||
clean_slate(ctx, g)
|
||||
r = g.command("$MD")
|
||||
ctx.check(not any(x.startswith("error") for x in r), "$MD refused at idle: %s", r)
|
||||
text = "\n".join(r) + drain_text(g, 1.0)
|
||||
ev["released_currents"] = list(_step_currents())
|
||||
ev["state_released"] = g.status_report()["state"]
|
||||
ev["kernel_state_released"] = hw.sysfs_read("cnc/state")
|
||||
ev["faults_released"] = hw.sysfs_int("cnc/faults")
|
||||
ev["homed_axes_released"] = fc.status().get("homed_axes")
|
||||
ctx.log("$MD: step currents %s, %s, cnc/state %s, faults %s, homed_axes %s",
|
||||
ev["released_currents"], ev["state_released"], ev["kernel_state_released"],
|
||||
ev["faults_released"], ev["homed_axes_released"])
|
||||
ctx.check(ev["released_currents"] == ["0", "0"],
|
||||
"the step currents read %s after $MD, not 0 and 0", ev["released_currents"])
|
||||
ctx.check(ev["state_released"].startswith("Alarm"), "not locked in alarm: %s", ev["state_released"])
|
||||
ctx.check(ev["kernel_state_released"] == "idle", "the kernel left idle: %s", ev["kernel_state_released"])
|
||||
ctx.check(ev["faults_released"] == 0, "a driver fault on release: %s", ev["faults_released"])
|
||||
ctx.check(not (ev["homed_axes_released"] or 0) & 3, "X or Y still referenced while released")
|
||||
ctx.check("Motors released" in text, "the sender was not told the motors are released")
|
||||
|
||||
# The rotors have relaxed by now: from here on the head is still.
|
||||
ctx.sleep(1.0)
|
||||
k0 = _kernel_position()
|
||||
t0 = time.time()
|
||||
refusals = {}
|
||||
for line in ("$J=G91X10F2400", "G0X10", "$X"):
|
||||
refusals[line] = _refused(ctx, g, line)
|
||||
ctx.check(refusals[line], "%s was accepted while the motors are released", line)
|
||||
ctx.sleep(1.0)
|
||||
p2px, p2py, n = accel.p2p(t0)
|
||||
ev["released_accel_p2p"] = [p2px, p2py, n]
|
||||
ctx.check(n > 0, "no accelerometer samples in the released window")
|
||||
ctx.check(max(p2px, p2py) < hw.ACCEL_P2P_MOVING,
|
||||
"the head moved while released (accel p2p x=%d y=%d)", p2px, p2py)
|
||||
g.realtime(0x18)
|
||||
ctx.sleep(2)
|
||||
g.drain()
|
||||
refusals["^X then $X"] = _refused(ctx, g, "$X")
|
||||
ctx.check(refusals["^X then $X"], "a soft reset and $X unlocked a released machine")
|
||||
ev["state_after_reset"] = g.status_report()["state"]
|
||||
ctx.check(ev["state_after_reset"].startswith("Alarm"),
|
||||
"a soft reset unlocked a released machine: %s", ev["state_after_reset"])
|
||||
k1 = _kernel_position()
|
||||
ev["refusals"] = refusals
|
||||
ev["kernel_position"] = [list(k0), list(k1)]
|
||||
ctx.log("refused while released: %s; accel p2p x=%d y=%d over %d samples; kernel record %s -> %s",
|
||||
refusals, p2px, p2py, n, k0, k1)
|
||||
ctx.check(k1 == k0, "the kernel played something while released: %s -> %s", k0, k1)
|
||||
|
||||
# forgectrl reads the release too: cloud homing moves the head, so the
|
||||
# switch is refused (for the release where cloud mode is enabled, for
|
||||
# cloud mode being off where it is not; the mode stays either way).
|
||||
st, body = fc.post("/mode", data={"controller": "cloud"})
|
||||
text = body if isinstance(body, str) else json.dumps(body)
|
||||
cloud_on = str(fc.settings().get("cloud_enabled", "")).lower() in ("1", "true", "yes", "on")
|
||||
ev["mode_cloud_released"] = {"status": st, "body": text, "cloud_enabled": cloud_on}
|
||||
ctx.log("POST /mode controller=cloud while released -> %s %s", st, text)
|
||||
ctx.check(st == 409, "a switch to cloud mode while released -> %s, expected 409", st)
|
||||
if cloud_on:
|
||||
ctx.check("released" in text, "the refusal does not name the release: %r", text)
|
||||
ctx.check(fc.get("/mode")[1].get("mode") == "grbl", "the mode changed under a released gantry")
|
||||
ctx.check(_step_currents() == ("0", "0"),
|
||||
"something energized the motors: step currents %s", _step_currents())
|
||||
|
||||
r = g.command("$ME")
|
||||
ctx.check(not any(x.startswith("error") for x in r), "$ME refused: %s", r)
|
||||
ctx.sleep(0.5)
|
||||
ev["energized_currents"] = list(_step_currents())
|
||||
ev["faults_energized"] = hw.sysfs_int("cnc/faults")
|
||||
ev["state_energized"] = g.status_report()["state"]
|
||||
ctx.check(tuple(ev["energized_currents"]) == HOLD_CURRENTS,
|
||||
"the step currents read %s after $ME, not the hold currents", ev["energized_currents"])
|
||||
ctx.check(ev["faults_energized"] == 0, "a driver fault on energize: %s", ev["faults_energized"])
|
||||
ctx.check(ev["state_energized"].startswith("Idle"), "not idle after $ME: %s", ev["state_energized"])
|
||||
|
||||
# The drivers are alive: the machine's own witness says so. The liveness
|
||||
# probe is built for the accelerometer (a slow, rough move sampled for
|
||||
# seconds); a quick smooth jog is not, and reads as nothing half the time.
|
||||
ctx.notice("The head moves 50 mm each way on X and on Y. Keep the bed clear.")
|
||||
last = run_check(ctx, "motion", lambda p: "Continue" if p.get("id") == "jogs" else None, 420)
|
||||
ctx.clear_notice()
|
||||
res = last.get("result") or {}
|
||||
ev["probe"] = res.get("probe")
|
||||
ev["moves"] = res.get("moves")
|
||||
ctx.log("after energize: probe '%s', moves %s", res.get("probe"),
|
||||
{k: v.get("witnessed") for k, v in (res.get("moves") or {}).items()})
|
||||
ctx.check(res.get("probe") and last.get("result") is not None and not last.get("error"),
|
||||
"the motion check did not complete after the energize: %s", last.get("error") or last)
|
||||
for name in ("+X", "-X", "+Y", "-Y"):
|
||||
m = (res.get("moves") or {}).get(name) or {}
|
||||
ctx.check(m.get("witnessed") is True,
|
||||
"the %s jog was not witnessed after the energize: the drivers did not come back (%s)", name, m)
|
||||
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("local") is False
|
||||
and (fc.get("/mode")[1] or {}).get("controller") == "running", 90)
|
||||
ctx.check(ok is not None, "the controller is not back in its normal posture")
|
||||
ev["rail_lines"] = [rail0, _rail_lines()]
|
||||
ctx.check(ev["rail_lines"][0] == ev["rail_lines"][1],
|
||||
"the kernel logged the 40 V supply changing across a release: %s", ev["rail_lines"])
|
||||
machine_idle(ctx)
|
||||
ctx.log("PASS: released with the rail untouched, every motion refused and nothing shipped, "
|
||||
"energized with no fault, and the liveness probe and the witnessed jogs say the drivers are alive")
|
||||
|
||||
|
||||
@test("homing.manual", title="Manual home: $H declares the gantry-stop position and jogs to an offset origin",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=5,
|
||||
covers=_MOTION_COVERS + [("forgectrl", "src/status.*"), ("forgectrl", "src/main.c")],
|
||||
requires=["motion.jog-roundtrip"],
|
||||
steps=["Bed clear; the head needs 35 mm of free +X travel and 3 mm of free +Y travel."],
|
||||
description="With homing_mode = manual and no manual home offset, $H ships nothing (the kernel's "
|
||||
"position record shows no byte played across it), sets X and Y to X0 Y0 where the head "
|
||||
"stands, marks X and Y homed with their soft limits on (a jog past the envelope is "
|
||||
"refused with error 15), leaves Z where it was, tells the sender the home was set by "
|
||||
"hand, and the anchor's source reads back as manual through forgectrl. With "
|
||||
"manual_home_x = 5 and manual_home_y = 3, $H declares the head at X-5 Y-3, says so, and "
|
||||
"jogs it to X0 Y0 before it answers: the kernel plays the move, Grbl and forgectrl both "
|
||||
"read X0 Y0, and the envelope starts at the origin. Whatever a check decides, the "
|
||||
"settings are restored, the false reference is dropped and the head returned before the "
|
||||
"machine is handed back.")
|
||||
def manual_home(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = hw.Forgectrl()
|
||||
settings = fc.settings() or {}
|
||||
keys = ("homing_mode", "manual_home_x", "manual_home_y")
|
||||
ev["found"] = {k: settings.get(k) for k in keys}
|
||||
offset = (5.0, 3.0)
|
||||
moved = None # how far the head is from where the run found it
|
||||
for k, v in (("homing_mode", "manual"), ("manual_home_x", ""), ("manual_home_y", "")):
|
||||
st, body = (fc.post("/settings", data={k: v}) if v else fc.post("/settings", params={k: ""}))
|
||||
ctx.check(st == 200, "%s=%r -> %s %s", k, v, st, body)
|
||||
try:
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
r = g.command("$J=G91X30F2400") # away from wherever the origin was
|
||||
ctx.check(not any(x.startswith("error") for x in r), "the outbound jog was refused: %s", r)
|
||||
moved = [30.0, 0.0]
|
||||
wait_idle(ctx, g)
|
||||
z0 = g.status_report()["MPos"][2]
|
||||
ctx.sleep(1.5) # the kernel has played the jog's tail
|
||||
k0 = _kernel_position()
|
||||
r = g.command("$H", timeout=15)
|
||||
ctx.check(not any(x.startswith("error") for x in r), "$H under manual refused: %s", r)
|
||||
text = "\n".join(r) + drain_text(g, 1.5)
|
||||
k1 = _kernel_position()
|
||||
rep = g.status_report()
|
||||
st = fc.status()
|
||||
ev.update(kernel_position=[list(k0), list(k1)], mpos=rep["MPos"], state=rep["state"],
|
||||
homed_axes=st.get("homed_axes"), home_source=st.get("home_source"),
|
||||
pos=st.get("pos"))
|
||||
ctx.log("$H: %s MPos %s; kernel record %s -> %s; forgectrl homed_axes %s source %s pos %s",
|
||||
rep["state"], rep["MPos"], k0, k1, ev["homed_axes"], ev["home_source"], ev["pos"])
|
||||
ctx.check(k1[3:] == k0[3:], "$H played pulse bytes: %s -> %s", k0[3:], k1[3:])
|
||||
ctx.check(k1[0] == 0 and k1[1] == 0, "the kernel counters were not cleared: %s", k1)
|
||||
ctx.check(rep["MPos"][0] == 0 and rep["MPos"][1] == 0, "X and Y are %s, not X0 Y0", rep["MPos"][:2])
|
||||
ctx.check(rep["MPos"][2] == z0, "Z changed across the home: %s -> %s", z0, rep["MPos"][2])
|
||||
ctx.check(rep["state"].startswith("Idle"), "not idle after the home: %s", rep["state"])
|
||||
ctx.check(MANUAL_NOTICE in text, "the sender was not told the home was set by hand")
|
||||
ctx.check((ev["homed_axes"] or 0) & 3 == 3, "forgectrl does not show X and Y homed")
|
||||
ctx.check(ev["home_source"] == "manual", "the anchor's source reads %r", ev["home_source"])
|
||||
ctx.check(ev["pos"] and abs(ev["pos"]["x"]) < 0.02 and abs(ev["pos"]["y"]) < 0.02,
|
||||
"forgectrl's position is not X0 Y0: %s", ev["pos"])
|
||||
# The envelope starts at the origin.
|
||||
ev["past_envelope"] = _refused(ctx, g, "$J=G91X-5F600")
|
||||
ctx.check(ev["past_envelope"] == "error:15",
|
||||
"a jog past the envelope after the home got %s, not error:15", ev["past_envelope"])
|
||||
|
||||
# With an offset: the head at the gantry stops is minus it, and $H jogs
|
||||
# it to the origin before it answers.
|
||||
st, body = fc.post("/settings", data={"manual_home_x": "%g" % offset[0],
|
||||
"manual_home_y": "%g" % offset[1]})
|
||||
ctx.check(st == 200, "manual_home_x/_y = %s -> %s %s", offset, st, body)
|
||||
k2 = _kernel_position()
|
||||
r = g.command("$H", timeout=15)
|
||||
refused = any(x.startswith("error") for x in r)
|
||||
text = "\n".join(r) + drain_text(g, 1.5)
|
||||
rep = g.status_report()
|
||||
if not refused: # declared at minus the offset, then moved
|
||||
moved = [30.0 + rep["MPos"][0] + offset[0], rep["MPos"][1] + offset[1]]
|
||||
ctx.check(not refused, "$H with an offset refused: %s", r)
|
||||
k3 = _kernel_position()
|
||||
st = fc.status()
|
||||
ev.update(offset_kernel_position=[list(k2), list(k3)], offset_mpos=rep["MPos"],
|
||||
offset_state=rep["state"], offset_pos=st.get("pos"))
|
||||
ctx.log("$H with offset %s: %s MPos %s; kernel record %s -> %s; forgectrl pos %s",
|
||||
offset, rep["state"], rep["MPos"], k2, k3, ev["offset_pos"])
|
||||
said = [ln for ln in text.splitlines() if "Manual home: the head is at X" in ln]
|
||||
ev["offset_said"] = said
|
||||
try:
|
||||
words = said[-1].split("the head is at ")[1].split(";")[0].split()
|
||||
declared = [float(words[0][1:]), float(words[1][1:])]
|
||||
except (IndexError, ValueError):
|
||||
declared = None
|
||||
ctx.check(declared is not None and abs(declared[0] + offset[0]) < 0.01
|
||||
and abs(declared[1] + offset[1]) < 0.01,
|
||||
"the sender was told the head stood at %s, not minus %s", declared, offset)
|
||||
ctx.check(k3[3:] != k2[3:], "the move to the origin played no pulse byte: %s -> %s", k2[3:], k3[3:])
|
||||
ctx.check(k3[0] != 0 and k3[1] != 0, "the kernel counters did not move on X and Y: %s", k3)
|
||||
ctx.check(rep["MPos"][0] == 0 and rep["MPos"][1] == 0,
|
||||
"the head is at %s when $H answered, not X0 Y0", rep["MPos"][:2])
|
||||
ctx.check(rep["state"].startswith("Idle"), "not idle when $H answered: %s", rep["state"])
|
||||
ctx.check(ev["offset_pos"] and abs(ev["offset_pos"]["x"]) < 0.02 and abs(ev["offset_pos"]["y"]) < 0.02,
|
||||
"forgectrl's position is not X0 Y0: %s", ev["offset_pos"])
|
||||
ev["offset_past_envelope"] = _refused(ctx, g, "$J=G91Y-1F600")
|
||||
ctx.check(ev["offset_past_envelope"] == "error:15",
|
||||
"a jog behind the origin got %s, not error:15", ev["offset_past_envelope"])
|
||||
finally:
|
||||
# Whatever a check above decided, the machine is handed back as it was
|
||||
# found: the settings restored, the false home dropped, the head returned.
|
||||
for k in keys:
|
||||
v = ev["found"][k]
|
||||
st, body = (fc.post("/settings", data={k: v}) if v else fc.post("/settings", params={k: ""}))
|
||||
ctx.log("restore %s=%r -> %s", k, v, st)
|
||||
if moved:
|
||||
_drop_reference(ctx, fc)
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
r = g.command("$J=G91X%.3fY%.3fF2400" % (-moved[0], -moved[1]))
|
||||
ctx.check(not any(x.startswith("error") for x in r), "the return jog was refused: %s", r)
|
||||
wait_idle(ctx, g)
|
||||
machine_idle(ctx)
|
||||
# The home cleared the kernel's counters away from where the run
|
||||
# began, so the return leaves them off zero with the head where
|
||||
# the run found it. The hand-back compares counters, and a
|
||||
# controller start zeroes them: left like this, the next test
|
||||
# that restarts the controller reads as out of place and the
|
||||
# hand-back "returns" a head that never moved (on the bench
|
||||
# reference it drove the head into the gantry stops). One more
|
||||
# start, with the head back, leaves the counters at zero where the
|
||||
# run began, which is what the rest of the catalog counts on.
|
||||
_drop_reference(ctx, fc)
|
||||
ctx.counters_rezeroed()
|
||||
machine_idle(ctx)
|
||||
ctx.log("PASS: a manual home with no offset shipped nothing and declared X0 Y0; with %s it declared "
|
||||
"minus that and jogged to X0 Y0 before answering; the soft limits start at the origin, Z was "
|
||||
"kept, and the home reads back as manual", offset)
|
||||
|
||||
|
||||
def _port_state(fc):
|
||||
st, body = fc.get("/motion/state")
|
||||
if st != 200 or not isinstance(body, dict):
|
||||
raise hw.HwError("forgectrl /motion/state -> %s %s" % (st, body))
|
||||
return body
|
||||
|
||||
|
||||
def _port_idle(ctx, fc, timeout=30.0):
|
||||
"""The port's own word that its jog is over. The suite's Grbl client
|
||||
sends no line while a port jog runs: a line from it is what cancels one."""
|
||||
ok = ctx.wait_for(lambda: (lambda s: s["state"] == "Idle" and not s["port_jog"])(_port_state(fc)),
|
||||
timeout, poll=0.1)
|
||||
ctx.check(ok is not None, "the port jog did not end: %s", _port_state(fc))
|
||||
machine_idle(ctx)
|
||||
|
||||
|
||||
def _words(body):
|
||||
return body if isinstance(body, str) else json.dumps(body)
|
||||
|
||||
|
||||
@test("motion.port-jog", title="The controller port: the panel jogs beside a connected Grbl client",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=4,
|
||||
covers=_MOTION_COVERS + [("forgectrl", "src/grblport.*"), ("forgectrl", "src/main.c"),
|
||||
("forgectrl", "src/status.*")],
|
||||
requires=["motion.jog-roundtrip"],
|
||||
steps=["Bed clear; the head needs 180 mm of free travel toward +X. Nobody touches the gantry."],
|
||||
description="With the suite connected as the Grbl client, POST /motion/jog moves the head by "
|
||||
"what was asked (the kernel's counters are the witness), the client is told "
|
||||
"([MSG:Panel jog]) and is not displaced: its next line still draws ok. "
|
||||
"GET /motion/state agrees with the kernel. With the client polling the way "
|
||||
"LightBurn does ('?' and an end of line), no port jog is refused for it, a 30 mm "
|
||||
"jog runs whole, and every poll draws its ok. POST /motion/cancel stops a long jog "
|
||||
"short. The client goes first: its line during a port jog cancels the jog and "
|
||||
"draws ok, never an error. A jog past the per-request bound is refused with "
|
||||
"nothing moved. POST /motion/release takes the step currents to 0 and /status "
|
||||
"says so, a jog is then refused in words, and POST /motion/energize restores "
|
||||
"the hold currents. The head is returned to where it was found.")
|
||||
def port_jog(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = hw.Forgectrl()
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
x0, y0 = kernel_start(ctx)
|
||||
ctx.sleep(0.6) # past the port's sender-quiet interval
|
||||
|
||||
# A bounded jog, beside the client.
|
||||
st, body = fc.post("/motion/jog", params={"x": "10", "feed": "2400"})
|
||||
ctx.check(st == 200, "POST /motion/jog x=10 -> %s %s", st, _words(body))
|
||||
_port_idle(ctx, fc)
|
||||
x1, y1 = kernel_xy_mm(ctx)
|
||||
ps = _port_state(fc)
|
||||
ev["jog"] = {"kernel": [x0, x1], "port_mpos": ps.get("mpos"), "sender": ps.get("sender")}
|
||||
ctx.log("port jog +10: kernel X %.3f -> %.3f, port state %s", x0, x1, ps)
|
||||
ctx.check(abs((x1 - x0) - 10.0) < 0.1, "the kernel moved %.3f mm, not 10", x1 - x0)
|
||||
ctx.check(abs(y1 - y0) < 0.05, "Y moved on an X jog: %.3f -> %.3f", y0, y1)
|
||||
ctx.check(ps.get("sender") is True, "the port does not see the connected client: %s", ps)
|
||||
text = drain_text(g, 0.5)
|
||||
ctx.check("Panel jog" in text, "the client was not told of the panel's jog: %r", text)
|
||||
r = g.command("G4 P0")
|
||||
ctx.check("ok" in r and not any(x.startswith("error") for x in r),
|
||||
"the client was displaced or refused after a port jog: %s", r)
|
||||
|
||||
# The client polls the way LightBurn does: '?' with an end of line
|
||||
# behind it. The '?' is a realtime character; the end of line is an
|
||||
# empty line, and an empty line is not the client speaking. No jog
|
||||
# is refused for it, a jog runs whole under it, each poll draws its ok.
|
||||
ctx.sleep(0.6)
|
||||
g.drain()
|
||||
polls, refused = 0, []
|
||||
for _ in range(8):
|
||||
g.send_raw(b"?\n")
|
||||
polls += 1
|
||||
st, body = fc.post("/motion/jog", params={"x": "0.5", "feed": "3000"})
|
||||
if st != 200:
|
||||
refused.append(_words(body))
|
||||
ctx.sleep(0.12)
|
||||
ctx.check(not refused, "%d of 8 port jogs were refused under a '?'+EOL poll: %s", len(refused), refused[:2])
|
||||
_port_idle(ctx, fc)
|
||||
xa = kernel_xy_mm(ctx)[0]
|
||||
st, body = fc.post("/motion/jog", params={"x": "30", "feed": "1200"}) # 1.5 s: several polls long
|
||||
ctx.check(st == 200, "the jog under the poll -> %s %s", st, _words(body))
|
||||
in_jog = 0
|
||||
deadline = time.time() + 6
|
||||
while time.time() < deadline:
|
||||
g.send_raw(b"?\n")
|
||||
polls += 1
|
||||
ctx.sleep(0.25)
|
||||
ps = _port_state(fc)
|
||||
if ps["state"] == "Idle" and not ps["port_jog"]:
|
||||
break
|
||||
in_jog += 1
|
||||
_port_idle(ctx, fc)
|
||||
xb = kernel_xy_mm(ctx)[0]
|
||||
oks = sum(1 for line in drain_text(g, 1.0).splitlines() if line.strip() == "ok")
|
||||
ev["poll"] = {"polls": polls, "oks": oks, "polls_in_jog": in_jog, "kernel": [xa, xb]}
|
||||
ctx.log("'?'+EOL poll: 8 of 8 jogs accepted; a 30 mm jog moved %.3f with %d polls into it; "
|
||||
"%d polls drew %d oks", xb - xa, in_jog, polls, oks)
|
||||
ctx.check(in_jog >= 3, "only %d polls landed inside the jog: the case did not test it", in_jog)
|
||||
ctx.check(abs((xb - xa) - 30.0) < 0.1, "a status poll cut the port jog short: moved %.3f of 30", xb - xa)
|
||||
ctx.check(oks == polls, "%d polls drew %d oks", polls, oks)
|
||||
x1 = xb
|
||||
|
||||
# Past the bound: refused at the door, nothing moved.
|
||||
st, body = fc.post("/motion/jog", params={"x": "101"})
|
||||
ev["past_bound"] = [st, _words(body)]
|
||||
ctx.check(st == 400, "a 101 mm jog -> %s %s, expected 400", st, _words(body))
|
||||
ctx.check(abs(kernel_xy_mm(ctx)[0] - x1) < 0.01, "a refused jog moved the head")
|
||||
|
||||
# The cancel stops a long jog short.
|
||||
ctx.sleep(0.6)
|
||||
st, body = fc.post("/motion/jog", params={"x": "40", "feed": "600"})
|
||||
ctx.check(st == 200, "the long jog -> %s %s", st, _words(body))
|
||||
ctx.sleep(1.0)
|
||||
st, body = fc.post("/motion/cancel")
|
||||
ctx.check(st == 200, "POST /motion/cancel -> %s %s", st, _words(body))
|
||||
_port_idle(ctx, fc)
|
||||
x2 = kernel_xy_mm(ctx)[0]
|
||||
ev["cancel"] = [x1, x2]
|
||||
ctx.log("canceled a 40 mm jog at %.3f mm", x2 - x1)
|
||||
ctx.check(1.0 < x2 - x1 < 35.0, "the cancel did not stop the jog short: moved %.3f of 40", x2 - x1)
|
||||
|
||||
# The client goes first: its line cancels the port's jog and draws ok.
|
||||
# A fast jog, so the cancel is a real deceleration: the core stays in
|
||||
# the jog state for the length of it, and a client line read in that
|
||||
# window is the error:9 the hold exists to prevent. A slow jog stops
|
||||
# at once and would pass with no hold at all.
|
||||
g.drain()
|
||||
ctx.sleep(0.6)
|
||||
st, body = fc.post("/motion/jog", params={"x": "100", "feed": "6000"})
|
||||
ctx.check(st == 200, "the fast jog -> %s %s", st, _words(body))
|
||||
ctx.sleep(0.3)
|
||||
r = g.command("G4 P0", timeout=15)
|
||||
_port_idle(ctx, fc)
|
||||
x3 = kernel_xy_mm(ctx)[0]
|
||||
ev["sender_first"] = {"reply": r, "kernel": [x2, x3]}
|
||||
ctx.log("the client's line during a port jog drew %s; the jog stopped at %.3f of 100", r, x3 - x2)
|
||||
ctx.check("ok" in r and not any(x.startswith("error") for x in r),
|
||||
"the client's line during a port jog drew %s, not ok", r)
|
||||
ctx.check(2.0 < x3 - x2 < 95.0, "the client's line did not cancel the port jog: moved %.3f", x3 - x2)
|
||||
|
||||
# The panel's own: the release and its end, by their routes.
|
||||
ctx.sleep(0.6)
|
||||
st, body = fc.post("/motion/release")
|
||||
ctx.check(st == 200, "POST /motion/release -> %s %s", st, _words(body))
|
||||
ctx.sleep(0.5)
|
||||
ev["released"] = {"currents": list(_step_currents()),
|
||||
"status": fc.status().get("motors_released"),
|
||||
"port": _port_state(fc).get("released")}
|
||||
ctx.check(ev["released"]["currents"] == ["0", "0"],
|
||||
"the step currents read %s after the release", ev["released"]["currents"])
|
||||
ctx.check(ev["released"]["status"] is True and ev["released"]["port"] is True,
|
||||
"the release is not reported: %s", ev["released"])
|
||||
k0 = _kernel_position()
|
||||
st, body = fc.post("/motion/jog", params={"x": "1"})
|
||||
ev["jog_released"] = [st, _words(body)]
|
||||
ctx.check(st == 409 and "released" in _words(body),
|
||||
"a jog while released -> %s %s", st, _words(body))
|
||||
ctx.check(_kernel_position() == k0, "the kernel played something while released")
|
||||
st, body = fc.post("/motion/energize")
|
||||
ctx.check(st == 200, "POST /motion/energize -> %s %s", st, _words(body))
|
||||
ctx.sleep(0.5)
|
||||
ev["energized"] = {"currents": list(_step_currents()),
|
||||
"status": fc.status().get("motors_released"),
|
||||
"faults": hw.sysfs_int("cnc/faults")}
|
||||
ctx.check(tuple(ev["energized"]["currents"]) == HOLD_CURRENTS and ev["energized"]["status"] is False,
|
||||
"not energized: %s", ev["energized"])
|
||||
ctx.check(ev["energized"]["faults"] == 0, "a driver fault on energize: %s", ev["energized"]["faults"])
|
||||
|
||||
# Back to where the head was found, by the kernel's own measure.
|
||||
ctx.sleep(0.6)
|
||||
for _ in range(3): # one request moves 100 mm at most
|
||||
back = x0 - kernel_xy_mm(ctx)[0]
|
||||
if abs(back) < 0.05:
|
||||
break
|
||||
st, body = fc.post("/motion/jog", params={"x": "%.3f" % max(-100.0, min(100.0, back)),
|
||||
"feed": "2400"})
|
||||
ctx.check(st == 200, "the return jog -> %s %s", st, _words(body))
|
||||
_port_idle(ctx, fc)
|
||||
ctx.sleep(0.6)
|
||||
check_kernel_returned(ctx, ev, (x0, y0), tag="port")
|
||||
machine_idle(ctx)
|
||||
ctx.log("PASS: the port jogged beside the client, the cancel and the client's own line each stopped "
|
||||
"a jog short, the bound held, and the release and the energize went through their routes")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the job runner
|
||||
|
||||
def _job(fc):
|
||||
st, body = fc.get("/job")
|
||||
if st != 200 or not isinstance(body, dict):
|
||||
raise hw.HwError("forgectrl /job -> %s %s" % (st, body))
|
||||
return body
|
||||
|
||||
|
||||
def _job_post(fc, program, name="forgetest", **fields):
|
||||
"""POST /job the way a client does: a multipart form, the program as
|
||||
the file part."""
|
||||
mark = "forgetestJobBoundary7d1"
|
||||
form = ([("name", name)] if name is not None else []) + sorted(fields.items())
|
||||
body = "".join('--%s\r\nContent-Disposition: form-data; name="%s"\r\n\r\n%s\r\n' % (mark, k, v)
|
||||
for k, v in form)
|
||||
body += ('--%s\r\nContent-Disposition: form-data; name="program"; filename="job.gcode"\r\n'
|
||||
'Content-Type: text/plain\r\n\r\n%s\r\n--%s--\r\n' % (mark, program, mark))
|
||||
return fc.post("/job", data=body.encode("ascii"),
|
||||
headers={"Content-Type": "multipart/form-data; boundary=%s" % mark})
|
||||
|
||||
|
||||
def _job_wait(ctx, fc, timeout, x0=None):
|
||||
"""Until the job is over; the record, and the farthest the kernel saw X go
|
||||
from x0. A caller that reads the distance passes the X it took before it
|
||||
posted the job: by the time this is called the head is already moving."""
|
||||
if x0 is None:
|
||||
x0 = kernel_xy_mm(ctx)[0]
|
||||
far = 0.0
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
far = max(far, abs(kernel_xy_mm(ctx)[0] - x0))
|
||||
if _job(fc)["state"] != "running":
|
||||
break
|
||||
ctx.sleep(0.1)
|
||||
return _job(fc), far
|
||||
|
||||
|
||||
def _lease_holder(fc):
|
||||
return ((fc.status().get("lease") or {}).get("holder") or {})
|
||||
|
||||
|
||||
@test("motion.job", title="The job runner: a posted program runs with the daemon as its sender",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=3,
|
||||
covers=_MOTION_COVERS + [("forgectrl", "src/jobrun.*"), ("forgectrl", "src/jobpost.*"),
|
||||
("forgectrl", "src/jobstream.*"), ("forgectrl", "src/lease.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/status.*")],
|
||||
requires=["motion.jog-roundtrip"],
|
||||
steps=["Bed clear; the head needs 60 mm of free travel toward +X. Nobody touches the gantry."],
|
||||
description="POST /job takes a G-code program as a multipart form and runs it with the daemon "
|
||||
"as the machine's one sender. A program with a $ line, and one with no name, are "
|
||||
"refused with 400 and nothing moved. With the suite connected as a Grbl client the "
|
||||
"job is refused with 409 (a sender is connected) and nothing moved. A dark program "
|
||||
"(out 20 mm, a dwell, back) then plays: while it does, /status names job:forgetest "
|
||||
"as the lease holder of kind sender, GET /job reports it running, and a second job, "
|
||||
"a port jog, and a settings write are each refused in the holder's name. At its "
|
||||
"end the record says done with every line and the runner's own M2 acknowledged, no "
|
||||
"discharge and no LASER_ON sample, the kernel's counters saw the 20 mm and are back "
|
||||
"where they began, and the lease is free. POST /job/abort then stops a long move "
|
||||
"short: the record says failed (aborted), the controller is in its alarm state, and "
|
||||
"the lease is free. A job sent into the alarm without unlock fails at its first "
|
||||
"line with nothing moved; the same job with unlock=1 clears the alarm with the "
|
||||
"runner's own $X and returns the head to where it was found.")
|
||||
def job_runner(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = hw.Forgectrl()
|
||||
machine_idle(ctx)
|
||||
x0, y0 = kernel_start(ctx)
|
||||
rest = _job(fc)
|
||||
ev["record_keys"] = sorted(rest)
|
||||
ctx.check({"state", "owner", "program", "lines", "sent", "acked", "elapsed_s", "lit", "emission",
|
||||
"reason"} <= set(rest), "GET /job lacks keys: %s", sorted(rest))
|
||||
ctx.check(rest["state"] != "running", "a job is already running: %s", rest)
|
||||
ctx.check(not _lease_holder(fc), "the machine is not free at the start: %s", _lease_holder(fc))
|
||||
|
||||
# Refused at the door, nothing moved.
|
||||
refused = {}
|
||||
st, body = _job_post(fc, "G21\n$X\nG91\nG1 X5 F1200\n")
|
||||
refused["a $ line"] = [st, _words(body)[:160]]
|
||||
ctx.check(st == 400 and "line 2" in _words(body) and "$ command" in _words(body),
|
||||
"a program with a $ line -> %s %s", st, _words(body)[:160])
|
||||
st, body = _job_post(fc, "G21\nG91\nG1 X5 F1200\n", name=None)
|
||||
refused["no name"] = [st, _words(body)[:160]]
|
||||
ctx.check(st == 400 and "name" in _words(body), "a job with no name -> %s %s", st, _words(body)[:160])
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
# The lease asks the controller's published state, which follows the connection.
|
||||
ok = ctx.wait_for(lambda: ((fc.status().get("lease") or {}).get("observed") or {}).get("sender"),
|
||||
10, poll=0.2)
|
||||
ctx.check(ok is not None, "the controller never reported the suite's client as connected")
|
||||
st, body = _job_post(fc, "G21\nG91\nG1 X5 F1200\n")
|
||||
refused["a connected client"] = [st, _words(body)[:160]]
|
||||
ctx.check(st == 409 and "sender is connected" in _words(body),
|
||||
"a job beside a connected Grbl client -> %s %s", st, _words(body)[:160])
|
||||
ev["refused_at_the_door"] = refused
|
||||
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.01, "a refused job moved the head")
|
||||
ok = ctx.wait_for(lambda: not ((fc.status().get("lease") or {}).get("observed") or {}).get("sender"),
|
||||
10, poll=0.2)
|
||||
ctx.check(ok is not None, "the controller still reports the suite's client as connected")
|
||||
|
||||
moved = 0.0
|
||||
try:
|
||||
# A dark program plays, and holds the machine while it does.
|
||||
st, body = _job_post(fc, "(forgetest motion.job)\nG21\nG91\nG1 X20 F1200 ; out\nG4 P2\n"
|
||||
"G1 X-20 F1200\nG90\n")
|
||||
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "running",
|
||||
"POST /job -> %s %s", st, _words(body)[:200])
|
||||
ok = ctx.wait_for(lambda: _lease_holder(fc).get("owner") == "job:forgetest", 5, poll=0.1)
|
||||
held = _lease_holder(fc)
|
||||
ev["holder"] = held
|
||||
ctx.log("the lease while the job plays: %s", held)
|
||||
ctx.check(ok is not None and held.get("kind") == "sender" and "forgetest" in (held.get("words") or ""),
|
||||
"the lease does not name the job: %s", held)
|
||||
rec = _job(fc)
|
||||
ctx.check(rec["state"] == "running" and rec["owner"] == "job:forgetest" and rec["program"] is True
|
||||
and rec["lines"] == 6, "the record while it plays: %s", rec)
|
||||
beside = {}
|
||||
for name, call in (("a second job", lambda: _job_post(fc, "G21\n", name="second")),
|
||||
("a port jog", lambda: fc.post("/motion/jog", params={"x": "1"})),
|
||||
("a settings write", lambda: fc.post("/settings", params={
|
||||
"ui_units": fc.settings().get("ui_units") or "metric"}))):
|
||||
st, body = call()
|
||||
beside[name] = [st, _words(body)[:160]]
|
||||
ctx.check(st == 409 and "a job (forgetest) holds the machine" in _words(body),
|
||||
"%s beside the job -> %s %s", name, st, _words(body)[:160])
|
||||
ev["refused_beside_the_job"] = beside
|
||||
rec, far = _job_wait(ctx, fc, 40, x0)
|
||||
x1 = kernel_xy_mm(ctx)[0]
|
||||
ev["dark_job"] = {"record": rec, "farthest_mm": round(far, 3), "kernel": [x0, x1]}
|
||||
ctx.log("the dark job: %s; the kernel saw %.3f mm out and ended %.3f from the start",
|
||||
rec, far, x1 - x0)
|
||||
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 (the runner's M2 is the one more)",
|
||||
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(19.0 < far < 21.0, "the kernel saw %.3f mm of the 20", far)
|
||||
ctx.check(abs(x1 - x0) < 0.1, "the job did not end where it began: %.3f mm off", x1 - x0)
|
||||
ctx.check(not _lease_holder(fc), "the lease was not given back: %s", _lease_holder(fc))
|
||||
|
||||
# The abort stops a long move short.
|
||||
st, body = _job_post(fc, "G21\nG91\nG1 X40 F300\nG90\n")
|
||||
ctx.check(st == 200, "the long job -> %s %s", st, _words(body)[:200])
|
||||
ctx.sleep(2.0)
|
||||
st, body = fc.post("/job/abort")
|
||||
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "failed"
|
||||
and "aborted" in body.get("reason", ""), "POST /job/abort -> %s %s", st, _words(body)[:200])
|
||||
ctx.sleep(1.0)
|
||||
moved = kernel_xy_mm(ctx)[0] - x0
|
||||
after = _port_state(fc)
|
||||
ev["abort"] = {"record": body, "moved_mm": round(moved, 3), "controller": after.get("state")}
|
||||
ctx.log("aborted a 40 mm move at %.3f mm; the controller is in %s", moved, after.get("state"))
|
||||
ctx.check(1.0 < moved < 35.0, "the abort did not stop the move short: moved %.3f of 40", moved)
|
||||
ctx.check(str(after.get("state", "")).startswith("Alarm"),
|
||||
"the controller is in %s after the abort, not its alarm state", after.get("state"))
|
||||
ctx.check(not _lease_holder(fc), "the lease was not given back after the abort: %s", _lease_holder(fc))
|
||||
|
||||
# Into the alarm: refused by the controller without the unlock, and
|
||||
# with it the head goes back where it was found.
|
||||
back = "G21\nG91\nG1 X%.3f F1200\nG90\n" % -moved
|
||||
st, body = _job_post(fc, back)
|
||||
ctx.check(st == 200, "the job into the alarm -> %s %s", st, _words(body)[:200])
|
||||
rec, far = _job_wait(ctx, fc, 20)
|
||||
ev["into_the_alarm"] = rec
|
||||
ctx.check(rec["state"] == "failed" and "line 1" in rec["reason"],
|
||||
"a job into the alarm without unlock: %s", rec)
|
||||
ctx.check(abs((kernel_xy_mm(ctx)[0] - x0) - moved) < 0.05, "a refused job moved the head")
|
||||
st, body = _job_post(fc, back, unlock="1")
|
||||
ctx.check(st == 200, "the unlocking job -> %s %s", st, _words(body)[:200])
|
||||
rec, far = _job_wait(ctx, fc, 40)
|
||||
ev["unlocked"] = rec
|
||||
ctx.log("the unlocking job: %s", rec)
|
||||
ctx.check(rec["state"] == "done" and rec["sent"] == rec["acked"] == rec["lines"] + 2,
|
||||
"the unlocking job (its $X and its M2 are the two more): %s", rec)
|
||||
moved = kernel_xy_mm(ctx)[0] - x0
|
||||
finally:
|
||||
if _job(fc)["state"] == "running":
|
||||
fc.post("/job/abort")
|
||||
ctx.sleep(1.0)
|
||||
left = kernel_xy_mm(ctx)[0] - x0
|
||||
if abs(left) > 0.05:
|
||||
ctx.log("returning the head %.3f mm through a Grbl client", -left)
|
||||
_return_x(ctx, left)
|
||||
check_kernel_returned(ctx, ev, (x0, y0), tag="job")
|
||||
machine_idle(ctx)
|
||||
ctx.log("PASS: a posted program ran as job:forgetest with the lease held as a sender, everything "
|
||||
"beside it was refused in its name, the abort stopped a move short and freed the machine, "
|
||||
"and the unlocking job cleared the alarm and returned the head")
|
||||
|
||||
@@ -427,8 +427,12 @@ def gate_blocks_controllers(ctx):
|
||||
ev["override_mode"] = m
|
||||
ctx.check(took is not None and isinstance(m, dict) and m.get("controller") == "running",
|
||||
"the controller did not come back under the override: %s", m)
|
||||
ctx.check(hw.grbl_port_open(timeout=5), "the Grbl port is closed with the controller running")
|
||||
ctx.log("override: the controller is back (%s)", m)
|
||||
# /mode reads running from the fork; the controller binds its port
|
||||
# a few tens of milliseconds later
|
||||
opened = ctx.wait_for(lambda: hw.grbl_port_open(timeout=1), 10, poll=0.05)
|
||||
ev["port_open_after_s"] = opened
|
||||
ctx.check(opened is not None, "the Grbl port is closed with the controller running")
|
||||
ctx.log("override: the controller is back (%s), the Grbl port accepts after %.3f s", m, opened)
|
||||
|
||||
# the real record is back under a restart; the override reads as found
|
||||
after = wiz(fc)
|
||||
@@ -495,7 +499,9 @@ def override_until_reboot(ctx):
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||
covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/sha256.*"), ("forgectrl", "src/main.c"),
|
||||
("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake")],
|
||||
("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake"),
|
||||
("forgectrl", "docs/advisories/safety-and-risk.md"), ("forgectrl", "docs/advisories/licenses.md"),
|
||||
("forgectrl", "docs/advisories/privacy.md"), ("forgectrl", "docs/advisories/cloud-service.md")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="GET /advisories/safety-and-risk serves the document as markdown with an ETag "
|
||||
"equal to the SHA-256 of the body and to the hash GET /wiz lists; an unknown "
|
||||
@@ -593,7 +599,11 @@ def advisories_rehash(ctx):
|
||||
description="The test makes its own account: the account record is moved aside under a "
|
||||
"forgectrl restart, the account route creates a temporary one with a password "
|
||||
"only the test knows, and the real record comes back under another restart at "
|
||||
"the end, the system accounts replayed and the temporary home removed. Over "
|
||||
"the end, the system accounts replayed and the temporary home removed. The data "
|
||||
"directory is set to 0700 first, as a strict umask leaves it: the render that makes "
|
||||
"the account opens it for search and nothing else (0711), and tried as the account, "
|
||||
"its home can be entered and written while the data directory cannot be listed and "
|
||||
"the account record cannot be read. Over "
|
||||
"HTTPS (self-signed, so unverified): GET /wiz reports no session and the "
|
||||
"certificate fingerprint; POST /login with a wrong password is refused (401); "
|
||||
"five failures lock the address (429, wait at most 30 s) and the right password "
|
||||
@@ -608,27 +618,88 @@ def account_login(ctx):
|
||||
before = wiz(fc)
|
||||
ev["account_before"] = (before.get("users") or {}).get("name")
|
||||
homes = "/data/forgefirm/home"
|
||||
with installed(ctx, {users_path(): None}):
|
||||
w = wiz(fc)
|
||||
ctx.check(not (w.get("users") or {}).get("exists"), "an account still exists: %s", w.get("users"))
|
||||
st, body = fc.post("/wiz/account", data={"name": name, "password": pw})
|
||||
ctx.log("POST /wiz/account (temporary %r) -> %s %s", name, st, body if isinstance(body, dict) else "")
|
||||
ctx.check(st == 200, "the temporary account was not created: %s %s", st, body)
|
||||
w = wiz(fc)
|
||||
ctx.check((w.get("users") or {}).get("name") == name, "the account reads %s", w.get("users"))
|
||||
login_checks(ctx, name, pw)
|
||||
rc, out = hw.initd("forgefirm-users", "reload")
|
||||
ev["users_reload_rc"] = rc
|
||||
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
|
||||
if name != ev["account_before"]:
|
||||
import shutil
|
||||
shutil.rmtree(os.path.join(homes, name), ignore_errors=True)
|
||||
dir_mode = os.stat(data_dir()).st_mode & 0o7777
|
||||
ev["data_dir_mode_found"] = "%04o" % dir_mode
|
||||
try:
|
||||
with installed(ctx, {users_path(): None}):
|
||||
w = wiz(fc)
|
||||
ctx.check(not (w.get("users") or {}).get("exists"), "an account still exists: %s", w.get("users"))
|
||||
# the data directory as a strict umask leaves it: the account's render has to open it
|
||||
os.chmod(data_dir(), 0o700)
|
||||
st, body = fc.post("/wiz/account", data={"name": name, "password": pw})
|
||||
ctx.log("POST /wiz/account (temporary %r) -> %s %s", name, st, body if isinstance(body, dict) else "")
|
||||
ctx.check(st == 200, "the temporary account was not created: %s %s", st, body)
|
||||
w = wiz(fc)
|
||||
ctx.check((w.get("users") or {}).get("name") == name, "the account reads %s", w.get("users"))
|
||||
reach = home_reach(name)
|
||||
ev["home_reach"] = reach
|
||||
ev["data_dir_mode_after_render"] = "%04o" % (os.stat(data_dir()).st_mode & 0o7777)
|
||||
ctx.log("as %s, from a data directory found 0700 (now %s): %s", name, ev["data_dir_mode_after_render"], reach)
|
||||
ctx.check(reach.get("cd") == "ok" and reach.get("write") == "ok",
|
||||
"the account cannot use its own home %s: %s", reach.get("home"), reach)
|
||||
ctx.check(ev["data_dir_mode_after_render"] == "0711", "the render left the data directory at %s, expected "
|
||||
"0711 (the search bit and nothing else)", ev["data_dir_mode_after_render"])
|
||||
ctx.check(reach.get("list_data_dir") == "EACCES" and reach.get("read_record") == "EACCES",
|
||||
"the account reads what is not its own: %s", reach)
|
||||
login_checks(ctx, name, pw)
|
||||
finally:
|
||||
# as found; the replay below is the product's own say on the mode
|
||||
os.chmod(data_dir(), dir_mode)
|
||||
rc, out = hw.initd("forgefirm-users", "reload")
|
||||
ev["users_reload_rc"] = rc
|
||||
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
|
||||
if name != ev["account_before"]:
|
||||
import shutil
|
||||
shutil.rmtree(os.path.join(homes, name), ignore_errors=True)
|
||||
after = wiz(fc)
|
||||
ctx.check((after.get("users") or {}).get("name") == ev["account_before"],
|
||||
"the account reads %r after the restore, was %r", (after.get("users") or {}).get("name"),
|
||||
ev["account_before"])
|
||||
|
||||
|
||||
# Run as root: becomes the account and tries its own home, and what is
|
||||
# not its own. One JSON line.
|
||||
HOME_REACH = r'''
|
||||
import errno, json, os, pwd, sys
|
||||
p = pwd.getpwnam(sys.argv[1])
|
||||
os.setgroups([])
|
||||
os.setgid(p.pw_gid)
|
||||
os.setuid(p.pw_uid)
|
||||
|
||||
|
||||
def tried(fn):
|
||||
try:
|
||||
fn()
|
||||
return "ok"
|
||||
except OSError as e:
|
||||
return errno.errorcode.get(e.errno, str(e.errno))
|
||||
|
||||
|
||||
def write():
|
||||
path = os.path.join(p.pw_dir, ".forgetest-reach")
|
||||
with open(path, "w") as f:
|
||||
f.write("x")
|
||||
os.remove(path)
|
||||
|
||||
|
||||
print(json.dumps({"uid": os.getuid(), "home": p.pw_dir, "cd": tried(lambda: os.chdir(p.pw_dir)), "write": tried(write),
|
||||
"list_data_dir": tried(lambda: os.listdir(sys.argv[2])),
|
||||
"read_record": tried(lambda: open(os.path.join(sys.argv[2], "users")).read())}))
|
||||
'''
|
||||
|
||||
|
||||
def home_reach(name):
|
||||
"""What the account can do with its own home and with the data
|
||||
directory around it, tried as the account."""
|
||||
import subprocess
|
||||
import sys
|
||||
p = subprocess.run([sys.executable, "-c", HOME_REACH, name, data_dir()], capture_output=True, text=True, timeout=30)
|
||||
try:
|
||||
return json.loads(p.stdout)
|
||||
except ValueError:
|
||||
return {"error": (p.stderr or p.stdout).strip()[-200:]}
|
||||
|
||||
|
||||
def login_checks(ctx, name, pw):
|
||||
"""The login rules against an account whose password the test knows."""
|
||||
ev = ctx.evidence
|
||||
@@ -896,12 +967,17 @@ def ssh_until_reboot(ctx):
|
||||
@test("setup.cloud-disabled-surface", title="Nothing points at the cloud while it is off",
|
||||
subsystem="setup", kind="auto", mode="grbl", est_min=1,
|
||||
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/settings.*"),
|
||||
("forgectrl", "src/wiz.c"), ("forgectrl", "src/hooks.h")],
|
||||
("forgectrl", "src/wiz.c"), ("forgectrl", "src/hooks.h"), ("forgectrl", "src/builtin.*")],
|
||||
requires=["forgectrl.settings-bounds"],
|
||||
description="The test turns cloud mode off itself with one write and puts every setting "
|
||||
"back as found. cloud_enabled=0 takes the gfcloud homing and the cloud boot mode "
|
||||
"down with it, as the cloud step does. With it at 0, POST /settings "
|
||||
"controller_mode=cloud and homing_mode=gfcloud are refused (409) and leave the "
|
||||
"back as found. GET /extensions lists cloud mode as a built-in with its two "
|
||||
"roles (homing gfcloud, controller cloud), enabled as cloud_enabled says and each "
|
||||
"role active exactly when its setting selects it. cloud_enabled=0 takes the "
|
||||
"gfcloud homing and the cloud boot mode "
|
||||
"down with it, as the cloud step does, and the list then reads off with no role "
|
||||
"active. With it at 0, POST /settings "
|
||||
"controller_mode=cloud and homing_mode=gfcloud are refused (409, in the words the "
|
||||
"table gives each role) and leave the "
|
||||
"settings unchanged, cloud_enabled=1 without the typed phrase is refused (400) "
|
||||
"and leaves it at 0, and POST /mode controller=cloud is refused (409) with a "
|
||||
"message that names cloud mode, the machine staying in GRBL mode.")
|
||||
@@ -912,6 +988,29 @@ def cloud_disabled_surface(ctx):
|
||||
found = fc.settings()
|
||||
prior = found.get("cloud_enabled") or ""
|
||||
ev["found"] = {k: found.get(k) for k in ("cloud_enabled", "homing_mode", "controller_mode")}
|
||||
|
||||
def cloud_entry():
|
||||
st, doc = fc.get("/extensions")
|
||||
ctx.check(st == 200 and isinstance(doc, dict), "GET /extensions -> %s", st)
|
||||
ext = [e for e in doc.get("extensions", []) if e.get("id") == "cloud"]
|
||||
ctx.check(len(ext) == 1, "GET /extensions lists cloud mode %d times", len(ext))
|
||||
return ext[0]
|
||||
|
||||
# The list agrees with the settings as found.
|
||||
e = cloud_entry()
|
||||
ev["extension_found"] = {"enabled": e.get("enabled"), "roles": e.get("roles")}
|
||||
ctx.check(e.get("builtin") is True and e.get("enable_key") == "cloud_enabled" and e.get("tab") == "gfcloud"
|
||||
and e.get("setup_step") == "cloud", "the cloud entry: %s", e)
|
||||
ctx.check(e.get("enabled") is (prior == "1"), "the list says enabled=%s with cloud_enabled=%r",
|
||||
e.get("enabled"), prior)
|
||||
roles = {r["role"]: r for r in e.get("roles", [])}
|
||||
ctx.check(set(roles) == {"homing", "controller"} and roles["homing"]["provider"] == "gfcloud"
|
||||
and roles["homing"]["fallback"] == "none" and roles["controller"]["provider"] == "cloud"
|
||||
and roles["controller"]["fallback"] == "grbl", "the cloud entry's roles: %s", e.get("roles"))
|
||||
for r in roles.values():
|
||||
want = prior == "1" and found.get(r["select_key"]) == r["provider"]
|
||||
ctx.check(r["active"] is want, "role %s reads active=%s with %s=%r", r["role"], r["active"],
|
||||
r["select_key"], found.get(r["select_key"]))
|
||||
try:
|
||||
if prior != "0":
|
||||
# the one write sweeps what pointed at the cloud
|
||||
@@ -927,12 +1026,18 @@ def cloud_disabled_surface(ctx):
|
||||
"cloud_enabled=0 left controller_mode at %r, expected grbl",
|
||||
swept.get("controller_mode"))
|
||||
ctx.check(fc.settings().get("cloud_enabled") == "0", "cloud_enabled does not read 0")
|
||||
off = cloud_entry()
|
||||
ev["extension_off"] = {"enabled": off.get("enabled"), "active": [r["active"] for r in off["roles"]]}
|
||||
ctx.check(off.get("enabled") is False and not any(r["active"] for r in off["roles"]),
|
||||
"the list with cloud mode off: %s", ev["extension_off"])
|
||||
before = fc.settings()
|
||||
for key, val in (("controller_mode", "cloud"), ("homing_mode", "gfcloud")):
|
||||
for key, val, words in (("controller_mode", "cloud", "cloud mode is not enabled on this machine"),
|
||||
("homing_mode", "gfcloud", "cloud homing needs cloud mode enabled")):
|
||||
st, body = fc.post("/settings", data={key: val})
|
||||
ev["settings %s=%s" % (key, val)] = st
|
||||
ctx.log("POST /settings %s=%s -> %s %s", key, val, st, body if isinstance(body, str) else "")
|
||||
ctx.check(st == 409, "%s=%s with cloud off -> %s, expected 409", key, val, st)
|
||||
ctx.check(isinstance(body, str) and words in body, "%s=%s was refused in other words: %r", key, val, body)
|
||||
# the pair in one request is refused too: the request's own switch counts
|
||||
st, body = fc.post("/settings", data={"cloud_enabled": "0", "controller_mode": "cloud"})
|
||||
ctx.check(st == 409, "cloud_enabled=0 with controller_mode=cloud -> %s, expected 409", st)
|
||||
@@ -972,6 +1077,149 @@ def cloud_disabled_surface(ctx):
|
||||
for key in ("cloud_enabled", "homing_mode", "controller_mode"):
|
||||
ctx.check((after.get(key) or "") == (found.get(key) or ""), "%s not restored: %r, was %r",
|
||||
key, after.get(key), found.get(key))
|
||||
back = cloud_entry()
|
||||
ctx.check(back.get("enabled") == e.get("enabled") and back.get("roles") == e.get("roles"),
|
||||
"the list did not come back as found: %s, was %s", back, e)
|
||||
|
||||
|
||||
# ----------------------------------------------------------- extensions
|
||||
|
||||
@test("setup.extensions-consent", title="Extensions are turned on over their own advisory",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"),
|
||||
("forgectrl", "src/wiz.c"), ("forgectrl", "src/settings.*"),
|
||||
("forgectrl", "docs/advisories/extensions.md"), ("forgectrl", "src/ui/embed_docs.cmake")],
|
||||
requires=["forgectrl.settings-bounds", "setup.advisories-rehash"],
|
||||
description="The Extensions advisory is on demand: GET /wiz does not list it among the "
|
||||
"first-run documents, GET /advisories/extensions serves it as markdown with an "
|
||||
"ETag equal to the SHA-256 of the body, and the first-run accept refuses it (400) "
|
||||
"and leaves the press. The test turns ext_enabled off itself and puts it back as "
|
||||
"found. From off, POST /settings ext_enabled=1 is refused without the advisory's "
|
||||
"hash and with a stale one (409), and without the typed phrase and with the phrase "
|
||||
"in another case (400); each refusal leaves the setting at 0 and the record on "
|
||||
"disk untouched. A request that carries the hash and the phrase beside a write the "
|
||||
"daemon refuses (controller_mode=cloud with cloud_enabled=0, 409) is refused whole "
|
||||
"and records nothing. With the "
|
||||
"hash and the phrase it is accepted (200): the setting reads 1, the record on disk "
|
||||
"holds on_demand.extensions with that hash and the typed method, the first-run "
|
||||
"documents and their press are as they were, and the data directory has gained "
|
||||
"the search bit for group and others and nothing else (a package's account walks "
|
||||
"through it to its own files). Re-sending 1 while it stands and "
|
||||
"sending 0 ask for nothing. The previous record is put back under a forgectrl "
|
||||
"restart.")
|
||||
def extensions_consent(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||
raw = read_file(record_path())
|
||||
ctx.check(raw, "no setup record at %s", record_path())
|
||||
before = wiz(fc)
|
||||
ev["before"] = wiz_summary(before)
|
||||
prior = fc.settings().get("ext_enabled") or ""
|
||||
dir_mode = os.stat(data_dir()).st_mode & 0o7777
|
||||
ev["found"] = {"ext_enabled": prior, "data_dir_mode": "%04o" % dir_mode}
|
||||
|
||||
def on_disk():
|
||||
try:
|
||||
return json.loads((read_file(record_path()) or b"{}").decode("utf-8"))
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
ctx.check("extensions" not in [d.get("id") for d in before.get("documents") or []],
|
||||
"GET /wiz lists the Extensions advisory among the first-run documents")
|
||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||
etag = hdrs.get("etag")
|
||||
ev["etag"] = etag
|
||||
ctx.log("GET /advisories/extensions -> %s, %d bytes, ETag %s", st, len(body), etag)
|
||||
ctx.check(st == 200 and body, "GET /advisories/extensions -> %s", st)
|
||||
ctx.check((hdrs.get("content-type") or "").startswith("text/markdown"),
|
||||
"the document is not served as markdown: %r", hdrs.get("content-type"))
|
||||
ctx.check(etag == hashlib.sha256(body).hexdigest(), "the ETag is not the SHA-256 of the body")
|
||||
|
||||
try:
|
||||
st, reply = fc.post("/wiz/advisories/accept",
|
||||
data={"doc": "extensions", "hash": etag, "phrase": SAFETY_PHRASE})
|
||||
ev["first_run_accept"] = st
|
||||
ctx.log("the first-run accept of the on-demand document -> %s %s", st, reply)
|
||||
ctx.check(st == 400, "the first-run accept of the on-demand document -> %s, expected 400", st)
|
||||
ctx.check(wiz(fc).get("acceptance_done") == before.get("acceptance_done"),
|
||||
"the refused accept changed the press record")
|
||||
|
||||
if prior == "1":
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
|
||||
ctx.check(st == 200, "ext_enabled=0 -> %s %s", st, reply)
|
||||
ctx.log("extensions turned off for the test (found %r)", prior)
|
||||
ctx.check((fc.settings().get("ext_enabled") or "0") == "0", "ext_enabled does not read off")
|
||||
start = on_disk()
|
||||
|
||||
for name, form, want, words in (
|
||||
("no hash", {"ext_enabled": "1"}, 409, "read the Extensions advisory first"),
|
||||
("stale hash", {"ext_enabled": "1", "advisory": "0" * 64, "phrase": SAFETY_PHRASE}, 409,
|
||||
"read the Extensions advisory first"),
|
||||
("no phrase", {"ext_enabled": "1", "advisory": etag}, 400, "type I UNDERSTAND"),
|
||||
("phrase in another case", {"ext_enabled": "1", "advisory": etag,
|
||||
"phrase": SAFETY_PHRASE.lower()}, 400, "type I UNDERSTAND"),
|
||||
("good consent beside a refused write", {"ext_enabled": "1", "advisory": etag,
|
||||
"phrase": SAFETY_PHRASE, "cloud_enabled": "0",
|
||||
"controller_mode": "cloud"},
|
||||
409, "cloud mode is not enabled on this machine")):
|
||||
st, reply = fc.post("/settings", data=form)
|
||||
ev[name] = st
|
||||
ctx.log("POST /settings ext_enabled=1, %s -> %s %s", name, st, reply if isinstance(reply, str) else "")
|
||||
ctx.check(st == want, "%s -> %s, expected %s", name, st, want)
|
||||
ctx.check(isinstance(reply, str) and words in reply, "%s was refused in other words: %r", name, reply)
|
||||
ctx.check((fc.settings().get("ext_enabled") or "0") == "0", "%s: a refused write turned extensions on", name)
|
||||
ctx.check(on_disk() == start, "%s: a refused write changed the record on disk", name)
|
||||
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
ev["accept"] = st
|
||||
ctx.log("POST /settings ext_enabled=1 with the hash and the phrase -> %s", st)
|
||||
ctx.check(st == 200, "the consent -> %s %r", st, reply)
|
||||
ctx.check(fc.settings().get("ext_enabled") == "1", "ext_enabled does not read 1")
|
||||
rec = on_disk()
|
||||
entry = (rec.get("on_demand") or {}).get("extensions") or {}
|
||||
ev["recorded"] = entry
|
||||
ctx.check(entry.get("hash") == etag and entry.get("method") == "typed" and entry.get("accepted"),
|
||||
"the record on disk does not carry the acceptance: %s", entry)
|
||||
ctx.check("extensions" not in (rec.get("advisories") or {}),
|
||||
"the acceptance landed among the first-run documents")
|
||||
# a package's account walks through the data directory to its own files: the
|
||||
# consent adds the search bit, and nothing else
|
||||
now_mode = os.stat(data_dir()).st_mode & 0o7777
|
||||
ev["data_dir_mode_after"] = "%04o" % now_mode
|
||||
ctx.check(now_mode == dir_mode | 0o011, "the data directory went from %04o to %04o, expected %04o",
|
||||
dir_mode, now_mode, dir_mode | 0o011)
|
||||
ctx.check(rec.get("advisories") == start.get("advisories") and rec.get("acceptance") == start.get("acceptance"),
|
||||
"the first-run documents or their press moved")
|
||||
after = wiz(fc)
|
||||
ctx.check(after.get("acceptance_done") == before.get("acceptance_done") and after.get("gate") == before.get("gate"),
|
||||
"the machine's gate moved with the consent: %s", wiz_summary(after))
|
||||
for form in ({"ext_enabled": "1"}, {"ext_enabled": "0"}):
|
||||
st, reply = fc.post("/settings", data=form)
|
||||
ctx.check(st == 200, "%s while on -> %s, expected 200", form, st)
|
||||
ctx.check(fc.settings().get("ext_enabled") == "0", "ext_enabled=0 did not turn extensions off")
|
||||
finally:
|
||||
if prior == "":
|
||||
st, reply = fc.post("/settings", params={"ext_enabled": ""})
|
||||
elif prior == "1":
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
else:
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": prior})
|
||||
ctx.log("restore ext_enabled=%r -> %s", prior, st)
|
||||
if prior != "1":
|
||||
os.chmod(data_dir(), dir_mode)
|
||||
with ctx.takeover():
|
||||
write_file(record_path(), raw)
|
||||
ctx.log("the previous record is back under a restart")
|
||||
|
||||
ctx.check((fc.settings().get("ext_enabled") or "") == prior, "ext_enabled not restored: %r, was %r",
|
||||
fc.settings().get("ext_enabled"), prior)
|
||||
ctx.check(read_file(record_path()) == raw, "the record on disk is not the one found")
|
||||
ctx.check(prior == "1" or os.stat(data_dir()).st_mode & 0o7777 == dir_mode, "the data directory's mode is not the one found")
|
||||
final = wiz(fc)
|
||||
ev["after_restore"] = wiz_summary(final)
|
||||
ctx.check(final.get("acceptance_done") == before.get("acceptance_done") and final.get("gate") == before.get("gate"),
|
||||
"the machine does not read as before: %s", wiz_summary(final))
|
||||
|
||||
|
||||
# ---------------------------------------------------------- the operator
|
||||
@@ -1175,25 +1423,30 @@ def first_run(ctx):
|
||||
except OSError:
|
||||
homes_before = set()
|
||||
state = {"temp_account": None}
|
||||
with Restore(ctx, FIRST_RUN_SETTINGS):
|
||||
with installed(ctx, {record_path(): seed, users_path(): None, override_path(): b""}):
|
||||
w = wiz(fc)
|
||||
ev["fresh"] = wiz_summary(w)
|
||||
ctx.check(w.get("first_run") is True and not (w.get("users") or {}).get("exists")
|
||||
and w.get("advisories_complete") is False and w.get("acceptance_done") is False,
|
||||
"the machine does not read as a first run: %s", wiz_summary(w))
|
||||
m = mode(fc)
|
||||
ctx.check(m.get("controller") == "gated", "the controller is %s on a first run", m.get("controller"))
|
||||
yield state
|
||||
# the real records are back; the system accounts follow the record
|
||||
rc, out = hw.initd("forgefirm-users", "reload")
|
||||
ev["users_reload_rc"] = rc
|
||||
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
|
||||
temp = state.get("temp_account")
|
||||
if temp and temp not in homes_before and temp != ev["account_before"]:
|
||||
import shutil
|
||||
shutil.rmtree(os.path.join(homes, temp), ignore_errors=True)
|
||||
ctx.log("removed the temporary home directory of %r", temp)
|
||||
try:
|
||||
with Restore(ctx, FIRST_RUN_SETTINGS):
|
||||
with installed(ctx, {record_path(): seed, users_path(): None, override_path(): b""}):
|
||||
w = wiz(fc)
|
||||
ev["fresh"] = wiz_summary(w)
|
||||
ctx.check(w.get("first_run") is True and not (w.get("users") or {}).get("exists")
|
||||
and w.get("advisories_complete") is False and w.get("acceptance_done") is False,
|
||||
"the machine does not read as a first run: %s", wiz_summary(w))
|
||||
m = mode(fc)
|
||||
ctx.check(m.get("controller") == "gated", "the controller is %s on a first run",
|
||||
m.get("controller"))
|
||||
yield state
|
||||
finally:
|
||||
# the real records are back, whether or not the block passed; the
|
||||
# system accounts follow the record, and a temporary account the
|
||||
# block created goes with its home
|
||||
rc, out = hw.initd("forgefirm-users", "reload")
|
||||
ev["users_reload_rc"] = rc
|
||||
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
|
||||
temp = state.get("temp_account")
|
||||
if temp and temp not in homes_before and temp != ev["account_before"]:
|
||||
import shutil
|
||||
shutil.rmtree(os.path.join(homes, temp), ignore_errors=True)
|
||||
ctx.log("removed the temporary home directory of %r", temp)
|
||||
after = wiz(fc)
|
||||
ev["after"] = wiz_summary(after)
|
||||
ctx.check((after.get("users") or {}).get("name") == ev["account_before"],
|
||||
|
||||
@@ -121,7 +121,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 "
|
||||
@@ -191,20 +191,28 @@ def check_sensors(ctx):
|
||||
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
||||
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
||||
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 six settings are written: the "
|
||||
"four floors at 55 percent of steady, the grace from the slowest spin-up, and the "
|
||||
"intake fans fitted (2 when both intakes turn; 1 when intake 2 never reads a pulse "
|
||||
"and intake 1 passes, and then intake 2 is reported not fitted and the intake floor "
|
||||
"comes from intake 1). Each fan judged must read at least 1000 rpm. The six "
|
||||
"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"]):
|
||||
last = run_check(ctx, "airflow", lambda p: None, 240)
|
||||
r = last.get("result") or {}
|
||||
fans = r.get("fans") or {}
|
||||
intakes = r.get("intake_fans")
|
||||
ctx.check(intakes in (1, 2), "the result carries no intake fan count: %r", intakes)
|
||||
ctx.log("intake fans fitted: %s", intakes)
|
||||
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
||||
f = fans.get(name) or {}
|
||||
if name == "intake 2" and intakes == 1:
|
||||
ctx.check(f == {"fitted": 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 +221,8 @@ 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))
|
||||
ctx.check(s.get("cool_intake_fans") == str(intakes),
|
||||
"cool_intake_fans reads %r, the check found %r", s.get("cool_intake_fans"), intakes)
|
||||
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
|
||||
|
||||
@@ -28,6 +28,7 @@ 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.*"),
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The cipher forgectrl's HTTPS listener chooses, and where its records
|
||||
are sealed.
|
||||
|
||||
The listener puts ChaCha20-Poly1305 first, whatever the client prefers,
|
||||
and the image seals the records in the kernel: GnuTLS hands each
|
||||
connection's keys to the socket after the handshake. ChaCha20-Poly1305
|
||||
runs in the kernel's NEON code; AES-GCM, for a client without ChaCha20,
|
||||
runs its AES on the CAAM crypto engine and its GHASH in NEON. This test
|
||||
reads all of it from the outside: the cipher the listener picks for a
|
||||
client that prefers AES-GCM, the kernel's TLS counters, the crypto
|
||||
engine's interrupt, and the drivers the kernel resolved, with each
|
||||
connection carrying the same page, which must arrive identical to the
|
||||
plain-HTTP copy.
|
||||
|
||||
Its own module: a test's fingerprint holds the shared text of the module
|
||||
it lives in, so a test added to forgectrl.py would move the fingerprint
|
||||
of every test there.
|
||||
"""
|
||||
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
from ..catalog import test
|
||||
|
||||
OPENSSL = "/usr/bin/openssl"
|
||||
ENGINE_IRQ = "2101000.jr" # the CAAM's first job ring
|
||||
RECORD = 16384 # the largest TLS record's plaintext
|
||||
PAGE = "/" # the panel page: large, and the same bytes every time
|
||||
CHACHA_DRIVER = "rfc7539(chacha20-neon,poly1305-neon)"
|
||||
# A desktop browser's offer: AES-GCM first, because its CPU has AES instructions.
|
||||
DESKTOP_13 = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256"
|
||||
DESKTOP_12 = "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305"
|
||||
|
||||
|
||||
def _tls_stat():
|
||||
"""The kernel's TLS counters, by name."""
|
||||
out = {}
|
||||
with open("/proc/net/tls_stat") as f:
|
||||
for line in f:
|
||||
p = line.split()
|
||||
if len(p) == 2 and p[1].isdigit():
|
||||
out[p[0]] = int(p[1])
|
||||
return out
|
||||
|
||||
|
||||
def _passive_opens():
|
||||
"""The TCP connections the machine has accepted, on every port and both
|
||||
families (the TCP counters are shared)."""
|
||||
with open("/proc/net/snmp") as f:
|
||||
rows = [line.split() for line in f if line.startswith("Tcp:")]
|
||||
return int(rows[1][rows[0].index("PassiveOpens")])
|
||||
|
||||
|
||||
def _engine_irqs():
|
||||
with open("/proc/interrupts") as f:
|
||||
for line in f:
|
||||
p = line.split()
|
||||
if p and p[-1] == ENGINE_IRQ and len(p) > 1 and p[1].isdigit():
|
||||
return int(p[1])
|
||||
return None
|
||||
|
||||
|
||||
def _driver(name):
|
||||
"""The driver answering an algorithm name: the entry of that name with
|
||||
the highest priority in /proc/crypto."""
|
||||
best, drv, entry = -1, None, {}
|
||||
with open("/proc/crypto") as f:
|
||||
lines = f.read().splitlines() + [""]
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
if entry.get("name") == name and int(entry.get("priority", -1)) > best:
|
||||
best, drv = int(entry["priority"]), entry.get("driver")
|
||||
entry = {}
|
||||
continue
|
||||
k, _, v = line.partition(":")
|
||||
entry[k.strip()] = v.strip()
|
||||
return drv
|
||||
|
||||
|
||||
def _fetch(version, offer, work):
|
||||
"""GET the page over HTTPS on loopback with the given offer. Returns
|
||||
the suite, the status line, the body, the kernel counters' change,
|
||||
the crypto engine's interrupts during the connection, and how many
|
||||
other connections the machine accepted meanwhile."""
|
||||
req = os.path.join(work, "req")
|
||||
with open(req, "wb") as f:
|
||||
f.write(b"GET " + PAGE.encode() + b" HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
|
||||
opt = ["-tls1_3", "-ciphersuites", offer] if version == "1.3" else ["-tls1_2", "-cipher", offer]
|
||||
s0, i0, p0 = _tls_stat(), _engine_irqs(), _passive_opens()
|
||||
with open(req, "rb") as stdin:
|
||||
r = subprocess.run([OPENSSL, "s_client", "-connect", "127.0.0.1:443", "-brief", "-ign_eof"] + opt,
|
||||
stdin=stdin, capture_output=True, timeout=60)
|
||||
s1, i1, p1 = _tls_stat(), _engine_irqs(), _passive_opens()
|
||||
info = {}
|
||||
for line in r.stderr.decode("utf-8", "replace").splitlines():
|
||||
k, _, v = line.partition(":")
|
||||
info[k.strip()] = v.strip()
|
||||
head, _, body = r.stdout.partition(b"\r\n\r\n")
|
||||
delta = {k: s1.get(k, 0) - s0.get(k, 0) for k in ("TlsTxSw", "TlsRxSw", "TlsDecryptError")}
|
||||
return (info.get("Ciphersuite"), head.split(b"\r\n")[0].decode("latin-1"), body, delta,
|
||||
(i1 or 0) - (i0 or 0), p1 - p0 - 1)
|
||||
|
||||
|
||||
@test("forgectrl.tls-records", title="HTTPS: ChaCha20 chosen, records sealed in the kernel",
|
||||
subsystem="forgectrl", kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c")],
|
||||
description="forgectrl's HTTPS listener chooses the cipher, ChaCha20-Poly1305 first, and the "
|
||||
"image seals the records in the kernel: GnuTLS hands each connection's keys to the "
|
||||
"socket after the handshake (/etc/gnutls/config turns it on). A client that lists "
|
||||
"AES-GCM first, as a desktop browser does, gets ChaCha20-Poly1305 over TLS 1.3 and "
|
||||
"TLS 1.2, and the kernel takes both directions' keys; the kernel's rfc7539 is the "
|
||||
"NEON ChaCha20 and Poly1305. A client offering AES-GCM alone gets it, the kernel "
|
||||
"takes its keys, and the crypto engine's interrupt counts the records: the AES runs "
|
||||
"on the CAAM (gcm(aes) is its ctr(aes) with the NEON GHASH), which writes each "
|
||||
"record by DMA while the CPU fills the next, so those connections run three times "
|
||||
"over each protocol and every copy must be exact. A TLS 1.2 client "
|
||||
"offering a CBC suite alone still connects and stays in GnuTLS, which is the control "
|
||||
"for the kernel's counters; the ChaCha20 runs are the control for the engine's "
|
||||
"interrupt. Every copy of the panel page must be the plain-HTTP page byte for byte, "
|
||||
"and the kernel must count no decrypt error. The counters and the interrupt are the "
|
||||
"machine's, so a connection during which another client connected (a panel "
|
||||
"reconnecting after a restart) is measured again. Nothing on the machine changes.")
|
||||
def tls_records(ctx):
|
||||
ev = ctx.evidence
|
||||
|
||||
conf = ""
|
||||
if os.path.exists("/etc/gnutls/config"):
|
||||
with open("/etc/gnutls/config") as f:
|
||||
conf = f.read()
|
||||
ctx.check(any(line.split() == ["ktls", "=", "true"] for line in conf.splitlines()),
|
||||
"/etc/gnutls/config does not turn kernel TLS on: %r", conf)
|
||||
with open("/proc/sys/net/ipv4/tcp_available_ulp") as f:
|
||||
ulp = f.read().split()
|
||||
ev["tcp_available_ulp"] = ulp
|
||||
ctx.check("tls" in ulp, "the kernel has no TLS layer (tcp_available_ulp %s)", ulp)
|
||||
ctx.check(_engine_irqs() is not None, "/proc/interrupts has no %s", ENGINE_IRQ)
|
||||
|
||||
with urllib.request.urlopen("http://127.0.0.1" + PAGE, timeout=30) as r:
|
||||
plain = r.read()
|
||||
ev["page_bytes"] = len(plain)
|
||||
ctx.check(len(plain) > 8 * RECORD, "the page is %d bytes, too small to span records", len(plain))
|
||||
records = len(plain) // RECORD
|
||||
|
||||
work = tempfile.mkdtemp(prefix="forgetest-tls-")
|
||||
try:
|
||||
runs = {}
|
||||
# The AES-GCM connections run three times each: their records are written by the crypto
|
||||
# engine's DMA while the CPU fills the next one, which is where a record's last bytes were
|
||||
# once lost.
|
||||
cases = [("desktop-1.3", "1.3", DESKTOP_13, "TLS_CHACHA20_POLY1305_SHA256", True),
|
||||
("desktop-1.2", "1.2", DESKTOP_12, "ECDHE-ECDSA-CHACHA20-POLY1305", True)]
|
||||
for n in (1, 2, 3):
|
||||
cases += [("aes-1.3#%d" % n, "1.3", "TLS_AES_128_GCM_SHA256", "TLS_AES_128_GCM_SHA256", True),
|
||||
("aes-1.2#%d" % n, "1.2", "ECDHE-ECDSA-AES128-GCM-SHA256", "ECDHE-ECDSA-AES128-GCM-SHA256",
|
||||
True)]
|
||||
cases.append(("cbc-1.2", "1.2", "ECDHE-ECDSA-AES128-SHA", "ECDHE-ECDSA-AES128-SHA", False))
|
||||
for label, version, offer, want, kernel in cases:
|
||||
# The kernel's counters and the engine's interrupt are the machine's, not the
|
||||
# connection's: a window in which another client connected (a panel reconnecting
|
||||
# after a restart, a page polling this suite once a second) says nothing about this
|
||||
# one, and is measured again, after a pause that no steady poller stays in step with.
|
||||
for attempt in range(1, 31):
|
||||
suite, status, body, delta, irqs, others = _fetch(version, offer, work)
|
||||
if others <= 0:
|
||||
break
|
||||
ctx.log("%s: %d other connection(s) during the window; measured again", label, others)
|
||||
ctx.sleep(random.uniform(0.1, 0.9))
|
||||
ctx.check(others <= 0, "%s: another client connected during each of %d windows", label, attempt)
|
||||
run = {"offer": offer, "suite": suite, "status": status, "bytes": len(body),
|
||||
"same_as_http": body == plain, "tls_stat": delta, "engine_irqs": irqs, "attempts": attempt}
|
||||
runs[label] = run
|
||||
ctx.log("%s: offer %s -> %s, %s, %d bytes, same %s, tls_stat %s, %s +%d",
|
||||
label, offer, suite, status, len(body), body == plain, delta, ENGINE_IRQ, irqs)
|
||||
ctx.check(suite == want, "%s: offered %s, the listener chose %s, not %s", label, offer, suite, want)
|
||||
ctx.check(status.endswith("200 OK") and body == plain,
|
||||
"%s: the page arrived %s, %d bytes, not the plain-HTTP %d", label, status, len(body),
|
||||
len(plain))
|
||||
ctx.check(delta["TlsDecryptError"] == 0, "%s: the kernel counted %d decrypt errors", label,
|
||||
delta["TlsDecryptError"])
|
||||
if kernel:
|
||||
ctx.check(delta["TlsTxSw"] == 1 and delta["TlsRxSw"] == 1,
|
||||
"%s: the kernel did not take both directions' keys (tls_stat %s)", label, delta)
|
||||
else:
|
||||
ctx.check(delta["TlsTxSw"] == 0 and delta["TlsRxSw"] == 0,
|
||||
"%s: the kernel took keys for a cipher it does not seal (tls_stat %s)", label, delta)
|
||||
ev["runs"] = runs
|
||||
|
||||
# The AES of AES-GCM runs on the crypto engine: its interrupt counts
|
||||
# the records. ChaCha20 never touches the engine, so those runs are
|
||||
# the control.
|
||||
for label in ("aes-1.3#1", "aes-1.2#1"):
|
||||
ctx.check(runs[label]["engine_irqs"] >= records // 4,
|
||||
"%s: %d records crossed and the crypto engine interrupted %d times", label, records,
|
||||
runs[label]["engine_irqs"])
|
||||
for label in ("desktop-1.3", "desktop-1.2"):
|
||||
ctx.check(runs[label]["engine_irqs"] < max(2, records // 8),
|
||||
"%s: the crypto engine interrupted %d times for ChaCha20 records", label,
|
||||
runs[label]["engine_irqs"])
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
# The drivers the kernel resolved for the two record ciphers (both
|
||||
# exist now: the connections above instantiated them).
|
||||
chacha, gcm = _driver("rfc7539(chacha20,poly1305)"), _driver("gcm(aes)")
|
||||
ev["drivers"] = {"rfc7539(chacha20,poly1305)": chacha, "gcm(aes)": gcm}
|
||||
ctx.log("rfc7539(chacha20,poly1305) is %s; gcm(aes) is %s", chacha, gcm)
|
||||
ctx.check(chacha == CHACHA_DRIVER, "the kernel's ChaCha20-Poly1305 is %s, not %s", chacha, CHACHA_DRIVER)
|
||||
ctx.check(gcm and "ctr-aes-caam" in gcm and "ghash-ce" in gcm,
|
||||
"the kernel's gcm(aes) is %s: not the crypto engine's AES with the NEON GHASH", gcm)
|
||||
@@ -0,0 +1,203 @@
|
||||
# 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)
|
||||
@@ -126,6 +126,88 @@ def slots_and_signature(ctx):
|
||||
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+")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# 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)
|
||||
@@ -451,6 +451,23 @@ footer a {
|
||||
font-size: 11.5px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.extadvdoc {
|
||||
max-height: 22rem;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.extadvdoc h1,
|
||||
.extadvdoc h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0.6rem 0 0.3rem;
|
||||
}
|
||||
.extcaps {
|
||||
margin: 0.2rem 0 0.2rem 1.1rem;
|
||||
padding: 0;
|
||||
}
|
||||
.extcaps li {
|
||||
margin: 0;
|
||||
}
|
||||
.hint {
|
||||
color: var(--og-dim);
|
||||
font-size: 12.5px;
|
||||
@@ -798,3 +815,26 @@ footer a {
|
||||
min-height: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* A package's card on the Extensions tab: the card and its heading (the
|
||||
label) are the panel's, the frame is the package's, and the frame is
|
||||
given a size rather than taking one. The frame takes the panel's color
|
||||
scheme, which the page inside reads as its prefers-color-scheme; it is
|
||||
transparent, so a page that paints no background sits on the card. */
|
||||
.extcard h2 .mono {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 400;
|
||||
color: var(--og-dim);
|
||||
}
|
||||
.extframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color-scheme: light;
|
||||
}
|
||||
[data-bs-theme='dark'] .extframe {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -139,6 +139,49 @@ class BaselineTests(unittest.TestCase):
|
||||
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
|
||||
self.assertEqual(items["position"].found, [1000, 0, 0])
|
||||
|
||||
def test_a_lost_counter_frame_never_moves_the_head(self):
|
||||
# The controller zeroes the step counters at every start and at
|
||||
# every home, and rewrites its anchor then. A run that began at
|
||||
# -6400 steps and restarted the controller ends at 0 with the head
|
||||
# where it was: the difference is not a distance, and the hand-back
|
||||
# must neither jog the head nor call it a leftover.
|
||||
anchor = os.path.join(self.tmp, "grblhal.homed")
|
||||
old_path, baseline.ANCHOR_PATH = baseline.ANCHOR_PATH, anchor
|
||||
try:
|
||||
with open(anchor, "w") as f:
|
||||
f.write("0 0 0 4 startup")
|
||||
self._pos(-6400, 0, 0)
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
self.assertIsNotNone(cap["frame"])
|
||||
os.unlink(anchor) # a controller start: a new anchor file
|
||||
with open(anchor + ".new", "w") as f:
|
||||
f.write("0 0 0 4 startup")
|
||||
os.replace(anchor + ".new", anchor)
|
||||
os.utime(anchor, ns=(5, 5)) # a freed inode can come straight back, inside one clock tick
|
||||
self._pos(0, 0, 0)
|
||||
left = b.enforce("post", captured=cap)
|
||||
self.assertEqual([x.item for x in left], [])
|
||||
self.assertTrue(any("re-zeroed its counters during the run" in l for l in self.lines), self.lines)
|
||||
# the same counters with the frame intact are a displaced head, as before
|
||||
self.lines.clear()
|
||||
self._pos(-6400, 0, 0)
|
||||
cap = b.capture()
|
||||
self._pos(0, 0, 0)
|
||||
left = b.enforce("post", captured=cap)
|
||||
self.assertEqual([x.item for x in left], ["position"])
|
||||
# and a test that vouches for the new frame is held to it
|
||||
self._pos(-6400, 0, 0)
|
||||
cap = b.capture()
|
||||
os.utime(anchor, ns=(1, 1)) # re-zeroed, and declared (ctx.counters_rezeroed)
|
||||
cap["position"], cap["rezero_declared"] = [0, 0, 0], True
|
||||
self._pos(0, 0, 0)
|
||||
self.assertEqual(b.enforce("post", captured=cap), [])
|
||||
self._pos(1000, 0, 0)
|
||||
self.assertEqual([x.item for x in b.enforce("post", captured=cap)], ["position"])
|
||||
finally:
|
||||
baseline.ANCHOR_PATH = old_path
|
||||
|
||||
def test_the_counters_are_read_at_the_modes_scale(self):
|
||||
# 6400 steps is 30 mm at x32 (a return within the bound; on the
|
||||
# host it stops at the missing controller) and 120 mm at x8
|
||||
@@ -391,22 +434,29 @@ class BaselineTests(unittest.TestCase):
|
||||
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}))
|
||||
|
||||
def test_stale_preconfig_reference_is_retaken_on_a_fresh_boot(self):
|
||||
# Both sides of BOOT_MAX_AGE_S, with the uptime held: read from the host, it can cross the limit
|
||||
# between boot_reference()'s reading and the test's (it did, on a CI runner 600 s after its boot).
|
||||
os.environ["FORGETEST_BOOT_ID"] = "test-boot-2"
|
||||
real = baseline.uptime_s
|
||||
try:
|
||||
path = os.path.join(self.tmp, "boot-test-boot-2.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump({"ts": "old", "sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000",
|
||||
"cnc/y_mode": "1"}}, f)
|
||||
ref = baseline.boot_reference(self.lines.append, self.tmp)
|
||||
up = baseline.uptime_s()
|
||||
if up is None or up > baseline.BOOT_MAX_AGE_S:
|
||||
# too old to retake: the stale reference stands, marked
|
||||
self.assertTrue(any("predates the controller's config" in l for l in self.lines))
|
||||
self.assertEqual(ref["ts"], "old")
|
||||
else:
|
||||
self.assertTrue(any("retaking" in l for l in self.lines))
|
||||
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
|
||||
for up, fresh in ((baseline.BOOT_MAX_AGE_S - 60, True), (baseline.BOOT_MAX_AGE_S + 60, False)):
|
||||
self.lines.clear()
|
||||
path = os.path.join(self.tmp, "boot-test-boot-2.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump({"ts": "old", "sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000",
|
||||
"cnc/y_mode": "1"}}, f)
|
||||
baseline.uptime_s = lambda up=up: up
|
||||
ref = baseline.boot_reference(self.lines.append, self.tmp)
|
||||
if fresh:
|
||||
self.assertTrue(any("retaking" in l for l in self.lines), self.lines)
|
||||
self.assertNotEqual(ref["ts"], "old")
|
||||
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
|
||||
else:
|
||||
# too old to retake: the stale reference stands, marked
|
||||
self.assertTrue(any("predates the controller's config" in l for l in self.lines), self.lines)
|
||||
self.assertEqual(ref["ts"], "old")
|
||||
finally:
|
||||
baseline.uptime_s = real
|
||||
os.environ.pop("FORGETEST_BOOT_ID", None)
|
||||
|
||||
|
||||
@@ -414,6 +464,71 @@ if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class ExtensionFreeTests(BaselineTests):
|
||||
"""A campaign's machine is extension-free: what a test made is removed,
|
||||
what is the operator's is reported and left alone."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.root = os.path.join(self.tmp, "ext")
|
||||
for d in ("pkg", "keys", "data"):
|
||||
os.makedirs(os.path.join(self.root, d))
|
||||
# a stand-in for the host's command line: remove <id> takes the package away
|
||||
self.forgeext = os.path.join(self.tmp, "forgeext")
|
||||
with open(self.forgeext, "w") as f:
|
||||
f.write('#!/bin/sh\n[ "$1" = remove ] && rm -rf "%s/pkg/$2" && echo \'{"ok": true}\'\n' % self.root)
|
||||
os.chmod(self.forgeext, 0o755)
|
||||
self.pool = {}
|
||||
self._saved = (baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT, baseline.hw.pool_pids, baseline.EXT_STOP_S)
|
||||
baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT = self.root, self.forgeext
|
||||
baseline.hw.pool_pids = lambda: dict(self.pool)
|
||||
baseline.EXT_STOP_S = 0.2
|
||||
|
||||
def tearDown(self):
|
||||
baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT, baseline.hw.pool_pids, baseline.EXT_STOP_S = self._saved
|
||||
super().tearDown()
|
||||
|
||||
def _package(self, id_):
|
||||
os.makedirs(os.path.join(self.root, "pkg", id_, "1.0.0"))
|
||||
|
||||
def _key(self, name):
|
||||
with open(os.path.join(self.root, "keys", name), "w") as f:
|
||||
f.write("x")
|
||||
|
||||
def test_an_installed_package_that_does_not_run_is_nobodys_leftover(self):
|
||||
self._package("org.example.notify")
|
||||
self._key("owner.pub")
|
||||
self.assertEqual(self.bl().enforce("pre", captured=None), [])
|
||||
|
||||
def test_what_a_test_left_behind_is_removed_and_the_operators_is_not(self):
|
||||
self._package("org.forgetest.reference")
|
||||
self._package("org.example.notify")
|
||||
self._key("forgetest-reference.pub")
|
||||
self._key("owner.pub")
|
||||
left = {x.item: x for x in self.bl().enforce("post", captured=None)}
|
||||
self.assertEqual(sorted(left), ["ext.owner_key", "ext.package"])
|
||||
self.assertEqual((left["ext.package"].found, left["ext.package"].action), ("org.forgetest.reference", "restored"))
|
||||
self.assertEqual((left["ext.owner_key"].found, left["ext.owner_key"].action), ("forgetest-reference.pub", "restored"))
|
||||
self.assertEqual(os.listdir(os.path.join(self.root, "pkg")), ["org.example.notify"])
|
||||
self.assertEqual(os.listdir(os.path.join(self.root, "keys")), ["owner.pub"])
|
||||
|
||||
def test_a_package_that_cannot_be_removed_says_so(self):
|
||||
self._package("org.forgetest.reference")
|
||||
with open(self.forgeext, "w") as f:
|
||||
f.write('#!/bin/sh\necho \'{"ok": false, "error": "the lock is held"}\'\nexit 1\n')
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
self.assertEqual([x.item for x in left], ["ext.package"])
|
||||
self.assertTrue(left[0].action.startswith("failed:") and "the lock is held" in left[0].action, left[0].action)
|
||||
|
||||
def test_running_extensions_are_reported_and_left_alone(self):
|
||||
self._package("org.example.notify")
|
||||
self.pool = {4242: 800}
|
||||
left = self.bl().enforce("pre", captured=None)
|
||||
self.assertEqual([(x.item, x.action) for x in left], [("ext.running", "unrestorable")])
|
||||
self.assertIn("org.example.notify", left[0].found)
|
||||
self.assertTrue(os.path.isdir(os.path.join(self.root, "pkg", "org.example.notify")))
|
||||
|
||||
|
||||
class 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
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""A test that lets the service move the head hands it back where it found
|
||||
it. Every service motion zeroes the step counters at its start, and a home
|
||||
and every controller start zero them again, so no counter reading says
|
||||
where the test found the head: the client's own record does. Every motion
|
||||
logs the counters it ended on, and the travel is their sum (x8 steps, the
|
||||
one mode a service motion runs at), plus what the counters read since. The
|
||||
log lines are the machine's own, trimmed to the motion records: a homing
|
||||
session whose one motion the cooling engine stopped short, and a whole
|
||||
three-motion one. Runs against scratch logs and fakes for the controller."""
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from forgetest.runner import Failed
|
||||
from forgetest.suite import cloud, homeoff
|
||||
|
||||
STOPPED = """\
|
||||
2026-09-25T15:56:19.641824+00:00 gfhome[7387] INFO gfhome:home service action: motion (ready)
|
||||
2026-09-25T15:56:19.644041+00:00 gfhome[7387] INFO machine:_motion start motion
|
||||
2026-09-25T15:56:19.970342+00:00 gfhome[7387] INFO machine:_run_loop starting run
|
||||
2026-09-25T15:56:20.905040+00:00 gfhome[7387] ERR machine:_run_loop run stopped short: 8130 of 21756 bytes played; the job did not finish
|
||||
2026-09-25T15:56:20.918652+00:00 gfhome[7387] INFO machine:_feed_and_run end positions (actual/expected): X (4390/12115), Y (2682/7402), Z (0/0)
|
||||
2026-09-25T15:56:20.937235+00:00 gfhome[7387] INFO machine:_motion_locked end positions (4390, 2682, 0)
|
||||
2026-09-25T15:56:20.938026+00:00 gfhome[7387] INFO machine:_motion end motion
|
||||
2026-09-25T15:56:20.938677+00:00 gfhome[7387] INFO basemachine:_finish_action motion [1588842275]: finished with event ":cancelled"
|
||||
2026-09-25T15:56:21.145580+00:00 gfhome[7387] INFO gfhome:home motion completed
|
||||
"""
|
||||
|
||||
WHOLE = "".join(
|
||||
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion start motion\n"
|
||||
"2026-09-07T20:%s gfhome[28592] INFO machine:_feed_and_run end positions (actual/expected): "
|
||||
"X (%d/%d), Y (%d/%d), Z (0/0)\n"
|
||||
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion_locked end positions (%d, %d, 0)\n"
|
||||
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion end motion\n"
|
||||
"2026-09-07T20:%s gfhome[28592] INFO basemachine:_finish_action motion [%d]: finished with event "
|
||||
"\":completed\"\n" % (t, t, x, x, y, y, t, x, y, t, t, i)
|
||||
for i, (t, x, y) in enumerate((("25:50.1", 4774, 4600), ("25:56.2", -71, -144), ("26:02.9", -13096, -7399))))
|
||||
|
||||
|
||||
class FakeGrbl:
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def command(self, line, timeout=None):
|
||||
self.ctx.jogs.append(line)
|
||||
return ["ok"]
|
||||
|
||||
|
||||
class FakeCtx:
|
||||
def __init__(self):
|
||||
self.forgectrl = object()
|
||||
self.jogs = []
|
||||
self.lines = []
|
||||
self.rezeroed = 0
|
||||
|
||||
def check(self, cond, msg, *args):
|
||||
if not cond:
|
||||
raise Failed(msg % args if args else msg)
|
||||
|
||||
def log(self, msg, *args):
|
||||
self.lines.append(msg % args if args else msg)
|
||||
|
||||
def grbl(self):
|
||||
return FakeGrbl(self)
|
||||
|
||||
def counters_rezeroed(self):
|
||||
self.rezeroed += 1
|
||||
|
||||
|
||||
class HandBackTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
self.log = os.path.join(self.dir, "gfhome.log")
|
||||
self.cloudlog = os.path.join(self.dir, "gfcloud.log")
|
||||
self.ctx = FakeCtx()
|
||||
self.anchor = True
|
||||
self.counters = [0, 0, 0]
|
||||
self.restarts = 0
|
||||
self.spm = 213.333 # the controller's x32 scale
|
||||
saved_logs = (cloud.GFHOME_LOG, cloud.GFCLOUD_LOG)
|
||||
self.addCleanup(lambda: setattr(cloud, "GFHOME_LOG", saved_logs[0]))
|
||||
self.addCleanup(lambda: setattr(cloud, "GFCLOUD_LOG", saved_logs[1]))
|
||||
cloud.GFHOME_LOG, cloud.GFCLOUD_LOG = self.log, self.cloudlog
|
||||
patches = {
|
||||
"counter_frame": lambda: [1, 2] if self.anchor else None,
|
||||
"read_position": lambda: list(self.counters),
|
||||
"counter_steps_per_mm": lambda: self.spm,
|
||||
"_drop_reference": self._restart,
|
||||
"clean_slate": lambda ctx, g: None,
|
||||
"wait_idle": self._jog_ends,
|
||||
"machine_idle": lambda ctx: None,
|
||||
}
|
||||
saved = {k: getattr(homeoff, k) for k in patches}
|
||||
self.addCleanup(lambda: [setattr(homeoff, k, v) for k, v in saved.items()])
|
||||
for k, v in patches.items():
|
||||
setattr(homeoff, k, v)
|
||||
self.addCleanup(shutil.rmtree, self.dir, True)
|
||||
|
||||
def _restart(self, ctx, fc):
|
||||
# a controller start zeroes the counters where the head stands and
|
||||
# removes the anchor
|
||||
self.restarts += 1
|
||||
self.counters = [0, 0, 0]
|
||||
self.anchor = False
|
||||
|
||||
def _jog_ends(self, ctx, g, timeout=30.0):
|
||||
# the jog played: the counters moved by what it asked, at the scale
|
||||
words = dict((w[0], float(w[1:])) for w in self.ctx.jogs[-1].split()[2:4])
|
||||
self.counters = [self.counters[0] + round(words["X"] * self.spm),
|
||||
self.counters[1] + round(words["Y"] * self.spm), 0]
|
||||
return 0.0, ["Jog", "Idle"], {}
|
||||
|
||||
def write(self, text, path=None):
|
||||
with open(path or self.log, "a", encoding="utf-8", newline="\n") as f:
|
||||
f.write(text)
|
||||
|
||||
def expect_jog(self, dx, dy):
|
||||
self.assertEqual(self.ctx.jogs, ["$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy)])
|
||||
|
||||
# -- the 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)
|
||||
ev = {}
|
||||
with self.assertRaises(Failed):
|
||||
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
|
||||
self.write(STOPPED)
|
||||
self.counters = [round(1.0 * self.spm), 0, 0]
|
||||
since = self.counters[0] / self.spm
|
||||
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
|
||||
self.expect_jog(4390 / homeoff.XY_STEPS_PER_MM + since, 2682 / homeoff.XY_STEPS_PER_MM)
|
||||
# dropped before the jog, and once more with the head back
|
||||
self.assertEqual(self.restarts, 2)
|
||||
self.assertEqual(self.counters, [0, 0, 0])
|
||||
self.assertEqual(self.ctx.rezeroed, 1)
|
||||
|
||||
def test_unhomed_the_counters_are_not_counted_twice(self):
|
||||
# a failed session leaves the last motion's end in the counters,
|
||||
# which the travel already counts
|
||||
self.write(STOPPED)
|
||||
self.counters = [4390, 2682, 0]
|
||||
self.anchor = False
|
||||
homeoff.camera_home_return(self.ctx, {"homed": False}, 0)
|
||||
self.expect_jog(4390 / homeoff.XY_STEPS_PER_MM, 2682 / homeoff.XY_STEPS_PER_MM)
|
||||
|
||||
def test_an_unknown_travel_moves_nothing(self):
|
||||
self.write(STOPPED.split("machine:_motion_locked")[0])
|
||||
with self.assertRaises(Failed):
|
||||
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
self.assertEqual(self.ctx.rezeroed, 0)
|
||||
|
||||
def test_a_restart_since_the_home_moves_nothing(self):
|
||||
# the anchor gone: the counters no longer read from the home
|
||||
self.write(STOPPED)
|
||||
self.anchor = False
|
||||
with self.assertRaises(Failed):
|
||||
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
|
||||
def test_a_head_that_never_moved_is_not_jogged(self):
|
||||
homeoff.camera_home_return(self.ctx, {}, 0)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
self.assertEqual((self.restarts, self.ctx.rezeroed), (1, 1))
|
||||
|
||||
# -- after cloud mode ----------------------------------------------------
|
||||
def test_after_cloud_mode_the_head_goes_back_by_the_clients_record(self):
|
||||
# the service's re-hunt, cut short by the switch back: to the middle
|
||||
# of the bed and one correction (the bench reference, 2026-09-25)
|
||||
self.write(WHOLE[:WHOLE.index("2026-09-07T20:26:02.9")].replace("gfhome[", "gfcloud["), self.cloudlog)
|
||||
ev = {}
|
||||
homeoff.cloud_mode_return(self.ctx, ev, 0)
|
||||
self.expect_jog((4774 - 71) / homeoff.XY_STEPS_PER_MM, (4600 - 144) / homeoff.XY_STEPS_PER_MM)
|
||||
self.assertEqual(tuple(ev["cloud_return"]["session_travel_steps"]), (4774 - 71, 4600 - 144))
|
||||
# no drop before the jog (the switch back started the controller), one after
|
||||
self.assertEqual((self.restarts, self.ctx.rezeroed), (1, 1))
|
||||
|
||||
def test_after_cloud_mode_an_unknown_travel_moves_nothing(self):
|
||||
self.write(STOPPED.replace("gfhome[", "gfcloud[").split("machine:_motion_locked")[0], self.cloudlog)
|
||||
with self.assertRaises(Failed):
|
||||
homeoff.cloud_mode_return(self.ctx, {}, 0)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
|
||||
# -- 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()
|
||||
@@ -28,6 +28,7 @@ import time
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest import baseline
|
||||
from forgetest.runner import Context, Failed, Run
|
||||
from forgetest.suite import cloud
|
||||
|
||||
@@ -185,7 +186,10 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.engine_line = EFFECTIVE_LINE # what the engine logs at the print; None = nothing
|
||||
self.client_limits = True # the client names its header limits
|
||||
cloud.QUIET_S = 0.4
|
||||
cloud.QUIET_TIMEOUT_S = 3
|
||||
# A deadline, not a wait: a quiet machine is seen at once. The
|
||||
# replays land their lines from threads, so under a loaded host a
|
||||
# tighter deadline expires before the last of them is written.
|
||||
cloud.QUIET_TIMEOUT_S = 15
|
||||
cloud.HUNT_TIMEOUT_S = 8
|
||||
self.script = None
|
||||
|
||||
@@ -556,14 +560,51 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.append(fixture("huntlid"))
|
||||
# a motion in flight (never idle within the timeout) fails, and says so
|
||||
self.fc.state["status"]["state"] = "running"
|
||||
cloud.QUIET_TIMEOUT_S = 3 # this one waits the deadline out; tearDown puts it back
|
||||
self.assertFails(cloud.enter_cloud, "still running service moves")
|
||||
|
||||
def test_the_quiet_waits_out_the_service_thinking_after_a_lid_image(self):
|
||||
# Between a lid image and the service's next move the log is silent
|
||||
# while the service works on the image (the re-hunt on the bench
|
||||
# reference, 2026-09-25). The quiet counts from the image's own
|
||||
# lines, so it is not declared in that gap: it comes after the move.
|
||||
cloud.QUIET_S = 1.0
|
||||
offset = cloud.log_size(self.log)
|
||||
p = "2026-09-25T16:43:%s+00:00 gfcloud[12821] INFO "
|
||||
self.append([p % "20.103479" + "gfuiservice:run service action request: motion (ready)",
|
||||
p % "20.105376" + "machine:_motion start motion",
|
||||
p % "22.617720" + "machine:_motion_locked end positions (12120, 7402, 0)",
|
||||
p % "22.618542" + "machine:_motion end motion",
|
||||
p % "22.619218" + "basemachine:_finish_action motion [1588864337]: finished with event "
|
||||
"\":completed\""])
|
||||
self.append([p % "23.249589" + "gfuiservice:run service action request: lid_image (ready)",
|
||||
p % "24.686536" + "websocket:img_upload COMPLETE"], delay=0.8)
|
||||
self.append([p % "29.667978" + "gfuiservice:run service action request: motion (ready)",
|
||||
p % "29.671663" + "machine:_motion start motion"], delay=1.6)
|
||||
run = Run("test", "cloud.x", "cloud.x")
|
||||
ctx = Context(run, None, helpers.make_test("cloud.x", []))
|
||||
self.assertTrue(cloud.wait_quiet(ctx, offset))
|
||||
self.assertIn("service action request: motion", cloud.log_lines_since(self.log, offset)[-2])
|
||||
|
||||
# -- the hunt with the lid open ------------------------------------------
|
||||
# -- the mode switch: hunt with the lid open, then $H -----------------------
|
||||
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
|
||||
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
|
||||
# A homing session's motions as gfhome logs them (the bench reference,
|
||||
# 2026-09-25): a small correction, then into the camera home.
|
||||
HOME_SESSION = [
|
||||
"2026-09-25T16:43:45.374951+00:00 gfhome[12982] INFO machine:_motion start motion",
|
||||
"2026-09-25T16:43:45.853976+00:00 gfhome[12982] INFO machine:_motion_locked end positions (-13, -13, 0)",
|
||||
"2026-09-25T16:43:45.854819+00:00 gfhome[12982] INFO machine:_motion end motion",
|
||||
"2026-09-25T16:43:45.856076+00:00 gfhome[12982] INFO basemachine:_finish_action motion [1588864529]: "
|
||||
"finished with event \":completed\"",
|
||||
"2026-09-25T16:43:51.600734+00:00 gfhome[12982] INFO machine:_motion start motion",
|
||||
"2026-09-25T16:43:53.605402+00:00 gfhome[12982] INFO machine:_motion_locked end positions (-13095, -7399, 0)",
|
||||
"2026-09-25T16:43:53.606171+00:00 gfhome[12982] INFO machine:_motion end motion",
|
||||
"2026-09-25T16:43:53.606916+00:00 gfhome[12982] INFO basemachine:_finish_action motion [1588864567]: "
|
||||
"finished with event \":completed\""]
|
||||
|
||||
def mode_switch_setup(self, hunt_lines=None, home_complete=True, lid_late=False):
|
||||
def mode_switch_setup(self, hunt_lines=None, home_complete=True, lid_late=False, home_lines=None):
|
||||
"""The fakes a mode-switch run needs: grbl to answer $H, the lid
|
||||
lamp attr, homing_mode = gfcloud, the service lines landing on
|
||||
the switch to cloud - the client's start at once, its session and
|
||||
@@ -572,11 +613,16 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
reads as a run to the cooling engine while it lasts) - the re-hunt
|
||||
on the lid close, and gfhome finishing the homing after $H. With
|
||||
lid_late the hunt is requested before the lid opens: the race the
|
||||
test must call."""
|
||||
test must call. The step counters are the machine's: a controller
|
||||
start zeroes them and removes the homing anchor, the home zeroes
|
||||
them and writes it, and a jog moves them by what it asked."""
|
||||
self.grbl = helpers.FakeGrbl().start()
|
||||
os.makedirs(self.sysfs + "pic", exist_ok=True)
|
||||
self._attr("pic/lid_led", "236")
|
||||
self.fc.state["settings"]["homing_mode"] = "gfcloud"
|
||||
anchor = os.path.join(self.tmp, "grblhal.homed")
|
||||
self.addCleanup(setattr, baseline, "ANCHOR_PATH", baseline.ANCHOR_PATH)
|
||||
baseline.ANCHOR_PATH = anchor
|
||||
lines = fixture("huntlid")
|
||||
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
|
||||
hunt_part, close_part = cut(post, "_switch_event lid closed")
|
||||
@@ -584,6 +630,20 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
hunt_part = hunt_lines(hunt_part)
|
||||
fc = self.fc
|
||||
|
||||
def controller_start():
|
||||
self._pos(0, 0, 3)
|
||||
if os.path.exists(anchor):
|
||||
os.remove(anchor)
|
||||
|
||||
def jog(line):
|
||||
if line.startswith("$J=G91"):
|
||||
words = dict((w[0], float(w[1:])) for w in line[3:].split() if w[0] in "XY")
|
||||
with open(self.sysfs + "cnc/position", "rb") as f:
|
||||
x, y, z = struct.unpack("<3i", f.read(12))
|
||||
self._pos(x + round(words.get("X", 0.0) * baseline.XY_STEPS_PER_MM),
|
||||
y + round(words.get("Y", 0.0) * baseline.XY_STEPS_PER_MM), z)
|
||||
self.grbl.on_command = jog
|
||||
|
||||
def hunt():
|
||||
self.append(hunt_part, delay=0.0)
|
||||
time.sleep(4.0) # the hunt outlasts the session wait on the bench
|
||||
@@ -604,7 +664,14 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
hunt()
|
||||
threading.Thread(target=land, daemon=True).start()
|
||||
elif path == "/mode" and form.get("controller") == "grbl":
|
||||
controller_start()
|
||||
self.grbl.state = "Idle"
|
||||
elif path == "/controller/stop":
|
||||
fc.state["mode"] = dict(fc.state["mode"], controller="standby", pid=0)
|
||||
elif path == "/controller/start":
|
||||
controller_start()
|
||||
fc.state["mode"] = dict(fc.state["mode"], controller="running", pid=4300, motion="verified")
|
||||
fc.state["status"]["homed"] = False
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
|
||||
@@ -619,7 +686,11 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
|
||||
def homing():
|
||||
self.grbl.state = "Home"
|
||||
with open(self.homelog, "ab") as f:
|
||||
f.write(("\n".join(home_lines or self.HOME_SESSION) + "\n").encode())
|
||||
time.sleep(0.4)
|
||||
self._pos(0, 0, 3)
|
||||
open(anchor, "w").close()
|
||||
self.grbl.state = "Idle"
|
||||
self.fc.state["status"]["homed"] = True
|
||||
if home_complete:
|
||||
@@ -654,6 +725,39 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.assertEqual([r["state"] for r in ev["actions"]], ["open", "close"])
|
||||
self.assertEqual(self.script.asked, [])
|
||||
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
|
||||
# The head goes back where the test found it, each stretch by its
|
||||
# client's record: the re-hunt of the excerpt ends 2/7 steps from
|
||||
# where the cloud mode began, and the homing went into the corner.
|
||||
self.assertEqual(tuple(ev["cloud_return"]["session_travel_steps"]), (2, 7))
|
||||
self.assertEqual(tuple(ev["return"]["session_travel_steps"]), (-13108, -7412))
|
||||
spm = baseline.XY_STEPS_PER_MM
|
||||
self.assertEqual([l for l in self.grbl.sent if l.startswith("$J=")],
|
||||
["$J=G91 G21 X%.3f Y%.3f F2400" % (-2 / spm, -7 / spm),
|
||||
"$J=G91 G21 X%.3f Y%.3f F2400" % (13108 / spm, 7412 / spm)])
|
||||
# a start after each jog, and the camera home dropped before the second
|
||||
self.assertEqual([p for p, f in self.fc.posts if p.startswith("/controller/")],
|
||||
["/controller/stop", "/controller/start"] * 3)
|
||||
with open(self.sysfs + "cnc/position", "rb") as f:
|
||||
self.assertEqual(struct.unpack("<3i", f.read(12))[:2], (0, 0))
|
||||
|
||||
def test_mode_switch_a_hand_back_that_fails_too_does_not_hide_the_failure(self):
|
||||
# gfhome never said it homed, and its last motion has no end on
|
||||
# record: the travel is unknown, the head is not moved, and the
|
||||
# failure reported is the test's own
|
||||
hooks = self.mode_switch_setup(home_complete=False, home_lines=self.HOME_SESSION[:5])
|
||||
threading.Thread(target=self.wait_home_command, daemon=True).start()
|
||||
self.assertFails(cloud.mode_switch, "no 'homing complete' line", hooks=hooks)
|
||||
run = self.script.run
|
||||
self.assertTrue(any("the head is not back" in l for l in run.lines), run.lines[-6:])
|
||||
self.assertIn("cannot be known", run.evidence["return"]["failed"])
|
||||
self.assertEqual(len([l for l in self.grbl.sent if l.startswith("$J=")]), 1) # the cloud stretch's
|
||||
|
||||
def test_mode_switch_fails_when_the_travel_in_cloud_mode_cannot_be_known(self):
|
||||
# the hunt's motion has no end on record (a client killed inside it)
|
||||
hooks = self.mode_switch_setup(
|
||||
hunt_lines=lambda part: [l for l in part if not l.endswith("machine:_motion end motion")])
|
||||
self.assertFails(cloud.mode_switch, "the travel in cloud mode cannot be known", hooks=hooks)
|
||||
self.assertEqual([l for l in self.grbl.sent if l.startswith("$J=")], [])
|
||||
|
||||
def test_mode_switch_opens_the_lid_behind_the_controller_and_ahead_of_the_hunt(self):
|
||||
# No controller starts with the enclosure open: the switch is made
|
||||
@@ -1003,6 +1107,23 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
hooks = {"press it. The print runs dark": lambda: self.append(run, delay=0.05)}
|
||||
self.assertFails(cloud.dark_print, "the latch is unlocked at the button wait", hooks=hooks)
|
||||
|
||||
def test_a_print_turned_away_before_the_button_fails_at_once_with_the_reason(self):
|
||||
"""The bench reference's own lines, 2026-09-20: the coolant read 27.4
|
||||
against the client's start ceiling of 27, and the test then sat out
|
||||
its two minutes and said only that the button wait never came."""
|
||||
self.in_offline()
|
||||
stamp = "2026-09-20T21:28:16.83+00:00 gfcloud[782] INFO "
|
||||
refused = [stamp + "gfuiservice:run service action request: print (ready)",
|
||||
stamp + "machine:_motion start motion",
|
||||
stamp + "machine:_safe_to_move machine temp is too high, temp: 27.4",
|
||||
stamp + "machine:_motion end motion",
|
||||
stamp + 'basemachine:_finish_action print [9005]: finished with event ":cancelled"']
|
||||
self.offline_print_hooks(refused)
|
||||
t0 = time.time()
|
||||
self.assertFails(cloud.dark_print, "turned the print away before the button wait: "
|
||||
"INFO machine:_safe_to_move machine temp is too high, temp: 27.4")
|
||||
self.assertLess(time.time() - t0, 30, "the refusal was not seen until the wait timed out")
|
||||
|
||||
def test_verdict_refuse_on_the_bench_excerpt(self):
|
||||
self.in_offline()
|
||||
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""cooling.intake-one-fan replayed host-side under the real runner
|
||||
Context against a scripted engine: the fake reads cool_intake_fans and
|
||||
the floors at every M8 (the engine reloads at run start), publishes
|
||||
intake_2 not fitted with no floor when one intake fan is set, holds every
|
||||
fan in grace for the configured seconds, then judges the bench readings
|
||||
against the floors. The negative engines are the two ways the change
|
||||
could go wrong: the setting ignored (intake 2 still judged) and the
|
||||
setting taking intake 1 out with it."""
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest.runner import Context, Failed, Run
|
||||
from forgetest.suite import cooling, cooling_intake
|
||||
from test_cooling_suite import FakeGrbl
|
||||
|
||||
|
||||
class IntakeOneFanTests(unittest.TestCase):
|
||||
READINGS = {"exhaust": 11640, "intake_1": 4157, "intake_2": 4158, "air_assist": 11050, "purge": 625}
|
||||
FLOORS = {"exhaust": ("cool_tach_exhaust_min_rpm", 6400.0, 20000.0),
|
||||
"intake_1": ("cool_tach_intake_min_rpm", 2290.0, 20000.0),
|
||||
"intake_2": ("cool_tach_intake_min_rpm", 2290.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_intake.INTAKE_TRIP_WAIT_S)
|
||||
cooling.VERDICT_WAIT_S = 4
|
||||
cooling.SESSION_END_WAIT_S = 3
|
||||
cooling_intake.INTAKE_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.fc.state["settings"].setdefault("cool_intake_fans", "")
|
||||
self.honors_setting = True # the engine takes intake 2 out with one intake fan
|
||||
self.drops_both = False # the engine takes intake 1 out with it
|
||||
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_intake.INTAKE_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.9, default * 1.1], "off": "low", "value": v,
|
||||
"state": "off" if v <= 0 else "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"
|
||||
cool.update(verdict="OK", fire_ok=True, hold=False, reason="")
|
||||
for g in cool.get("fan_gates", {}).values():
|
||||
if g["state"] != "not fitted":
|
||||
g["state"] = "idle"
|
||||
threading.Thread(target=end, daemon=True).start()
|
||||
return
|
||||
if line != "M8":
|
||||
return
|
||||
cool["phase"] = "run"
|
||||
one = self.fc.state["settings"].get("cool_intake_fans") == "1" and self.honors_setting
|
||||
out = {"intake_2"} | ({"intake_1"} if self.drops_both else set()) if one else set()
|
||||
floors = {fan: 0.0 if fan in out else self.setting(key, default)
|
||||
for fan, (key, default, _h) in self.FLOORS.items()}
|
||||
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=[],
|
||||
fan_gates={f: {"reading": self.READINGS[f], "floor": floors[f],
|
||||
"state": "not fitted" if f in out else "grace"} for f in floors})
|
||||
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 f not in out:
|
||||
cool["fan_gates"][f]["state"] = "ok" if self.READINGS[f] >= floors[f] else "under"
|
||||
time.sleep(0.3)
|
||||
if cool["phase"] != "run":
|
||||
return
|
||||
for f in floors:
|
||||
if f not in out and floors[f] > self.READINGS[f]:
|
||||
cool["fan_gates"][f]["state"] = "TRIPPED"
|
||||
if cool["verdict"] != "AIRFLOW":
|
||||
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]))
|
||||
threading.Thread(target=judge, daemon=True).start()
|
||||
|
||||
def run_test(self):
|
||||
run = Run("test", "cooling.intake-one-fan", "t")
|
||||
ctx = Context(run, None, helpers.make_test("cooling.intake-one-fan", []))
|
||||
cooling_intake.intake_one_fan(ctx)
|
||||
return run
|
||||
|
||||
def settings_posts(self):
|
||||
return [f for p, f in self.fc.posts if p == "/settings"]
|
||||
|
||||
def restored(self):
|
||||
return {"cool_intake_fans": "", "cool_tach_intake_min_rpm": "", "cool_fan_grace_s": ""}
|
||||
|
||||
def test_one_fan_then_intake_1_trip_then_restore_pass(self):
|
||||
run = self.run_test()
|
||||
ev = run.evidence
|
||||
self.assertEqual(ev["one_fan"]["intake_2"]["state"], "not fitted")
|
||||
self.assertEqual(ev["one_fan"]["intake_1"]["state"], "ok")
|
||||
self.assertEqual(ev["intake_1_trip"]["intake_1"]["state"], "TRIPPED")
|
||||
self.assertEqual(ev["intake_1_trip"]["intake_2"]["state"], "not fitted")
|
||||
self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values()))
|
||||
posts = self.settings_posts()
|
||||
self.assertEqual(posts[0], {"cool_intake_fans": "1", "cool_fan_grace_s": "8"})
|
||||
self.assertEqual(posts[1], {"cool_tach_intake_min_rpm": "20000.0"})
|
||||
self.assertEqual(posts[-1], self.restored())
|
||||
self.assertEqual(self.grbl.commands.count("M8"), 3)
|
||||
self.assertEqual(self.grbl.commands.count("M9"), 3)
|
||||
|
||||
def test_an_engine_that_ignores_the_setting_fails_and_restores(self):
|
||||
self.honors_setting = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("intake_2 is not published not fitted", str(cm.exception))
|
||||
self.assertEqual(self.settings_posts()[-1], self.restored())
|
||||
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
||||
|
||||
def test_an_engine_that_drops_intake_1_too_fails_and_restores(self):
|
||||
self.drops_both = True
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("intake_1 is not judged ok at the intake floor", str(cm.exception))
|
||||
self.assertEqual(self.settings_posts()[-1], self.restored())
|
||||
|
||||
def test_a_machine_already_at_one_intake_fan_is_refused_untouched(self):
|
||||
self.fc.state["settings"]["cool_intake_fans"] = "1"
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("already 1", str(cm.exception))
|
||||
self.assertEqual(self.settings_posts(), [])
|
||||
self.assertEqual(self.grbl.commands.count("M8"), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -26,7 +26,28 @@ import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest.runner import Context, Failed, Run
|
||||
from forgetest.suite import cooling
|
||||
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:
|
||||
@@ -256,7 +277,7 @@ if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class GateOffTests(unittest.TestCase):
|
||||
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
|
||||
@@ -267,6 +288,7 @@ class GateOffTests(unittest.TestCase):
|
||||
|
||||
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
|
||||
@@ -292,6 +314,7 @@ class GateOffTests(unittest.TestCase):
|
||||
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):
|
||||
@@ -327,7 +350,7 @@ class GateOffTests(unittest.TestCase):
|
||||
time.sleep(0.3)
|
||||
cool["phase"] = "smoke"
|
||||
if self.temps_line:
|
||||
self.fc.state["logs_tail"]["text"] += (
|
||||
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)
|
||||
@@ -341,7 +364,7 @@ class GateOffTests(unittest.TestCase):
|
||||
v = self.ceiling()
|
||||
off = v >= self.TOP
|
||||
if off and self.log_line:
|
||||
self.fc.state["logs_tail"]["text"] += (
|
||||
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 []
|
||||
@@ -418,6 +441,18 @@ class GateOffTests(unittest.TestCase):
|
||||
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:
|
||||
@@ -433,7 +468,7 @@ class GateOffTests(unittest.TestCase):
|
||||
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "30")
|
||||
|
||||
|
||||
class CriticalTierTests(unittest.TestCase):
|
||||
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
|
||||
@@ -448,6 +483,7 @@ class CriticalTierTests(unittest.TestCase):
|
||||
|
||||
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
|
||||
@@ -471,6 +507,7 @@ class CriticalTierTests(unittest.TestCase):
|
||||
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 ""
|
||||
@@ -523,7 +560,7 @@ class CriticalTierTests(unittest.TestCase):
|
||||
crit_off = tcrit >= self.ROWS["cool_temp_critical_c"][3]
|
||||
off = ["coolant_critical"] if crit_off else []
|
||||
if crit_off:
|
||||
self.fc.state["logs_tail"]["text"] += (
|
||||
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:
|
||||
|
||||
@@ -207,6 +207,10 @@ class ClientTests(unittest.TestCase):
|
||||
f.act("interlock", "open")
|
||||
f.act("button", "press")
|
||||
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {}))
|
||||
time.sleep(0.15)
|
||||
# a check that must reach the machine while the button is down asks for the longest press
|
||||
f.act("button", "press", ms=500)
|
||||
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {"ms": 500}))
|
||||
time.sleep(0.15) # the fake's pulse ends
|
||||
self.assertEqual(sorted(fx.Fixture.energized(f.status())), ["interlock", "lid"])
|
||||
f.release()
|
||||
@@ -310,6 +314,24 @@ class CatalogTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
catalog.test("z.auto", title="t", subsystem="z", hands=("app",))(lambda ctx: None)
|
||||
|
||||
def test_every_action_a_test_takes_is_declared(self):
|
||||
# The queues route a test by its kind and its actions: an auto test
|
||||
# runs unattended with or without a fixture, and an operator test
|
||||
# whose actions the fixture covers runs unattended only while it
|
||||
# does. A test that acts on the lid, the interlock or the button
|
||||
# without saying so runs unattended with no fixture and waits for a
|
||||
# person who is not there.
|
||||
import inspect
|
||||
import re
|
||||
from forgetest import catalog
|
||||
bad = []
|
||||
for t in catalog.all_tests(catalog.load_suite()):
|
||||
src = inspect.getsource(t.fn)
|
||||
taken = set(re.findall(r'ctx\.act\(\s*"(\w+)"', src))
|
||||
if taken and (t.kind == "auto" or not taken <= set(t.actions)):
|
||||
bad.append("%s (%s) acts on %s, declares %s" % (t.id, t.kind, sorted(taken), list(t.actions)))
|
||||
self.assertEqual(bad, [])
|
||||
|
||||
|
||||
class StubFixture:
|
||||
"""What the runner needs of a fixture, scripted."""
|
||||
@@ -330,10 +352,10 @@ class StubFixture:
|
||||
def covers(self, channel):
|
||||
return channel in self.channels and (channel != "button" or self.button_enabled)
|
||||
|
||||
def act(self, channel, state):
|
||||
def act(self, channel, state, ms=None):
|
||||
if self.fail:
|
||||
raise fx.FixtureError("the box is off")
|
||||
self.acts.append((channel, state))
|
||||
self.acts.append((channel, state) if ms is None else (channel, state, ms))
|
||||
if state == "open":
|
||||
self.held.append(channel)
|
||||
if self.fc is not None and channel in ("lid", "interlock"):
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# 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()
|
||||
@@ -120,6 +120,19 @@ class CoverageReportTests(unittest.TestCase):
|
||||
self.assertNotIn("forgetest", m.coverage_report(man, [], allow=[]),
|
||||
"dev-only components are outside the report")
|
||||
|
||||
def test_behavioral_paths_are_never_allowed_away(self):
|
||||
# An advisory document is behavior (the consent is to its hash): the allowlist's docs/** and
|
||||
# **/*.md do not take it out of the report, as they do not take it out of a fingerprint.
|
||||
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"]})
|
||||
doc = helpers.make_test("a.two", [("forgectrl", "docs/advisories/privacy.md")])
|
||||
self.assertEqual(m.coverage_report(man, [rest, doc]), {})
|
||||
self.assertNotEqual(m.fingerprint(man, doc.covers),
|
||||
m.fingerprint(helpers.with_file(man, "forgectrl", "docs/advisories/privacy.md", "new"),
|
||||
doc.covers))
|
||||
|
||||
def test_non_behavioral_paths_are_outside_every_fingerprint(self):
|
||||
man = helpers.make_manifest()
|
||||
t = helpers.make_test("a.one", [("forgectrl", "**")])
|
||||
@@ -132,6 +145,17 @@ class CoverageReportTests(unittest.TestCase):
|
||||
t.covers))
|
||||
self.assertTrue(m.non_behavioral("forgectrl", "tools/devserver.py"))
|
||||
self.assertFalse(m.non_behavioral("grblhal-glowforge", "tools/devserver.py"))
|
||||
# forgeext's image content is its binary and init script: the kit, the template, the host tools
|
||||
# and the shared package workflow are not, and the host's sources are
|
||||
for path in ("sdk/js/ffx-bridge.js", "template/manifest.json", "tools/ffx", ".github/actions/kit/action.yml"):
|
||||
self.assertTrue(m.non_behavioral("forgeext", path), path)
|
||||
for path in ("src/api.c", "init/forgeext.init", "CMakeLists.txt"):
|
||||
self.assertFalse(m.non_behavioral("forgeext", path), path)
|
||||
# an advisory is a document forgectrl serves and records consent to by its hash: behavior, though
|
||||
# it is Markdown under docs/
|
||||
self.assertFalse(m.non_behavioral("forgectrl", "docs/advisories/extensions.md"))
|
||||
self.assertTrue(m.non_behavioral("forgectrl", "docs/SERVICES.md"))
|
||||
self.assertTrue(m.non_behavioral("grblhal-glowforge", "docs/advisories/x.md"))
|
||||
|
||||
def test_an_entry_that_selects_nothing_is_reported(self):
|
||||
man = helpers.make_manifest()
|
||||
@@ -219,7 +243,7 @@ def two(ctx):
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def write(self, text):
|
||||
catalog._PARTS.pop(self.path, None)
|
||||
catalog.forget(self.path)
|
||||
with open(self.path, "w", newline="\n") as f:
|
||||
f.write(text)
|
||||
|
||||
@@ -254,12 +278,12 @@ def two(ctx):
|
||||
sib = os.path.join(self.tmp, "judge.py")
|
||||
with open(sib, "w", newline="\n") as f:
|
||||
f.write("def judge(x):\n return x > 1\n")
|
||||
catalog._PARTS.pop(sib, None)
|
||||
catalog.forget(sib)
|
||||
self.write("from .judge import judge\n" + self.MODULE)
|
||||
a1, b1 = self.shas()
|
||||
with open(sib, "w", newline="\n") as f:
|
||||
f.write("def judge(x):\n return x > 2\n")
|
||||
catalog._PARTS.pop(sib, None)
|
||||
catalog.forget(sib)
|
||||
a2, b2 = self.shas()
|
||||
self.assertNotEqual(a1, a2)
|
||||
self.assertNotEqual(b1, b2)
|
||||
@@ -271,7 +295,7 @@ def two(ctx):
|
||||
|
||||
def test_line_endings_do_not_count(self):
|
||||
a1, b1 = self.shas()
|
||||
catalog._PARTS.pop(self.path, None)
|
||||
catalog.forget(self.path)
|
||||
with open(self.path, "wb") as f:
|
||||
f.write(self.MODULE.replace("\n", "\r\n").encode())
|
||||
self.assertEqual((a1, b1), self.shas())
|
||||
|
||||
@@ -141,8 +141,8 @@ class ActTests(unittest.TestCase):
|
||||
def covers(self, channel):
|
||||
return channel == "lid"
|
||||
|
||||
def act(self, channel, state):
|
||||
self.done.append((channel, state))
|
||||
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
|
||||
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
|
||||
"""What keeps the page answering the operator instead of the timer.
|
||||
|
||||
Two things went wrong on the bench and are pinned here:
|
||||
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.
|
||||
|
||||
@@ -171,6 +175,63 @@ class FingerprintCacheTests(unittest.TestCase):
|
||||
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
|
||||
|
||||
@@ -137,5 +137,124 @@ class SensorsCheckTests(unittest.TestCase):
|
||||
self.assertIn(("/wiz/sensors/abort", {}), [(p, f) for p, f in self.fake.posts])
|
||||
|
||||
|
||||
class AirflowCheckTests(unittest.TestCase):
|
||||
"""setup.check-airflow's judge against a scripted daemon: the check
|
||||
writes its floors and the intake fans fitted, and the test reads the
|
||||
fans the count says are there. A machine built with one intake fan
|
||||
reports intake 2 not fitted; the bench reference has two."""
|
||||
|
||||
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, intakes):
|
||||
fans = {"exhaust": dict(self.FAN), "intake 1": dict(self.FAN), "intake 2": dict(self.FAN),
|
||||
"air assist": dict(self.FAN)}
|
||||
if intakes == 1:
|
||||
fans["intake 2"] = {"fitted": False}
|
||||
return {"fans": fans, "intake_fans": intakes, "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_tach_intake_min_rpm": "2300"})
|
||||
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.polls = 0
|
||||
self.wizard_result = self.result(2)
|
||||
self.wizard_writes = None # what the check writes; None = what its result says
|
||||
|
||||
def on_get(path, q):
|
||||
if path == "/wiz/dark":
|
||||
if self.dark["running"]:
|
||||
self.polls += 1
|
||||
if self.polls >= 2:
|
||||
r = self.wizard_result
|
||||
s = self.fake.state["settings"]
|
||||
s.update(r["floors"])
|
||||
s["cool_intake_fans"] = self.wizard_writes or str(r["intake_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}
|
||||
return None
|
||||
|
||||
def on_post(path, form):
|
||||
s = self.fake.state["settings"]
|
||||
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 == "":
|
||||
s.pop(k, None)
|
||||
else:
|
||||
s[k] = v
|
||||
return 200, dict(s)
|
||||
return None
|
||||
self.fake.on_get = on_get
|
||||
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 run_check(self):
|
||||
t = catalog.load_suite()["setup.check-airflow"]
|
||||
run = Run("test", t.id, t.title)
|
||||
t.fn(Context(run, None, t))
|
||||
return run
|
||||
|
||||
def assert_put_back(self):
|
||||
s = self.fake.state["settings"]
|
||||
self.assertEqual(s.get("cool_tach_intake_min_rpm"), "2300")
|
||||
self.assertNotIn("cool_intake_fans", s)
|
||||
self.assertNotIn("cool_tach_exhaust_min_rpm", s)
|
||||
|
||||
def test_two_intake_fans_pass_and_are_put_back(self):
|
||||
self.run_check()
|
||||
self.assert_put_back()
|
||||
|
||||
def test_one_intake_fan_passes_with_intake_2_not_fitted(self):
|
||||
self.wizard_result = self.result(1)
|
||||
self.run_check()
|
||||
self.assert_put_back()
|
||||
|
||||
def test_one_intake_fan_with_the_setting_left_at_two_fails(self):
|
||||
self.wizard_result = self.result(1)
|
||||
self.wizard_writes = "2"
|
||||
with self.assertRaises(Exception) as cm:
|
||||
self.run_check()
|
||||
self.assertIn("cool_intake_fans reads '2', the check found 1", str(cm.exception))
|
||||
self.assert_put_back()
|
||||
|
||||
def test_a_result_without_the_count_fails(self):
|
||||
r = self.result(2)
|
||||
del r["intake_fans"]
|
||||
self.wizard_result = r
|
||||
self.wizard_writes = "2"
|
||||
with self.assertRaises(Exception) as cm:
|
||||
self.run_check()
|
||||
self.assertIn("no intake fan count", str(cm.exception))
|
||||
self.assert_put_back()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -16,7 +16,7 @@ import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest import baseline, catalog
|
||||
from forgetest.runner import Context, Run
|
||||
from forgetest.runner import Context, Failed, Run
|
||||
from forgetest.suite import setup
|
||||
|
||||
IDS = ("setup.gate-blocks-controllers", "setup.override-until-reboot",
|
||||
@@ -375,6 +375,26 @@ class CloudDisabledSurfaceTests(unittest.TestCase):
|
||||
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()
|
||||
@@ -385,6 +405,13 @@ class CloudDisabledSurfaceTests(unittest.TestCase):
|
||||
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"]
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""A takeover hands back the cloud client it found. In cloud mode
|
||||
forgectrl's start at the end of a takeover starts a cloud client, and what
|
||||
that client is comes only from gfcloud's one-start markers. Started bare it
|
||||
is the online client with the service's connect-time hunt, and the moves
|
||||
the service sends after the hunt outlast the run. The start is made under
|
||||
the no-hunt marker, and under the offline marker when the takeover found
|
||||
the offline service, which it then proves came back; 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."""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from forgetest import baseline, hw, runner
|
||||
|
||||
from helpers import FakeForgectrl
|
||||
|
||||
|
||||
class FakeRun:
|
||||
def __init__(self, cap):
|
||||
self.baseline_captured = cap
|
||||
self.lines = []
|
||||
|
||||
def log(self, s):
|
||||
self.lines.append(s)
|
||||
|
||||
|
||||
class TakeoverClientTests(unittest.TestCase):
|
||||
OLD_PID, NEW_PID = 200, 201
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-tkc-")
|
||||
self.proc = os.path.join(self.tmp, "proc")
|
||||
os.makedirs(os.path.join(self.proc, "net"))
|
||||
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
|
||||
for group in ("cnc", "pic", "head"):
|
||||
os.makedirs(self.sysfs + group)
|
||||
os.environ["GF_SYSFS_ROOT"] = self.sysfs
|
||||
os.environ["FORGETEST_PROC_ROOT"] = self.proc
|
||||
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "forgetest.active")
|
||||
self.saved = (baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER, baseline.OFFLINE_SOCKET,
|
||||
hw.initd, runner.Takeover.OFFLINE_BACK_S)
|
||||
baseline.NOHUNT_MARKER = os.path.join(self.tmp, "gfcloud-nohunt")
|
||||
baseline.OFFLINE_MARKER = os.path.join(self.tmp, "gfcloud-offline")
|
||||
baseline.OFFLINE_SOCKET = "/run/gfcloud-offline.sock"
|
||||
runner.Takeover.OFFLINE_BACK_S = 0
|
||||
baseline.Baseline._unreachable_until = 0.0
|
||||
self.unix = [] # (pid, inode, path, listening)
|
||||
self.fc = FakeForgectrl().start()
|
||||
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": self.OLD_PID,
|
||||
"motion": "verified"}
|
||||
self.calls = []
|
||||
self.at_start = None # the markers present when forgectrl started
|
||||
self.comes_up = "running" # the controller forgectrl's start settles on
|
||||
hw.initd = self._initd
|
||||
|
||||
def tearDown(self):
|
||||
(baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER, baseline.OFFLINE_SOCKET,
|
||||
hw.initd, runner.Takeover.OFFLINE_BACK_S) = self.saved
|
||||
self.fc.stop()
|
||||
for k in ("GF_SYSFS_ROOT", "FORGETEST_PROC_ROOT", "FORGETEST_MARKER"):
|
||||
os.environ.pop(k, None)
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
# -- the fakes ------------------------------------------------------
|
||||
def _socket(self, pid, inode, path, listening=True):
|
||||
self.unix.append((pid, inode, path, listening))
|
||||
fd_dir = os.path.join(self.proc, str(pid), "fd")
|
||||
os.makedirs(fd_dir, exist_ok=True)
|
||||
os.symlink("socket:[%d]" % inode, os.path.join(fd_dir, str(len(os.listdir(fd_dir)) + 3)))
|
||||
with open(os.path.join(self.proc, "net", "unix"), "w") as f:
|
||||
f.write("Num RefCount Protocol Flags Type St Inode Path\n")
|
||||
for _pid, ino, p, lis in self.unix:
|
||||
f.write("00000000: 00000002 00000000 %08X 0001 %s %d %s\n"
|
||||
% (0x10000 if lis else 0, "01" if lis else "03", ino, p))
|
||||
|
||||
def _offline_client(self, pid):
|
||||
self._socket(pid, 9000 + pid, baseline.OFFLINE_SOCKET)
|
||||
|
||||
def _initd(self, service, action, timeout=60):
|
||||
"""forgectrl stop/start; the start plays gfcloud's: it reads and
|
||||
takes down the markers, and it is the offline service when the
|
||||
offline marker was there."""
|
||||
self.calls.append((service, action))
|
||||
if action != "start":
|
||||
return 0, ""
|
||||
self.at_start = sorted(os.path.basename(p) for p in (baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER)
|
||||
if os.path.exists(p))
|
||||
mode = self.fc.state["mode"]
|
||||
if self.comes_up != "running":
|
||||
self.fc.state["mode"] = dict(mode, controller=self.comes_up, pid=None, motion="unverified")
|
||||
return 0, ""
|
||||
offline = os.path.exists(baseline.OFFLINE_MARKER)
|
||||
for p in (baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER):
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
self.fc.state["mode"] = dict(mode, controller="running", pid=self.NEW_PID, motion="verified")
|
||||
if offline and mode.get("mode") == "cloud":
|
||||
self._offline_client(self.NEW_PID)
|
||||
return 0, ""
|
||||
|
||||
def takeover(self):
|
||||
cap = {"position": None, "mode": self.fc.state["mode"]["mode"]}
|
||||
run = FakeRun(cap)
|
||||
with runner.Takeover(run.log, "test.id", run=run):
|
||||
pass
|
||||
return cap, run
|
||||
|
||||
def markers_left(self):
|
||||
return [p for p in (baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER) if os.path.exists(p)]
|
||||
|
||||
# -- the socket judge -------------------------------------------------
|
||||
def test_the_listener_is_the_process_own_descriptor(self):
|
||||
self._offline_client(self.OLD_PID)
|
||||
self.assertTrue(hw.listens_on(self.OLD_PID, baseline.OFFLINE_SOCKET))
|
||||
# the path alone is not enough: another process's socket, a
|
||||
# connected (not listening) socket, another path, no pid
|
||||
self.assertFalse(hw.listens_on(self.NEW_PID, baseline.OFFLINE_SOCKET))
|
||||
self._socket(self.NEW_PID, 7001, baseline.OFFLINE_SOCKET, listening=False)
|
||||
self.assertFalse(hw.listens_on(self.NEW_PID, baseline.OFFLINE_SOCKET))
|
||||
self.assertFalse(hw.listens_on(self.OLD_PID, "/run/forgefirm/grbl.ctl"))
|
||||
self.assertFalse(hw.listens_on(None, baseline.OFFLINE_SOCKET))
|
||||
|
||||
# -- the takeover ------------------------------------------------------
|
||||
def test_the_offline_service_comes_back_offline(self):
|
||||
# exthost.armed-freeze's case: the setup record put back under a
|
||||
# takeover while the offline service ran
|
||||
self._offline_client(self.OLD_PID)
|
||||
cap, run = self.takeover()
|
||||
self.assertEqual(self.calls, [("forgectrl", "stop"), ("forgectrl", "start")])
|
||||
self.assertEqual(self.at_start, ["gfcloud-nohunt", "gfcloud-offline"])
|
||||
self.assertTrue(hw.listens_on(self.NEW_PID, baseline.OFFLINE_SOCKET))
|
||||
self.assertNotIn("restart_clients", cap)
|
||||
self.assertEqual(self.markers_left(), [])
|
||||
self.assertTrue(any("back as the offline service (pid %d)" % self.NEW_PID in l for l in run.lines),
|
||||
run.lines)
|
||||
|
||||
def test_an_online_client_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"])
|
||||
self.assertFalse(hw.listens_on(self.NEW_PID, baseline.OFFLINE_SOCKET))
|
||||
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"}
|
||||
cap, run = self.takeover()
|
||||
self.assertEqual(self.at_start, [])
|
||||
self.assertNotIn("restart_clients", cap)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,244 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""A takeover judges the head where the baseline cannot. forgectrl's start
|
||||
at the end of a takeover is a controller start, which re-zeroes the step
|
||||
counters wherever the head stands, so after it a head left out reads as
|
||||
home. The takeover reads the counters as the controller goes away (against
|
||||
where the run expects the head) and before it comes back (against where the
|
||||
takeover found it); a miss is a leftover that fails the run, and the head is
|
||||
never moved on it. Runs against a fake sysfs tree and a fake anchor file."""
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from forgetest import baseline, hw, runner
|
||||
|
||||
|
||||
class FakeRun:
|
||||
def __init__(self, cap):
|
||||
self.baseline_captured = cap
|
||||
self.lines = []
|
||||
|
||||
def log(self, s):
|
||||
self.lines.append(s)
|
||||
|
||||
|
||||
class TakeoverPositionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# the clean machine test_baseline.py builds, so the post pass
|
||||
# finds nothing but what a test here leaves
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-tko-")
|
||||
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
|
||||
self.leds = os.path.join(self.tmp, "leds") + os.sep
|
||||
for group in ("cnc", "pic", "head", "thermal"):
|
||||
os.makedirs(self.sysfs + group)
|
||||
for name in baseline.BUTTON_LEDS + ("lid_led",):
|
||||
os.makedirs(self.leds + name)
|
||||
with open(self.leds + name + "/brightness", "w") as f:
|
||||
f.write("0")
|
||||
for attr, val in baseline.fixed_sysfs() + baseline.IDLE_READBACKS:
|
||||
self._attr(attr, val)
|
||||
self._attr("cnc/interlock_circuit", "45")
|
||||
self._attr("pic/lid_led", "0")
|
||||
self._pos(0, 0, 0)
|
||||
os.environ["GF_SYSFS_ROOT"] = self.sysfs
|
||||
os.environ["GF_LEDS_ROOT"] = self.leds
|
||||
os.environ["FORGECTRL_URL"] = "http://127.0.0.1:1" # nothing listens
|
||||
baseline.Baseline._unreachable_until = 0.0
|
||||
self.anchor = os.path.join(self.tmp, "grblhal.homed")
|
||||
self._new_anchor(1)
|
||||
self.old_anchor, baseline.ANCHOR_PATH = baseline.ANCHOR_PATH, self.anchor
|
||||
self.spm = baseline.counter_steps_per_mm()
|
||||
|
||||
def tearDown(self):
|
||||
baseline.ANCHOR_PATH = self.old_anchor
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
for k in ("GF_SYSFS_ROOT", "GF_LEDS_ROOT", "FORGECTRL_URL"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
def _attr(self, attr, val):
|
||||
with open(self.sysfs + attr, "w") as f:
|
||||
f.write(str(val))
|
||||
|
||||
def _pos(self, x, y, z):
|
||||
with open(self.sysfs + "cnc/position", "wb") as f:
|
||||
f.write(struct.pack("<5i", x, y, z, 0, 0))
|
||||
|
||||
def _new_anchor(self, ns):
|
||||
"""A controller start or a home: a new anchor file."""
|
||||
with open(self.anchor + ".new", "w") as f:
|
||||
f.write("0 0 0 4 startup")
|
||||
os.replace(self.anchor + ".new", self.anchor)
|
||||
os.utime(self.anchor, ns=(ns, ns))
|
||||
|
||||
def cap(self):
|
||||
return {"position": baseline.read_position(), "frame": baseline.counter_frame()}
|
||||
|
||||
def takeover(self, cap):
|
||||
run = FakeRun(cap)
|
||||
return runner.Takeover(run.log, "test.id", run=run), run
|
||||
|
||||
def mm(self, mm):
|
||||
return round(mm * self.spm)
|
||||
|
||||
def start(self, t):
|
||||
"""Takeover.__enter__'s order: the frame is read while the
|
||||
controller runs, then its exit takes the anchor with it (forgectrl
|
||||
unlinks it) and leaves the counters, which are then read."""
|
||||
t.frame = baseline.counter_frame()
|
||||
if os.path.exists(self.anchor):
|
||||
os.unlink(self.anchor)
|
||||
t.position_at_start()
|
||||
|
||||
def test_a_head_where_the_run_expects_it_is_not_recorded(self):
|
||||
cap = self.cap()
|
||||
t, run = self.takeover(cap)
|
||||
self.start(t)
|
||||
t.position_at_end()
|
||||
self.assertNotIn("restart_positions", cap)
|
||||
|
||||
def test_a_head_left_out_at_the_start_is_recorded(self):
|
||||
cap = self.cap()
|
||||
self._pos(self.mm(10), self.mm(10), 0)
|
||||
t, run = self.takeover(cap)
|
||||
self.start(t)
|
||||
self.assertEqual(cap["restart_positions"],
|
||||
[{"where": "takeover start", "found": [self.mm(10), self.mm(10), 0],
|
||||
"expected": [0, 0, 0]}])
|
||||
|
||||
def test_the_frame_is_read_before_the_controller_goes(self):
|
||||
# found on the bench: forgectrl unlinks the anchor when the
|
||||
# controller exits, so a frame read after the stop never matched
|
||||
# and the check judged nothing. Read before the stop, it does.
|
||||
cap = self.cap()
|
||||
self._pos(self.mm(10), self.mm(10), 0)
|
||||
late, run = self.takeover(cap)
|
||||
os.unlink(self.anchor)
|
||||
late.frame = baseline.counter_frame()
|
||||
late.position_at_start()
|
||||
self.assertNotIn("restart_positions", cap)
|
||||
self._new_anchor(1)
|
||||
cap = self.cap() # a run that began at the origin in this frame
|
||||
cap["position"] = [0, 0, 0]
|
||||
on_time, run = self.takeover(cap)
|
||||
self.start(on_time)
|
||||
self.assertEqual([r["where"] for r in cap["restart_positions"]], ["takeover start"])
|
||||
|
||||
def test_the_dead_band_is_not_a_leftover(self):
|
||||
cap = self.cap()
|
||||
self._pos(self.mm(0.05), 0, 0)
|
||||
t, run = self.takeover(cap)
|
||||
self.start(t)
|
||||
self.assertNotIn("restart_positions", cap)
|
||||
|
||||
def test_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
|
||||
# left out, the record put back under a takeover, whose restart
|
||||
# zeroes the counters where the head stands
|
||||
cap = self.cap()
|
||||
self._new_anchor(3) # the camera home
|
||||
cap["position"], cap["rezero_declared"], cap["frame"] = [0, 0, 0], True, baseline.counter_frame()
|
||||
self._pos(self.mm(10), self.mm(10), 0)
|
||||
t, run = self.takeover(cap)
|
||||
self.start(t)
|
||||
t.position_at_end()
|
||||
self._new_anchor(4) # forgectrl's start: a controller start
|
||||
self._pos(0, 0, 0)
|
||||
lines = []
|
||||
left = baseline.Baseline(lines.append).enforce("post", captured=cap)
|
||||
self.assertEqual([x.item for x in left], ["position at the takeover start"])
|
||||
self.assertTrue(left[0].action.startswith("unrestorable"), left[0].action)
|
||||
self.assertIn("not moved", left[0].action)
|
||||
self.assertEqual(baseline.read_position(), [0, 0, 0])
|
||||
# without the takeover's record the same run reads clean: the gap
|
||||
del cap["restart_positions"]
|
||||
self.assertEqual(baseline.Baseline(lines.append).enforce("post", captured=cap), [])
|
||||
|
||||
def test_a_failed_reading_never_keeps_forgectrl_down(self):
|
||||
# the exit path restores the attributes, relocks the latch and
|
||||
# starts forgectrl whatever the position reading does
|
||||
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
|
||||
calls = []
|
||||
real = (hw.initd, baseline.read_position, runner.Takeover.wait_settled)
|
||||
hw.initd = lambda service, action, timeout=60: (calls.append((service, action)) or (0, ""))
|
||||
|
||||
def broken():
|
||||
raise OSError("unreadable")
|
||||
baseline.read_position = broken
|
||||
runner.Takeover.wait_settled = lambda self: None
|
||||
try:
|
||||
cap = {"position": [0, 0, 0], "frame": baseline.counter_frame()}
|
||||
t, run = self.takeover(cap)
|
||||
t.found = ([0, 0, 0], hw.sysfs_read("cnc/x_mode"))
|
||||
t.saved = {"cnc/x_mode": hw.sysfs_read("cnc/x_mode")}
|
||||
t.__exit__(None, None, None)
|
||||
finally:
|
||||
hw.initd, baseline.read_position, runner.Takeover.wait_settled = real
|
||||
os.environ.pop("FORGETEST_MARKER", None)
|
||||
self.assertEqual(calls, [("forgectrl", "start")])
|
||||
with open(self.sysfs + "cnc/laser_latch") as f:
|
||||
self.assertEqual(f.read().strip(), "1")
|
||||
self.assertTrue(any("could not be judged" in l for l in run.lines), run.lines)
|
||||
|
||||
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: 71fad52e8ff5695bce3b9c6bc8530a1468426819
|
||||
commit: f012d8e82aa0dbfa511214e46a1e13559b1a0d74
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user