mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8d4560ff0 | ||
|
|
00cc009871 | ||
|
|
c47f7d9a86 | ||
|
|
c217e24905 | ||
|
|
b94c990bbc | ||
|
|
8d8ca2a8c6 | ||
|
|
4fbf14195b | ||
|
|
d3fe1d90b9 | ||
|
|
594b6990fd | ||
|
|
57ba3454b4 | ||
|
|
d58ee39086 | ||
|
|
b2c42f0cc3 | ||
|
|
1dd6ad608b | ||
|
|
f0c40e7d4f | ||
|
|
d858a23f45 | ||
|
|
c7b80ab2e8 | ||
|
|
468e92655a | ||
|
|
d898b5659d | ||
|
|
6967308485 | ||
|
|
64301d3221 | ||
|
|
61030da69b | ||
|
|
0107c2cad4 | ||
|
|
867b1938e4 | ||
|
|
b2f50ad765 | ||
|
|
4a95595afd | ||
|
|
8af8b197ee | ||
|
|
2936890eaa | ||
|
|
1ea686ef85 | ||
|
|
093a7bbbde | ||
|
|
3e5c1a342a | ||
|
|
5f19ae01cd | ||
|
|
74fa8a4ee6 | ||
|
|
be9e4198ba | ||
|
|
291f75815b | ||
|
|
c2ca2686a7 | ||
|
|
722de73a39 | ||
|
|
e36a322ec5 | ||
|
|
81df43e7a4 | ||
|
|
59c4516c00 | ||
|
|
0dc8c746e5 | ||
|
|
8fc5250d6d | ||
|
|
bfb5cb27d8 | ||
|
|
527d864eaf | ||
|
|
e6e5ca1c94 | ||
|
|
79d2c07734 | ||
|
|
b0fa4ccaf5 | ||
|
|
9f715f08de | ||
|
|
a86d66049f | ||
|
|
bd624410a2 | ||
|
|
143ef11a60 | ||
|
|
0df0162c8d | ||
|
|
54f65746cd | ||
|
|
60e06c77f0 | ||
|
|
8795a6d527 | ||
|
|
97287aa6a9 | ||
|
|
ff9796cde6 | ||
|
|
da0299924f | ||
|
|
637fb67007 | ||
|
|
9c6b13e510 | ||
|
|
5a74213919 | ||
|
|
d6f648b539 | ||
|
|
2f330715f3 | ||
|
|
ba0bf41749 | ||
|
|
da0c70703d | ||
|
|
7968ebd347 | ||
|
|
0845ab32a1 | ||
|
|
db9acf9910 | ||
|
|
aa0d86635c | ||
|
|
ec9e9860c9 | ||
|
|
9e862971cf | ||
|
|
cf7490de78 | ||
|
|
92c69f23fa | ||
|
|
7c45642f05 | ||
|
|
5d2aa41a1c | ||
|
|
358c287891 | ||
|
|
1f2c795d8c | ||
|
|
9711a7fa06 | ||
|
|
abccbf48e5 | ||
|
|
77c11c52e4 | ||
|
|
05cf7341a4 | ||
|
|
82c3596d6a | ||
|
|
867136d77e | ||
|
|
b99c92e047 | ||
|
|
5cb18e64ab | ||
|
|
38cce236a0 | ||
|
|
380f67d666 | ||
|
|
61f44011aa | ||
|
|
9258dea885 | ||
|
|
970f10a9e2 | ||
|
|
3bb16a48c3 | ||
|
|
774ae52b61 | ||
|
|
7605a90946 | ||
|
|
1fef9c6f51 | ||
|
|
d5369630d9 | ||
|
|
b3efab9c46 | ||
|
|
133b61a062 | ||
|
|
75c4a65444 | ||
|
|
88ec984e28 | ||
|
|
6002da8d12 | ||
|
|
0f28427c22 | ||
|
|
3b21057dfe | ||
|
|
b182a5ab0e | ||
|
|
b132965e15 | ||
|
|
d9a0990510 | ||
|
|
a2bc4233d5 | ||
|
|
8cf2212e63 | ||
|
|
5b29a8f9d3 | ||
|
|
0e37b0812e | ||
|
|
0ca6c4be9f | ||
|
|
44ba07ea94 | ||
|
|
c4717c6926 | ||
|
|
8ff37d188d | ||
|
|
c88c1979ca | ||
|
|
8a01b239ce | ||
|
|
ef2a8607c2 | ||
|
|
758b17c5fe | ||
|
|
30dba96883 | ||
|
|
64f552fc28 | ||
|
|
fb804e32bd | ||
|
|
8a9c6a9062 | ||
|
|
6a48cd3969 | ||
|
|
8a93202c00 | ||
|
|
a100c9c3eb | ||
|
|
c7c96f9cbe | ||
|
|
4a0338a03f | ||
|
|
61b2913e3a | ||
|
|
765521455d | ||
|
|
18104a0e51 | ||
|
|
0ee48d76a7 | ||
|
|
a3127c29cd | ||
|
|
2319735e60 | ||
|
|
22ce1d0d6d | ||
|
|
298abf8a12 | ||
|
|
8ba998c3ef | ||
|
|
94f7c23ade | ||
|
|
8d68cee5bf | ||
|
|
99902a1579 | ||
|
|
f4de5cdbfd | ||
|
|
c484107779 | ||
|
|
78c40ef1ee | ||
|
|
e2cf034422 | ||
|
|
33461556c3 | ||
|
|
4e78ef03d7 | ||
|
|
fe977c0dcc | ||
|
|
678a155dba | ||
|
|
b506e67bce | ||
|
|
a3d921b7ee | ||
|
|
6edd3e565c | ||
|
|
97b10683c2 | ||
|
|
f773866f3e | ||
|
|
a697fe0f63 | ||
|
|
85d266e572 | ||
|
|
eb8935c9b1 | ||
|
|
a73ee9c6bf | ||
|
|
38886d5832 | ||
|
|
c01699ee94 | ||
|
|
b334c4cc33 | ||
|
|
431f0f29fa | ||
|
|
9a9bb9a47b | ||
|
|
f2b7626d79 | ||
|
|
566d836ca0 | ||
|
|
58cad9a8d8 | ||
|
|
5787260a3d | ||
|
|
0b941fd743 | ||
|
|
557ed02590 | ||
|
|
d612c0099a | ||
|
|
67da1ab9bc | ||
|
|
917b4bb55e | ||
|
|
3025996c86 | ||
|
|
f88c7784d4 | ||
|
|
cd00b0d16f | ||
|
|
9bfdba44c8 | ||
|
|
e32c86fb10 | ||
|
|
5920d9bc77 | ||
|
|
8831d9b20e | ||
|
|
61b68c387c | ||
|
|
5c282ce167 | ||
|
|
89731e654b | ||
|
|
d577629c80 | ||
|
|
9d9f235219 | ||
|
|
b5de7cda9d | ||
|
|
f84b9df040 | ||
|
|
533ae7e46d | ||
|
|
d17c348e91 | ||
|
|
ac8c4318a7 | ||
|
|
fff0980079 | ||
|
|
0416986e52 | ||
|
|
9b4ff7e0f8 | ||
|
|
f90c7c01a2 | ||
|
|
4a7d11d48d | ||
|
|
243320f65d | ||
|
|
4d3d173445 | ||
|
|
dc43ccf3bf | ||
|
|
76906f3aef | ||
|
|
57b428ebd4 | ||
|
|
7ca1c0f936 | ||
|
|
db2baa03bb | ||
|
|
4e79ee5f92 | ||
|
|
326c3e758b | ||
|
|
dd6f459d7b | ||
|
|
1e059bb90c | ||
|
|
cb9cd53bda | ||
|
|
00ded74189 | ||
|
|
e99888753f | ||
|
|
bb186247d1 | ||
|
|
9887606cfc | ||
|
|
62992fbd7d | ||
|
|
24dd2b49f6 | ||
|
|
904dfb4acb | ||
|
|
a47c0c7f6d | ||
|
|
a2b391b8f6 | ||
|
|
f54d086625 | ||
|
|
7d982070c0 | ||
|
|
b54b94e8ae | ||
|
|
8ee4ee3946 | ||
|
|
335c6dea9d | ||
|
|
53e4fa20e9 | ||
|
|
e5fa4445c0 | ||
|
|
8379aa5451 | ||
|
|
ab0a515a85 | ||
|
|
908e0c76b8 | ||
|
|
969bac6013 | ||
|
|
5c7727b9bf | ||
|
|
4c9dcca7e6 | ||
|
|
1c8197faa3 | ||
|
|
0cb9044e1d | ||
|
|
628f2f7a7f | ||
|
|
9cc2e4eef4 | ||
|
|
60db95634b | ||
|
|
2547a8eb68 | ||
|
|
296fd686e1 | ||
|
|
9139e923b1 | ||
|
|
de324cc32f | ||
|
|
58d1ac4746 | ||
|
|
4e0c90b5a5 | ||
|
|
6458f6aec0 | ||
|
|
9fae47cf14 | ||
|
|
f51140e528 | ||
|
|
c1591e4f69 | ||
|
|
adcd1adb9a | ||
|
|
71a79b6675 | ||
|
|
0bddc9eeab | ||
|
|
a8a02596cd | ||
|
|
5249c1ecf8 | ||
|
|
d530f773e2 | ||
|
|
8ad3f79e26 | ||
|
|
f274eb1137 | ||
|
|
bf3483e994 | ||
|
|
f4b70a0826 | ||
|
|
6c1d68f2c3 | ||
|
|
9585febb7c | ||
|
|
ef8ef0ba3c | ||
|
|
9c22240480 | ||
|
|
54e1689889 | ||
|
|
e810d52c9d | ||
|
|
0258268aae | ||
|
|
474e14e0e8 | ||
|
|
eaa0b0741b | ||
|
|
54d238d67d | ||
|
|
3f4cd2b621 | ||
|
|
aaabfdf9b9 | ||
|
|
0ef2047625 | ||
|
|
d122a6ff1d | ||
|
|
e3d23b8099 | ||
|
|
0203887dc9 | ||
|
|
942d9dde99 | ||
|
|
7bd9c45fd3 | ||
|
|
4300f7f4a8 | ||
|
|
6e4a26ac61 | ||
|
|
4a6bd5a179 | ||
|
|
5c242ca4b4 | ||
|
|
340132e520 | ||
|
|
5a587c3536 | ||
|
|
7012c7d1b2 | ||
|
|
7ad6b53647 | ||
|
|
0c2dd37b2f | ||
|
|
1e855ab212 | ||
|
|
d7c23cce19 | ||
|
|
ff454ed537 | ||
|
|
a3e83c4fd5 | ||
|
|
b7091effb7 | ||
|
|
4eb0932099 | ||
|
|
c2c627d9b6 | ||
|
|
cb41a6030c | ||
|
|
76d43686d0 | ||
|
|
64a720bbab | ||
|
|
5a3bd902fe | ||
|
|
faaa6cb40b | ||
|
|
2fa5800519 | ||
|
|
c425822b82 | ||
|
|
d3bab940b3 | ||
|
|
44393f3c11 | ||
|
|
05d68ba0a8 | ||
|
|
1c56427cae | ||
|
|
74adaf40b8 | ||
|
|
674cd1903c | ||
|
|
0f116c8b7f | ||
|
|
d763f33fa2 | ||
|
|
c23dedd89a | ||
|
|
cd5098b6b3 | ||
|
|
e48b2c8e62 | ||
|
|
0870a835c4 | ||
|
|
86ce0419e5 | ||
|
|
e908db7f3a | ||
|
|
7bf8e3d4ed | ||
|
|
95c8cc8f54 | ||
|
|
9878c8d3b9 | ||
|
|
b68d790b71 | ||
|
|
d218b3de25 | ||
|
|
914c72f8e5 | ||
|
|
6b35f9c534 | ||
|
|
c4ea96127c | ||
|
|
c255b265c5 | ||
|
|
36b4a3a5f4 | ||
|
|
b90c53c739 | ||
|
|
a9a585d44d | ||
|
|
cd8a01a3d9 | ||
|
|
03947cd94c | ||
|
|
b51e695fb1 | ||
|
|
bf066d4e27 | ||
|
|
57465d2c00 | ||
|
|
df4581134e | ||
|
|
1d9b5538d6 | ||
|
|
d0291ec7cb | ||
|
|
8654c36e99 | ||
|
|
5002d59dd7 | ||
|
|
a80d7aabfb | ||
|
|
d4e2f85538 | ||
|
|
0312dec22b | ||
|
|
13beb51e26 | ||
|
|
4aaedc8080 | ||
|
|
799e0e829c | ||
|
|
a4b757add5 | ||
|
|
e19e7c5304 | ||
|
|
d7d68c556e | ||
|
|
d17655c3c0 | ||
|
|
07141d60fc | ||
|
|
2e82b13438 | ||
|
|
7baa6a0352 | ||
|
|
16deb4896f | ||
|
|
5e04f4f4cd | ||
|
|
1179d5e7c1 | ||
|
|
c0f53a865f | ||
|
|
9b5558dfbc | ||
|
|
da3725c6de | ||
|
|
304c4e7186 | ||
|
|
7487f907f4 | ||
|
|
298d711533 | ||
|
|
e733bbc270 | ||
|
|
6050c0e703 | ||
|
|
b08e5ab929 | ||
|
|
3f81597c93 | ||
|
|
7319c718fe | ||
|
|
52b398e11d | ||
|
|
fe6a65b7fe | ||
|
|
d7cb506c46 | ||
|
|
87a8ccbd59 | ||
|
|
f75629a4df | ||
|
|
8a1fa91db5 | ||
|
|
1d4c6b5a57 | ||
|
|
8658973d5c | ||
|
|
81fca45cb5 | ||
|
|
21d3a482c6 | ||
|
|
43cc540a09 | ||
|
|
f919f2e622 | ||
|
|
984c0697e3 | ||
|
|
744376d8e8 | ||
|
|
e1207316ec | ||
|
|
31ac4bbcbd | ||
|
|
5848eea6f7 | ||
|
|
cced57da6c | ||
|
|
8ef2d2845a | ||
|
|
21488f74fd | ||
|
|
76bf87939f | ||
|
|
e232adbe4f | ||
|
|
c985991cda | ||
|
|
d92393395a | ||
|
|
0a05b6b114 | ||
|
|
ff117c4f6b | ||
|
|
ee25b89799 | ||
|
|
b18cb15072 | ||
|
|
55b940e702 | ||
|
|
3dfee18b96 | ||
|
|
ea0642f942 | ||
|
|
97a4cae346 | ||
|
|
3afd64dbc7 | ||
|
|
572ee8a3d7 | ||
|
|
26ed519348 | ||
|
|
9e9d03ba94 | ||
|
|
2aa92bc63a | ||
|
|
e10784b2d8 | ||
|
|
aee0d18baa | ||
|
|
58d4d91948 | ||
|
|
cbabf7d111 | ||
|
|
341c6399a0 | ||
|
|
e41b10d655 | ||
|
|
ea3fce2efd | ||
|
|
daa76ca828 | ||
|
|
e9443a60ef | ||
|
|
cc927aca5f | ||
|
|
5dddea12ee | ||
|
|
89ba97e310 | ||
|
|
08c83917af | ||
|
|
562631d9bf | ||
|
|
e92e500d91 | ||
|
|
2c1d73880a | ||
|
|
9fd49ba6ad | ||
|
|
b08d3e5f30 | ||
|
|
95ae38916d | ||
|
|
107374c2ae | ||
|
|
7e43355011 | ||
|
|
44cdda0109 | ||
|
|
12ecebe3fa | ||
|
|
1b47597133 | ||
|
|
683511f563 | ||
|
|
1e3b2716b7 | ||
|
|
58bc5e293a | ||
|
|
6063b93683 | ||
|
|
1c1830ae8d | ||
|
|
3c095ccbd1 | ||
|
|
2219ec24f7 | ||
|
|
3d9e9ebd60 | ||
|
|
be7927c60a | ||
|
|
050eaf5f4c | ||
|
|
01d336875e | ||
|
|
abf85fccfe | ||
|
|
5d7ca46c07 | ||
|
|
cd2107fe3c | ||
|
|
26069a8258 | ||
|
|
2b25a937a5 | ||
|
|
6811bbf825 | ||
|
|
25f2721500 | ||
|
|
d2131c161b | ||
|
|
501e1d4591 | ||
|
|
e9d3ce40a8 | ||
|
|
630c9c3da4 | ||
|
|
00f77f7985 | ||
|
|
b26a687daa | ||
|
|
d1bbecb9ee | ||
|
|
419710ef4b | ||
|
|
96c96b63c6 | ||
|
|
6c418bfb92 | ||
|
|
a2f1b8e9e8 | ||
|
|
fcf183eefd | ||
|
|
4f13d8a43f | ||
|
|
21251bc89f | ||
|
|
d8dfdcdd18 | ||
|
|
0f5dec57e9 | ||
|
|
42a01940f1 | ||
|
|
9c1d0c5073 | ||
|
|
cea0439e17 | ||
|
|
63792a8ed2 | ||
|
|
39a973df02 | ||
|
|
b9a63e677f | ||
|
|
6c9cce3184 | ||
|
|
882a1b1b2d | ||
|
|
1097184e13 | ||
|
|
5035b2744e | ||
|
|
55fdaebf69 | ||
|
|
63dffdc596 | ||
|
|
3fdb0c1636 | ||
|
|
11fec891ed | ||
|
|
9d410c646a | ||
|
|
b7eb5fdac5 | ||
|
|
2aac59e0e4 | ||
|
|
888e51d84e | ||
|
|
75e0eb24da | ||
|
|
af3a693c0b | ||
|
|
548d25a4ff | ||
|
|
8baed9aa93 | ||
|
|
3ba1d9c769 | ||
|
|
4da39f6131 | ||
|
|
2f1cb88452 | ||
|
|
607ceb32e7 | ||
|
|
85914c16d0 | ||
|
|
4184e84558 | ||
|
|
bcf476be74 | ||
|
|
8cd98de605 | ||
|
|
8ba0ce84f3 | ||
|
|
c1d781a137 | ||
|
|
bb3dedcd68 | ||
|
|
cf24bf3200 | ||
|
|
2108b9706b | ||
|
|
f2a5d0f6b6 | ||
|
|
fcfa4e6d65 | ||
|
|
ad865bb3c3 | ||
|
|
4d22905c2f | ||
|
|
b30afd7171 | ||
|
|
d7f7318e5d | ||
|
|
a12ef810f4 | ||
|
|
392bf49e5b | ||
|
|
e791475703 | ||
|
|
29e0c588c9 | ||
|
|
51161703b4 | ||
|
|
980176ecc7 | ||
|
|
061def2343 | ||
|
|
7b320c34ac | ||
|
|
962ac5f98c | ||
|
|
1a723fa303 | ||
|
|
32cc3ecdee | ||
|
|
f201f95c73 | ||
|
|
b422887ca5 | ||
|
|
06b9dc5f92 | ||
|
|
8d798fa4b9 | ||
|
|
421909a6a8 | ||
|
|
d75ff717ac | ||
|
|
44137ae650 | ||
|
|
82d5911a0e | ||
|
|
dc34fc6602 | ||
|
|
247cfacb40 | ||
|
|
b3a8924c91 | ||
|
|
f396dbde34 | ||
|
|
f92147093e | ||
|
|
8fa10f7b28 | ||
|
|
3d1c9edfeb | ||
|
|
3ab4e39e72 | ||
|
|
ea93a194a6 | ||
|
|
6f53e628f3 | ||
|
|
4c6cca1511 | ||
|
|
855a4f1b28 | ||
|
|
009944a29b | ||
|
|
2907072f0b | ||
|
|
e8b52b4469 | ||
|
|
fb956971b7 | ||
|
|
c7e419ff6b | ||
|
|
c08f1ad7e3 | ||
|
|
53d4e7f935 | ||
|
|
78875f8e89 | ||
|
|
d69bfad77a | ||
|
|
1a5fc27530 | ||
|
|
14470e9122 | ||
|
|
83627a6aea | ||
|
|
e92cce2b7e | ||
|
|
4a52a4dd05 | ||
|
|
e0da938e4b | ||
|
|
0596a08f01 | ||
|
|
527660c2f5 | ||
|
|
43707b4aca | ||
|
|
00ef5a675b | ||
|
|
d42a1588c7 | ||
|
|
90384350b3 | ||
|
|
624f5ea11b | ||
|
|
cd255b3f2e | ||
|
|
be36eefb4a | ||
|
|
7edeffdaef | ||
|
|
1631b35e66 | ||
|
|
768c67be4c | ||
|
|
bf75739b63 | ||
|
|
42f7c1de37 | ||
|
|
73d8f87b70 | ||
|
|
73aa6e973d | ||
|
|
2566a71e23 | ||
|
|
9d520e04d9 | ||
|
|
29ce635570 |
@@ -0,0 +1,56 @@
|
|||||||
|
# forgefixture: the bench actuator's firmware, proven on the host.
|
||||||
|
#
|
||||||
|
# - the policy test: the decisions that need no hardware (channel
|
||||||
|
# names, the loop request words, the button pulse clamp, the key
|
||||||
|
# comparison), compiled with gcc and run
|
||||||
|
# - the firmware build: the whole project in the pinned ESP-IDF
|
||||||
|
# container, with a placeholder fixture.env, so a change that does
|
||||||
|
# not compile never reaches a bench
|
||||||
|
|
||||||
|
name: fixture-ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'fixture/**'
|
||||||
|
- '.github/workflows/fixture-ci.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'fixture/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
policy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Policy host test
|
||||||
|
working-directory: fixture
|
||||||
|
run: CC=gcc sh test/run.sh
|
||||||
|
|
||||||
|
firmware:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
container:
|
||||||
|
image: espressif/idf:v5.5.5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Placeholder fixture.env (the build needs one; the values are not used)
|
||||||
|
working-directory: fixture
|
||||||
|
run: |
|
||||||
|
printf 'WIFI_SSID=ci-network\nWIFI_PSK=ci-passphrase\nAPI_KEY=%s\nHOSTNAME=forgefixture\n' \
|
||||||
|
0123456789abcdef0123456789abcdef > fixture.env
|
||||||
|
- name: Build
|
||||||
|
working-directory: fixture
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
. /opt/esp/idf/export.sh >/dev/null
|
||||||
|
idf.py set-target esp32s3 >/dev/null
|
||||||
|
idf.py build
|
||||||
|
- name: Size
|
||||||
|
working-directory: fixture
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
. /opt/esp/idf/export.sh >/dev/null
|
||||||
|
idf.py size | tail -n 20
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# forgetest: the release acceptance tool's host-side proof.
|
||||||
|
#
|
||||||
|
# - unit tests: campaign rules, fingerprints, artifact build/verify (the
|
||||||
|
# release gate's decision, including the negative fixtures), runner +
|
||||||
|
# HTTP API end to end with a fake catalog and a fake bench tool, the
|
||||||
|
# suites replayed on the machine's own log lines, and the check that
|
||||||
|
# every log phrase the cloud suite greps for is one the pinned cloud
|
||||||
|
# app can log (it reads the app sources from the manifest cache)
|
||||||
|
# - the source bundle of a release: which recipe of the image needs
|
||||||
|
# source, which archive answers for it, and the refusal that stops a
|
||||||
|
# release with source missing
|
||||||
|
# - shared UI files: theme.css and the vendored Bootstrap the page
|
||||||
|
# carries are byte-identical to forgectrl's at its pinned revision
|
||||||
|
# - coverage lint: every source path of every component pinned by the
|
||||||
|
# recipes must be selected by some catalog test's coverage globs (the
|
||||||
|
# tree manifest is generated from the pins with git - no Yocto build);
|
||||||
|
# enforced: an uncovered path fails the job (https://docs.forgefirm.org/developers/acceptance/).
|
||||||
|
|
||||||
|
name: forgetest-ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'forgetest/**'
|
||||||
|
- 'scripts/acceptance-gate.py'
|
||||||
|
- 'scripts/manifest-from-tree.py'
|
||||||
|
- 'scripts/source-bundle.py'
|
||||||
|
- 'kas/source-bundle.yml'
|
||||||
|
- 'meta-forgefirm/recipes-forgefirm/**'
|
||||||
|
- '.github/workflows/forgetest-ci.yml'
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Checkout forgefirm
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: forgefirm
|
||||||
|
|
||||||
|
- name: Checkout meta-openglow (sibling)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: openglow-org/meta-openglow
|
||||||
|
ref: scarthgap
|
||||||
|
path: meta-openglow
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Tree manifest from the recipe pins (also fetches the pinned app sources the needle check reads)
|
||||||
|
working-directory: forgefirm
|
||||||
|
run: python scripts/manifest-from-tree.py --out tree-manifest.json
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
working-directory: forgefirm/forgetest
|
||||||
|
# -B: no bytecode caches under the package directory (the recipe's fetch
|
||||||
|
# checksum would otherwise move without a source change)
|
||||||
|
run: python -B -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
- name: No undefined names (the live drills never execute on the host)
|
||||||
|
working-directory: forgefirm/forgetest
|
||||||
|
run: |
|
||||||
|
pip install pyflakes
|
||||||
|
if python -B -m pyflakes forgetest tests | grep -E "undefined name"; then exit 1; fi
|
||||||
|
|
||||||
|
- name: Shared UI files match forgectrl at its pinned revision
|
||||||
|
working-directory: forgefirm
|
||||||
|
run: python scripts/check-ui-vendor.py
|
||||||
|
|
||||||
|
- name: Coverage lint (enforced)
|
||||||
|
working-directory: forgefirm/forgetest
|
||||||
|
run: python -B -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Gate self-check on the tree manifest
|
||||||
|
working-directory: forgefirm
|
||||||
|
run: |
|
||||||
|
# The gate must load the catalog and refuse a missing/unauthorized
|
||||||
|
# artifact cleanly (exit 1/2, never a traceback).
|
||||||
|
set +e
|
||||||
|
echo '{}' > empty.json
|
||||||
|
python scripts/acceptance-gate.py empty.json tree-manifest.json --quiet
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
test "$rc" -eq 1
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# kas-managed upstream layer checkouts (poky, meta-openembedded, meta-freescale...)
|
||||||
|
/layers/
|
||||||
|
|
||||||
|
# Yocto build output (incl. deploy/images) and caches
|
||||||
|
/build/
|
||||||
|
/downloads/
|
||||||
|
/sstate-cache/
|
||||||
|
|
||||||
|
# kas
|
||||||
|
*.lock.yml.bak
|
||||||
|
|
||||||
|
# release.sh staging output
|
||||||
|
/release-staging/
|
||||||
|
|
||||||
|
# Python bytecode
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
# manifest-from-tree.py fetch cache
|
||||||
|
/.manifest-cache/
|
||||||
|
|
||||||
|
# manifest-from-tree.py default output
|
||||||
|
/tree-manifest.json
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Instructions for AI coding agents that work in this repository. A human
|
||||||
|
contributor is welcome to read it too: it is the same set of rules.
|
||||||
|
|
||||||
|
## ForgeFIRM repository rules
|
||||||
|
|
||||||
|
### Safety on the machine
|
||||||
|
|
||||||
|
- The laser never fires with the coolant pump deliberately off. The heater-only
|
||||||
|
flow-calibrate and flow-verify tools are the only pump-off state, and the
|
||||||
|
tube stays dark during them.
|
||||||
|
- The 40 V motor rail stays up while the machine is on. Never cycle it for a
|
||||||
|
controller handover. A rail glitch can leave the stepper drivers
|
||||||
|
unserviceable.
|
||||||
|
|
||||||
|
### The iteration loop
|
||||||
|
|
||||||
|
- Edit, cross-build in the Yocto build environment, hot-deploy to the
|
||||||
|
machine, prove. Then one commit, one push, one pin bump for the proven
|
||||||
|
change.
|
||||||
|
- A full image build happens once, at the end of a work item, never per fix.
|
||||||
|
- Kernel and BSP changes ride one image flash, batched. Validate a `.ko` or
|
||||||
|
overlay change on the image that ships it. Never hot-swap a `.ko` onto a
|
||||||
|
board that is about to be reflashed.
|
||||||
|
|
||||||
|
### Acceptance coverage
|
||||||
|
|
||||||
|
- Evaluate every change against the acceptance catalog in
|
||||||
|
`forgetest/forgetest/suite/`. Does an existing test exercise the changed
|
||||||
|
behavior? Does its `covers` map name the files you touched? If not, add or
|
||||||
|
widen one in the same change. The coverage lint
|
||||||
|
(`python3 -m forgetest.coverage --enforce`) is the floor.
|
||||||
|
- The `SRCREV` of a component and the `PV` that moves with it live in
|
||||||
|
`<recipe>-pin.inc` next to the recipe. Nothing else goes in that file. The
|
||||||
|
image manifest keeps `*-pin.inc` out of the layer content hash. Thus a pin
|
||||||
|
bump invalidates only the acceptance tests that cover that component. A
|
||||||
|
pin in a recipe body counts as a platform change and needs a full
|
||||||
|
acceptance campaign.
|
||||||
|
- Test a harness change yourself, on the host, before you hand it to the
|
||||||
|
operator for a bench run. A harness that fails on its own defect costs the
|
||||||
|
operator a full re-run cycle.
|
||||||
|
- A test declares its `mode`, and the runner switches. Cloud tests stay in
|
||||||
|
cloud mode. Keep `requires` lists minimal, and never gate one test behind a
|
||||||
|
chain of others.
|
||||||
|
- Never exercise a gate or a limit through the `GFCOOL_*` environment
|
||||||
|
overrides in a test. Use the settings API, and restore it in the teardown.
|
||||||
|
|
||||||
|
### CI couplings that set the push order
|
||||||
|
|
||||||
|
- The CI of `grblHAL-glowforge` gets the laser harnesses from `scripts/bench/`
|
||||||
|
at the head of `master`, unpinned. Push a harness change here before the
|
||||||
|
driver change that needs it.
|
||||||
|
- The acceptance page shares its theme and its vendored Bootstrap with the
|
||||||
|
`forgectrl` panel, byte for byte. Land a UI change in `forgectrl` first.
|
||||||
|
Push it and pin it. Only then push this repository.
|
||||||
|
- The CI of `kernel-module-glowforge` cross-builds against the BSP from
|
||||||
|
`meta-openglow`. Push `meta-openglow` before a module change that needs a
|
||||||
|
BSP change.
|
||||||
|
|
||||||
|
### Builds and images
|
||||||
|
|
||||||
|
- Builds run only in the dedicated Yocto build environment that the Build page
|
||||||
|
of the site defines (kas and bitbake). Never use a native host build of a
|
||||||
|
component as proof, not even for a compile check.
|
||||||
|
- Every build makes both images: `forgefirm-image` (the release image) and
|
||||||
|
`forgefirm-image-dev` (the bench image, with `forgetest` on port 8090). The
|
||||||
|
dev image needs an explicit `--target`. Report both artifact names.
|
||||||
|
- After a kernel-module pin bump, rebuild the kernel and the module in the
|
||||||
|
same run: `bitbake -c cleansstate linux-fslc kernel-module-glowforge`, then
|
||||||
|
the images. The kernel version suffix is not reproducible across
|
||||||
|
re-checkouts, and a rootfs whose kernel and module disagree fails.
|
||||||
|
- Read the layer HEAD lines at the top of a build log before you trust a build
|
||||||
|
that moves a pin.
|
||||||
|
|
||||||
|
### The record of the work
|
||||||
|
|
||||||
|
This repository carries no status document and no dated log. Both were
|
||||||
|
retired: every fact about the machine and the firmware is on the
|
||||||
|
documentation site, and **the record of a piece of work is the commit message
|
||||||
|
that carries it**: what was done, how it was proven, and what it replaced, in
|
||||||
|
the same place as the change itself.
|
||||||
|
|
||||||
|
A measurement session or a bench drill is written up in the commit that
|
||||||
|
lands its result. Nothing accumulates in a file.
|
||||||
|
|
||||||
|
### On the bench
|
||||||
|
|
||||||
|
These rules apply whenever an agent works with the operator at a machine.
|
||||||
|
|
||||||
|
- **One armed run per turn.** Start the run, let it finish, stop, report what
|
||||||
|
happened against what was expected, and wait for the operator's
|
||||||
|
confirmation before any further armed run or machine command. Never chain a
|
||||||
|
jog, a fix, or a re-run onto a burn in the same step. Dry runs (latch
|
||||||
|
locked, no emission) can be batched. Say which is which before you start.
|
||||||
|
- **Zero inference during live fire.** Report only what the combined
|
||||||
|
observations confirm: the operator's eyes, the drill output, the logs, the
|
||||||
|
sysfs readbacks. Everything else is labeled "unconfirmed" and is not acted
|
||||||
|
on.
|
||||||
|
- **One Grbl connection.** Never open a second connection to TCP port 23 while
|
||||||
|
a drill, an acceptance test, or a sender holds one. The last connection
|
||||||
|
wins, the displaced client's cleanup never arrives, and its queued commands
|
||||||
|
wait behind the arm gate. Read state through `forgectrl` (HTTP) and sysfs
|
||||||
|
only. After a displaced or aborted drill: soft reset, confirm that the RX
|
||||||
|
buffer reads full, `M5`, confirm that `armed` is false, before anyone
|
||||||
|
touches the button.
|
||||||
|
- **The operator touches the machine.** The operator runs the install, the
|
||||||
|
service restart, and the reflash on the machine. Never modify the software
|
||||||
|
of a running machine unless the operator asks for that exact step.
|
||||||
|
- **Bench hygiene.** Stage test files in `/tmp`. A file that must survive a
|
||||||
|
reboot goes in `/data/bench-scratch/`, and that directory is deleted whole
|
||||||
|
at the end of the session. Nothing goes loose in `/data`. Remove everything
|
||||||
|
you put on the board, in the same session, and list `/data` before you end.
|
||||||
|
A bench tool worth a second use goes in `scripts/bench/`. The dev image
|
||||||
|
installs those tools under `/usr/share/forgetest/bench/`. Run them from
|
||||||
|
there.
|
||||||
|
|
||||||
|
## Project-wide rules
|
||||||
|
|
||||||
|
The sections above are specific to this repository. The rules below apply to
|
||||||
|
every repository of the OpenGlow ForgeFIRM project: the firmware components,
|
||||||
|
the build base, the documentation, and the hardware designs. Where a
|
||||||
|
repository rule and a rule below conflict, the safety, proof, and hygiene
|
||||||
|
rules below win. The documentation site, <https://docs.forgefirm.org/>, is
|
||||||
|
the source of truth for every fact about the machine and the firmware. Read
|
||||||
|
its Developers section before you change code.
|
||||||
|
|
||||||
|
### What ForgeFIRM is
|
||||||
|
|
||||||
|
ForgeFIRM is open firmware for stock Glowforge lasers (Basic, Plus, Pro). It
|
||||||
|
runs on the factory NXP i.MX6 control board with no hardware modification. It
|
||||||
|
replaces the cloud-dependent factory userspace with an open Linux image that
|
||||||
|
controls the machine locally. In GRBL mode the machine is a standard grblHAL
|
||||||
|
controller for LightBurn and other senders on TCP port 23. Cloud mode runs the
|
||||||
|
factory experience through the Glowforge web service, and it is a deliberate,
|
||||||
|
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
|
||||||
|
and cloud mode runs end to end.
|
||||||
|
|
||||||
|
One controller runs at a time under `forgectrl`, the machine-services daemon,
|
||||||
|
which owns the pulse device, the cooling engine, the cameras, and the control
|
||||||
|
panel. The kernel module plays the pulse stream into the stepper drivers and
|
||||||
|
owns the laser latch and the safety readbacks. The Technical section of the
|
||||||
|
site describes the whole stack.
|
||||||
|
|
||||||
|
### The repositories
|
||||||
|
|
||||||
|
All repositories live under the OpenGlow organization on GitHub,
|
||||||
|
<https://github.com/openglow-org>. For development, check every repository out
|
||||||
|
as a sibling in one base directory named `openglow-forgefirm`. The build
|
||||||
|
scripts expect that layout. Each repository is its own git repository. The
|
||||||
|
base directory is not.
|
||||||
|
|
||||||
|
| Repository | Role | Default branch | License |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `forgefirm` | The build and release base: the `meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the install and release scripts, the acceptance tool `forgetest/`, the bench tools `scripts/bench/`, and the bench actuator firmware `fixture/`. It carries no project documentation. | `master` | MIT (layer metadata) |
|
||||||
|
| `meta-openglow` | The BSP layers `meta-glowforge-bsp` and `meta-openglow-core`: kernel, device tree, U-Boot, board recipes. | `scarthgap` | MIT (layer metadata) |
|
||||||
|
| `forgectrl` | The machine-services daemon (C) on HTTPS port 443, with the read-only routes on HTTP port 80: supervisor, pulse-device broker, motion-liveness gate, cooling engine, cameras, telemetry, settings, diagnostics, control panel, A/B updates. | `main` | MIT |
|
||||||
|
| `grblHAL-glowforge` | The grblHAL driver for the stock board, the GRBL-mode controller. The core is a submodule at `src/grbl`. Machine constants are in `src/boards/glowforge.h`. | `main` | GPL-3.0-or-later |
|
||||||
|
| `grblHAL-core` | The fork of the grblHAL core that the driver uses. Minimum change, no rationale comments. | `forgefirm` | The upstream grblHAL license |
|
||||||
|
| `kernel-module-glowforge` | `glowforge.ko`: the SDMA + EPIT pulse engine, the laser latch, the safety readbacks, the sensors. | `master` | GPL-2.0-or-later |
|
||||||
|
| `python3-gfhardware` | The `gfhardware` hardware library and the cloud-mode applications in `forgefirm-app/`. | `master` | MIT, with one LGPL-2.1-or-later component |
|
||||||
|
| `Glowforge-Utilities` | `gfutilities` on PyPI: the factory protocol and service layer that cloud mode uses. | `master` | MIT |
|
||||||
|
| `forgefirm-docs` | The documentation site. | `main` | CC BY-SA 4.0 |
|
||||||
|
| `openglow-serial-adapter` | The USB-C serial-console adapter for the control board. | `main` | CC BY-NC-SA 4.0 |
|
||||||
|
|
||||||
|
The full table, the license details, and the component diagram are on the
|
||||||
|
site: <https://docs.forgefirm.org/developers/>.
|
||||||
|
|
||||||
|
### Safety first, and in that order
|
||||||
|
|
||||||
|
The machine is a laser, and the operator is the safety authority at it. These
|
||||||
|
rules bind every change, in every repository, that can affect what the machine
|
||||||
|
does.
|
||||||
|
|
||||||
|
- The hardware chain is the safety boundary. Software only adds gates on top
|
||||||
|
of it. `LASER_ON` is never a bare GPIO. The kernel laser latch is locked by
|
||||||
|
default, and every close of the pulse device locks it again.
|
||||||
|
- Never defeat a switch, an interlock, or a readback, in hardware or in
|
||||||
|
software, not even for a test.
|
||||||
|
- Never change a fire gate, a thermal limit, or a safety default to make a
|
||||||
|
test pass.
|
||||||
|
- The order of work is absolute: emission and motion first, robustness and
|
||||||
|
hygiene after. Review a change that touches emission or motion for that
|
||||||
|
before anything else.
|
||||||
|
- A change that can put energy where it was not commanded gets a regression
|
||||||
|
test with the fix, in the same commit, never after.
|
||||||
|
- Position counters, homing anchors, and a homed flag are not proof of
|
||||||
|
physical motion. The head accelerometer is, and so are the operator's eyes.
|
||||||
|
|
||||||
|
### Proof before done
|
||||||
|
|
||||||
|
A feature or fix is not complete until it is proven. The order of preference:
|
||||||
|
|
||||||
|
1. A host test that runs in CI. The Test page of the site lists the host tests
|
||||||
|
of each repository.
|
||||||
|
2. A bench drill on the bench reference, recorded in the commit message that
|
||||||
|
carries the change.
|
||||||
|
3. Documented reasoning.
|
||||||
|
|
||||||
|
Also:
|
||||||
|
|
||||||
|
- Run the host tests of this repository before every commit. Where the build
|
||||||
|
uses `-Werror`, a warning is a failure.
|
||||||
|
- A component that ships in the image is also evaluated against the release
|
||||||
|
acceptance catalog in the `forgefirm` repository (Test page, "Coverage
|
||||||
|
currency"). A behavior change with no catalog consequence gets a sentence
|
||||||
|
of justification in the commit message.
|
||||||
|
- For a component that ships in the image, the proof build is the Yocto
|
||||||
|
cross-build that the Build page of the site defines, never a native host
|
||||||
|
build.
|
||||||
|
- Report outcomes faithfully. Quote failing output as it is. Name a skipped
|
||||||
|
step as skipped. Never narrate an expected result as an observed one.
|
||||||
|
|
||||||
|
### The workflow: prove locally, push when proven
|
||||||
|
|
||||||
|
- Nothing goes to a public repository until it is proven: in the host tests
|
||||||
|
for host-only changes, on the bench for changes that alter what the machine
|
||||||
|
does.
|
||||||
|
- Iterate locally. Then make one commit and one push for the proven change.
|
||||||
|
No chains of fix-up pushes. Every push runs CI, and CI minutes are a budget.
|
||||||
|
- Commit only finished work, and only when asked. Never commit intermediate
|
||||||
|
findings, working notes, plans, audits, or status files. If a conclusion is
|
||||||
|
still moving, it is not ready for a repository. Unpushed mistakes come out
|
||||||
|
with `git reset --mixed <base>`, which keeps the working tree.
|
||||||
|
- "Commit" and "commit and build" include the push of every touched
|
||||||
|
repository, in dependency order. Never hold a push back to protect earlier
|
||||||
|
unpushed commits on the same branch. The only exception is an explicit "do
|
||||||
|
not push" for that session.
|
||||||
|
- Never push, force-push, tag, cut a release, refresh a lockfile, bump a
|
||||||
|
submodule pointer, or bump a pin unless asked.
|
||||||
|
|
||||||
|
### Pins
|
||||||
|
|
||||||
|
A firmware component reaches the image only through its pin: an exact
|
||||||
|
`SRCREV` in `meta-forgefirm` (in the `forgefirm` repository) for ForgeFIRM
|
||||||
|
components, or in `meta-openglow` for BSP components. There is no `AUTOREV`.
|
||||||
|
A commit in a component repository changes nothing on a machine until its pin
|
||||||
|
moves. The order is always: push the source repository, bump its pin, then
|
||||||
|
run `bitbake -c fetch <recipe>` to make sure that the pin resolves. Some
|
||||||
|
pairs of repositories have a CI coupling that fixes the push order between
|
||||||
|
them. The Release flow page of the site lists them, and each affected
|
||||||
|
repository names its own in the section above.
|
||||||
|
|
||||||
|
### Git conventions
|
||||||
|
|
||||||
|
- A commit is attributed to the human who is responsible for it, in both the
|
||||||
|
author and the committer fields. Never list an AI assistant as an author or
|
||||||
|
a co-author. Never add an AI attribution trailer or a "Generated with" line
|
||||||
|
to a commit message or a pull request. In a fresh clone, set the local
|
||||||
|
`user.name` and `user.email` before the first commit.
|
||||||
|
- A commit message says what changed and why, in the present tense, in
|
||||||
|
Simplified Technical English. The "why" of a change to fork code lives here
|
||||||
|
and nowhere else.
|
||||||
|
- Keep line endings LF in every file that Linux tooling consumes: patches,
|
||||||
|
shell scripts, recipes, device trees. Check the bytes after an edit from a
|
||||||
|
Windows tool. A CRLF patch breaks `do_patch`.
|
||||||
|
- Never commit build output, `__pycache__`, virtual environments, editor
|
||||||
|
files, a local knowledge graph (`graphify-out/`), captured machine data that
|
||||||
|
carries an identity, tokens, keys, or passwords.
|
||||||
|
- Never rewrite published history unless the operator asks for it.
|
||||||
|
- A pull request to an upstream repository is never automatic. The developer
|
||||||
|
decides if and when to send one. You can remind them that upstream
|
||||||
|
candidates exist, but the decision is theirs.
|
||||||
|
|
||||||
|
### Writing rules
|
||||||
|
|
||||||
|
They apply to everything: documentation, README files, code comments,
|
||||||
|
docstrings, log and console text, commit messages, issue text, and replies.
|
||||||
|
|
||||||
|
- **American English.** analyze, behavior, color, center, gray, catalog,
|
||||||
|
license, judgment, program, artifact, percent, toward, among, while,
|
||||||
|
learned. The style lint in `forgefirm-docs` rejects the British forms.
|
||||||
|
- **ASD-STE100 Simplified Technical English.** Short sentences: 20 words in
|
||||||
|
a procedure, 25 in a description. One topic per sentence, one instruction
|
||||||
|
per sentence. Active voice, imperative in procedures, simple present in
|
||||||
|
descriptions. "Must" for necessity, "can" for possibility. No "should",
|
||||||
|
"would", "could", "may". Prefer: do, make sure, use, start, show, occur,
|
||||||
|
before, after, because, for example, that is. No "etc.", no "and/or".
|
||||||
|
Noun clusters of three words at most. Warnings and cautions come before the
|
||||||
|
step they protect. Warm and direct is good. A joke that shades a fact is
|
||||||
|
not.
|
||||||
|
- **No em dashes.** Never, anywhere. Use a colon, a period, or a comma.
|
||||||
|
- **Present tense, present state.** Text describes the code and the machine
|
||||||
|
as they are. No history narrative, no "the old version did", no "evolved
|
||||||
|
from", no story of how the code got here. Upstream copyright and
|
||||||
|
derivation attribution to public projects stays: that is license credit,
|
||||||
|
not narrative.
|
||||||
|
- **No workstation paths.** Nothing outside a repository appears as a path in
|
||||||
|
it: no drive letters, no WSL mounts, no home directories, no sibling-repo
|
||||||
|
relative paths, no private reference directories. Name the artifact
|
||||||
|
instead: "the captured factory pulse files", "that bench session".
|
||||||
|
- **No bench-machine identity.** Never a hostname, a serial number, a fuse
|
||||||
|
value, a private IP address, or a login of the bench machine, in any file,
|
||||||
|
comment, commit message, sample, or test fixture. Use neutral placeholders
|
||||||
|
of the `ABC-123` form. Record the fact of a verification, never the values.
|
||||||
|
- **No factory firmware code.** Never cite the factory firmware binary or its
|
||||||
|
code: no offsets, no addresses, no quoted or decompiled code. State the
|
||||||
|
recovered fact (a formula, a constant, a behavior) and stop. Hardware
|
||||||
|
addresses (IOMUX pad values, sensor registers, boot offsets) are fine.
|
||||||
|
- **No rationale comments in fork code.** Code destined upstream (for
|
||||||
|
example, the grblHAL core fork) carries the minimum change and no "why we
|
||||||
|
did this" comments. The reasoning goes in the commit message. Patches
|
||||||
|
carried in the Yocto layers are exempt. The `ForgeFIRM:` comment convention
|
||||||
|
there is deliberate.
|
||||||
|
- **Interface docs describe the lever, not the policy.** A sysfs attribute
|
||||||
|
doc says what the attribute is, its range, its units, its conversion. How
|
||||||
|
the factory firmware drives it does not belong there.
|
||||||
|
- **No definitive legal or regulatory statements.** The project is not a
|
||||||
|
lawyer. The only permitted form: modifying the machine, including
|
||||||
|
replacing its firmware, may have legal and regulatory ramifications, and it
|
||||||
|
is the end user's responsibility to adhere to the laws, regulations,
|
||||||
|
certifications, and insurance terms that apply where they are. Warranty
|
||||||
|
wording is "may void your warranty", never "voids". Never cite a regulation,
|
||||||
|
a standard, or a certification regime as applying or not applying. The
|
||||||
|
affiliation disclaimer lives in the site footer only. It is also the user's
|
||||||
|
responsibility to make sure that they stay within the vendor's terms of
|
||||||
|
service when they use a feature that calls that vendor's cloud services.
|
||||||
|
- **Copyright.** Copyright in this project belongs to 514 LLC d/b/a
|
||||||
|
OpenGlow. Every new source file carries two lines in its header, in the
|
||||||
|
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
|
||||||
|
`Written by Scott Wiederhold`. Keep the year, or the year range, that
|
||||||
|
the file already has. The copyright notices of other holders stay
|
||||||
|
unchanged.
|
||||||
|
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
|
||||||
|
under the license of its repository.
|
||||||
|
|
||||||
|
### Where documentation goes
|
||||||
|
|
||||||
|
- **One home.** A fact lives on the documentation site, or it does not exist.
|
||||||
|
A repository README says what the repository is, how to build and test it,
|
||||||
|
and where the documentation is. Nothing else.
|
||||||
|
- **The currency rule.** A change carries a documentation commit when it adds,
|
||||||
|
removes, or renames an interface (a sysfs attribute, an HTTP route, a
|
||||||
|
settings key, a G-code or `$` setting), or when it corrects a measured
|
||||||
|
fact. A measured fact says how it was obtained, and on what: unless the page
|
||||||
|
says otherwise, a measurement on the site was taken on the bench reference,
|
||||||
|
which the site defines.
|
||||||
|
- **A moved document is deleted.** No stub and no redirect stays at the old
|
||||||
|
path.
|
||||||
|
- **Every diagram is Mermaid.** No ASCII art and no image of a diagram.
|
||||||
|
- There is no roadmap page. Open items are tracked as GitHub issues once the
|
||||||
|
repositories accept them.
|
||||||
|
- Do not add plan files, audit files, working notes, or status files to any
|
||||||
|
repository. The project has **no status document and no dated log**: the
|
||||||
|
site describes the present, and the record of what was done, how it was
|
||||||
|
proven, and what it replaced goes in the commit message that carries the
|
||||||
|
change.
|
||||||
|
|
||||||
|
### Analysis and reporting
|
||||||
|
|
||||||
|
- Rank engineering options on technical merit only: performance, correctness,
|
||||||
|
robustness, verifiability. Process cost (an acceptance campaign, an image
|
||||||
|
flash, a new dependency) is a one-line footnote, never a ranking factor.
|
||||||
|
The operator decides the process cost.
|
||||||
|
- Never infer or assume a hardware fact. Label every unverified claim as
|
||||||
|
unverified, and say how to verify it.
|
||||||
|
- Report only what the observations confirm. Everything else is labeled
|
||||||
|
"unconfirmed" and is not acted on.
|
||||||
|
|
||||||
|
### General practice for agents
|
||||||
|
|
||||||
|
- Read the README of this repository, this file, and the Developers section
|
||||||
|
of the site before you change code.
|
||||||
|
- Orient in the code before you edit. If a local knowledge graph
|
||||||
|
(`graphify-out/`) exists in the working tree or its parent, query it first.
|
||||||
|
It is a local artifact and is never committed.
|
||||||
|
- Keep a change minimal and scoped to the request. No drive-by reformatting,
|
||||||
|
no unrelated cleanups, no renames for taste. Match the style of the
|
||||||
|
surrounding code.
|
||||||
|
- Do not add a dependency without a stated reason. The target is a
|
||||||
|
single-core machine with a small eMMC, and every package rides the image.
|
||||||
|
- Extend an existing test or tool instead of adding a parallel one.
|
||||||
|
- Never create a file in the repository that the request does not need.
|
||||||
|
- Ask before anything destructive or outward-facing: a push, a force-push, a
|
||||||
|
tag, a release, a history rewrite, or any change to a machine.
|
||||||
|
- When a fact is unknown, say so and say how to find out. Never fill a gap
|
||||||
|
with a plausible number.
|
||||||
|
- Use the exact names of the project: kas, bitbake, pin, SRCREV, campaign,
|
||||||
|
fingerprint, drill, sysfs, the latch, the armed window. Do not invent names.
|
||||||
|
|
||||||
|
### Before you commit
|
||||||
|
|
||||||
|
1. The host tests of this repository pass, with `-Werror` where the build
|
||||||
|
uses it.
|
||||||
|
2. The change is proven at the highest level available: CI test, bench drill,
|
||||||
|
or documented reasoning.
|
||||||
|
3. For a component that ships in the image: the acceptance catalog covers the
|
||||||
|
changed behavior, or the commit message says why it has no catalog
|
||||||
|
consequence.
|
||||||
|
4. New files carry the 514 LLC d/b/a OpenGlow copyright, the `Written by`
|
||||||
|
line, and an SPDX line. Line endings are LF.
|
||||||
|
5. No em dashes, no British spellings, no workstation paths, no bench
|
||||||
|
identity, no factory firmware code, no history narrative, no AI
|
||||||
|
attribution.
|
||||||
|
6. Documentation is current: interfaces and measured facts (the currency
|
||||||
|
rule).
|
||||||
|
7. The commit is one settled change, attributed to the human author, and it
|
||||||
|
is pushed only when the operator asks.
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# Build
|
|
||||||
Instructions are for a Linux environment supported by Yocto.
|
|
||||||
|
|
||||||
To get the build OpenGlow/ForgeFIRM you need to have `repo` installed:
|
|
||||||
|
|
||||||
## Install the `repo` utility:
|
|
||||||
|
|
||||||
```console
|
|
||||||
mkdir ~/bin
|
|
||||||
curl http://commondatastorage.googleapis.com/git-repo-downloads/repo > ~/bin/repo
|
|
||||||
chmod a+x ~/bin/repo
|
|
||||||
PATH=${PATH}:~/bin
|
|
||||||
```
|
|
||||||
Add the following to the end of ```~/.bashrc``` to permanently add ```repo``` to your path:
|
|
||||||
```console
|
|
||||||
export PATH=~/bin:$PATH
|
|
||||||
```
|
|
||||||
## Download the BSP source:
|
|
||||||
|
|
||||||
```console
|
|
||||||
mkdir forgefirm
|
|
||||||
cd forgefirm
|
|
||||||
repo init -u https://github.com/ScottW514/forgefirm.git -b master -m default.xml
|
|
||||||
repo sync
|
|
||||||
```
|
|
||||||
|
|
||||||
At the end of the commands you have all meta packages you need to build the basic firmware images.
|
|
||||||
|
|
||||||
## Build the OpenGlow/ForgeFIRM image for factory control boards
|
|
||||||
First time environment setup:
|
|
||||||
```console
|
|
||||||
(in forgefirm directory)
|
|
||||||
MACHINE=glowforge DISTRO=forgefirm . setup-environment build
|
|
||||||
bitbake forgefirm-image
|
|
||||||
```
|
|
||||||
Subsequent builds:
|
|
||||||
```console
|
|
||||||
(in forgefirm directory)
|
|
||||||
. setup-environment build
|
|
||||||
bitbake forgefirm-image
|
|
||||||
```
|
|
||||||
|
|
||||||
## Bootable Image:
|
|
||||||
The bootable image can be found in ```forgefirm/build/tmp/deploy/images/```.
|
|
||||||
|
|
||||||
It can be written directly to an SDCARD:
|
|
||||||
```console
|
|
||||||
(in forgefirm/build directory)
|
|
||||||
cd tmp/deploy/images/glowforge
|
|
||||||
sudo zcat forgefirm-image-glowforge.wic.gz | dd of=/dev/sdX bs=1M
|
|
||||||
```
|
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
Copyright 2018-2020
|
Copyright 2018-2026 514 LLC d/b/a OpenGlow
|
||||||
Scott Wiederhold <s.e.wiederhold@gmail.com>
|
Written by Scott Wiederhold
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -1,15 +1,86 @@
|
|||||||
# OpenGlow/ForgeFIRM Firmware for Glowforge
|
# OpenGlow / ForgeFIRM firmware for Glowforge
|
||||||
This repository contains opensource firmware for Glowforge brand CNC lasers.
|
|
||||||
|
|
||||||
* [Latest Release](https://github.com/ScottW514/forgefirm/releases)
|
> ### BETA
|
||||||
* [Installation Instructions](https://community.openglow.org/t/dual-boot-install-of-openglow-on-factory-flash/697)
|
>
|
||||||
* [Build Instructions](https://github.com/ScottW514/meta-forgefirm/blob/master/BUILD.md)
|
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
|
||||||
* [Community Support](https://community.openglow.org)
|
> Expect problems, and expect frequent updates. Upgrade whenever a newer
|
||||||
|
> release is available, and report what you find on the
|
||||||
|
> [community forum](https://community.openglow.org).
|
||||||
|
|
||||||
# Roadmap
|
Open firmware for Glowforge brand CNC lasers. ForgeFIRM replaces the
|
||||||
* Base image that supports all Glowforge hardware (complete)
|
cloud-dependent factory software on the **stock control board**, with no
|
||||||
* Support Glowforge web service - Python implementation (mostly complete)
|
hardware modification, and gives the machine a local controller, a local web
|
||||||
* REST API w/ G-Code support (in progress)
|
control panel, and a standard Grbl interface. The factory cloud experience
|
||||||
* Browser-based GUI (pending)
|
stays available as an option.
|
||||||
|
|
||||||
This project is for experimental purposes only, and is not supported or endorsed by Glowforge.
|
This repository is the **base of the build and of the release**: the
|
||||||
|
`meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the
|
||||||
|
install and release scripts, the acceptance tool (`forgetest/`), the bench
|
||||||
|
tools (`scripts/bench/`), the bench actuator firmware (`fixture/`), and the
|
||||||
|
release artifacts (`releases/`).
|
||||||
|
|
||||||
|
## Start here
|
||||||
|
|
||||||
|
**<https://docs.forgefirm.org/>** is the documentation, and the source of
|
||||||
|
truth for every fact about the machine and the firmware.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Read this first | [Safety](https://docs.forgefirm.org/safety/) |
|
||||||
|
| Put it on a machine | [Installation](https://docs.forgefirm.org/install/) |
|
||||||
|
| Use it | [Usage](https://docs.forgefirm.org/usage/), [LightBurn](https://docs.forgefirm.org/usage/lightburn/) |
|
||||||
|
| How the machine works | [Technical](https://docs.forgefirm.org/technical/machine/) |
|
||||||
|
| How ForgeFIRM works with it | [ForgeFIRM internals](https://docs.forgefirm.org/technical/forgefirm/) |
|
||||||
|
| Build, test, release | [Developers](https://docs.forgefirm.org/developers/) |
|
||||||
|
| Downloads | [Releases](https://github.com/openglow-org/forgefirm/releases) |
|
||||||
|
| Questions | [Community forum](https://community.openglow.org) |
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kas build kas/forgefirm-glowforge.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
[Build](https://docs.forgefirm.org/developers/building/) covers the host
|
||||||
|
setup, the two images, the source variant and the debug kernel.
|
||||||
|
[Release flow](https://docs.forgefirm.org/developers/release-flow/) covers the
|
||||||
|
pins, the push order and the signing pipeline.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd forgetest && python3 -m unittest discover -s tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
The acceptance catalog that gates a release, and the bench tools, are on
|
||||||
|
[Acceptance](https://docs.forgefirm.org/developers/acceptance/) and
|
||||||
|
[The bench](https://docs.forgefirm.org/developers/bench/).
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
[AGENTS.md](AGENTS.md) carries the rules for this repository and for the
|
||||||
|
project: safety ordering, proof before done, the push order, and the writing
|
||||||
|
rules. They apply to human contributors too, and
|
||||||
|
[Contribute](https://docs.forgefirm.org/developers/contributing/) is the same
|
||||||
|
set on the site.
|
||||||
|
|
||||||
|
## What this costs
|
||||||
|
|
||||||
|
Nothing. ForgeFIRM is free in both senses, under MIT and GPL licenses. There
|
||||||
|
is no paid tier, no license key, no subscription and no Pro edition. If
|
||||||
|
someone offers to sell it to you, the licenses allow it, but what you take
|
||||||
|
home is their build rather than this one: get it from the source.
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
**These machines contain a CO2 laser: it burns, blinds, and starts fires.**
|
||||||
|
Never defeat the lid switches or the interlock. Never leave a running job
|
||||||
|
unattended. Keep a fire extinguisher within reach. Read
|
||||||
|
[Safety](https://docs.forgefirm.org/safety/) before you cut your first job,
|
||||||
|
and [Regulatory and legal](https://docs.forgefirm.org/install/#regulatory-and-legal)
|
||||||
|
before you install.
|
||||||
|
|
||||||
|
**This is experimental software. Use of it could seriously maim or kill you or
|
||||||
|
others, and it may void your warranty. Use it at your own risk.**
|
||||||
|
|
||||||
|
This project is not affiliated with or endorsed by Glowforge.
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
POKY_BBLAYERS_CONF_VERSION = "2"
|
|
||||||
|
|
||||||
BBPATH = "${TOPDIR}"
|
|
||||||
BSPDIR := "${@os.path.abspath(os.path.dirname(d.getVar('FILE', True)) + '/../..')}"
|
|
||||||
|
|
||||||
BBFILES ?= ""
|
|
||||||
BBLAYERS = " \
|
|
||||||
${BSPDIR}/sources/poky/meta \
|
|
||||||
${BSPDIR}/sources/poky/meta-poky \
|
|
||||||
\
|
|
||||||
${BSPDIR}/sources/meta-openembedded/meta-oe \
|
|
||||||
\
|
|
||||||
${BSPDIR}/sources/meta-freescale \
|
|
||||||
${BSPDIR}/sources/meta-freescale-distro \
|
|
||||||
\
|
|
||||||
${BSPDIR}/sources/meta-openembedded/meta-python \
|
|
||||||
${BSPDIR}/sources/meta-openembedded/meta-networking \
|
|
||||||
\
|
|
||||||
${BSPDIR}/sources/meta-openglow/meta-openglow-core \
|
|
||||||
${BSPDIR}/sources/meta-openglow/meta-glowforge-bsp \
|
|
||||||
\
|
|
||||||
${BSPDIR}/sources/forgefirm/meta-forgefirm \
|
|
||||||
"
|
|
||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!-- Repo Manifest -->
|
|
||||||
<manifest>
|
|
||||||
|
|
||||||
<default sync-j="4" revision="master"/>
|
|
||||||
|
|
||||||
<remote fetch="https://git.yoctoproject.org/git" name="yocto"/>
|
|
||||||
<remote fetch="https://github.com/Freescale" name="freescale"/>
|
|
||||||
<remote fetch="https://github.com/openembedded" name="oe"/>
|
|
||||||
<remote fetch="https://github.com/ScottW514" name="scottw514"/>
|
|
||||||
|
|
||||||
<project remote="yocto" revision="master" name="poky" path="sources/poky"/>
|
|
||||||
<project remote="yocto" revision="master" name="meta-freescale" path="sources/meta-freescale"/>
|
|
||||||
<project remote="freescale" revision="master" name="meta-freescale-distro" path="sources/meta-freescale-distro"/>
|
|
||||||
|
|
||||||
<project remote="oe" revision="master" name="meta-openembedded" path="sources/meta-openembedded"/>
|
|
||||||
|
|
||||||
<project remote="scottw514" revision="master" name="meta-openglow" path="sources/meta-openglow"/>
|
|
||||||
|
|
||||||
<project remote="scottw514" revision="master" name="forgefirm" path="sources/forgefirm">
|
|
||||||
<copyfile dest="base/conf/bblayers.conf" src="base/conf/forgefirm-bblayers.conf"/>
|
|
||||||
<copyfile dest="setup-environment" src="scripts/setup-environment"/>
|
|
||||||
</project>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# The bench's own wifi details and API key: never committed.
|
||||||
|
fixture.env
|
||||||
|
|
||||||
|
# ESP-IDF build products and the per-checkout config (regenerated from
|
||||||
|
# sdkconfig.defaults).
|
||||||
|
build/
|
||||||
|
sdkconfig
|
||||||
|
sdkconfig.old
|
||||||
|
managed_components/
|
||||||
|
|
||||||
|
|
||||||
|
# Host test binaries.
|
||||||
|
test/policy_test
|
||||||
|
test/*.o
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# forgefixture: the ForgeFIRM bench actuator (ESP32-S3, ESP-IDF).
|
||||||
|
# The wifi details and the API key come from fixture.env next to this
|
||||||
|
# file (see main/CMakeLists.txt and README.md); nothing else is needed.
|
||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
|
||||||
|
set(SDKCONFIG_DEFAULTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.defaults")
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
project(forgefixture)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# forgefixture: the bench actuator
|
||||||
|
|
||||||
|
The acceptance campaign asks a person for about eighty small things,
|
||||||
|
and most of them are the same three: open the lid, pull the interlock,
|
||||||
|
press the button. This is the box that does those on request, so the
|
||||||
|
tool can run an operator test with nobody in the room. An ESP32-S3
|
||||||
|
DevKitC-1 on the bench network drives three relays wired into the
|
||||||
|
machine's own connectors; the acceptance tool (`forgetest`, on the
|
||||||
|
machine) asks it over HTTP and then watches the machine for the result,
|
||||||
|
the way it watches an operator's hand today.
|
||||||
|
|
||||||
|
Nothing here touches the laser's safety chain. Two of the contacts are
|
||||||
|
normally closed and sit in series with loops the chain already reads;
|
||||||
|
the third is normally open across the button input and is only ever
|
||||||
|
pulsed. With the fixture unpowered, unplugged, rebooting or crashed, the
|
||||||
|
machine is a stock machine.
|
||||||
|
|
||||||
|
## Hardware
|
||||||
|
|
||||||
|
| Part | What |
|
||||||
|
|---|---|
|
||||||
|
| ESP32-S3-DevKitC-1 | any flash size; powered from its USB port by a **USB wall adapter** (see grounds) |
|
||||||
|
| 3 × 1-channel 3.3 V optocoupler relay modules, high-level trigger | VCC, GND, IN; coil ~100 mA each |
|
||||||
|
| a 2-pin header and jumper | the button channel's enable |
|
||||||
|
| the interposer harness at the machine | bench-local; described in the project's hardware facts bank, not here |
|
||||||
|
|
||||||
|
Pins on the DevKit (all plain GPIOs, no strapping, USB, flash or PSRAM
|
||||||
|
role):
|
||||||
|
|
||||||
|
| Signal | GPIO | Relay contact | Where in the machine |
|
||||||
|
|---|---|---|---|
|
||||||
|
| lid | 4 | **NC**, in series with the lid-switch loop | energized = the loop opens = lid open |
|
||||||
|
| interlock | 5 | **NC**, in series with the interlock loop | energized = the loop opens = interlock pulled |
|
||||||
|
| button | 6 | **NO**, across the button input | energized = pressed; pulsed only, 20 to 500 ms |
|
||||||
|
| button enable | 7 | input, internal pull-up | a jumper to GND enables the button channel; no jumper, no presses |
|
||||||
|
|
||||||
|
Relay coils from the machine's 3.3 V (the modules' VCC and GND), the
|
||||||
|
three IN pins from the GPIOs above, the DevKit's GND to the modules'
|
||||||
|
GND. The modules' opto inputs share GND with their coils, so the ESP32
|
||||||
|
and the machine share a ground: power the DevKit from a wall adapter,
|
||||||
|
not from a PC, unless you want that PC's ground on the machine.
|
||||||
|
|
||||||
|
## Build and flash
|
||||||
|
|
||||||
|
The only input is `fixture.env`: the wifi network, the API key, the
|
||||||
|
hostname. Everything else is pinned (ESP-IDF v5.5, the mDNS component in
|
||||||
|
`dependencies.lock`).
|
||||||
|
|
||||||
|
./fixture.sh env # fixture.env from the example, with a fresh key; fill in the wifi
|
||||||
|
./fixture.sh build # idf.py if installed, else the espressif/idf container (docker or podman)
|
||||||
|
./fixture.sh flash COM5 # or /dev/ttyUSB0; esptool from pip talks to the board directly
|
||||||
|
./fixture.sh monitor COM5 # the log
|
||||||
|
|
||||||
|
Either USB port of the DevKit flashes; the one marked UART shows the log
|
||||||
|
too. `fixture.env` is git-ignored and never leaves the bench. A build in
|
||||||
|
the container works anywhere docker or podman runs (Git Bash on Windows
|
||||||
|
included); `pip install esptool pyserial` is the whole host-side
|
||||||
|
requirement for flashing and watching.
|
||||||
|
|
||||||
|
## What it does on the network
|
||||||
|
|
||||||
|
It joins the wifi as `forgefixture` (the `HOSTNAME` in `fixture.env`),
|
||||||
|
sends that name in its DHCP request, and announces `forgefixture.local`
|
||||||
|
over mDNS with a `_forgefixture._tcp` service. It reconnects forever and
|
||||||
|
never sleeps the radio. The API is HTTP on port 80, JSON, LAN only, no
|
||||||
|
OTA (the flash happens over USB and nowhere else).
|
||||||
|
|
||||||
|
Every request carries the key in `X-Fixture-Key`; without it, or with
|
||||||
|
a wrong one, every path answers 401 and the attempt is logged with the
|
||||||
|
caller's address.
|
||||||
|
|
||||||
|
| Request | Does |
|
||||||
|
|---|---|
|
||||||
|
| `GET /` or `/state` | identity, firmware version, uptime, the three channels' states, whether the button is enabled, the wifi link |
|
||||||
|
| `POST /lid {"state":"open"}` | energizes the lid channel (the loop opens); `"close"` releases it |
|
||||||
|
| `POST /interlock {"state":"open"}` | the same for the interlock loop |
|
||||||
|
| `POST /button {"ms":200}` | one press, `ms` clamped into 20 to 500 (200 when absent); 409 while the enable jumper is out or a press is still in progress |
|
||||||
|
| `POST /release` | every channel released |
|
||||||
|
|
||||||
|
An action answers with the state as `GET /` shows it. The fixture does
|
||||||
|
not read the machine's switches; the tool verifies every action through
|
||||||
|
the machine's own readings, which is the point.
|
||||||
|
|
||||||
|
curl -s -H "X-Fixture-Key: $KEY" http://forgefixture.local/
|
||||||
|
curl -s -H "X-Fixture-Key: $KEY" -d '{"state":"open"}' http://forgefixture.local/lid
|
||||||
|
|
||||||
|
## The tool's side
|
||||||
|
|
||||||
|
`forgetest` looks for `/data/forgetest/fixture.json` on the machine:
|
||||||
|
|
||||||
|
{"hostname": "forgefixture", "key": "<the API_KEY>", "ip": null,
|
||||||
|
"channels": ["lid", "interlock", "button"], "arm_press": false}
|
||||||
|
|
||||||
|
`ip` overrides the mDNS lookup (the tool resolves `<hostname>.local`
|
||||||
|
itself; the image has no mDNS resolver). `channels` names what is
|
||||||
|
wired. `arm_press` stays false unless the fixture may press the button
|
||||||
|
to arm the laser for a live test; by default that press is a person's.
|
||||||
|
The file is mode 0600 and bench-local. With the fixture up, an
|
||||||
|
`operator` test whose actions it covers runs in the unattended queue;
|
||||||
|
`live` tests keep their kind and their acknowledgment, and every action
|
||||||
|
records who performed it. Presses are spaced by the tool: the next one
|
||||||
|
waits for the last pulse to end plus a 300 ms release, so the machine
|
||||||
|
sees two presses and not one long one.
|
||||||
|
|
||||||
|
## What keeps it safe
|
||||||
|
|
||||||
|
- Every line is driven low first thing at boot, before the radio or the
|
||||||
|
server exist, and after any reset.
|
||||||
|
- The button is a pulse whose end is armed before the line rises; a hung
|
||||||
|
task trips the task watchdog, which panics and reboots to all-low.
|
||||||
|
- The button channel needs the jumper; a LAN key leak cannot press the
|
||||||
|
button on a fixture whose jumper is out.
|
||||||
|
- NC contacts in the loops: a fixture fault can only add an open, never
|
||||||
|
mask a real lid open or a pulled interlock.
|
||||||
|
- No OTA, no configuration over the network, nothing persisted beyond
|
||||||
|
the wifi driver's own calibration.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
main/ the firmware: main.c, wifi.c, api.c, relays.c, policy.c (the pure decisions)
|
||||||
|
test/ the host test of policy.c (./fixture.sh test)
|
||||||
|
fixture.env.example, sdkconfig.defaults, dependencies.lock
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
dependencies:
|
||||||
|
espressif/mdns:
|
||||||
|
component_hash: e81ca7a7f53ea34e78274df054da692c272e1315572876b237b1748e267c013b
|
||||||
|
dependencies:
|
||||||
|
- name: idf
|
||||||
|
require: private
|
||||||
|
version: '>=5.0'
|
||||||
|
source:
|
||||||
|
registry_url: https://components.espressif.com/
|
||||||
|
type: service
|
||||||
|
version: 1.11.3
|
||||||
|
idf:
|
||||||
|
source:
|
||||||
|
type: idf
|
||||||
|
version: 5.5.5
|
||||||
|
direct_dependencies:
|
||||||
|
- espressif/mdns
|
||||||
|
- idf
|
||||||
|
manifest_hash: 90c16fd2316c504ccf02e5191bcb9886c6850e5b89a8d08c35e1539ead49703d
|
||||||
|
target: esp32s3
|
||||||
|
version: 2.0.0
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# forgefixture build settings. Copy to fixture.env (git-ignored) and fill
|
||||||
|
# in; `./fixture.sh env` does the copy and generates the API key.
|
||||||
|
# KEY=value, one per line; quotes around a value are optional.
|
||||||
|
|
||||||
|
# The wifi network the fixture joins (2.4 GHz; the ESP32-S3 has no 5 GHz).
|
||||||
|
WIFI_SSID=
|
||||||
|
WIFI_PSK=
|
||||||
|
|
||||||
|
# The key every API request must carry in X-Fixture-Key. The same value
|
||||||
|
# goes into the bench's /data/forgetest/fixture.json. Any string; 32 hex
|
||||||
|
# characters from `openssl rand -hex 16` is what `./fixture.sh env` writes.
|
||||||
|
API_KEY=
|
||||||
|
|
||||||
|
# The fixture's hostname: announced over mDNS (<HOSTNAME>.local) and in
|
||||||
|
# its DHCP request. Leave empty for forgefixture.
|
||||||
|
HOSTNAME=
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# forgefixture: build, flash and watch the bench actuator's firmware.
|
||||||
|
#
|
||||||
|
# ./fixture.sh env fixture.env from the example, with a fresh API key
|
||||||
|
# ./fixture.sh build build (native idf.py if installed, else the IDF container)
|
||||||
|
# ./fixture.sh flash [PORT] write the build to the board over USB (esptool)
|
||||||
|
# ./fixture.sh monitor [PORT] the board's log (idf.py monitor, or pyserial's miniterm)
|
||||||
|
# ./fixture.sh test the host test of the policy (needs a C compiler)
|
||||||
|
#
|
||||||
|
# The container build needs docker or podman and pulls espressif/idf once
|
||||||
|
# (IDF_IMAGE below); flashing never runs in the container: esptool is a
|
||||||
|
# pip package (pip install esptool) and talks to the port directly.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
IDF_IMAGE="${IDF_IMAGE:-docker.io/espressif/idf:v5.5.5}"
|
||||||
|
CHIP=esp32s3
|
||||||
|
BAUD="${BAUD:-460800}"
|
||||||
|
|
||||||
|
have() { command -v "$1" >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
runner() {
|
||||||
|
if have docker; then echo docker; elif have podman; then echo podman; else
|
||||||
|
echo "neither idf.py nor docker/podman found: install ESP-IDF, or a container runtime for $IDF_IMAGE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# A Windows path for the bind mount when run from Git Bash, which would
|
||||||
|
# otherwise rewrite /project into a Windows path.
|
||||||
|
host_path() {
|
||||||
|
if have cygpath; then cygpath -w "$PWD"; else echo "$PWD"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_env() {
|
||||||
|
if [ -e fixture.env ]; then
|
||||||
|
echo "fixture.env exists; edit it, or remove it first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
key=$( (have openssl && openssl rand -hex 16) || python3 -c 'import secrets; print(secrets.token_hex(16))')
|
||||||
|
sed -e "s/^API_KEY=.*/API_KEY=$key/" fixture.env.example > fixture.env
|
||||||
|
chmod 600 fixture.env 2>/dev/null || true
|
||||||
|
echo "fixture.env written with a fresh API key; fill in WIFI_SSID and WIFI_PSK"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_build() {
|
||||||
|
[ -e fixture.env ] || { echo "no fixture.env: run ./fixture.sh env first" >&2; exit 1; }
|
||||||
|
# The file holds the WiFi PSK and the API key: nobody but the owner
|
||||||
|
# reads it (an MSYS shell cannot express the mode, so it is not judged).
|
||||||
|
if [ "$(uname -o 2>/dev/null)" != "Msys" ] && [ -n "$(find fixture.env -perm /077 2>/dev/null)" ]; then
|
||||||
|
echo "fixture.env is group- or world-readable: chmod 600 fixture.env" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if have idf.py; then
|
||||||
|
idf.py set-target "$CHIP" >/dev/null
|
||||||
|
idf.py build
|
||||||
|
else
|
||||||
|
r=$(runner)
|
||||||
|
MSYS_NO_PATHCONV=1 "$r" run --rm -v "$(host_path):/project" -w /project -e HOME=/tmp "$IDF_IMAGE" \
|
||||||
|
bash -c "idf.py set-target $CHIP >/dev/null && idf.py build"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
port_arg() {
|
||||||
|
if [ -n "${1:-}" ]; then echo "$1"; elif [ -n "${PORT:-}" ]; then echo "$PORT"; else
|
||||||
|
echo "which port? ./fixture.sh flash PORT (COM5, /dev/ttyUSB0, /dev/cu.usbserial-*)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_flash() {
|
||||||
|
port=$(port_arg "${1:-}")
|
||||||
|
[ -e build/flash_args ] || { echo "no build yet: ./fixture.sh build" >&2; exit 1; }
|
||||||
|
if have idf.py; then
|
||||||
|
idf.py -p "$port" flash
|
||||||
|
else
|
||||||
|
have esptool.py || have esptool || { echo "esptool not found: pip install esptool" >&2; exit 1; }
|
||||||
|
tool=$(have esptool.py && echo esptool.py || echo esptool)
|
||||||
|
(cd build && "$tool" --chip "$CHIP" -p "$port" -b "$BAUD" --before default_reset --after hard_reset \
|
||||||
|
write_flash @flash_args)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_monitor() {
|
||||||
|
port=$(port_arg "${1:-}")
|
||||||
|
if have idf.py; then
|
||||||
|
idf.py -p "$port" monitor
|
||||||
|
else
|
||||||
|
python3 -m serial.tools.miniterm --raw "$port" 115200
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_test() {
|
||||||
|
sh test/run.sh
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
env) cmd_env ;;
|
||||||
|
build) cmd_build ;;
|
||||||
|
flash) cmd_flash "${2:-}" ;;
|
||||||
|
monitor) cmd_monitor "${2:-}" ;;
|
||||||
|
test) cmd_test ;;
|
||||||
|
*) sed -n '2,12p' "$0"; exit 1 ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# The fixture's one input is fixture.env at the project root: KEY=value
|
||||||
|
# lines, read here into a generated header. No file, or a required key
|
||||||
|
# left empty, stops the build with the reason.
|
||||||
|
set(FIXTURE_ENV "${CMAKE_CURRENT_LIST_DIR}/../fixture.env")
|
||||||
|
if(NOT EXISTS "${FIXTURE_ENV}")
|
||||||
|
message(FATAL_ERROR
|
||||||
|
"fixture.env not found at ${FIXTURE_ENV}: copy fixture.env.example to "
|
||||||
|
"fixture.env and fill in WIFI_SSID, WIFI_PSK and API_KEY (./fixture.sh env does it)")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(FIXTURE_WIFI_SSID "")
|
||||||
|
set(FIXTURE_WIFI_PSK "")
|
||||||
|
set(FIXTURE_API_KEY "")
|
||||||
|
set(FIXTURE_HOSTNAME "")
|
||||||
|
|
||||||
|
# Read the file whole and split on newlines ourselves: file(STRINGS)
|
||||||
|
# would also split on semicolons, which a passphrase may carry.
|
||||||
|
file(READ "${FIXTURE_ENV}" _env_text)
|
||||||
|
string(REPLACE ";" "\\;" _env_text "${_env_text}")
|
||||||
|
string(REGEX REPLACE "\r?\n" ";" _env_lines "${_env_text}")
|
||||||
|
foreach(_line IN LISTS _env_lines)
|
||||||
|
if(_line MATCHES "^[ \t]*#" OR _line MATCHES "^[ \t]*$")
|
||||||
|
continue()
|
||||||
|
endif()
|
||||||
|
if(_line MATCHES "^[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=(.*)$")
|
||||||
|
set(_key "${CMAKE_MATCH_1}")
|
||||||
|
set(_val "${CMAKE_MATCH_2}")
|
||||||
|
string(STRIP "${_val}" _val)
|
||||||
|
string(REGEX REPLACE "^\"(.*)\"$" "\\1" _val "${_val}")
|
||||||
|
string(REGEX REPLACE "^'(.*)'$" "\\1" _val "${_val}")
|
||||||
|
set(FIXTURE_${_key} "${_val}")
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "fixture.env: cannot read line: ${_line}")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
foreach(_key WIFI_SSID API_KEY)
|
||||||
|
if("${FIXTURE_${_key}}" STREQUAL "")
|
||||||
|
message(FATAL_ERROR "fixture.env: ${_key} is empty")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
if("${FIXTURE_HOSTNAME}" STREQUAL "")
|
||||||
|
set(FIXTURE_HOSTNAME "forgefixture")
|
||||||
|
endif()
|
||||||
|
string(LENGTH "${FIXTURE_HOSTNAME}" _hostlen)
|
||||||
|
if(NOT FIXTURE_HOSTNAME MATCHES "^[a-z0-9][a-z0-9-]*$" OR FIXTURE_HOSTNAME MATCHES "-$" OR _hostlen GREATER 32)
|
||||||
|
message(FATAL_ERROR "fixture.env: HOSTNAME must be a DNS label (lowercase letters, digits, hyphens; 32 at most)")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Into C string literals.
|
||||||
|
foreach(_key WIFI_SSID WIFI_PSK API_KEY HOSTNAME)
|
||||||
|
string(REPLACE "\\" "\\\\" FIXTURE_${_key} "${FIXTURE_${_key}}")
|
||||||
|
string(REPLACE "\"" "\\\"" FIXTURE_${_key} "${FIXTURE_${_key}}")
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
set(_gen_dir "${CMAKE_CURRENT_BINARY_DIR}/generated")
|
||||||
|
file(MAKE_DIRECTORY "${_gen_dir}")
|
||||||
|
configure_file("${CMAKE_CURRENT_LIST_DIR}/fixture_config.h.in" "${_gen_dir}/fixture_config.h" @ONLY)
|
||||||
|
|
||||||
|
idf_component_register(
|
||||||
|
SRCS "main.c" "wifi.c" "relays.c" "api.c" "policy.c"
|
||||||
|
INCLUDE_DIRS "." "${_gen_dir}"
|
||||||
|
REQUIRES nvs_flash esp_wifi esp_netif esp_event esp_http_server json esp_timer driver
|
||||||
|
)
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/*
|
||||||
|
* forgefixture API (see api.h).
|
||||||
|
*
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
|
#include "api.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
|
||||||
|
#include "cJSON.h"
|
||||||
|
#include "esp_http_server.h"
|
||||||
|
#include "esp_idf_version.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
#include "lwip/sockets.h"
|
||||||
|
|
||||||
|
#include "policy.h"
|
||||||
|
#include "relays.h"
|
||||||
|
#include "version.h"
|
||||||
|
#include "wifi.h"
|
||||||
|
|
||||||
|
static const char *TAG = "api";
|
||||||
|
|
||||||
|
static const char *s_key;
|
||||||
|
static const char *s_hostname;
|
||||||
|
|
||||||
|
#define BODY_MAX 256
|
||||||
|
|
||||||
|
/* The peer's address for the log: every action is attributed. */
|
||||||
|
static void peer(httpd_req_t *req, char *buf, size_t n)
|
||||||
|
{
|
||||||
|
struct sockaddr_in addr; /* IPv4 only: the fixture speaks nothing else */
|
||||||
|
socklen_t len = sizeof(addr);
|
||||||
|
int fd = httpd_req_to_sockfd(req);
|
||||||
|
buf[0] = '\0';
|
||||||
|
if (fd >= 0 && getpeername(fd, (struct sockaddr *)&addr, &len) == 0 && addr.sin_family == AF_INET)
|
||||||
|
inet_ntoa_r(addr.sin_addr, buf, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t send_json(httpd_req_t *req, const char *status, cJSON *obj)
|
||||||
|
{
|
||||||
|
char *text = cJSON_PrintUnformatted(obj);
|
||||||
|
cJSON_Delete(obj);
|
||||||
|
if (text == NULL)
|
||||||
|
return httpd_resp_send_500(req);
|
||||||
|
httpd_resp_set_status(req, status);
|
||||||
|
httpd_resp_set_type(req, "application/json");
|
||||||
|
esp_err_t r = httpd_resp_send(req, text, HTTPD_RESP_USE_STRLEN);
|
||||||
|
cJSON_free(text);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t send_error(httpd_req_t *req, const char *status, const char *msg)
|
||||||
|
{
|
||||||
|
cJSON *o = cJSON_CreateObject();
|
||||||
|
cJSON_AddStringToObject(o, "error", msg);
|
||||||
|
return send_json(req, status, o);
|
||||||
|
}
|
||||||
|
|
||||||
|
static cJSON *state_json(void)
|
||||||
|
{
|
||||||
|
relays_state_t st = relays_state();
|
||||||
|
char ip[40];
|
||||||
|
wifi_ip(ip, sizeof(ip));
|
||||||
|
|
||||||
|
cJSON *o = cJSON_CreateObject();
|
||||||
|
cJSON_AddStringToObject(o, "device", "forgefixture");
|
||||||
|
cJSON_AddStringToObject(o, "hostname", s_hostname);
|
||||||
|
cJSON_AddStringToObject(o, "version", FIXTURE_VERSION);
|
||||||
|
cJSON_AddStringToObject(o, "idf", esp_get_idf_version());
|
||||||
|
cJSON_AddNumberToObject(o, "uptime_s", (double)(esp_timer_get_time() / 1000000LL));
|
||||||
|
cJSON *ch = cJSON_AddObjectToObject(o, "channels");
|
||||||
|
for (int i = 0; i < CH_COUNT; i++)
|
||||||
|
cJSON_AddStringToObject(ch, policy_channel_name((channel_t)i),
|
||||||
|
policy_state_name((channel_t)i, st.energized[i]));
|
||||||
|
cJSON_AddBoolToObject(o, "button_enabled", st.button_enabled);
|
||||||
|
cJSON_AddBoolToObject(o, "button_pulsing", st.button_pulsing);
|
||||||
|
cJSON *w = cJSON_AddObjectToObject(o, "wifi");
|
||||||
|
cJSON_AddBoolToObject(w, "connected", wifi_connected());
|
||||||
|
cJSON_AddStringToObject(w, "ip", ip);
|
||||||
|
cJSON_AddNumberToObject(w, "rssi", wifi_rssi());
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The key check, before anything else on every path. */
|
||||||
|
static bool authorized(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
char presented[96] = {0};
|
||||||
|
esp_err_t r = httpd_req_get_hdr_value_str(req, "X-Fixture-Key", presented, sizeof(presented));
|
||||||
|
bool ok = (r == ESP_OK) && policy_key_matches(presented, s_key);
|
||||||
|
if (!ok) {
|
||||||
|
char who[48];
|
||||||
|
peer(req, who, sizeof(who));
|
||||||
|
ESP_LOGW(TAG, "%s %s from %s: refused (%s)", http_method_str(req->method), req->uri, who,
|
||||||
|
r == ESP_OK ? "wrong key" : "no key");
|
||||||
|
send_error(req, "401 Unauthorized", "X-Fixture-Key missing or wrong");
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The JSON body, or NULL (an error already sent). An empty body parses
|
||||||
|
* as an empty object so "POST /button" alone means the default pulse. */
|
||||||
|
static cJSON *body_json(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
char buf[BODY_MAX + 1];
|
||||||
|
if (req->content_len > BODY_MAX) {
|
||||||
|
send_error(req, "413 Payload Too Large", "body over 256 bytes");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
size_t got = 0;
|
||||||
|
while (got < req->content_len) {
|
||||||
|
int n = httpd_req_recv(req, buf + got, req->content_len - got);
|
||||||
|
if (n <= 0) {
|
||||||
|
send_error(req, "400 Bad Request", "body not received");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
got += (size_t)n;
|
||||||
|
}
|
||||||
|
buf[got] = '\0';
|
||||||
|
cJSON *o = got ? cJSON_Parse(buf) : cJSON_CreateObject();
|
||||||
|
if (o == NULL || !cJSON_IsObject(o)) {
|
||||||
|
cJSON_Delete(o);
|
||||||
|
send_error(req, "400 Bad Request", "body is not a JSON object");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t h_state(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
if (!authorized(req))
|
||||||
|
return ESP_OK;
|
||||||
|
return send_json(req, "200 OK", state_json());
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t h_loop(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
if (!authorized(req))
|
||||||
|
return ESP_OK;
|
||||||
|
int ch = policy_channel_from_path(req->uri);
|
||||||
|
cJSON *body = body_json(req);
|
||||||
|
if (body == NULL)
|
||||||
|
return ESP_OK;
|
||||||
|
const cJSON *state = cJSON_GetObjectItemCaseSensitive(body, "state");
|
||||||
|
bool energize;
|
||||||
|
if (!cJSON_IsString(state) || !policy_parse_loop_state(state->valuestring, &energize)) {
|
||||||
|
cJSON_Delete(body);
|
||||||
|
return send_error(req, "400 Bad Request", "state must be \"open\" or \"close\"");
|
||||||
|
}
|
||||||
|
cJSON_Delete(body);
|
||||||
|
char who[48];
|
||||||
|
peer(req, who, sizeof(who));
|
||||||
|
ESP_LOGI(TAG, "%s %s by %s", policy_channel_name((channel_t)ch), energize ? "open" : "close", who);
|
||||||
|
relays_set_loop((channel_t)ch, energize);
|
||||||
|
return send_json(req, "200 OK", state_json());
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t h_button(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
if (!authorized(req))
|
||||||
|
return ESP_OK;
|
||||||
|
cJSON *body = body_json(req);
|
||||||
|
if (body == NULL)
|
||||||
|
return ESP_OK;
|
||||||
|
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(body, "ms");
|
||||||
|
int want = cJSON_IsNumber(ms) ? (int)ms->valuedouble : 0;
|
||||||
|
cJSON_Delete(body);
|
||||||
|
int pulse = policy_button_ms(want);
|
||||||
|
char who[48];
|
||||||
|
peer(req, who, sizeof(who));
|
||||||
|
esp_err_t r = relays_pulse_button(pulse);
|
||||||
|
if (r == ESP_ERR_NOT_ALLOWED) {
|
||||||
|
ESP_LOGW(TAG, "button press by %s refused: enable jumper out", who);
|
||||||
|
return send_error(req, "409 Conflict", "button disabled: the enable jumper is out");
|
||||||
|
}
|
||||||
|
if (r == ESP_ERR_INVALID_STATE) {
|
||||||
|
ESP_LOGW(TAG, "button press by %s refused: a pulse is in progress", who);
|
||||||
|
return send_error(req, "409 Conflict", "a button pulse is in progress");
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "button %d ms by %s", pulse, who);
|
||||||
|
cJSON *o = state_json();
|
||||||
|
cJSON_AddNumberToObject(o, "pulse_ms", pulse);
|
||||||
|
return send_json(req, "200 OK", o);
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t h_release(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
if (!authorized(req))
|
||||||
|
return ESP_OK;
|
||||||
|
char who[48];
|
||||||
|
peer(req, who, sizeof(who));
|
||||||
|
ESP_LOGI(TAG, "release by %s", who);
|
||||||
|
relays_release();
|
||||||
|
return send_json(req, "200 OK", state_json());
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t h_404(httpd_req_t *req, httpd_err_code_t err)
|
||||||
|
{
|
||||||
|
(void)err;
|
||||||
|
return send_error(req, "404 Not Found", "no such path");
|
||||||
|
}
|
||||||
|
|
||||||
|
void api_start(const char *api_key, const char *hostname)
|
||||||
|
{
|
||||||
|
s_key = api_key;
|
||||||
|
s_hostname = hostname;
|
||||||
|
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
|
||||||
|
cfg.server_port = 80;
|
||||||
|
cfg.lru_purge_enable = true;
|
||||||
|
cfg.max_open_sockets = 4;
|
||||||
|
httpd_handle_t srv = NULL;
|
||||||
|
ESP_ERROR_CHECK(httpd_start(&srv, &cfg));
|
||||||
|
|
||||||
|
static const httpd_uri_t routes[] = {
|
||||||
|
{.uri = "/", .method = HTTP_GET, .handler = h_state},
|
||||||
|
{.uri = "/state", .method = HTTP_GET, .handler = h_state},
|
||||||
|
{.uri = "/lid", .method = HTTP_POST, .handler = h_loop},
|
||||||
|
{.uri = "/interlock", .method = HTTP_POST, .handler = h_loop},
|
||||||
|
{.uri = "/button", .method = HTTP_POST, .handler = h_button},
|
||||||
|
{.uri = "/release", .method = HTTP_POST, .handler = h_release},
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++)
|
||||||
|
ESP_ERROR_CHECK(httpd_register_uri_handler(srv, &routes[i]));
|
||||||
|
httpd_register_err_handler(srv, HTTPD_404_NOT_FOUND, h_404);
|
||||||
|
ESP_LOGI(TAG, "listening on :%d", cfg.server_port);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* forgefixture API: HTTP on port 80, JSON, every request under the key.
|
||||||
|
*
|
||||||
|
* GET / identity, uptime, the channels' states, the
|
||||||
|
* enable jumper, the wifi link
|
||||||
|
* GET /state the same
|
||||||
|
* POST /lid {"state": "open" | "close"}
|
||||||
|
* POST /interlock {"state": "open" | "close"}
|
||||||
|
* POST /button {"ms": 200} one pulse, clamped to 20..500 ms
|
||||||
|
* POST /release every channel released
|
||||||
|
*
|
||||||
|
* The key travels in the X-Fixture-Key header; without it, or with a
|
||||||
|
* wrong one, every path answers 401. A lid or interlock "open" energizes
|
||||||
|
* the channel (the loop opens); "close" releases it. The button is
|
||||||
|
* refused with 409 while the enable jumper is out or a pulse is still in
|
||||||
|
* progress. Errors are {"error": "..."}; actions answer with the state
|
||||||
|
* as GET / shows it.
|
||||||
|
*
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
void api_start(const char *api_key, const char *hostname);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/* Generated from fixture.env by main/CMakeLists.txt. Do not edit. */
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#define FIXTURE_WIFI_SSID "@FIXTURE_WIFI_SSID@"
|
||||||
|
#define FIXTURE_WIFI_PSK "@FIXTURE_WIFI_PSK@"
|
||||||
|
#define FIXTURE_API_KEY "@FIXTURE_API_KEY@"
|
||||||
|
#define FIXTURE_HOSTNAME "@FIXTURE_HOSTNAME@"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
## The managed components this firmware uses (fetched by the IDF
|
||||||
|
## component manager on the first build; dependencies.lock pins them).
|
||||||
|
dependencies:
|
||||||
|
idf: ">=5.3"
|
||||||
|
espressif/mdns: "^1.11.3"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* forgefixture: the ForgeFIRM bench actuator. An ESP32-S3 on the bench
|
||||||
|
* network drives three relays at the machine's connectors (the lid
|
||||||
|
* loop, the interlock loop, the button) so the acceptance tool can open
|
||||||
|
* a lid, pull an interlock or press the button without a hand in the
|
||||||
|
* room. The firmware's whole job is to do exactly that on request, and
|
||||||
|
* nothing at all otherwise: every line low at boot and after any reset,
|
||||||
|
* the button only ever pulsed, every request under a key.
|
||||||
|
*
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "nvs_flash.h"
|
||||||
|
|
||||||
|
#include "api.h"
|
||||||
|
#include "fixture_config.h"
|
||||||
|
#include "relays.h"
|
||||||
|
#include "version.h"
|
||||||
|
#include "wifi.h"
|
||||||
|
|
||||||
|
static const char *TAG = "forgefixture";
|
||||||
|
|
||||||
|
void app_main(void)
|
||||||
|
{
|
||||||
|
/* The relays first: whatever else happens at boot, the lines are low
|
||||||
|
* before the radio or the server exist. */
|
||||||
|
relays_init();
|
||||||
|
|
||||||
|
esp_err_t r = nvs_flash_init();
|
||||||
|
if (r == ESP_ERR_NVS_NO_FREE_PAGES || r == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||||
|
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||||
|
r = nvs_flash_init();
|
||||||
|
}
|
||||||
|
ESP_ERROR_CHECK(r);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "forgefixture %s, hostname %s", FIXTURE_VERSION, FIXTURE_HOSTNAME);
|
||||||
|
wifi_start(FIXTURE_HOSTNAME, FIXTURE_WIFI_SSID, FIXTURE_WIFI_PSK);
|
||||||
|
api_start(FIXTURE_API_KEY, FIXTURE_HOSTNAME);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* forgefixture policy (see policy.h). No ESP-IDF here: test/policy_test.c
|
||||||
|
* compiles this file with a host compiler.
|
||||||
|
*
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
|
#include "policy.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static const char *const NAMES[CH_COUNT] = {"lid", "interlock", "button"};
|
||||||
|
|
||||||
|
const char *policy_channel_name(channel_t ch)
|
||||||
|
{
|
||||||
|
return (ch >= 0 && ch < CH_COUNT) ? NAMES[ch] : "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
int policy_channel_from_path(const char *path)
|
||||||
|
{
|
||||||
|
if (path == NULL || path[0] != '/')
|
||||||
|
return -1;
|
||||||
|
for (int i = 0; i < CH_COUNT; i++)
|
||||||
|
if (strcmp(path + 1, NAMES[i]) == 0)
|
||||||
|
return i;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool policy_parse_loop_state(const char *word, bool *energize)
|
||||||
|
{
|
||||||
|
if (word == NULL)
|
||||||
|
return false;
|
||||||
|
if (strcmp(word, "open") == 0) {
|
||||||
|
*energize = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (strcmp(word, "close") == 0 || strcmp(word, "closed") == 0) {
|
||||||
|
*energize = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int policy_button_ms(int requested)
|
||||||
|
{
|
||||||
|
if (requested <= 0)
|
||||||
|
return BUTTON_DEFAULT_MS;
|
||||||
|
if (requested < BUTTON_MIN_MS)
|
||||||
|
return BUTTON_MIN_MS;
|
||||||
|
if (requested > BUTTON_MAX_MS)
|
||||||
|
return BUTTON_MAX_MS;
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *policy_state_name(channel_t ch, bool energized)
|
||||||
|
{
|
||||||
|
if (ch == CH_BUTTON)
|
||||||
|
return energized ? "pressed" : "idle";
|
||||||
|
return energized ? "open" : "closed";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool policy_key_matches(const char *presented, const char *expected)
|
||||||
|
{
|
||||||
|
if (presented == NULL || expected == NULL)
|
||||||
|
return false;
|
||||||
|
size_t lp = strlen(presented), le = strlen(expected);
|
||||||
|
size_t n = lp > le ? lp : le;
|
||||||
|
unsigned diff = (unsigned)(lp != le);
|
||||||
|
for (size_t i = 0; i < n; i++) {
|
||||||
|
unsigned char a = i < lp ? (unsigned char)presented[i] : 0;
|
||||||
|
unsigned char b = i < le ? (unsigned char)expected[i] : 0;
|
||||||
|
diff |= (unsigned)(a ^ b);
|
||||||
|
}
|
||||||
|
return diff == 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* forgefixture policy: the decisions that need no hardware, kept apart
|
||||||
|
* so the host test can hold them to account. What a channel is called,
|
||||||
|
* what a request may ask of it, and how long a button press may last.
|
||||||
|
*
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
/* The three channels, in the order of the GPIO table in relays.c. */
|
||||||
|
typedef enum {
|
||||||
|
CH_LID = 0, /* NC contact in the lid-switch loop: energized = loop open */
|
||||||
|
CH_INTERLOCK = 1, /* NC contact in the interlock loop: energized = loop open */
|
||||||
|
CH_BUTTON = 2, /* NO contact across the button input: energized = pressed */
|
||||||
|
CH_COUNT = 3
|
||||||
|
} channel_t;
|
||||||
|
|
||||||
|
/* A button press is a pulse and nothing else: never held, never longer
|
||||||
|
* than BUTTON_MAX_MS whatever the request says, never shorter than the
|
||||||
|
* debounce the machine's input needs to see it. */
|
||||||
|
#define BUTTON_DEFAULT_MS 200
|
||||||
|
#define BUTTON_MIN_MS 20
|
||||||
|
#define BUTTON_MAX_MS 500
|
||||||
|
|
||||||
|
/* The channel's name on the API and in the log. */
|
||||||
|
const char *policy_channel_name(channel_t ch);
|
||||||
|
|
||||||
|
/* The channel named by an API path ("/lid", "/interlock", "/button"), or
|
||||||
|
* -1. */
|
||||||
|
int policy_channel_from_path(const char *path);
|
||||||
|
|
||||||
|
/* The level a lid or interlock request asks for: "open" energizes the
|
||||||
|
* channel (the loop opens), "close" or "closed" releases it. Returns
|
||||||
|
* false for any other word. */
|
||||||
|
bool policy_parse_loop_state(const char *word, bool *energize);
|
||||||
|
|
||||||
|
/* The pulse a button request gets: the default for 0 or an absent
|
||||||
|
* value, otherwise the request clamped into [BUTTON_MIN_MS,
|
||||||
|
* BUTTON_MAX_MS]. A negative request is the default too. */
|
||||||
|
int policy_button_ms(int requested);
|
||||||
|
|
||||||
|
/* What the state of a channel is called on the API: a loop channel is
|
||||||
|
* "open" or "closed", the button "pressed" or "idle". */
|
||||||
|
const char *policy_state_name(channel_t ch, bool energized);
|
||||||
|
|
||||||
|
/* Constant-time equality of two keys, so a wrong key costs the same
|
||||||
|
* whichever byte is wrong. */
|
||||||
|
bool policy_key_matches(const char *presented, const char *expected);
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/*
|
||||||
|
* forgefixture relays (see relays.h).
|
||||||
|
*
|
||||||
|
* The safety argument lives here: the lid and interlock contacts are
|
||||||
|
* normally closed and the button contact normally open, so a line that
|
||||||
|
* is low leaves the machine exactly as it is without the fixture. Every
|
||||||
|
* line is driven low first thing at boot, after any reset, and by
|
||||||
|
* relays_release(); the button is only ever pulsed, its end set by a
|
||||||
|
* one-shot timer that is armed before the line goes high.
|
||||||
|
*
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
|
#include "relays.h"
|
||||||
|
|
||||||
|
#include "driver/gpio.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/semphr.h"
|
||||||
|
|
||||||
|
static const char *TAG = "relays";
|
||||||
|
|
||||||
|
static const gpio_num_t GPIOS[CH_COUNT] = {RELAY_GPIO_LID, RELAY_GPIO_INTERLOCK, RELAY_GPIO_BUTTON};
|
||||||
|
|
||||||
|
static SemaphoreHandle_t s_lock;
|
||||||
|
static esp_timer_handle_t s_pulse_end;
|
||||||
|
static bool s_energized[CH_COUNT];
|
||||||
|
static bool s_pulsing;
|
||||||
|
|
||||||
|
static void drive(channel_t ch, bool level)
|
||||||
|
{
|
||||||
|
gpio_set_level(GPIOS[ch], level ? 1 : 0);
|
||||||
|
s_energized[ch] = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void pulse_end(void *arg)
|
||||||
|
{
|
||||||
|
(void)arg;
|
||||||
|
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||||
|
drive(CH_BUTTON, false);
|
||||||
|
s_pulsing = false;
|
||||||
|
xSemaphoreGive(s_lock);
|
||||||
|
ESP_LOGI(TAG, "button released");
|
||||||
|
}
|
||||||
|
|
||||||
|
void relays_init(void)
|
||||||
|
{
|
||||||
|
/* Low before the pins become outputs: the DevKit's pull state at
|
||||||
|
* reset is not a relay's idea of off. */
|
||||||
|
for (int i = 0; i < CH_COUNT; i++) {
|
||||||
|
gpio_reset_pin(GPIOS[i]);
|
||||||
|
gpio_set_level(GPIOS[i], 0);
|
||||||
|
gpio_set_direction(GPIOS[i], GPIO_MODE_OUTPUT);
|
||||||
|
gpio_set_level(GPIOS[i], 0);
|
||||||
|
s_energized[i] = false;
|
||||||
|
}
|
||||||
|
gpio_reset_pin(ENABLE_GPIO_BUTTON);
|
||||||
|
gpio_set_direction(ENABLE_GPIO_BUTTON, GPIO_MODE_INPUT);
|
||||||
|
gpio_set_pull_mode(ENABLE_GPIO_BUTTON, GPIO_PULLUP_ONLY);
|
||||||
|
|
||||||
|
s_lock = xSemaphoreCreateMutex();
|
||||||
|
const esp_timer_create_args_t args = {
|
||||||
|
.callback = pulse_end,
|
||||||
|
.name = "button-pulse",
|
||||||
|
};
|
||||||
|
ESP_ERROR_CHECK(esp_timer_create(&args, &s_pulse_end));
|
||||||
|
ESP_LOGI(TAG, "lid GPIO%d, interlock GPIO%d, button GPIO%d (enable jumper GPIO%d): all released",
|
||||||
|
RELAY_GPIO_LID, RELAY_GPIO_INTERLOCK, RELAY_GPIO_BUTTON, ENABLE_GPIO_BUTTON);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool button_enabled(void)
|
||||||
|
{
|
||||||
|
return gpio_get_level(ENABLE_GPIO_BUTTON) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t relays_set_loop(channel_t ch, bool energize)
|
||||||
|
{
|
||||||
|
if (ch != CH_LID && ch != CH_INTERLOCK)
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||||
|
drive(ch, energize);
|
||||||
|
xSemaphoreGive(s_lock);
|
||||||
|
ESP_LOGI(TAG, "%s %s", policy_channel_name(ch), policy_state_name(ch, energize));
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t relays_pulse_button(int ms)
|
||||||
|
{
|
||||||
|
if (!button_enabled())
|
||||||
|
return ESP_ERR_NOT_ALLOWED;
|
||||||
|
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||||
|
if (s_pulsing) {
|
||||||
|
xSemaphoreGive(s_lock);
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
s_pulsing = true;
|
||||||
|
/* The end is armed before the line rises. */
|
||||||
|
ESP_ERROR_CHECK(esp_timer_start_once(s_pulse_end, (uint64_t)ms * 1000ULL));
|
||||||
|
drive(CH_BUTTON, true);
|
||||||
|
xSemaphoreGive(s_lock);
|
||||||
|
ESP_LOGI(TAG, "button pressed for %d ms", ms);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void relays_release(void)
|
||||||
|
{
|
||||||
|
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||||
|
esp_timer_stop(s_pulse_end); /* harmless when not running */
|
||||||
|
for (int i = 0; i < CH_COUNT; i++)
|
||||||
|
drive((channel_t)i, false);
|
||||||
|
s_pulsing = false;
|
||||||
|
xSemaphoreGive(s_lock);
|
||||||
|
ESP_LOGI(TAG, "all released");
|
||||||
|
}
|
||||||
|
|
||||||
|
relays_state_t relays_state(void)
|
||||||
|
{
|
||||||
|
relays_state_t st;
|
||||||
|
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||||
|
for (int i = 0; i < CH_COUNT; i++)
|
||||||
|
st.energized[i] = s_energized[i];
|
||||||
|
st.button_pulsing = s_pulsing;
|
||||||
|
xSemaphoreGive(s_lock);
|
||||||
|
st.button_enabled = button_enabled();
|
||||||
|
return st;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* forgefixture relays: the three channels on their GPIOs, the button's
|
||||||
|
* pulse timer, and the enable jumper that gates the button.
|
||||||
|
*
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include "policy.h"
|
||||||
|
|
||||||
|
/* DevKitC-1 pins with no strapping, USB, flash or PSRAM role. Active
|
||||||
|
* high into the relay modules' opto inputs. */
|
||||||
|
#define RELAY_GPIO_LID 4
|
||||||
|
#define RELAY_GPIO_INTERLOCK 5
|
||||||
|
#define RELAY_GPIO_BUTTON 6
|
||||||
|
/* The enable jumper for the button channel: input with the pull-up on,
|
||||||
|
* the jumper shorts it to GND. No jumper = high = button disabled. */
|
||||||
|
#define ENABLE_GPIO_BUTTON 7
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
bool energized[CH_COUNT];
|
||||||
|
bool button_enabled; /* the jumper is in */
|
||||||
|
bool button_pulsing; /* a press is in progress */
|
||||||
|
} relays_state_t;
|
||||||
|
|
||||||
|
/* Every line low before anything else runs. */
|
||||||
|
void relays_init(void);
|
||||||
|
|
||||||
|
/* Lid or interlock: hold the channel energized (loop open) or released
|
||||||
|
* (loop closed). ESP_ERR_INVALID_ARG for the button, which is never
|
||||||
|
* held. */
|
||||||
|
esp_err_t relays_set_loop(channel_t ch, bool energize);
|
||||||
|
|
||||||
|
/* The button: one pulse of `ms` (already clamped by policy_button_ms).
|
||||||
|
* ESP_ERR_NOT_ALLOWED without the enable jumper, ESP_ERR_INVALID_STATE
|
||||||
|
* while a pulse is still in progress. */
|
||||||
|
esp_err_t relays_pulse_button(int ms);
|
||||||
|
|
||||||
|
/* Everything low, a pulse in progress cut short. */
|
||||||
|
void relays_release(void);
|
||||||
|
|
||||||
|
relays_state_t relays_state(void);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/* forgefixture firmware version, reported by GET /. */
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#define FIXTURE_VERSION "1.0.0"
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/*
|
||||||
|
* forgefixture wifi (see wifi.h).
|
||||||
|
*
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
|
#include "wifi.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "esp_event.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_netif.h"
|
||||||
|
#include "esp_wifi.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "mdns.h"
|
||||||
|
|
||||||
|
static const char *TAG = "wifi";
|
||||||
|
|
||||||
|
static esp_netif_t *s_netif;
|
||||||
|
static volatile bool s_connected;
|
||||||
|
static esp_ip4_addr_t s_ip;
|
||||||
|
static char s_hostname[32];
|
||||||
|
|
||||||
|
static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data)
|
||||||
|
{
|
||||||
|
(void)arg;
|
||||||
|
(void)data;
|
||||||
|
if (id == WIFI_EVENT_STA_START) {
|
||||||
|
esp_wifi_connect();
|
||||||
|
} else if (id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||||
|
const wifi_event_sta_disconnected_t *d = data;
|
||||||
|
s_connected = false;
|
||||||
|
ESP_LOGW(TAG, "disconnected (reason %d), reconnecting", d ? d->reason : -1);
|
||||||
|
/* A short pause keeps a wrong passphrase from becoming a storm;
|
||||||
|
* the event loop task tolerates it. */
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||||
|
esp_wifi_connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_ip(void *arg, esp_event_base_t base, int32_t id, void *data)
|
||||||
|
{
|
||||||
|
(void)arg;
|
||||||
|
(void)base;
|
||||||
|
if (id == IP_EVENT_STA_GOT_IP) {
|
||||||
|
const ip_event_got_ip_t *ev = data;
|
||||||
|
s_ip = ev->ip_info.ip;
|
||||||
|
s_connected = true;
|
||||||
|
ESP_LOGI(TAG, "joined: " IPSTR " as %s.local", IP2STR(&ev->ip_info.ip), s_hostname);
|
||||||
|
} else if (id == IP_EVENT_STA_LOST_IP) {
|
||||||
|
s_connected = false;
|
||||||
|
ESP_LOGW(TAG, "address lost");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void mdns_start(const char *hostname)
|
||||||
|
{
|
||||||
|
ESP_ERROR_CHECK(mdns_init());
|
||||||
|
ESP_ERROR_CHECK(mdns_hostname_set(hostname));
|
||||||
|
ESP_ERROR_CHECK(mdns_instance_name_set("ForgeFIRM bench fixture"));
|
||||||
|
mdns_txt_item_t txt[] = {
|
||||||
|
{"device", "forgefixture"},
|
||||||
|
{"channels", "lid,interlock,button"},
|
||||||
|
};
|
||||||
|
ESP_ERROR_CHECK(mdns_service_add(NULL, "_forgefixture", "_tcp", 80, txt, 2));
|
||||||
|
ESP_ERROR_CHECK(mdns_service_add(NULL, "_http", "_tcp", 80, txt, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
void wifi_start(const char *hostname, const char *ssid, const char *psk)
|
||||||
|
{
|
||||||
|
strncpy(s_hostname, hostname, sizeof(s_hostname) - 1);
|
||||||
|
ESP_ERROR_CHECK(esp_netif_init());
|
||||||
|
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||||
|
s_netif = esp_netif_create_default_wifi_sta();
|
||||||
|
ESP_ERROR_CHECK(esp_netif_set_hostname(s_netif, hostname)); /* DHCP option 12 */
|
||||||
|
|
||||||
|
wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT();
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_init(&init));
|
||||||
|
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &on_wifi, NULL));
|
||||||
|
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &on_ip, NULL));
|
||||||
|
|
||||||
|
wifi_config_t cfg = {0};
|
||||||
|
strncpy((char *)cfg.sta.ssid, ssid, sizeof(cfg.sta.ssid) - 1);
|
||||||
|
strncpy((char *)cfg.sta.password, psk, sizeof(cfg.sta.password) - 1);
|
||||||
|
cfg.sta.threshold.authmode = psk[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
|
||||||
|
cfg.sta.pmf_cfg.capable = true;
|
||||||
|
cfg.sta.pmf_cfg.required = false;
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &cfg));
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_start());
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
|
||||||
|
|
||||||
|
mdns_start(hostname);
|
||||||
|
ESP_LOGI(TAG, "joining %s", ssid);
|
||||||
|
}
|
||||||
|
|
||||||
|
void wifi_ip(char *buf, size_t n)
|
||||||
|
{
|
||||||
|
if (s_connected)
|
||||||
|
snprintf(buf, n, IPSTR, IP2STR(&s_ip));
|
||||||
|
else
|
||||||
|
snprintf(buf, n, "0.0.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
int wifi_rssi(void)
|
||||||
|
{
|
||||||
|
wifi_ap_record_t ap;
|
||||||
|
if (!s_connected || esp_wifi_sta_get_ap_info(&ap) != ESP_OK)
|
||||||
|
return 0;
|
||||||
|
return ap.rssi;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wifi_connected(void)
|
||||||
|
{
|
||||||
|
return s_connected;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* forgefixture wifi: a station that joins the bench network and stays
|
||||||
|
* joined, announces its hostname over DHCP and mDNS, and never sleeps
|
||||||
|
* the radio (the API answers in milliseconds, not on the next beacon).
|
||||||
|
*
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
void wifi_start(const char *hostname, const char *ssid, const char *psk);
|
||||||
|
|
||||||
|
/* The station's address as text ("0.0.0.0" while not joined). */
|
||||||
|
void wifi_ip(char *buf, size_t n);
|
||||||
|
|
||||||
|
/* Signal strength in dBm, 0 while not joined. */
|
||||||
|
int wifi_rssi(void);
|
||||||
|
|
||||||
|
bool wifi_connected(void);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# forgefixture build defaults (ESP32-S3 DevKitC-1). idf.py merges these
|
||||||
|
# into sdkconfig on the first configure; sdkconfig itself is not tracked.
|
||||||
|
CONFIG_IDF_TARGET="esp32s3"
|
||||||
|
|
||||||
|
# Any DevKitC-1 has at least 8 MB; a 4 MB layout with one large app
|
||||||
|
# partition fits every one of them and leaves nothing to choose.
|
||||||
|
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||||
|
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
|
||||||
|
|
||||||
|
# A hung task reboots the board to every relay line low rather than
|
||||||
|
# holding a button press: the task watchdog panics, and a panic resets.
|
||||||
|
CONFIG_ESP_TASK_WDT_INIT=y
|
||||||
|
CONFIG_ESP_TASK_WDT_PANIC=y
|
||||||
|
CONFIG_ESP_TASK_WDT_TIMEOUT_S=5
|
||||||
|
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=y
|
||||||
|
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=y
|
||||||
|
CONFIG_ESP_INT_WDT=y
|
||||||
|
CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT=y
|
||||||
|
|
||||||
|
# Brownout (the relays share the board's 3.3 V with nothing else, but the
|
||||||
|
# USB supply is whatever it is): reset rather than limp.
|
||||||
|
CONFIG_ESP_BROWNOUT_DET=y
|
||||||
|
|
||||||
|
# The log on both USB ports: the UART bridge and the native USB-Serial-JTAG.
|
||||||
|
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
|
||||||
|
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
|
||||||
|
|
||||||
|
# HTTP server: a few clients, small requests.
|
||||||
|
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
|
||||||
|
CONFIG_HTTPD_MAX_URI_LEN=128
|
||||||
|
CONFIG_HTTPD_PURGE_BUF_LEN=32
|
||||||
|
|
||||||
|
# Wifi station only; no Bluetooth.
|
||||||
|
CONFIG_BT_ENABLED=n
|
||||||
|
CONFIG_ESP_WIFI_NVS_ENABLED=y
|
||||||
|
CONFIG_LWIP_IPV6=n
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* forgefixture policy, held to account on the host: gcc -o policy_test
|
||||||
|
* policy_test.c ../main/policy.c (see run.sh).
|
||||||
|
*
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "../main/policy.h"
|
||||||
|
|
||||||
|
static int failures;
|
||||||
|
|
||||||
|
#define CHECK(cond) do { if (!(cond)) { failures++; printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); } } while (0)
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
/* names and paths */
|
||||||
|
CHECK(strcmp(policy_channel_name(CH_LID), "lid") == 0);
|
||||||
|
CHECK(strcmp(policy_channel_name(CH_INTERLOCK), "interlock") == 0);
|
||||||
|
CHECK(strcmp(policy_channel_name(CH_BUTTON), "button") == 0);
|
||||||
|
CHECK(policy_channel_from_path("/lid") == CH_LID);
|
||||||
|
CHECK(policy_channel_from_path("/interlock") == CH_INTERLOCK);
|
||||||
|
CHECK(policy_channel_from_path("/button") == CH_BUTTON);
|
||||||
|
CHECK(policy_channel_from_path("/lids") == -1);
|
||||||
|
CHECK(policy_channel_from_path("lid") == -1);
|
||||||
|
CHECK(policy_channel_from_path(NULL) == -1);
|
||||||
|
|
||||||
|
/* a loop request: open energizes, close/closed releases, nothing else */
|
||||||
|
bool e = false;
|
||||||
|
CHECK(policy_parse_loop_state("open", &e) && e);
|
||||||
|
CHECK(policy_parse_loop_state("close", &e) && !e);
|
||||||
|
CHECK(policy_parse_loop_state("closed", &e) && !e);
|
||||||
|
CHECK(!policy_parse_loop_state("Open", &e));
|
||||||
|
CHECK(!policy_parse_loop_state("on", &e));
|
||||||
|
CHECK(!policy_parse_loop_state("", &e));
|
||||||
|
CHECK(!policy_parse_loop_state(NULL, &e));
|
||||||
|
|
||||||
|
/* the button pulse: a default, a floor, a ceiling, never a hold */
|
||||||
|
CHECK(policy_button_ms(0) == BUTTON_DEFAULT_MS);
|
||||||
|
CHECK(policy_button_ms(-5) == BUTTON_DEFAULT_MS);
|
||||||
|
CHECK(policy_button_ms(1) == BUTTON_MIN_MS);
|
||||||
|
CHECK(policy_button_ms(250) == 250);
|
||||||
|
CHECK(policy_button_ms(500) == BUTTON_MAX_MS);
|
||||||
|
CHECK(policy_button_ms(501) == BUTTON_MAX_MS);
|
||||||
|
CHECK(policy_button_ms(60000) == BUTTON_MAX_MS);
|
||||||
|
CHECK(BUTTON_MAX_MS <= 500);
|
||||||
|
|
||||||
|
/* state names */
|
||||||
|
CHECK(strcmp(policy_state_name(CH_LID, true), "open") == 0);
|
||||||
|
CHECK(strcmp(policy_state_name(CH_LID, false), "closed") == 0);
|
||||||
|
CHECK(strcmp(policy_state_name(CH_INTERLOCK, true), "open") == 0);
|
||||||
|
CHECK(strcmp(policy_state_name(CH_BUTTON, true), "pressed") == 0);
|
||||||
|
CHECK(strcmp(policy_state_name(CH_BUTTON, false), "idle") == 0);
|
||||||
|
|
||||||
|
/* the key: exact, whole, and never by prefix */
|
||||||
|
CHECK(policy_key_matches("abc123", "abc123"));
|
||||||
|
CHECK(!policy_key_matches("abc12", "abc123"));
|
||||||
|
CHECK(!policy_key_matches("abc1234", "abc123"));
|
||||||
|
CHECK(!policy_key_matches("", "abc123"));
|
||||||
|
CHECK(!policy_key_matches("abc123", ""));
|
||||||
|
CHECK(!policy_key_matches(NULL, "abc123"));
|
||||||
|
CHECK(!policy_key_matches("ABC123", "abc123"));
|
||||||
|
|
||||||
|
if (failures) {
|
||||||
|
printf("%d failure(s)\n", failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printf("policy: all checks passed\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# The host test of the fixture's policy: needs only a C compiler.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
${CC:-cc} -std=c11 -Wall -Wextra -Werror -o policy_test policy_test.c ../main/policy.c
|
||||||
|
./policy_test
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# forgetest - the ForgeFIRM release acceptance tool
|
||||||
|
|
||||||
|
The daemon behind `http://<machine>:8090/` on the dev image: runs the
|
||||||
|
acceptance catalog against the machine, keeps the append-only result log,
|
||||||
|
decides which results still apply to the image that is running, exports
|
||||||
|
the release artifact `scripts/release.sh` gates on, and serves the bench
|
||||||
|
diagnostics page. The contract - catalog, campaigns, fingerprints,
|
||||||
|
inheritance, the gate, the coverage rule - is
|
||||||
|
[the Acceptance page of the documentation site](https://docs.forgefirm.org/developers/acceptance/).
|
||||||
|
|
||||||
|
## Run the host tests
|
||||||
|
|
||||||
|
cd forgetest
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
## Run the daemon on a workstation (against a mock or a manifest file)
|
||||||
|
|
||||||
|
FORGETEST_DATA=/tmp/ft FORGETEST_MANIFEST=../tree-manifest.json \
|
||||||
|
FORGECTRL_URL=http://<machine> python3 -m forgetest --port 8090
|
||||||
|
|
||||||
|
`scripts/manifest-from-tree.py` produces `tree-manifest.json` from the recipe
|
||||||
|
pins; the coverage lint is `python3 -m forgetest.coverage --manifest ...`.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `FORGETEST_DATA` | `/data/forgetest` | results.jsonl, bench.jsonl, token, export/ |
|
||||||
|
| `FORGETEST_MANIFEST` | `/etc/forgefirm-manifest.json` | the image manifest |
|
||||||
|
| `FORGETEST_PORT`, `FORGETEST_HOST` | 8090, 0.0.0.0 | listener |
|
||||||
|
| `FORGETEST_BENCH_DIR` | `/usr/share/forgetest/bench` | the installed bench scripts |
|
||||||
|
| `FORGETEST_BENCH_DATA` | `<FORGETEST_DATA>/bench` | passed to bench tools: where they keep their data files (with `GF_HOST=127.0.0.1` and the panel token in `GF_TOKEN`) |
|
||||||
|
| `FORGETEST_MARKER` | `/run/forgetest.active` | takeover marker |
|
||||||
|
| `FORGECTRL_URL`, `FORGECTRL_TOKEN_FILE` | `http://127.0.0.1`, `/data/forgefirm/panel.token` | forgectrl client (HTTP; the token authorizes writes from the board) |
|
||||||
|
| `FORGECTRL_TLS_URL` | `https://127.0.0.1` | forgectrl over HTTPS (self-signed, unverified), for the login test |
|
||||||
|
| `GF_SYSFS_ROOT` | `/sys/glowforge/` | kernel module sysfs |
|
||||||
|
| `GRBL_HOST`, `GRBL_PORT` | 127.0.0.1, 23 | Grbl TCP |
|
||||||
|
|
||||||
|
## Adding a test
|
||||||
|
|
||||||
|
Register it in the subsystem module under `forgetest/suite/` with
|
||||||
|
`@test(...)`: id `subsystem.name`, kind, hardware, `mode` (the controller
|
||||||
|
mode the test needs; the runner switches to it first), `covers`,
|
||||||
|
`requires`, `always`, steps. The body gets a `Context` (`log`, `check`, `fail`,
|
||||||
|
`prompt`, `confirm`, `instruct`, `sleep`, `evidence`, `forgectrl`, `sysfs`,
|
||||||
|
`grbl`, `takeover`). Return normally for PASS, raise `runner.Failed` for
|
||||||
|
FAIL. Then run the unit tests and the coverage lint.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
### BEGIN INIT INFO
|
||||||
|
# Provides: forgetest
|
||||||
|
# Required-Start: $network forgectrl
|
||||||
|
# Required-Stop:
|
||||||
|
# Default-Start: 2 3 4 5
|
||||||
|
# Default-Stop: 0 1 6
|
||||||
|
# Short-Description: ForgeFIRM release acceptance tool (dev image, HTTP :8090)
|
||||||
|
### END INIT INFO
|
||||||
|
|
||||||
|
# Dev-image only. The daemon reads /etc/forgefirm-manifest.json, keeps its
|
||||||
|
# state under /data/forgetest, and serves the acceptance + bench pages on
|
||||||
|
# port 8090 (FORGETEST_PORT). It starts after forgectrl and stops before
|
||||||
|
# the controllers at shutdown; a takeover left behind by a hard stop is
|
||||||
|
# recovered at the next start (forgectrl is started again).
|
||||||
|
|
||||||
|
PIDFILE=/var/run/forgetest.pid
|
||||||
|
DATA=/data/forgetest
|
||||||
|
LOG=$DATA/daemon.log
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
start)
|
||||||
|
echo "Starting forgetest"
|
||||||
|
mkdir -p "$DATA"
|
||||||
|
# The bench seed: the commissioning override lifts the wizard part of
|
||||||
|
# the controller gate until the next reboot (/run is tmpfs), so a
|
||||||
|
# campaign runs on a machine whose required wizards are not complete.
|
||||||
|
# Nothing lifts the agreements or the account: the bench accepts them
|
||||||
|
# once, and that stays in /data.
|
||||||
|
mkdir -p /run/forgefirm
|
||||||
|
: > /run/forgefirm/commissioning-override
|
||||||
|
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
|
||||||
|
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
echo "Stopping forgetest"
|
||||||
|
start-stop-daemon -K -q -p $PIDFILE
|
||||||
|
rm -f $PIDFILE
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
$0 stop
|
||||||
|
sleep 1
|
||||||
|
$0 start
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
if [ -f $PIDFILE ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then
|
||||||
|
echo "forgetest is running"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "forgetest is not running"
|
||||||
|
exit 3
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {start|stop|restart|status}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""forgetest - the ForgeFIRM release acceptance tool.
|
||||||
|
|
||||||
|
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
|
||||||
|
against the machine, keeps the append-only result log, decides which
|
||||||
|
results still apply to the image that is running, and exports the
|
||||||
|
release artifact the release gate reads. The bench diagnostics page rides
|
||||||
|
the same daemon.
|
||||||
|
"""
|
||||||
|
|
||||||
|
VERSION = "0.1.0"
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""forgetest daemon entry point.
|
||||||
|
|
||||||
|
python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH]
|
||||||
|
|
||||||
|
Environment: FORGETEST_DATA (state directory, default /data/forgetest),
|
||||||
|
FORGETEST_MANIFEST, FORGETEST_BENCH_DIR, FORGETEST_MARKER, and the hw.py
|
||||||
|
overrides.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from . import VERSION
|
||||||
|
from . import bench as _bench
|
||||||
|
from . import catalog as _catalog
|
||||||
|
from . import manifest as _manifest
|
||||||
|
from . import server as _server
|
||||||
|
from .log import Log, data_dir
|
||||||
|
from .runner import Runner, configure_journal
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(prog="forgetest")
|
||||||
|
ap.add_argument("--port", type=int, default=int(os.environ.get("FORGETEST_PORT") or 8090))
|
||||||
|
ap.add_argument("--host", default=os.environ.get("FORGETEST_HOST") or "::")
|
||||||
|
ap.add_argument("--manifest", default=None)
|
||||||
|
ap.add_argument("--version", action="version", version="forgetest %s" % VERSION)
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = _manifest.Manifest.load(args.manifest)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
print("forgetest: cannot load the image manifest: %s" % e, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
registry = _catalog.load_suite()
|
||||||
|
os.makedirs(data_dir(), exist_ok=True)
|
||||||
|
configure_journal()
|
||||||
|
log = Log()
|
||||||
|
bench = _bench.Bench()
|
||||||
|
runner = Runner(log, manifest, registry, bench)
|
||||||
|
token = _server.load_token()
|
||||||
|
app = _server.App(runner, token)
|
||||||
|
srv = _server.make_server(app, args.host, args.port)
|
||||||
|
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
def on_signal(*_):
|
||||||
|
stop.set()
|
||||||
|
signal.signal(signal.SIGTERM, on_signal)
|
||||||
|
signal.signal(signal.SIGINT, on_signal)
|
||||||
|
print("forgetest %s: %d tests, image %s (%s), listening on %s:%d"
|
||||||
|
% (VERSION, len(registry), manifest.version, (manifest.content_sha or "")[:12],
|
||||||
|
args.host, args.port), file=sys.stderr, flush=True)
|
||||||
|
th = threading.Thread(target=srv.serve_forever, name="forgetest-http", daemon=True)
|
||||||
|
th.start()
|
||||||
|
try:
|
||||||
|
while not stop.is_set():
|
||||||
|
stop.wait(1.0)
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
srv.server_close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""The release artifact (acceptance.json / acceptance.md) and its verification.
|
||||||
|
|
||||||
|
The exporter serializes the campaign state with, for every catalog test,
|
||||||
|
the winning result record (same-campaign PASS or inherited PASS, with its
|
||||||
|
origin) and the fingerprint it was recorded under. The gate
|
||||||
|
(scripts/acceptance-gate.py) calls verify() with the artifact, the
|
||||||
|
release build's manifest, and the catalog from the same source tree, and
|
||||||
|
recomputes every fingerprint - a PASS applies to the release exactly when
|
||||||
|
the recomputation matches. The artifact is self-hashed so an edited file
|
||||||
|
is caught before any of that.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from . import VERSION
|
||||||
|
from . import campaign as _campaign
|
||||||
|
from . import manifest as _manifest
|
||||||
|
from .log import now_ts
|
||||||
|
|
||||||
|
FORMAT = 1
|
||||||
|
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
|
||||||
|
"duration_s", "message", "evidence", "answers", "log", "operator")
|
||||||
|
|
||||||
|
|
||||||
|
def _record(rec):
|
||||||
|
return {k: rec.get(k) for k in _RESULT_KEYS if k in rec}
|
||||||
|
|
||||||
|
|
||||||
|
def build(state, tests, manifest, records, catalog_hash):
|
||||||
|
"""The artifact dict (self-hash included). records: the full log,
|
||||||
|
used to find the winning record for each test."""
|
||||||
|
results = [r for r in records if r.get("t") == "result"]
|
||||||
|
by_key = {}
|
||||||
|
for r in results:
|
||||||
|
by_key[(r.get("test"), r.get("ts"), r.get("campaign"))] = r
|
||||||
|
|
||||||
|
tests_out = []
|
||||||
|
for t in tests:
|
||||||
|
st = state["tests"][t.id]
|
||||||
|
origin = st.get("origin")
|
||||||
|
rec = None
|
||||||
|
if origin:
|
||||||
|
rec = by_key.get((t.id, origin.get("ts"), origin.get("campaign")))
|
||||||
|
entry = t.definition()
|
||||||
|
entry.update({
|
||||||
|
"title": t.title,
|
||||||
|
"source_sha": t.source_sha,
|
||||||
|
"fingerprint": st["fingerprint"],
|
||||||
|
"satisfied": st["satisfied"],
|
||||||
|
"inherited": st["status"] == "inherited",
|
||||||
|
"record": _record(rec) if rec else None,
|
||||||
|
})
|
||||||
|
tests_out.append(entry)
|
||||||
|
|
||||||
|
art = {
|
||||||
|
"format": FORMAT,
|
||||||
|
"tool_version": VERSION,
|
||||||
|
"exported_at": now_ts(),
|
||||||
|
"image": {"name": manifest.image_name, "version": manifest.version},
|
||||||
|
"manifest_sha": manifest.content_sha,
|
||||||
|
"identity_sha": manifest.identity_sha(),
|
||||||
|
"catalog_hash": catalog_hash,
|
||||||
|
"campaign": state["campaign"],
|
||||||
|
"invalidate": state["invalidate"],
|
||||||
|
"authorized": state["authorized"],
|
||||||
|
"counts": state["counts"],
|
||||||
|
"manifest": manifest.data,
|
||||||
|
"tests": tests_out,
|
||||||
|
}
|
||||||
|
art["sha256"] = _manifest.sha256_text(_manifest.canonical(art))
|
||||||
|
return art
|
||||||
|
|
||||||
|
|
||||||
|
def to_json(art):
|
||||||
|
return json.dumps(art, sort_keys=True, indent=1) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def to_markdown(art):
|
||||||
|
lines = []
|
||||||
|
img = art.get("image", {})
|
||||||
|
lines.append("# ForgeFIRM acceptance - %s" % img.get("version", "?"))
|
||||||
|
lines.append("")
|
||||||
|
lines.append("- Image: `%s` (%s)" % (img.get("version", "?"), img.get("name", "?")))
|
||||||
|
lines.append("- Manifest identity: `%s`" % (art.get("manifest_sha") or "?"))
|
||||||
|
lines.append("- Catalog: `%s`" % (art.get("catalog_hash") or "?"))
|
||||||
|
c = art.get("campaign") or {}
|
||||||
|
lines.append("- Campaign: `%s` opened %s" % (c.get("id", "-"), c.get("ts", "-")))
|
||||||
|
inv = art.get("invalidate")
|
||||||
|
if inv:
|
||||||
|
lines.append("- Full campaign required since %s: %s" % (inv.get("ts"), inv.get("reason")))
|
||||||
|
lines.append("- Exported: %s" % art.get("exported_at"))
|
||||||
|
lines.append("- **Release authorized: %s**" % ("YES" if art.get("authorized") else "NO"))
|
||||||
|
cnt = art.get("counts", {})
|
||||||
|
lines.append("- Tests: %s total, %s satisfied (%s inherited), %s required"
|
||||||
|
% (cnt.get("total"), cnt.get("satisfied"), cnt.get("inherited"), cnt.get("required")))
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| Test | Kind | Result | Run at | Campaign | Inherited |")
|
||||||
|
lines.append("|---|---|---|---|---|---|")
|
||||||
|
for t in art.get("tests", []):
|
||||||
|
rec = t.get("record") or {}
|
||||||
|
kind = t.get("kind", "")
|
||||||
|
if t.get("always"):
|
||||||
|
kind += ", core"
|
||||||
|
if t.get("hardware") == "takeover":
|
||||||
|
kind += ", takeover"
|
||||||
|
lines.append("| `%s` | %s | %s | %s | `%s` | %s |" % (
|
||||||
|
t.get("id"), kind, rec.get("result", "-"), rec.get("ts", "-"),
|
||||||
|
rec.get("campaign", "-"), "yes" if t.get("inherited") else "no"))
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Artifact sha256: `%s`" % art.get("sha256"))
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def verify(art, release_manifest, tests, catalog_hash, expect_machine=None):
|
||||||
|
"""Gate decision. Returns (ok, rows, problems).
|
||||||
|
|
||||||
|
rows: per-test dicts for the report. problems: list of strings; empty
|
||||||
|
means the artifact authorizes the release manifest.
|
||||||
|
"""
|
||||||
|
problems = []
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
body = dict(art)
|
||||||
|
sha = body.pop("sha256", None)
|
||||||
|
if sha != _manifest.sha256_text(_manifest.canonical(body)):
|
||||||
|
problems.append("artifact self-hash mismatch (edited or truncated file)")
|
||||||
|
return False, rows, problems
|
||||||
|
if art.get("format") != FORMAT:
|
||||||
|
problems.append("artifact format %r, expected %r" % (art.get("format"), FORMAT))
|
||||||
|
if not art.get("authorized"):
|
||||||
|
problems.append("artifact does not claim authorization")
|
||||||
|
|
||||||
|
if expect_machine and release_manifest.platform.get("machine") != expect_machine:
|
||||||
|
problems.append("release manifest machine %r, expected %r"
|
||||||
|
% (release_manifest.platform.get("machine"), expect_machine))
|
||||||
|
if art.get("catalog_hash") != catalog_hash:
|
||||||
|
problems.append("catalog changed since the campaign (artifact %s, tree %s)"
|
||||||
|
% ((art.get("catalog_hash") or "?")[:12], catalog_hash[:12]))
|
||||||
|
|
||||||
|
tests = list(tests)
|
||||||
|
by_id = {t.id: t for t in tests}
|
||||||
|
art_tests = {t["id"]: t for t in art.get("tests", [])}
|
||||||
|
for tid in sorted(set(by_id) - set(art_tests)):
|
||||||
|
problems.append("test %s is in the catalog but not in the artifact" % tid)
|
||||||
|
for tid in sorted(set(art_tests) - set(by_id)):
|
||||||
|
problems.append("test %s is in the artifact but not in the catalog" % tid)
|
||||||
|
|
||||||
|
epoch = (art.get("invalidate") or {}).get("ts")
|
||||||
|
for t in tests:
|
||||||
|
a = art_tests.get(t.id)
|
||||||
|
if a is None:
|
||||||
|
continue
|
||||||
|
row = {"id": t.id, "always": t.always, "inherited": bool(a.get("inherited"))}
|
||||||
|
rec = a.get("record")
|
||||||
|
ok = True
|
||||||
|
why = []
|
||||||
|
if a.get("covers") != [list(c) for c in t.covers] or a.get("always") != t.always \
|
||||||
|
or a.get("kind") != t.kind or a.get("requires") != list(t.requires):
|
||||||
|
ok = False
|
||||||
|
why.append("definition differs from the catalog in the tree")
|
||||||
|
if a.get("source_sha") != t.source_sha:
|
||||||
|
ok = False
|
||||||
|
why.append("test implementation changed since the campaign")
|
||||||
|
if not rec:
|
||||||
|
ok = False
|
||||||
|
why.append("no PASS recorded")
|
||||||
|
else:
|
||||||
|
if rec.get("result") != _campaign.PASS:
|
||||||
|
ok = False
|
||||||
|
why.append("recorded result is %s" % rec.get("result"))
|
||||||
|
fp = t.fingerprint(release_manifest)
|
||||||
|
row["fingerprint"] = fp
|
||||||
|
if rec.get("fingerprint") != fp:
|
||||||
|
ok = False
|
||||||
|
why.append("fingerprint differs from the release build (domain changed)")
|
||||||
|
if a.get("inherited"):
|
||||||
|
if t.always:
|
||||||
|
ok = False
|
||||||
|
why.append("always-required test may not be inherited")
|
||||||
|
if epoch and (rec.get("ts") or "") <= epoch:
|
||||||
|
ok = False
|
||||||
|
why.append("inherited PASS predates the invalidate-all")
|
||||||
|
row["ts"] = rec.get("ts")
|
||||||
|
row["ok"] = ok
|
||||||
|
row["why"] = why
|
||||||
|
rows.append(row)
|
||||||
|
if not ok:
|
||||||
|
problems.append("%s: %s" % (t.id, "; ".join(why)))
|
||||||
|
return not problems, rows, problems
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,392 @@
|
|||||||
|
"""The bench diagnostics page: registry of the bench tools and the
|
||||||
|
subprocess runner behind the #bench tab.
|
||||||
|
|
||||||
|
The registry lists every tool of scripts/bench (the README is the human
|
||||||
|
index; this is the machine one) with its safety class and argument spec.
|
||||||
|
A tool is runnable from the page once `ported` is set: the script is
|
||||||
|
installed under the tool directory (/usr/share/forgetest/bench on the
|
||||||
|
image, override FORGETEST_BENCH_DIR) and runs as a subprocess with the
|
||||||
|
form's arguments, output streamed to the page. Unported tools are listed
|
||||||
|
so the catalog of what exists is complete, with Start disabled.
|
||||||
|
|
||||||
|
Safety classes:
|
||||||
|
dry reads or dry motion, no emission, forgectrl stays up
|
||||||
|
takeover needs forgectrl stopped and the pulse device free
|
||||||
|
scope a takeover whose result only means something with the named
|
||||||
|
instrument on the bench (a scope on LASER_PWM / LASER_ON)
|
||||||
|
live laser emission possible (operator acknowledgment required)
|
||||||
|
|
||||||
|
The tools that also run from a LAN host (gfbench.py: GF_HOST) run on the
|
||||||
|
board here with GF_HOST=127.0.0.1, the panel token in GF_TOKEN, and
|
||||||
|
their data files under FORGETEST_BENCH_DATA (<data>/bench/). Bench runs
|
||||||
|
are recorded in <data>/bench.jsonl and never enter a campaign.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from .log import data_dir, now_ts
|
||||||
|
|
||||||
|
DEFAULT_TOOL_DIR = "/usr/share/forgetest/bench"
|
||||||
|
|
||||||
|
|
||||||
|
def _arg(name, type="str", default=None, help="", choices=None, flag=None):
|
||||||
|
"""One form field. `flag` names the option the value is passed as
|
||||||
|
(--feed 600); without it the value is positional, in registry order."""
|
||||||
|
a = {"name": name, "type": type, "default": default, "help": help}
|
||||||
|
if choices:
|
||||||
|
a["choices"] = list(choices)
|
||||||
|
if flag:
|
||||||
|
a["flag"] = flag
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS = [
|
||||||
|
# -- board-side, dry ------------------------------------------------------
|
||||||
|
{"id": "check-pwm", "title": "Laser PWM register check", "script": "check_pwm.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True, "args": [],
|
||||||
|
"desc": "Reads PWM2 PWMCR/PWMPR via /dev/mem; expects divider 13 x ~127 counts = ~40 kHz. Read-only."},
|
||||||
|
{"id": "pacing-test", "title": "Protocol-loop pacing check", "script": "pacing_test.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("mm", "float", 30.0, "jog distance (+X first)"), _arg("feed", "float", 600.0, "feed rate")],
|
||||||
|
"desc": "Dry motion: idle/parked states coarse-paced, active motion tight-paced, hold/resume mid-move keeps position."},
|
||||||
|
{"id": "bench-m2", "title": "Motion-quality bench", "script": "bench_m2.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True, "args": [],
|
||||||
|
"argv_fixed": ["127.0.0.1"],
|
||||||
|
"desc": "Bounded round-trip jogs (sanity, max-rate, diagonal) + feed-hold/resume; reports peak feed, transitions, drift."},
|
||||||
|
{"id": "bench-phase2", "title": "End-of-data protocol bench", "script": "bench_phase2.py",
|
||||||
|
"safety": "takeover", "where": "board", "ported": True, "args": [],
|
||||||
|
"desc": "Underrun detection/ack, parked no-replay guard, resume(0), continuous feed, run/underrun cycles. Motors locked, laser latched."},
|
||||||
|
{"id": "cp-watchdog", "title": "HV charge-pump watchdog timing", "script": "cp_watchdog_timing.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("duration_s", "float", 14.0, "capture length")],
|
||||||
|
"desc": "Latches CHG_PUMP feed pulses, polls the watchdog readbacks while commanding short local jogs. Motion only, laser locked."},
|
||||||
|
{"id": "accel-fast", "title": "Head accelerometer sampler", "script": "accel_fast.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("duration_s", "float", 5.0, "capture length"),
|
||||||
|
_arg("jog1", "str", None, "optional mid-capture jog, e.g. $J=G91X20F2400"),
|
||||||
|
_arg("jog2", "str", None, "optional second jog")],
|
||||||
|
"desc": "Direct-I2C sampler for the head-bus LIS2HH12s with optional mid-capture jogs. CSV to /tmp/accel.csv."},
|
||||||
|
{"id": "bump-seek", "title": "Accelerometer bump-seek homing prototype", "script": "bump_seek.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("direction", "choice", "-", "X direction", ["-", "+"]),
|
||||||
|
_arg("feed", "int", 120, "creep feed"), _arg("segment_mm", "float", 15.0, "jog segment"),
|
||||||
|
_arg("max_mm", "float", 200.0, "travel bound")],
|
||||||
|
"desc": "Creeps toward a rail in bounded jog segments, detects the contact jolt, jog-cancels and backs off."},
|
||||||
|
{"id": "accel-crash-probe", "title": "Head accel crash-detector probe", "script": "accel_crash_probe.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("seconds", "float", 20.0, "arm-and-watch window"),
|
||||||
|
_arg("mode", "choice", "coexist", "coexist keeps st_accel bound (forgectrl up); unbind frees it",
|
||||||
|
["coexist", "unbind"], flag="--mode"),
|
||||||
|
_arg("ths", "int", 40, "per-axis threshold register value 0..255", flag="--ths"),
|
||||||
|
_arg("dur", "int", 0, "IG_DUR1 duration counter (ODR samples)", flag="--dur"),
|
||||||
|
_arg("axes", "str", "xyz", "axes to arm for high events", flag="--axes"),
|
||||||
|
_arg("jog", "str", None, "optional one jog at t=2 s, e.g. $J=G91X5F1000 (+X only)", flag="--jog")],
|
||||||
|
"desc": "Arms the head LIS2HH12's on-chip interrupt generator (IG1) and polls IG_SRC1 for a latched strike. "
|
||||||
|
"coexist mode reaches the IG registers over i2c-dev with st_accel still bound (the forgectrl-only "
|
||||||
|
"path proof); provoke a trip by hand or with --jog. Touches the IG registers and CTRL1 "
|
||||||
|
"(saved, run at 800 Hz, restored; the IG only samples at a running ODR), no emission, "
|
||||||
|
"no full-scale change. CSV to /tmp/accel_crash.csv."},
|
||||||
|
# -- board-side, takeover / scope ------------------------------------------
|
||||||
|
{"id": "pwm-sweep", "title": "LASER_PWM scope sweep", "script": "pwm_sweep.py",
|
||||||
|
"safety": "scope", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("mode", "choice", "check", "check = read-only, sweep = duty staircase", ["check", "sweep"])],
|
||||||
|
"desc": "check: readbacks + PWM2 dump; sweep: PWMSAR through 50/25/75/6/100 percent with 4 s holds. "
|
||||||
|
"Locked state (the takeover): latch relocked, refuses if FIRE or LASER_ON reads active."},
|
||||||
|
{"id": "pwm-hold", "title": "LASER_PWM scope hold", "script": "pwm_hold.py",
|
||||||
|
"safety": "scope", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("sar", "int", 64, "PWMSAR value"), _arg("seconds", "int", 10, "hold time")],
|
||||||
|
"desc": "Holds one PWMSAR value for a scope window, then restores. Locked state (the takeover): "
|
||||||
|
"latch relocked, refuses if FIRE or LASER_ON reads active."},
|
||||||
|
{"id": "fire-test", "title": "FIRE drop-timing test (A/B/U)", "script": "fire_test.py",
|
||||||
|
"safety": "takeover", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("mode", "choice", "A", "A latch locked, B unlocked/unarmed, U true underrun", ["A", "B", "U"])],
|
||||||
|
"desc": "Duty 0 throughout; refuses to unlock if HV reports good. Software witnesses + the PSU-connector LASER_ON scope point."},
|
||||||
|
{"id": "pwm-stream", "title": "LASER_PWM stream-path test", "script": "pwm_stream_test.py",
|
||||||
|
"safety": "takeover", "where": "board", "ported": True, "args": [],
|
||||||
|
"desc": "Streams power bytes only (no steps, no FIRE, motor_lock=15, latch locked) through /dev/glowforge; "
|
||||||
|
"PASS = counters unmoved, idle at the end, no FIRE/emission read back. The scope on LASER_PWM sees the duty steps."},
|
||||||
|
{"id": "gate-a-kernel", "title": "Kernel laser-safety drills K1/K2/K3", "script": "gate_a_kernel_drills.py",
|
||||||
|
"safety": "takeover", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("drill", "choice", "K1", "K1 stop floor, K2 resume honors latch, K3 mid-ramp unlock", ["K1", "K2", "K3"])],
|
||||||
|
"desc": "Software witnesses (cnc/state, laser_enable, laser_on, interlock bit 3); K3 refuses if HV reports good."},
|
||||||
|
{"id": "platform-drills", "title": "Kernel platform drills", "script": "platform_drills.py",
|
||||||
|
"safety": "takeover", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("drill", "choice", "decay", "deadman / rmmod / decay / led / all",
|
||||||
|
["deadman", "rmmod", "decay", "led", "all"])],
|
||||||
|
"desc": "Dead-man trip readback, rmmod/modprobe cycles under load, decay/microstep readback, LED sequence."},
|
||||||
|
# -- cooling ------------------------------------------------------------------
|
||||||
|
{"id": "flow-confirm", "title": "Coolant flow suspicion/confirmation drill", "script": "flow_confirm_drill.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True, "args": [],
|
||||||
|
"desc": "One M8 session walks the verdict state machine through real pump-off transients; PASS/FAIL per transition."},
|
||||||
|
{"id": "flow-escalate", "title": "Coolant starved re-check escalation drill", "script": "flow_escalate_drill.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("budget_s", "int", 60, "cool_confirm_max_s for the drill (60-3600), restored after")],
|
||||||
|
"desc": "With the pump off the job-start check reads SUSPECT and the engine must escalate to FAULT when "
|
||||||
|
"the confirmation budget expires; the budget setting is shortened for the drill and restored."},
|
||||||
|
{"id": "flow-characterize", "title": "Coolant flow characterization", "script": "flow_characterize.py",
|
||||||
|
"safety": "takeover", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("duty", "int", 30, "heater duty percent")],
|
||||||
|
"desc": "Baseline -> flow -> no-flow -> recovery with the factory temperature curve; aborts past 45 C "
|
||||||
|
"downstream. Drives the heater and pump directly (about 9 minutes)."},
|
||||||
|
{"id": "flow-sustained", "title": "Coolant sustained re-check run", "script": "flow_sustained.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("minutes", "float", 30.0, "how long to hold M8")],
|
||||||
|
"desc": "Long run of the real re-check cadence via M8: verdicts, false faults, loop heat accumulation."},
|
||||||
|
{"id": "flow-warm", "title": "Coolant warm-baseline validation", "script": "flow_warm_validate.py",
|
||||||
|
"safety": "takeover", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("cycles", "int", 3, "cycles per case (flow / no-flow)"),
|
||||||
|
_arg("target_c", "float", 28.0, "warm the upstream sensor to this before each check"),
|
||||||
|
_arg("warm_min", "float", 20.0, "warm-up budget per check, minutes")],
|
||||||
|
"desc": "Runs the real check (40 percent / 50 s, cut-profile fans) from a heater-warmed baseline, alternating "
|
||||||
|
"flow and no-flow; results to the bench data directory. Slow: about 15 minutes per cycle."},
|
||||||
|
{"id": "offset-probe", "title": "Coolant-sensor offset probe", "script": "offset_probe.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("mode", "choice", "survey", "survey: one actuator at a time; ladder: the air-assist "
|
||||||
|
"duty ladder; jog: the air assist at run duty while the gantry jogs",
|
||||||
|
["survey", "ladder", "jog"])],
|
||||||
|
"desc": "Which actuator moves both coolant thermistors together? Switches the exhaust (100/50/25 "
|
||||||
|
"percent), intakes, air assist, purge, heater, pump, TEC and lid lamp one at a time, dark, "
|
||||||
|
"with both sensors at 25 Hz, and scores the common-mode step at every edge and the level "
|
||||||
|
"toggling inside every dwell; every value is restored on exit. The pump stops once for "
|
||||||
|
"8 s with the tube dark and the heater off. About five minutes."},
|
||||||
|
{"id": "aa-offset-check", "title": "Coolant offset correction under the run airflow", "script": "aa_offset_check.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("dwell", "float", 20.0, "seconds to read under the run profile")],
|
||||||
|
"desc": "M8 brings the fans to the run profile (no laser-on, no press) while the raw coolant "
|
||||||
|
"counts, /status and the engine's readings are averaged before, during and after: with "
|
||||||
|
"cool_aa_offset_counts at the machine's value the readings hold still while the raw counts "
|
||||||
|
"step; at zero they drop by about a degree. About a minute."},
|
||||||
|
{"id": "critical-tier", "title": "Coolant critical-tier warm-loop drill", "script": "critical_tier_drill.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("max_seconds", "int", 1200, "give up after this long without CRITICAL", flag="--max-seconds")],
|
||||||
|
"desc": "Sets the ceiling, the resume gate and the critical line a few tenths above the live upstream "
|
||||||
|
"reading and lets the engine's own flow-check heater warm the loop through them inside one M8 "
|
||||||
|
"session: OVERTEMP at the ceiling, then CRITICAL (fire blocked, hold, no resume), the fault ending "
|
||||||
|
"with the session; settings restored and re-read. Results to the bench data directory."},
|
||||||
|
{"id": "flow-recheck", "title": "Coolant re-check characterization", "script": "flow_recheck_char.py",
|
||||||
|
"safety": "takeover", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("heater_pct", "int", 50, "heater duty percent"), _arg("window_s", "int", 30, "re-check window")],
|
||||||
|
"desc": "Short in-run re-checks and the differential metric, flow vs no-flow from a settled loop; "
|
||||||
|
"aborts past 45 C downstream (about 5 minutes)."},
|
||||||
|
{"id": "flow-matrix", "title": "Coolant flow-detection design matrix", "script": "flow_matrix.py",
|
||||||
|
"safety": "takeover", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("duties", "str", "10,15,20,30,40,50", "heater duties, percent, comma-separated"),
|
||||||
|
_arg("repeats", "int", 5, "interleaved repeats per case")],
|
||||||
|
"desc": "duty x flow/no-flow x repeats from a common cooled baseline; cost and precision tables and a "
|
||||||
|
"ranked shortlist (the derivation of cool_flow_rise). Very slow: about 1.6 h for the full matrix; "
|
||||||
|
"resumable from the results file in the bench data directory."},
|
||||||
|
{"id": "flow-sampler", "title": "Coolant sampler", "script": "flow_sampler.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("duration_s", "int", 30, "capture length"), _arg("interval_s", "float", 1.0, "sample interval")],
|
||||||
|
"desc": "Prints elapsed,raw_down,raw_up at the interval; the sampler behind the flow tools."},
|
||||||
|
{"id": "temp-calibrate", "title": "Coolant temperature spot-check", "script": "temp_calibrate.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("mode", "choice", "watch", "coolant: watch / point / fit; supply (pic/pwr_temp): "
|
||||||
|
"supply-watch / supply-point / supply-fit",
|
||||||
|
["watch", "point", "fit", "supply-watch", "supply-point", "supply-fit"]),
|
||||||
|
_arg("value", "str", None, "point: the thermometer reading in C; watch: seconds (default 60)")],
|
||||||
|
"desc": "Pairs a measured temperature with averaged raw readings; fits a per-machine line. The coolant "
|
||||||
|
"sensors, or the power supply's (thermometer on its heatsink, against the documented unverified "
|
||||||
|
"guess). Points accumulate in the bench data directory."},
|
||||||
|
{"id": "fan-test", "title": "Fan/coolant bench", "script": "fan_test.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True, "args": [],
|
||||||
|
"desc": "Snapshots fan PWMs/tachs/temps, drives M8 -> cut fans, M9 -> cooldown -> idle; the tach "
|
||||||
|
"readbacks in each snapshot are the evidence."},
|
||||||
|
{"id": "fan-floor", "title": "Fan floor measurement", "script": "fan_floor_measure.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("mode", "choice", "spinup", "spinup (M8, from idle) / cut (sample only, during a real cut)",
|
||||||
|
["spinup", "cut"]),
|
||||||
|
_arg("seconds", "int", 120, "sampling window", flag="--seconds"),
|
||||||
|
_arg("steady", "int", 60, "spinup: the steady window at the end", flag="--steady")],
|
||||||
|
"desc": "The numbers the airflow gates ship with: per fan the steady speed at run duty, the time to "
|
||||||
|
"90 percent and the spread, the purge current off and on, and (cut) the spread under a real "
|
||||||
|
"cut; results to the bench data directory."},
|
||||||
|
# -- laser (live) --------------------------------------------------------------
|
||||||
|
{"id": "live-fire", "title": "LIVE laser drills", "script": "live_fire_drills.py",
|
||||||
|
"safety": "live", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("drill", "choice", "witness", "witness / hold / ircut / expstop / ctrlstart",
|
||||||
|
["witness", "hold", "ircut", "expstop", "ctrlstart"]),
|
||||||
|
_arg("power", "int", 1000, "ircut: S value"), _arg("feed", "int", 300, "ircut: F value")],
|
||||||
|
"desc": "Emission witness, disarm grace in Hold, lid-IR characterization cut, armed "
|
||||||
|
"kill on the expected-stop path (+ the separate controller restart). The operator's arm press is "
|
||||||
|
"required for every drill; eye protection, fire watch, extinguisher, exhaust."},
|
||||||
|
{"id": "resume-dark-lead", "title": "Pause / resume chain timing (dark lead)", "script": "resume_dark_lead.py",
|
||||||
|
"safety": "live", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("run", "choice", "dry", "dry travel, or LIVE FIRE", ["dry", "live"], flag="--run"),
|
||||||
|
_arg("mode", "choice", "m3", "laser mode for a live run", ["m3", "m4"], flag="--mode"),
|
||||||
|
_arg("power", "int", 400, "live: S value", flag="--power"),
|
||||||
|
_arg("feed", "float", 600.0, "feed rate", flag="--feed"),
|
||||||
|
_arg("len", "float", 60.0, "move length in mm (+X)", flag="--len"),
|
||||||
|
_arg("passes", "int", 1, "alternating +X/-X moves", flag="--passes"),
|
||||||
|
_arg("secs", "float", 45.0, "sampling window", flag="--secs"),
|
||||||
|
_arg("auto", "str", "", "dry only: 'P,R' seconds to send ! and ~ unattended", flag="--auto")],
|
||||||
|
"desc": "Samples LASER_ON, FIRE, HV_ENABLE and the charge-pump watchdog straight off the SoC pads "
|
||||||
|
"across a pause and a resume, with motion dated from the kernel counters: how long HV survives "
|
||||||
|
"a pause, how fast the chain re-arms, and - on a live run - the dark lead between FIRE and "
|
||||||
|
"LASER_ON that a resumed cut loses. Dry by default; --run live needs the arm press, eye "
|
||||||
|
"protection, fire watch, extinguisher, exhaust."},
|
||||||
|
{"id": "pgood-probe", "title": "Supply power-good line against the chain", "script": "pgood_probe.py",
|
||||||
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("secs", "float", 90.0, "sampling window", flag="--secs")],
|
||||||
|
"desc": "Watches the supply's power-good line (cnc/laser_pgood, reported as the raw pin level) beside "
|
||||||
|
"LASER_ON, FIRE, the charge-pump watchdog, HV_ENABLE and the doors through the kernel readbacks at a "
|
||||||
|
"few hundred hertz, with hv_current at 20 Hz, and prints every transition. It only watches: drive "
|
||||||
|
"the machine meanwhile (a jog, an armed cut, a pause, a lid open). Needs no pad mapping."},
|
||||||
|
# -- host-side harnesses (CI) ------------------------------------------------------
|
||||||
|
{"id": "laser-stream-test", "title": "Laser pulse-stream emission harness", "script": "laser_stream_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "Null-sink controller stream capture against the feeder contract. A CI harness (the grblHAL repo): "
|
||||||
|
"it needs the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||||
|
{"id": "laser-lifecycle-test", "title": "Armed-window lifecycle harness", "script": "laser_lifecycle_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "Arm/disarm lifecycle on the null-sink controller. 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": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the "
|
||||||
|
"lens stands, and neither a $20 nor a $132 write frees it. 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"])],
|
||||||
|
"desc": "For each mode given: stores xy_microsteps, waits for the restarted controller, streams a 60-pass "
|
||||||
|
"raster of 150 mm at F12000 with the laser off, and reports the peak feed, the controller CPU, the "
|
||||||
|
"kernel counters against the start, underruns and clamped events. Needs the head with 150 mm of "
|
||||||
|
"free +X and 12 mm of free +Y travel, no other Grbl client. Ends at x8."},
|
||||||
|
{"id": "xy-pattern-accel", "title": "XY microstep modes by the head accelerometer, the machine silent",
|
||||||
|
"script": "xy_pattern_accel.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"])],
|
||||||
|
"desc": "From home at F12000: to (18, 9) in, to (9, 9) in, a 9 in circle from its mid-bottom back to "
|
||||||
|
"(9, 9), to (9, 0), home. Per mode: every fan, the coolant pump and the TEC commanded off through "
|
||||||
|
"the engine's quiet hold (the machine silent, the modes audible), the fixed 10 s wait, the head "
|
||||||
|
"accelerometer sampled over the bus at 800 Hz through the pattern; "
|
||||||
|
"cruise-window RMS and peak-to-peak per leg and overall, the kernel counters against home, "
|
||||||
|
"underruns; JSON with the trace to the bench data directory. Needs the machine homed and at home, "
|
||||||
|
"the lid closed, no other Grbl client, the bed clear. Ends at x8."},
|
||||||
|
{"id": "arc-tolerance-sweep", "title": "How fine an arc the controller can plan ($12 ladder)",
|
||||||
|
"script": "arc_tolerance_sweep.py", "safety": "dry", "where": "board", "ported": True,
|
||||||
|
"args": [_arg("mode", "choice", "16", "the XY microstep mode", ["8", "16", "32"], flag="--mode"),
|
||||||
|
_arg("ladder", "str", "0.002 0.001 0.0005 0.00025 0.0001", "the $12 values to try, in order")],
|
||||||
|
"desc": "From home, the machine silent: to (9, 9) in, then the 9 in circle at F12000 once per $12 in the "
|
||||||
|
"ladder. Per rung: the chords and boundaries a second, the circle time against the ideal, the lowest "
|
||||||
|
"feed mid-circle, the fewest free planner blocks, the controller CPU, clamped events, underruns, the "
|
||||||
|
"accelerometer's cruise RMS. $12 goes back to what it was; the head returns home. Needs the machine "
|
||||||
|
"homed and at home, the lid closed, no other Grbl client."},
|
||||||
|
{"id": "planner-blocks-test", "title": "Planner buffer depth harness", "script": "planner_blocks_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "A deep planner buffer starts: the controller restarted at $398=400 and at $398=1000 answers "
|
||||||
|
"on the port, reports the depth in its status report and runs a move. 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": "xy-mode-test", "title": "XY microstep mode harness", "script": "xy_mode_test.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "The XY scale is the microstep mode's, never typed: xy_microsteps sets $100/$101 and the "
|
||||||
|
"machine tick, a typed $100 is overwritten, and $110/$111 are held under a lowered tick. 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": "puls-profile", "title": "Factory .puls profile decoder", "script": "puls_profile.py",
|
||||||
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
|
"desc": "Decodes factory pulse streams into velocity/accel profiles. Runs anywhere; needs a .puls file "
|
||||||
|
"(the reference captures live off the machine), so it is not a bench-page tool."},
|
||||||
|
{"id": "debug-kernel-drills", "title": "Debug-kernel lock drills (load/unload, forced defer)",
|
||||||
|
"script": "debug_kernel_drills.py",
|
||||||
|
"safety": "takeover", "where": "board", "ported": True, "args": [],
|
||||||
|
"desc": "Runs only on the debug-kernel image (kas/forgefirm-glowforge-debug.yml): three glowforge.ko "
|
||||||
|
"load/unload cycles under DEBUG_MUTEXES and a forced -EPROBE_DEFER unwind, each read against "
|
||||||
|
"dmesg for lock splats. Cycles the 40 V rail; refuses on a non-debug kernel or a non-idle machine."},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Files in scripts/bench the page never offers: the helper module the
|
||||||
|
# host/board tools share (the C feeder and the build scripts are not
|
||||||
|
# python), and the two lens stall drills. A stall slips the lens stepper's
|
||||||
|
# rotor under full torque, so those run from the shell on the bench
|
||||||
|
# reference machine only (scripts/bench/README.md).
|
||||||
|
NOT_TOOLS = ("gfbench.py", "lens_travel.py", "lens_stop_accel.py")
|
||||||
|
|
||||||
|
|
||||||
|
class Bench:
|
||||||
|
def __init__(self, tools=None, tool_dir=None, index_path=None):
|
||||||
|
self.tools = list(tools if tools is not None else TOOLS)
|
||||||
|
self._by_id = {t["id"]: t for t in self.tools}
|
||||||
|
self._tool_dir = tool_dir
|
||||||
|
self.index_path = index_path or os.path.join(data_dir(), "bench.jsonl")
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def tool_dir(self):
|
||||||
|
return self._tool_dir or os.environ.get("FORGETEST_BENCH_DIR") or DEFAULT_TOOL_DIR
|
||||||
|
|
||||||
|
def get(self, tool_id):
|
||||||
|
return self._by_id.get(tool_id)
|
||||||
|
|
||||||
|
def command(self, tool, args):
|
||||||
|
"""argv for a tool with the form's arguments. Returns
|
||||||
|
(ok, argv, error)."""
|
||||||
|
script = os.path.join(self.tool_dir(), tool["script"])
|
||||||
|
if not os.path.exists(script):
|
||||||
|
return False, None, "script not installed: %s" % tool["script"]
|
||||||
|
argv = [sys.executable, script] + list(tool.get("argv_fixed", []))
|
||||||
|
for spec in tool.get("args", []):
|
||||||
|
raw = args.get(spec["name"], spec.get("default"))
|
||||||
|
if raw is None or raw == "":
|
||||||
|
if spec.get("default") in (None, ""):
|
||||||
|
continue # optional and absent: not passed at all
|
||||||
|
raw = spec["default"]
|
||||||
|
try:
|
||||||
|
if spec["type"] == "int":
|
||||||
|
val = str(int(raw))
|
||||||
|
elif spec["type"] == "float":
|
||||||
|
val = repr(float(raw))
|
||||||
|
elif spec["type"] == "choice":
|
||||||
|
if str(raw) not in spec["choices"]:
|
||||||
|
return False, None, "%s must be one of %s" % (spec["name"], spec["choices"])
|
||||||
|
val = str(raw)
|
||||||
|
else:
|
||||||
|
val = str(raw)
|
||||||
|
if any(ch in val for ch in "\0\n\r"):
|
||||||
|
return False, None, "%s: invalid characters" % spec["name"]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False, None, "%s: invalid %s" % (spec["name"], spec["type"])
|
||||||
|
if spec.get("flag"):
|
||||||
|
argv.append(spec["flag"])
|
||||||
|
argv.append(val)
|
||||||
|
argv += list(tool.get("argv_fixed_after", []))
|
||||||
|
return True, argv, None
|
||||||
|
|
||||||
|
def record(self, tool, args, run):
|
||||||
|
rec = {"ts": run.started_ts, "tool": tool["id"], "args": args, "result": run.finished,
|
||||||
|
"log_tail": run.lines[-50:]}
|
||||||
|
with self._lock:
|
||||||
|
os.makedirs(os.path.dirname(self.index_path), exist_ok=True)
|
||||||
|
with open(self.index_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(rec, sort_keys=True, separators=(",", ":")) + "\n")
|
||||||
|
|
||||||
|
def last_runs(self):
|
||||||
|
out = {}
|
||||||
|
try:
|
||||||
|
with open(self.index_path, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
try:
|
||||||
|
rec = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
out[rec.get("tool")] = {"ts": rec.get("ts"), "result": rec.get("result"),
|
||||||
|
"args": rec.get("args")}
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
def listing(self):
|
||||||
|
last = self.last_runs()
|
||||||
|
items = []
|
||||||
|
for t in self.tools:
|
||||||
|
item = {k: t[k] for k in ("id", "title", "script", "safety", "where", "ported", "args", "desc")}
|
||||||
|
item["installed"] = os.path.exists(os.path.join(self.tool_dir(), t["script"]))
|
||||||
|
item["last"] = last.get(t["id"])
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def describe_command(self, tool, args):
|
||||||
|
ok, argv, err = self.command(tool, args)
|
||||||
|
return " ".join(shlex.quote(a) for a in argv) if ok else err
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Campaign rules: which results apply, what is required, and whether a
|
||||||
|
release is authorized.
|
||||||
|
|
||||||
|
A campaign is bound to one image (manifest content hash) and one catalog
|
||||||
|
(catalog hash). It is open from its record until a FAIL/ERROR result in
|
||||||
|
it, an invalidate-all, an explicit reset, or a different image or
|
||||||
|
catalog. Rules per test T with domain fingerprint F(T):
|
||||||
|
|
||||||
|
satisfied by campaign a PASS in the open campaign with fingerprint F(T)
|
||||||
|
satisfied by inheritance (never for the always-required core) the newest
|
||||||
|
PASS anywhere in the history with fingerprint F(T)
|
||||||
|
and newer than the last invalidate-all
|
||||||
|
required otherwise (reason: always / never-passed /
|
||||||
|
domain-changed)
|
||||||
|
|
||||||
|
authorized <=> a campaign is open and every catalog test is satisfied
|
||||||
|
|
||||||
|
Pure functions over the record list; nothing here touches hardware or
|
||||||
|
the filesystem, which keeps the rules unit-testable and lets the release
|
||||||
|
gate reason with the same code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PASS, FAIL, ERROR, ABORTED = "PASS", "FAIL", "ERROR", "ABORTED"
|
||||||
|
CLOSING = (FAIL, ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(rec):
|
||||||
|
if rec is None:
|
||||||
|
return None
|
||||||
|
keys = ("ts", "result", "campaign", "image", "fingerprint", "duration_s", "message")
|
||||||
|
return {k: rec.get(k) for k in keys if k in rec}
|
||||||
|
|
||||||
|
|
||||||
|
def open_campaign(records, manifest_sha, catalog_hash):
|
||||||
|
"""(open_campaign_record_or_None, last_campaign_record_or_None,
|
||||||
|
closed_by_or_None, invalidate_record_or_None)."""
|
||||||
|
current = None
|
||||||
|
closed_by = None
|
||||||
|
invalidate = None
|
||||||
|
for r in records:
|
||||||
|
t = r.get("t")
|
||||||
|
if t == "campaign":
|
||||||
|
current = r
|
||||||
|
closed_by = None
|
||||||
|
elif t == "result":
|
||||||
|
if current and r.get("campaign") == current.get("id") and r.get("result") in CLOSING:
|
||||||
|
closed_by = closed_by or "fail"
|
||||||
|
elif t == "invalidate":
|
||||||
|
invalidate = r
|
||||||
|
if current:
|
||||||
|
closed_by = closed_by or "invalidate"
|
||||||
|
elif t == "reset":
|
||||||
|
if current:
|
||||||
|
closed_by = closed_by or "reset"
|
||||||
|
if current and not closed_by:
|
||||||
|
if current.get("manifest_sha") != manifest_sha:
|
||||||
|
closed_by = "image"
|
||||||
|
elif current.get("catalog_hash") != catalog_hash:
|
||||||
|
closed_by = "catalog"
|
||||||
|
return (current if current and not closed_by else None), current, closed_by, invalidate
|
||||||
|
|
||||||
|
|
||||||
|
def compute(records, tests, manifest, catalog_hash, running=None):
|
||||||
|
"""The state the page shows and the exporter serializes.
|
||||||
|
|
||||||
|
tests: iterable of catalog.Test. running: id of the test in progress
|
||||||
|
(marked in the per-test status), or None.
|
||||||
|
"""
|
||||||
|
results = [r for r in records if r.get("t") == "result"]
|
||||||
|
campaign, last, closed_by, invalidate = open_campaign(records, manifest.content_sha, catalog_hash)
|
||||||
|
epoch = invalidate.get("ts") if invalidate else None
|
||||||
|
open_id = campaign.get("id") if campaign else None
|
||||||
|
# The invalidate record keeps its epoch for good: nothing older than it
|
||||||
|
# inherits. The page and the artifact report it only while it still asks
|
||||||
|
# for something, that is until a campaign has started after it.
|
||||||
|
invalidate_shown = invalidate
|
||||||
|
if invalidate:
|
||||||
|
seen = False
|
||||||
|
for r in records:
|
||||||
|
if r is invalidate:
|
||||||
|
seen = True
|
||||||
|
elif seen and r.get("t") == "campaign":
|
||||||
|
invalidate_shown = None
|
||||||
|
break
|
||||||
|
|
||||||
|
by_test = {}
|
||||||
|
for r in results:
|
||||||
|
by_test.setdefault(r.get("test"), []).append(r)
|
||||||
|
|
||||||
|
out_tests = {}
|
||||||
|
tests = list(tests)
|
||||||
|
for t in tests:
|
||||||
|
fp = t.fingerprint(manifest)
|
||||||
|
hist = by_test.get(t.id, [])
|
||||||
|
last_r = hist[-1] if hist else None
|
||||||
|
origin = None
|
||||||
|
status = "none"
|
||||||
|
satisfied = False
|
||||||
|
reason = None
|
||||||
|
same = None
|
||||||
|
if open_id:
|
||||||
|
for r in reversed(hist):
|
||||||
|
if r.get("campaign") == open_id and r.get("result") == PASS and r.get("fingerprint") == fp:
|
||||||
|
same = r
|
||||||
|
break
|
||||||
|
if same is not None:
|
||||||
|
status, satisfied, origin, reason = "pass", True, same, "campaign"
|
||||||
|
elif t.always:
|
||||||
|
reason = "always"
|
||||||
|
else:
|
||||||
|
# The newest record on the current fingerprint decides: a PASS
|
||||||
|
# is inherited, a FAIL or ERROR after it blocks the inheritance
|
||||||
|
# (the test has to be run again), an ABORTED run says nothing.
|
||||||
|
inh = None
|
||||||
|
blocked = None
|
||||||
|
for r in reversed(hist):
|
||||||
|
if r.get("fingerprint") != fp:
|
||||||
|
continue
|
||||||
|
if epoch and (r.get("ts") or "") <= epoch:
|
||||||
|
continue
|
||||||
|
res = r.get("result")
|
||||||
|
if res == PASS:
|
||||||
|
inh = r
|
||||||
|
break
|
||||||
|
if res in (FAIL, ERROR):
|
||||||
|
blocked = r
|
||||||
|
break
|
||||||
|
if inh is not None:
|
||||||
|
status, satisfied, origin, reason = "inherited", True, inh, "inherited"
|
||||||
|
elif blocked is not None and any(r.get("result") == PASS and r.get("fingerprint") == fp
|
||||||
|
and (r.get("ts") or "") < (blocked.get("ts") or "")
|
||||||
|
for r in hist):
|
||||||
|
reason = "failed-since"
|
||||||
|
else:
|
||||||
|
reason = "domain-changed" if any(r.get("result") == PASS for r in hist) else "never-passed"
|
||||||
|
if not satisfied and last_r is not None:
|
||||||
|
status = {PASS: "stale", FAIL: "fail", ERROR: "error", ABORTED: "aborted"}.get(last_r.get("result"), "none")
|
||||||
|
if running == t.id:
|
||||||
|
status = "running"
|
||||||
|
out_tests[t.id] = {
|
||||||
|
"fingerprint": fp,
|
||||||
|
"status": status,
|
||||||
|
"satisfied": satisfied,
|
||||||
|
"required": not satisfied,
|
||||||
|
"reason": reason,
|
||||||
|
"last": _summary(last_r),
|
||||||
|
"origin": _summary(origin),
|
||||||
|
}
|
||||||
|
|
||||||
|
# requires: a prerequisite counts when it is satisfied (campaign or
|
||||||
|
# inherited); the always-required core is the freshness mechanism.
|
||||||
|
for t in tests:
|
||||||
|
missing = [r for r in t.requires if not out_tests.get(r, {}).get("satisfied")]
|
||||||
|
out_tests[t.id]["missing_requires"] = missing
|
||||||
|
out_tests[t.id]["requires_met"] = not missing
|
||||||
|
|
||||||
|
n_sat = sum(1 for v in out_tests.values() if v["satisfied"])
|
||||||
|
n_inh = sum(1 for v in out_tests.values() if v["status"] == "inherited")
|
||||||
|
counts = {"total": len(tests), "satisfied": n_sat, "inherited": n_inh,
|
||||||
|
"required": len(tests) - n_sat}
|
||||||
|
authorized = bool(campaign) and n_sat == len(tests) and len(tests) > 0
|
||||||
|
return {
|
||||||
|
"campaign": campaign,
|
||||||
|
"last_campaign": last,
|
||||||
|
"closed_by": closed_by,
|
||||||
|
"invalidate": invalidate_shown,
|
||||||
|
"authorized": authorized,
|
||||||
|
"counts": counts,
|
||||||
|
"tests": out_tests,
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""The acceptance catalog: test definitions and the registry.
|
||||||
|
|
||||||
|
A test is a function decorated with @test(...). The decorator records
|
||||||
|
what the release gate needs to know without running anything: the id,
|
||||||
|
the subsystem, the kind (auto / operator / live), how it takes the
|
||||||
|
hardware (api / takeover), the controller mode it needs (if any), what
|
||||||
|
source it covers, what it requires, and whether it belongs to the
|
||||||
|
always-required core. Two more fields describe the operator's part
|
||||||
|
without affecting the gate: `actions`, the machine actions the test asks
|
||||||
|
for by name (the page lists them before a start; a bench actuator can
|
||||||
|
perform them), and `precheck`, a condition the machine must meet for the
|
||||||
|
test to start at all (a reason string refuses the start, the way an
|
||||||
|
unmet prerequisite does, and records no result). The function body runs
|
||||||
|
under the runner with a Context (log, prompts, evidence, hardware
|
||||||
|
helpers) and reports by returning normally (PASS) or raising
|
||||||
|
runner.Failed (FAIL).
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from . import manifest as _manifest
|
||||||
|
|
||||||
|
KINDS = ("auto", "operator", "live")
|
||||||
|
HARDWARE = ("api", "takeover")
|
||||||
|
MODES = ("grbl", "cloud")
|
||||||
|
# The machine actions a test may ask of the operator (Context.act):
|
||||||
|
# the lid, the remote-interlock loop, and the big button. Everything a
|
||||||
|
# test needs done to the machine is one of these, so a bench actuator
|
||||||
|
# that covers a channel can stand in for the hands on it.
|
||||||
|
ACTIONS = ("lid", "interlock", "button")
|
||||||
|
_ID_RX = re.compile(r"^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$")
|
||||||
|
|
||||||
|
REGISTRY = {}
|
||||||
|
|
||||||
|
|
||||||
|
class Test:
|
||||||
|
def __init__(self, id, title, subsystem, kind, hardware, covers, requires,
|
||||||
|
always, est_min, steps, description, fn, mode=None, actions=(),
|
||||||
|
precheck=None, hands=()):
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.subsystem = subsystem
|
||||||
|
self.kind = kind
|
||||||
|
self.hardware = hardware
|
||||||
|
self.mode = mode
|
||||||
|
self.covers = tuple((str(c), str(g)) for c, g in covers)
|
||||||
|
self.requires = tuple(requires)
|
||||||
|
self.always = bool(always)
|
||||||
|
self.est_min = est_min
|
||||||
|
self.steps = tuple(steps)
|
||||||
|
self.actions = tuple(actions)
|
||||||
|
self.hands = tuple(hands)
|
||||||
|
self.precheck = precheck
|
||||||
|
self.description = description or (fn.__doc__ or "").strip()
|
||||||
|
self.fn = fn
|
||||||
|
self._source_sha = None
|
||||||
|
self._fp = (None, None) # (manifest content sha, fingerprint)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_sha(self):
|
||||||
|
"""The hash of the test's own implementation: its function (the
|
||||||
|
decorator included) together with the code its module shares
|
||||||
|
among its tests - everything outside the @test functions. Part
|
||||||
|
of the fingerprint: a change inside one test's body invalidates
|
||||||
|
that test's earlier passes and no other; a change to a helper
|
||||||
|
invalidates the tests of that module. A test the module does not
|
||||||
|
define in the @test form hashes its whole file."""
|
||||||
|
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):
|
||||||
|
"""The domain fingerprint on this manifest, memoized by the
|
||||||
|
manifest's content hash: the page recomputes every test's
|
||||||
|
fingerprint on every poll, and a manifest never changes under a
|
||||||
|
running tool."""
|
||||||
|
key = manifest.content_sha
|
||||||
|
if key and self._fp[0] == key:
|
||||||
|
return self._fp[1]
|
||||||
|
fp = _manifest.fingerprint(manifest, self.covers, extra=[self.source_sha])
|
||||||
|
if key:
|
||||||
|
self._fp = (key, fp)
|
||||||
|
return fp
|
||||||
|
|
||||||
|
def definition(self):
|
||||||
|
"""The gate-visible definition (no implementation, no prose)."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"subsystem": self.subsystem,
|
||||||
|
"kind": self.kind,
|
||||||
|
"hardware": self.hardware,
|
||||||
|
"covers": [list(c) for c in self.covers],
|
||||||
|
"requires": list(self.requires),
|
||||||
|
"always": self.always,
|
||||||
|
}
|
||||||
|
|
||||||
|
def describe(self):
|
||||||
|
d = self.definition()
|
||||||
|
d.update({"title": self.title, "est_min": self.est_min, "mode": self.mode,
|
||||||
|
"steps": list(self.steps), "actions": list(self.actions),
|
||||||
|
"hands": list(self.hands), "precheck": bool(self.precheck),
|
||||||
|
"description": self.description})
|
||||||
|
return d
|
||||||
|
|
||||||
|
def fixture_runnable(self, channels):
|
||||||
|
"""Whether a bench actuator covering `channels` lets this test
|
||||||
|
run with nobody in the room: an operator test whose every action
|
||||||
|
is a covered channel and which asks a person for nothing else. A
|
||||||
|
live test never qualifies (the arm press and the fire watch are
|
||||||
|
a person's); an auto test needs no fixture."""
|
||||||
|
if self.kind != "operator" or self.hands or not self.actions:
|
||||||
|
return False
|
||||||
|
return all(a in channels for a in self.actions)
|
||||||
|
|
||||||
|
def cannot_start(self):
|
||||||
|
"""The reason the test cannot start on the machine as it is, or
|
||||||
|
None. Evaluated right before a start; never a result."""
|
||||||
|
if self.precheck is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
reason = self.precheck()
|
||||||
|
except Exception as e: # noqa: BLE001 - a broken precheck refuses, it never crashes the runner
|
||||||
|
return "precheck errored: %s: %s" % (type(e).__name__, e)
|
||||||
|
return reason or None
|
||||||
|
|
||||||
|
|
||||||
|
def source_file_sha(path):
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = f.read().replace(b"\r\n", b"\n")
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
_PARTS = {} # path -> (shared sha, {test id: own sha}); the files never change under a run
|
||||||
|
_PARTS_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def module_parts(path):
|
||||||
|
"""(shared sha, {test id: own sha}) for a suite module: the test ids
|
||||||
|
are read from the @test("...") decorators on the module's top-level
|
||||||
|
functions; a test's own text runs from its first decorator line to
|
||||||
|
the end of its body; the shared text is every other line of the
|
||||||
|
file. Line endings normalized."""
|
||||||
|
with _PARTS_LOCK:
|
||||||
|
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")
|
||||||
|
lines = text.split("\n")
|
||||||
|
spans = {}
|
||||||
|
try:
|
||||||
|
tree = ast.parse(text)
|
||||||
|
except SyntaxError:
|
||||||
|
tree = None
|
||||||
|
for node in (tree.body if tree is not None else []):
|
||||||
|
if not isinstance(node, ast.FunctionDef):
|
||||||
|
continue
|
||||||
|
for d in node.decorator_list:
|
||||||
|
if (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == "test"
|
||||||
|
and d.args and isinstance(d.args[0], ast.Constant) and isinstance(d.args[0].value, str)):
|
||||||
|
start = min([dd.lineno for dd in node.decorator_list] + [node.lineno])
|
||||||
|
spans[d.args[0].value] = (start, node.end_lineno)
|
||||||
|
owned = set()
|
||||||
|
for a, b in spans.values():
|
||||||
|
owned.update(range(a, b + 1))
|
||||||
|
shared = "\n".join(ln for i, ln in enumerate(lines, 1) if i not in owned)
|
||||||
|
own = {tid: _manifest.sha256_text("\n".join(lines[a - 1:b])) for tid, (a, b) in spans.items()}
|
||||||
|
parts = (_manifest.sha256_text(shared), own)
|
||||||
|
with _PARTS_LOCK:
|
||||||
|
_PARTS[path] = parts
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def sibling_imports(path, seen=None):
|
||||||
|
"""The suite modules a module imports with `from .x import ...`, as
|
||||||
|
paths, transitively, in a stable order. A helper a test calls from
|
||||||
|
a sibling module is part of the judge, so its text belongs in the
|
||||||
|
test's fingerprint."""
|
||||||
|
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)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
of every sibling suite module the module imports."""
|
||||||
|
shared, own = module_parts(path)
|
||||||
|
if test_id not in own:
|
||||||
|
return source_file_sha(path)
|
||||||
|
helpers = [module_parts(p)[0] for p in sorted(sibling_imports(path))]
|
||||||
|
return _manifest.sha256_text("%s:%s" % (":".join([shared] + helpers), own[test_id]))
|
||||||
|
|
||||||
|
|
||||||
|
def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers=(),
|
||||||
|
requires=(), always=False, est_min=1, steps=(), description="", actions=(),
|
||||||
|
precheck=None, hands=()):
|
||||||
|
"""`mode` names the controller mode the test needs live when it starts
|
||||||
|
("grbl" or "cloud"); the runner switches the machine there before the
|
||||||
|
test and leaves it there, so a queue crosses modes only where a test
|
||||||
|
asks it to. None means the test runs in whatever mode it finds (or
|
||||||
|
manages the mode itself, as the cloud tests do through enter_cloud,
|
||||||
|
which also waits for the service session).
|
||||||
|
|
||||||
|
`actions` names the machine actions (ACTIONS) the test performs
|
||||||
|
through Context.act; an `auto` test declares none. `hands` names what
|
||||||
|
the test asks of a person beyond those actions ("app" for a job in
|
||||||
|
the Glowforge app, "scrap", "mark" for an eye on the result...): an
|
||||||
|
operator test with none, whose actions a bench actuator covers, runs
|
||||||
|
unattended when one is up. `precheck` is a callable returning a
|
||||||
|
reason string when the machine cannot run the test as it is (None
|
||||||
|
when it can)."""
|
||||||
|
if not _ID_RX.match(id):
|
||||||
|
raise ValueError("test id %r must look like subsystem.name" % id)
|
||||||
|
if kind not in KINDS:
|
||||||
|
raise ValueError("test %s: kind %r" % (id, kind))
|
||||||
|
if hardware not in HARDWARE:
|
||||||
|
raise ValueError("test %s: hardware %r" % (id, hardware))
|
||||||
|
if mode is not None and mode not in MODES:
|
||||||
|
raise ValueError("test %s: mode %r" % (id, mode))
|
||||||
|
for c, g in covers:
|
||||||
|
if c in _manifest.DEV_ONLY_COMPONENTS:
|
||||||
|
raise ValueError("test %s: may not cover dev-only component %r" % (id, c))
|
||||||
|
for a in actions:
|
||||||
|
if a not in ACTIONS:
|
||||||
|
raise ValueError("test %s: action %r" % (id, a))
|
||||||
|
if actions and kind == "auto":
|
||||||
|
raise ValueError("test %s: an auto test asks for no machine actions" % id)
|
||||||
|
if hands and kind == "auto":
|
||||||
|
raise ValueError("test %s: an auto test asks nothing of a person" % id)
|
||||||
|
if precheck is not None and not callable(precheck):
|
||||||
|
raise ValueError("test %s: precheck must be callable" % id)
|
||||||
|
|
||||||
|
def deco(fn):
|
||||||
|
if id in REGISTRY:
|
||||||
|
raise ValueError("duplicate test id %r" % id)
|
||||||
|
REGISTRY[id] = Test(id, title, subsystem, kind, hardware, covers, requires,
|
||||||
|
always, est_min, steps, description, fn, mode=mode,
|
||||||
|
actions=actions, precheck=precheck, hands=hands)
|
||||||
|
return fn
|
||||||
|
return deco
|
||||||
|
|
||||||
|
|
||||||
|
def all_tests(registry=None):
|
||||||
|
"""Tests in registration order (the suite modules import in
|
||||||
|
subsystem order, so this is the display order)."""
|
||||||
|
return list((registry if registry is not None else REGISTRY).values())
|
||||||
|
|
||||||
|
|
||||||
|
def get(id, registry=None):
|
||||||
|
return (registry if registry is not None else REGISTRY).get(id)
|
||||||
|
|
||||||
|
|
||||||
|
def order_by_requires(tests, selected):
|
||||||
|
"""`selected` ids in an order that runs a prerequisite before the test
|
||||||
|
that names it.
|
||||||
|
|
||||||
|
Registration order otherwise, so a run reads down the page. A
|
||||||
|
prerequisite outside the selection places nothing: either it is
|
||||||
|
already satisfied, or the run that needs it will be refused and
|
||||||
|
recorded as skipped. validate() has ruled out cycles; the stack check
|
||||||
|
only keeps a malformed registry from recursing forever.
|
||||||
|
"""
|
||||||
|
want = set(selected)
|
||||||
|
by_id = {t.id: t for t in tests}
|
||||||
|
out, placed = [], set()
|
||||||
|
|
||||||
|
def visit(tid, stack):
|
||||||
|
if tid in placed or tid not in want or tid not in by_id or tid in stack:
|
||||||
|
return
|
||||||
|
stack.add(tid)
|
||||||
|
for r in by_id[tid].requires:
|
||||||
|
visit(r, stack)
|
||||||
|
stack.discard(tid)
|
||||||
|
placed.add(tid)
|
||||||
|
out.append(tid)
|
||||||
|
|
||||||
|
for t in tests:
|
||||||
|
visit(t.id, set())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def validate(registry=None):
|
||||||
|
"""Every `requires` names a known test and there are no cycles."""
|
||||||
|
reg = registry if registry is not None else REGISTRY
|
||||||
|
for t in reg.values():
|
||||||
|
for r in t.requires:
|
||||||
|
if r not in reg:
|
||||||
|
raise ValueError("test %s requires unknown test %s" % (t.id, r))
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def visit(tid, stack):
|
||||||
|
if tid in stack:
|
||||||
|
raise ValueError("requires cycle: %s" % " -> ".join(stack + [tid]))
|
||||||
|
if seen.get(tid):
|
||||||
|
return
|
||||||
|
for r in reg[tid].requires:
|
||||||
|
visit(r, stack + [tid])
|
||||||
|
seen[tid] = True
|
||||||
|
for tid in reg:
|
||||||
|
visit(tid, [])
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_hash(registry=None):
|
||||||
|
"""Identity of the catalog's definitions (ids, kinds, coverage,
|
||||||
|
requirements, core membership) - not of the implementations, which
|
||||||
|
the per-test fingerprints carry."""
|
||||||
|
defs = sorted((t.definition() for t in all_tests(registry)), key=lambda d: d["id"])
|
||||||
|
return _manifest.sha256_text(_manifest.canonical(defs))
|
||||||
|
|
||||||
|
|
||||||
|
def load_suite():
|
||||||
|
"""Import the suite modules (each registers its tests) and validate."""
|
||||||
|
from . import suite # noqa: F401 (registers on import)
|
||||||
|
validate()
|
||||||
|
return REGISTRY
|
||||||
|
|
||||||
|
|
||||||
|
def suite_dir():
|
||||||
|
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "suite")
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Coverage lint: every source path in the manifest must be selected by
|
||||||
|
some test's coverage globs, except the allowlisted non-behavioral paths,
|
||||||
|
and every coverage glob must select at least one path of its component.
|
||||||
|
|
||||||
|
Why a hard rule: under the domain model an uncovered file is worse than an
|
||||||
|
untested one - a change there leaves every inherited PASS valid when it
|
||||||
|
should have invalidated them. The lint is the floor (the file is
|
||||||
|
fingerprinted by at least one test); whether that test exercises the
|
||||||
|
change stays with the change author. An entry that selects nothing is
|
||||||
|
the same defect from the other side: the test's fingerprint ignores the
|
||||||
|
file it named.
|
||||||
|
|
||||||
|
python3 -m forgetest.coverage [--manifest PATH] [--enforce] [--json]
|
||||||
|
|
||||||
|
Exit 0 when nothing is uncovered (or when only reporting), 1 under
|
||||||
|
--enforce with uncovered paths, 2 on a usage or load error.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import catalog as _catalog
|
||||||
|
from . import manifest as _manifest
|
||||||
|
|
||||||
|
# Non-behavioral paths need no acceptance coverage, and no fingerprint
|
||||||
|
# carries them: the one list, in the manifest module.
|
||||||
|
ALLOW = _manifest.NON_BEHAVIORAL
|
||||||
|
|
||||||
|
|
||||||
|
def run(manifest, tests, allow=ALLOW):
|
||||||
|
return _manifest.coverage_report(manifest, tests, allow)
|
||||||
|
|
||||||
|
|
||||||
|
def empty(manifest, tests):
|
||||||
|
return _manifest.empty_covers(manifest, tests)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(description="forgetest coverage lint")
|
||||||
|
ap.add_argument("--manifest", default=None, help="manifest JSON (default: the running image's)")
|
||||||
|
ap.add_argument("--enforce", action="store_true", help="exit 1 when any path is uncovered")
|
||||||
|
ap.add_argument("--json", action="store_true", help="machine-readable report")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
try:
|
||||||
|
manifest = _manifest.Manifest.load(args.manifest)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
print("cannot load manifest: %s" % e, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
registry = _catalog.load_suite()
|
||||||
|
tests = _catalog.all_tests(registry)
|
||||||
|
report = run(manifest, tests)
|
||||||
|
total = sum(len(v) for v in report.values())
|
||||||
|
hollow = empty(manifest, tests)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({"uncovered": report, "total": total, "tests": len(tests),
|
||||||
|
"empty": [list(e) for e in hollow]}, indent=1, sort_keys=True))
|
||||||
|
else:
|
||||||
|
for comp in sorted(report):
|
||||||
|
print("%s: %d uncovered path(s)" % (comp, len(report[comp])))
|
||||||
|
for p in report[comp]:
|
||||||
|
print(" %s" % p)
|
||||||
|
for tid, comp, pat in hollow:
|
||||||
|
print("%s: covers (%s, %s) selects nothing" % (tid, comp, pat))
|
||||||
|
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, %d tests"
|
||||||
|
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(tests)))
|
||||||
|
if args.enforce and (total or hollow):
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"""The bench actuator (forgefixture): the box on the bench network that
|
||||||
|
opens the lid loop, pulls the interlock loop and presses the button on
|
||||||
|
request, so an operator test can run with nobody in the room.
|
||||||
|
|
||||||
|
Configured by a bench-local file, FIXTURE_CONFIG (default
|
||||||
|
/data/forgetest/fixture.json, mode 0600, never in the repo):
|
||||||
|
|
||||||
|
{"hostname": "forgefixture", "key": "<the fixture's API key>",
|
||||||
|
"ip": null, "channels": ["lid", "interlock", "button"],
|
||||||
|
"arm_press": false}
|
||||||
|
|
||||||
|
`hostname` is resolved as <hostname>.local by the resolver here (the
|
||||||
|
image carries no mDNS resolver; the fixture answers the query itself);
|
||||||
|
`ip` overrides that. `channels` names what is wired. `arm_press` is the
|
||||||
|
one policy switch: whether the fixture may press the button to arm the
|
||||||
|
laser for a live test (a person's press by default).
|
||||||
|
|
||||||
|
The fixture does not read the machine: every action the runner asks of
|
||||||
|
it is verified through the machine's own readings afterward, exactly as
|
||||||
|
an operator's hand is (Context.act). Contract: fixture/README.md.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
FIXTURE_CONFIG = os.environ.get("FORGETEST_FIXTURE_CONFIG", "/data/forgetest/fixture.json")
|
||||||
|
CHANNELS = ("lid", "interlock", "button")
|
||||||
|
MDNS_GROUP = "224.0.0.251"
|
||||||
|
MDNS_PORT = 5353
|
||||||
|
RESOLVE_TTL_S = 300.0 # a resolved address is trusted this long
|
||||||
|
BUTTON_GAP_S = 0.3 # the release between two presses: long enough for the controller to see it
|
||||||
|
PULSE_WAIT_S = 1.5 # a pulse in progress ends within the firmware's 500 ms clamp
|
||||||
|
|
||||||
|
|
||||||
|
class FixtureError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- mDNS
|
||||||
|
|
||||||
|
def mdns_query(name):
|
||||||
|
"""One mDNS A query for `name` (e.g. "forgefixture.local"), as the
|
||||||
|
bytes on the wire: a standard DNS header with id 0, one question,
|
||||||
|
class IN with the unicast-response bit set so the answer comes back
|
||||||
|
to this socket rather than the group."""
|
||||||
|
labels = b"".join(struct.pack("B", len(p)) + p.encode("ascii") for p in name.rstrip(".").split("."))
|
||||||
|
header = struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0)
|
||||||
|
return header + labels + b"\x00" + struct.pack("!HH", 1, 0x8001)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_name(data, off):
|
||||||
|
"""A DNS name at `off`, compression pointers followed; (name, next
|
||||||
|
offset)."""
|
||||||
|
parts = []
|
||||||
|
jumped = False
|
||||||
|
end = off
|
||||||
|
guard = 0
|
||||||
|
while True:
|
||||||
|
if off >= len(data):
|
||||||
|
raise ValueError("truncated name")
|
||||||
|
n = data[off]
|
||||||
|
if n == 0:
|
||||||
|
off += 1
|
||||||
|
break
|
||||||
|
if n & 0xC0 == 0xC0:
|
||||||
|
ptr = struct.unpack("!H", data[off:off + 2])[0] & 0x3FFF
|
||||||
|
if not jumped:
|
||||||
|
end = off + 2
|
||||||
|
jumped = True
|
||||||
|
off = ptr
|
||||||
|
guard += 1
|
||||||
|
if guard > 32:
|
||||||
|
raise ValueError("pointer loop")
|
||||||
|
continue
|
||||||
|
parts.append(data[off + 1:off + 1 + n].decode("ascii", "replace"))
|
||||||
|
off += 1 + n
|
||||||
|
if not jumped:
|
||||||
|
end = off
|
||||||
|
return ".".join(parts).lower(), end
|
||||||
|
|
||||||
|
|
||||||
|
def mdns_answers(data, name):
|
||||||
|
"""The IPv4 addresses in an mDNS response's A records for `name`
|
||||||
|
(answers and additionals; a response is anything with QR set)."""
|
||||||
|
name = name.rstrip(".").lower()
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
_id, flags, qd, an, ns, ar = struct.unpack("!HHHHHH", data[:12])
|
||||||
|
if not flags & 0x8000:
|
||||||
|
return out
|
||||||
|
off = 12
|
||||||
|
for _ in range(qd):
|
||||||
|
_n, off = _read_name(data, off)
|
||||||
|
off += 4
|
||||||
|
for _ in range(an + ns + ar):
|
||||||
|
rname, off = _read_name(data, off)
|
||||||
|
rtype, rclass, _ttl, rdlen = struct.unpack("!HHIH", data[off:off + 10])
|
||||||
|
off += 10
|
||||||
|
rdata = data[off:off + rdlen]
|
||||||
|
off += rdlen
|
||||||
|
if rtype == 1 and rdlen == 4 and rname == name:
|
||||||
|
out.append(socket.inet_ntoa(rdata))
|
||||||
|
except (struct.error, ValueError):
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _local_ipv4s():
|
||||||
|
"""The interface addresses to send a multicast query from: the
|
||||||
|
default first (None), then every IPv4 address this host answers to."""
|
||||||
|
out = [None]
|
||||||
|
try:
|
||||||
|
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET, socket.SOCK_DGRAM):
|
||||||
|
ip = info[4][0]
|
||||||
|
if ip not in out and not ip.startswith("127."):
|
||||||
|
out.append(ip)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_mdns(hostname, timeout=2.0, tries=3):
|
||||||
|
"""The IPv4 address of <hostname>.local, asked of the network
|
||||||
|
directly; None when nothing answers."""
|
||||||
|
name = hostname if hostname.endswith(".local") else hostname + ".local"
|
||||||
|
query = mdns_query(name)
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
|
||||||
|
# On the mDNS port and in the group, so a responder that answers
|
||||||
|
# the group rather than the asker is heard too; a port already
|
||||||
|
# taken (a resolver daemon) means that daemon would have served
|
||||||
|
# the name, so the ephemeral port and unicast replies will do.
|
||||||
|
try:
|
||||||
|
sock.bind(("", MDNS_PORT))
|
||||||
|
except OSError:
|
||||||
|
sock.bind(("", 0))
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
|
||||||
|
socket.inet_aton(MDNS_GROUP) + socket.inet_aton("0.0.0.0"))
|
||||||
|
except OSError:
|
||||||
|
pass # no group membership: unicast replies still arrive
|
||||||
|
for _ in range(tries):
|
||||||
|
# The query goes out of every local IPv4 interface that will
|
||||||
|
# take it (a host with a VPN or a VM adapter as its default
|
||||||
|
# route would otherwise never reach the bench network).
|
||||||
|
sent = False
|
||||||
|
for ifaddr in _local_ipv4s():
|
||||||
|
try:
|
||||||
|
if ifaddr is not None:
|
||||||
|
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(ifaddr))
|
||||||
|
sock.sendto(query, (MDNS_GROUP, MDNS_PORT))
|
||||||
|
sent = True
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if not sent:
|
||||||
|
return None
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while True:
|
||||||
|
left = deadline - time.time()
|
||||||
|
if left <= 0:
|
||||||
|
break
|
||||||
|
sock.settimeout(left)
|
||||||
|
try:
|
||||||
|
data, _peer = sock.recvfrom(2048)
|
||||||
|
except socket.timeout:
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
for ip in mdns_answers(data, name):
|
||||||
|
return ip
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- client
|
||||||
|
|
||||||
|
def load_config(path=None):
|
||||||
|
"""The bench's fixture config, or None when there is none (a bench
|
||||||
|
without a fixture: the runner behaves exactly as before)."""
|
||||||
|
path = path or FIXTURE_CONFIG
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
except ValueError as e:
|
||||||
|
raise FixtureError("%s: not JSON: %s" % (path, e))
|
||||||
|
if not isinstance(cfg, dict) or not cfg.get("key"):
|
||||||
|
raise FixtureError("%s: needs at least a key" % path)
|
||||||
|
cfg.setdefault("hostname", "forgefixture")
|
||||||
|
cfg.setdefault("ip", None)
|
||||||
|
cfg.setdefault("port", 80)
|
||||||
|
chans = cfg.get("channels")
|
||||||
|
if chans is None:
|
||||||
|
chans = list(CHANNELS)
|
||||||
|
bad = [c for c in chans if c not in CHANNELS]
|
||||||
|
if bad:
|
||||||
|
raise FixtureError("%s: unknown channel(s) %s" % (path, ", ".join(bad)))
|
||||||
|
cfg["channels"] = list(chans)
|
||||||
|
cfg["arm_press"] = bool(cfg.get("arm_press", False))
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
class Fixture:
|
||||||
|
"""The actuator as the runner sees it: which channels it covers, an
|
||||||
|
action per channel, a release, its state. Every request carries the
|
||||||
|
key; the address comes from the config's ip or the mDNS lookup,
|
||||||
|
re-resolved when a request fails to connect."""
|
||||||
|
|
||||||
|
def __init__(self, cfg, timeout=3.0, resolver=resolve_mdns):
|
||||||
|
self.hostname = cfg["hostname"]
|
||||||
|
self.ip_override = cfg.get("ip") or None
|
||||||
|
self.port = int(cfg.get("port") or 80)
|
||||||
|
self.key = cfg["key"]
|
||||||
|
self.channels = tuple(cfg["channels"])
|
||||||
|
self.arm_press = bool(cfg.get("arm_press", False))
|
||||||
|
self.timeout = timeout
|
||||||
|
self._resolver = resolver
|
||||||
|
self._ip = self.ip_override
|
||||||
|
self._resolved_at = time.time() if self.ip_override else 0.0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.last_state = None
|
||||||
|
self._pulse_end = 0.0 # when this client's last press ends (its pulse_ms)
|
||||||
|
|
||||||
|
# -- address -------------------------------------------------------
|
||||||
|
def address(self, refresh=False):
|
||||||
|
with self._lock:
|
||||||
|
if self.ip_override:
|
||||||
|
return self.ip_override
|
||||||
|
stale = time.time() - self._resolved_at > RESOLVE_TTL_S
|
||||||
|
if self._ip is None or refresh or stale:
|
||||||
|
ip = self._resolver(self.hostname)
|
||||||
|
if ip is None:
|
||||||
|
raise FixtureError("%s.local did not answer the mDNS lookup (set ip in the config "
|
||||||
|
"to skip it)" % self.hostname)
|
||||||
|
self._ip = ip
|
||||||
|
self._resolved_at = time.time()
|
||||||
|
return self._ip
|
||||||
|
|
||||||
|
def where(self):
|
||||||
|
return "%s (%s)" % (self.hostname, self._ip or "unresolved")
|
||||||
|
|
||||||
|
# -- requests ------------------------------------------------------
|
||||||
|
def _request(self, method, path, body=None, retry=True):
|
||||||
|
ip = self.address()
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
host = ip if self.port == 80 else "%s:%d" % (ip, self.port)
|
||||||
|
req = urllib.request.Request("http://%s%s" % (host, path), data=data, method=method,
|
||||||
|
headers={"X-Fixture-Key": self.key, "Host": host,
|
||||||
|
"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode("utf-8", "replace") or "{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
body = json.loads(e.read().decode("utf-8", "replace") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
body = {}
|
||||||
|
return e.code, body
|
||||||
|
except (urllib.error.URLError, socket.timeout, OSError) as e:
|
||||||
|
if retry and not self.ip_override:
|
||||||
|
# the address may have moved: one fresh lookup, one retry
|
||||||
|
self.address(refresh=True)
|
||||||
|
return self._request(method, path, body, retry=False)
|
||||||
|
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e))
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
st, body = self._request("GET", "/")
|
||||||
|
if st == 401:
|
||||||
|
raise FixtureError("fixture %s refused the key" % self.where())
|
||||||
|
if st != 200 or body.get("device") != "forgefixture":
|
||||||
|
raise FixtureError("fixture %s: unexpected answer %s %s" % (self.where(), st, body))
|
||||||
|
self.last_state = body
|
||||||
|
return body
|
||||||
|
|
||||||
|
def covers(self, channel):
|
||||||
|
"""The runner asks this per action. The button needs the jumper
|
||||||
|
in, which the fixture reports; a channel it does not cover falls
|
||||||
|
back to the operator."""
|
||||||
|
if channel not in self.channels:
|
||||||
|
return False
|
||||||
|
if channel == "button":
|
||||||
|
st = self.last_state
|
||||||
|
return bool(st and st.get("button_enabled"))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def act(self, channel, state):
|
||||||
|
if channel == "button":
|
||||||
|
if state != "press":
|
||||||
|
raise FixtureError("the button is only ever pressed")
|
||||||
|
st, body = self._press()
|
||||||
|
else:
|
||||||
|
if state not in ("open", "close"):
|
||||||
|
raise FixtureError("%s: unknown state %r" % (channel, state))
|
||||||
|
st, body = self._request("POST", "/" + channel, {"state": state})
|
||||||
|
if st != 200:
|
||||||
|
raise FixtureError("fixture %s: %s %s -> %s %s" % (self.where(), channel, state, st,
|
||||||
|
body.get("error") or body))
|
||||||
|
self.last_state = body
|
||||||
|
return body
|
||||||
|
|
||||||
|
def _press(self):
|
||||||
|
"""One press, spaced from the last one. The controller detects
|
||||||
|
the button's rising edge, so a press must follow a release it has
|
||||||
|
seen: the next press waits for the last pulse to end (the
|
||||||
|
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
|
||||||
|
progress (a press this client did not time) is waited out once
|
||||||
|
against the fixture's state, then retried."""
|
||||||
|
wait = self._pulse_end + BUTTON_GAP_S - time.time()
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait)
|
||||||
|
st, body = self._request("POST", "/button", {})
|
||||||
|
if st == 409 and "in progress" in str(body.get("error", "")):
|
||||||
|
deadline = time.time() + PULSE_WAIT_S
|
||||||
|
while time.time() < deadline:
|
||||||
|
time.sleep(0.1)
|
||||||
|
s, b = self._request("GET", "/")
|
||||||
|
if s == 200 and not b.get("button_pulsing"):
|
||||||
|
break
|
||||||
|
time.sleep(BUTTON_GAP_S)
|
||||||
|
st, body = self._request("POST", "/button", {})
|
||||||
|
if st == 200:
|
||||||
|
self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0
|
||||||
|
return st, body
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
st, body = self._request("POST", "/release", {})
|
||||||
|
if st != 200:
|
||||||
|
raise FixtureError("fixture %s: release -> %s %s" % (self.where(), st, body.get("error") or body))
|
||||||
|
self.last_state = body
|
||||||
|
return body
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def energized(state):
|
||||||
|
"""The channels a state report shows energized (a loop open, the
|
||||||
|
button pressed)."""
|
||||||
|
ch = (state or {}).get("channels") or {}
|
||||||
|
return [c for c, s in ch.items() if s in ("open", "pressed")]
|
||||||
|
|
||||||
|
def summary(self):
|
||||||
|
# The radio signal and the uptime go in the record: this actuator
|
||||||
|
# is on wifi, and a run that lost it wants both numbers to say
|
||||||
|
# whether the link faded or the box restarted. Without them a
|
||||||
|
# drop is only ever "it was gone".
|
||||||
|
st = self.last_state or {}
|
||||||
|
wifi = st.get("wifi") or {}
|
||||||
|
return {"hostname": self.hostname, "ip": self._ip, "channels": list(self.channels),
|
||||||
|
"button_enabled": bool(st.get("button_enabled")), "arm_press": self.arm_press,
|
||||||
|
"version": st.get("version"), "uptime_s": st.get("uptime_s"),
|
||||||
|
"rssi": wifi.get("rssi")}
|
||||||
|
|
||||||
|
|
||||||
|
def probe(log, path=None, resolver=resolve_mdns):
|
||||||
|
"""The bench's fixture, up and answering, or None: no config means
|
||||||
|
no fixture; a config whose fixture does not answer is logged and
|
||||||
|
treated the same, so the run goes to the operator."""
|
||||||
|
try:
|
||||||
|
cfg = load_config(path)
|
||||||
|
except FixtureError as e:
|
||||||
|
log("fixture: %s - running without it" % e)
|
||||||
|
return None
|
||||||
|
if cfg is None:
|
||||||
|
return None
|
||||||
|
fx = Fixture(cfg, resolver=resolver)
|
||||||
|
try:
|
||||||
|
st = fx.status()
|
||||||
|
except FixtureError as e:
|
||||||
|
log("fixture: %s - running without it" % e)
|
||||||
|
return None
|
||||||
|
chans = ", ".join(fx.channels)
|
||||||
|
log("fixture up: %s at %s, v%s, covers %s%s" % (
|
||||||
|
fx.hostname, fx._ip, st.get("version"), chans,
|
||||||
|
"" if st.get("button_enabled") or "button" not in fx.channels
|
||||||
|
else " (button disabled: the enable jumper is out)"))
|
||||||
|
return fx
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Fixture", "FixtureError", "CHANNELS", "FIXTURE_CONFIG", "load_config", "probe",
|
||||||
|
"resolve_mdns", "mdns_query", "mdns_answers"]
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
"""Hardware and service access for the suite: forgectrl's HTTP API, the
|
||||||
|
kernel module's sysfs, the init scripts, and the Grbl TCP port.
|
||||||
|
|
||||||
|
Everything is reachable through environment overrides so the suite can be
|
||||||
|
exercised against a mock on a host:
|
||||||
|
FORGECTRL_URL default http://127.0.0.1
|
||||||
|
FORGECTRL_TOKEN_FILE default /data/forgefirm/panel.token
|
||||||
|
GF_SYSFS_ROOT default /sys/glowforge/ (must end with '/')
|
||||||
|
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
|
||||||
|
FORGETEST_INITD default /etc/init.d
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
class HwError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ forgectrl
|
||||||
|
|
||||||
|
# The supervisor's levers answer only when the switch is done: a mode
|
||||||
|
# switch waits for the old controller to exit, the new one to start (a
|
||||||
|
# pending liveness probe first) and its first job-state report (up to
|
||||||
|
# 15 s; the emulator's comes with its start, a real client's with its
|
||||||
|
# machine); a stop waits for the child to go. They get their own timeout,
|
||||||
|
# above the daemon's own deadlines, so a slow but honest switch is never
|
||||||
|
# read as a dead daemon.
|
||||||
|
SLOW_PATHS = ("/mode", "/controller/start", "/controller/stop")
|
||||||
|
SLOW_TIMEOUT_S = 120.0
|
||||||
|
|
||||||
|
|
||||||
|
class Forgectrl:
|
||||||
|
"""Thin client for the machine-services daemon."""
|
||||||
|
|
||||||
|
def __init__(self, base=None, token=None, timeout=10.0, slow_timeout=SLOW_TIMEOUT_S):
|
||||||
|
self.base = (base or os.environ.get("FORGECTRL_URL") or "http://127.0.0.1").rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
self.slow_timeout = slow_timeout
|
||||||
|
self._token = token
|
||||||
|
|
||||||
|
def timeout_for(self, method, path):
|
||||||
|
return self.slow_timeout if method == "POST" and path in SLOW_PATHS else self.timeout
|
||||||
|
|
||||||
|
@property
|
||||||
|
def token(self):
|
||||||
|
if self._token is None:
|
||||||
|
path = os.environ.get("FORGECTRL_TOKEN_FILE") or "/data/forgefirm/panel.token"
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
self._token = f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
self._token = ""
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
def host_header(self):
|
||||||
|
return urllib.parse.urlsplit(self.base).netloc
|
||||||
|
|
||||||
|
def request(self, method, path, params=None, data=None, headers=None, auth=True, raw=False):
|
||||||
|
"""Returns (status, body). body is parsed JSON when the response
|
||||||
|
is JSON, else text (or bytes when raw=True). Never raises on an
|
||||||
|
HTTP error status - the suite asserts on codes."""
|
||||||
|
url = self.base + path
|
||||||
|
if params:
|
||||||
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
|
||||||
|
body = None
|
||||||
|
hdrs = {"Host": self.host_header()}
|
||||||
|
if headers:
|
||||||
|
hdrs.update(headers)
|
||||||
|
if data is not None:
|
||||||
|
if isinstance(data, (dict, list)):
|
||||||
|
body = urllib.parse.urlencode(data).encode()
|
||||||
|
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
elif isinstance(data, str):
|
||||||
|
body = data.encode()
|
||||||
|
else:
|
||||||
|
body = data
|
||||||
|
if auth and self.token:
|
||||||
|
hdrs.setdefault("X-ForgeFIRM-Token", self.token)
|
||||||
|
req = urllib.request.Request(url, data=body, method=method, headers=hdrs)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=self.timeout_for(method, path)) as resp:
|
||||||
|
status = resp.status
|
||||||
|
content = resp.read()
|
||||||
|
ctype = resp.headers.get("Content-Type", "")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
status = e.code
|
||||||
|
content = e.read()
|
||||||
|
ctype = e.headers.get("Content-Type", "") if e.headers else ""
|
||||||
|
except (urllib.error.URLError, socket.timeout, OSError) as e:
|
||||||
|
raise HwError("forgectrl %s %s: %s" % (method, path, e))
|
||||||
|
if raw:
|
||||||
|
return status, content
|
||||||
|
text = content.decode("utf-8", "replace")
|
||||||
|
if "json" in ctype:
|
||||||
|
try:
|
||||||
|
return status, json.loads(text)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return status, text
|
||||||
|
|
||||||
|
def get(self, path, **kw):
|
||||||
|
return self.request("GET", path, **kw)
|
||||||
|
|
||||||
|
def post(self, path, **kw):
|
||||||
|
return self.request("POST", path, **kw)
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
st, body = self.get("/status")
|
||||||
|
if st != 200 or not isinstance(body, dict):
|
||||||
|
raise HwError("forgectrl /status -> %s" % st)
|
||||||
|
return body
|
||||||
|
|
||||||
|
def settings(self):
|
||||||
|
st, body = self.get("/settings")
|
||||||
|
if st != 200 or not isinstance(body, dict):
|
||||||
|
raise HwError("forgectrl /settings -> %s" % st)
|
||||||
|
return body
|
||||||
|
|
||||||
|
def wait_idle(self, timeout=60.0, poll=0.5, abort=None):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
if abort and abort():
|
||||||
|
raise HwError("aborted while waiting for idle")
|
||||||
|
try:
|
||||||
|
if self.status().get("state") == "idle":
|
||||||
|
return True
|
||||||
|
except HwError:
|
||||||
|
pass
|
||||||
|
time.sleep(poll)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- sysfs
|
||||||
|
|
||||||
|
def sysfs_root():
|
||||||
|
r = os.environ.get("GF_SYSFS_ROOT") or "/sys/glowforge/"
|
||||||
|
return r if r.endswith("/") else r + "/"
|
||||||
|
|
||||||
|
|
||||||
|
def sysfs_read(attr, default=None):
|
||||||
|
try:
|
||||||
|
with open(sysfs_root() + attr, "r") as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def sysfs_int(attr, default=None):
|
||||||
|
v = sysfs_read(attr)
|
||||||
|
if v is None or v == "":
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(v.split()[0], 0)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def sysfs_write(attr, value):
|
||||||
|
with open(sysfs_root() + attr, "w") as f:
|
||||||
|
f.write(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ head accelerometer
|
||||||
|
|
||||||
|
# The head accelerometer (LIS2HH12 on i2c-3 at 0x1e): the supervisor's
|
||||||
|
# motion-liveness witness, and the catalog's. Resolved by bus address,
|
||||||
|
# never by iio index. The raw sysfs read is slow (~150 ms), which still
|
||||||
|
# lands several samples in a one-second move; the verdict is peak-to-peak
|
||||||
|
# on X or Y, against the thresholds forgectrl's probe established (a live
|
||||||
|
# head reads 1800-2900 on its probe move, a wedged one <= 250).
|
||||||
|
HEAD_ACCEL_I2C = "3-001e"
|
||||||
|
ACCEL_P2P_MOVING = 800
|
||||||
|
|
||||||
|
|
||||||
|
def head_accel_dir():
|
||||||
|
"""The head accel's iio directory, or None. GF_IIO_ROOT overrides the
|
||||||
|
/sys/bus/iio/devices root (host tests)."""
|
||||||
|
root = os.environ.get("GF_IIO_ROOT") or "/sys/bus/iio/devices"
|
||||||
|
try:
|
||||||
|
names = sorted(os.listdir(root))
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
for n in names:
|
||||||
|
d = os.path.join(root, n)
|
||||||
|
try:
|
||||||
|
target = os.readlink(d)
|
||||||
|
except OSError:
|
||||||
|
target = ""
|
||||||
|
if HEAD_ACCEL_I2C in target or HEAD_ACCEL_I2C in n:
|
||||||
|
return d
|
||||||
|
# a plain directory (host fixture): its name carries the address
|
||||||
|
try:
|
||||||
|
with open(os.path.join(d, "name")) as f:
|
||||||
|
if HEAD_ACCEL_I2C in f.read():
|
||||||
|
return d
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def head_accel_read(d):
|
||||||
|
"""(x, y) raw counts, or None."""
|
||||||
|
out = []
|
||||||
|
for axis in ("x", "y"):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(d, "in_accel_%s_raw" % axis)) as f:
|
||||||
|
out.append(int(f.read().strip()))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
return tuple(out)
|
||||||
|
|
||||||
|
|
||||||
|
class AccelSampler:
|
||||||
|
"""Samples the head accelerometer in a thread for as long as it is
|
||||||
|
running; `p2p(t0, t1)` is the peak-to-peak on X and Y over the samples
|
||||||
|
in that window and how many there were. Used as a context manager
|
||||||
|
around a motion the test wants the head to have made."""
|
||||||
|
|
||||||
|
def __init__(self, period=0.0):
|
||||||
|
self.dir = head_accel_dir()
|
||||||
|
self.period = period
|
||||||
|
self.samples = [] # (t, x, y)
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._th = None
|
||||||
|
self.errors = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self):
|
||||||
|
return self.dir is not None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
if self.dir is not None:
|
||||||
|
self._th = threading.Thread(target=self._loop, daemon=True, name="forgetest-accel")
|
||||||
|
self._th.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
self._stop.set()
|
||||||
|
if self._th is not None:
|
||||||
|
self._th.join(2.0)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _loop(self):
|
||||||
|
while not self._stop.is_set():
|
||||||
|
v = head_accel_read(self.dir)
|
||||||
|
if v is None:
|
||||||
|
self.errors += 1
|
||||||
|
else:
|
||||||
|
self.samples.append((time.time(), v[0], v[1]))
|
||||||
|
if self.period:
|
||||||
|
self._stop.wait(self.period)
|
||||||
|
|
||||||
|
def p2p(self, t0, t1=None):
|
||||||
|
"""(p2p_x, p2p_y, n) over [t0, t1]; (0, 0, 0) with no samples."""
|
||||||
|
t1 = time.time() if t1 is None else t1
|
||||||
|
xs = [x for t, x, _y in self.samples if t0 <= t <= t1]
|
||||||
|
ys = [y for t, _x, y in self.samples if t0 <= t <= t1]
|
||||||
|
if not xs:
|
||||||
|
return 0, 0, 0
|
||||||
|
return max(xs) - min(xs), max(ys) - min(ys), len(xs)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- button LEDs
|
||||||
|
|
||||||
|
BUTTON_LEDS = ("button_led_1", "button_led_2", "button_led_3")
|
||||||
|
|
||||||
|
|
||||||
|
def leds_root():
|
||||||
|
r = os.environ.get("GF_LEDS_ROOT") or "/sys/class/leds/"
|
||||||
|
return r if r.endswith("/") else r + "/"
|
||||||
|
|
||||||
|
|
||||||
|
def button_leds():
|
||||||
|
"""The three button LEDs' commanded levels (the smooth trigger's
|
||||||
|
`target`, which the controller writes; `brightness` follows it with
|
||||||
|
a fade and is read where no target exists), or None where unreadable."""
|
||||||
|
out = []
|
||||||
|
for name in BUTTON_LEDS:
|
||||||
|
val = None
|
||||||
|
for attr in ("target", "brightness"):
|
||||||
|
try:
|
||||||
|
with open(leds_root() + name + "/" + attr) as f:
|
||||||
|
val = int(f.read().strip())
|
||||||
|
break
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
out.append(val)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def button_lit():
|
||||||
|
"""True when any button LED is on, False when all three read 0, None
|
||||||
|
when none is readable."""
|
||||||
|
vals = [v for v in button_leds() if v is not None]
|
||||||
|
if not vals:
|
||||||
|
return None
|
||||||
|
return any(v > 0 for v in vals)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- init.d
|
||||||
|
|
||||||
|
def initd(service, action, timeout=60):
|
||||||
|
"""Run /etc/init.d/<service> <action>; returns (rc, output)."""
|
||||||
|
base = os.environ.get("FORGETEST_INITD") or "/etc/init.d"
|
||||||
|
script = os.path.join(base, service)
|
||||||
|
try:
|
||||||
|
p = subprocess.run([script, action], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
timeout=timeout)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as e:
|
||||||
|
return 127, str(e)
|
||||||
|
return p.returncode, p.stdout.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def pidof(comm):
|
||||||
|
"""PIDs whose /proc/<pid>/comm equals comm (15-char kernel limit applies)."""
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
for pid in os.listdir("/proc"):
|
||||||
|
if not pid.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open("/proc/%s/comm" % pid) as f:
|
||||||
|
if f.read().strip() == comm:
|
||||||
|
out.append(int(pid))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def pgrep_f(needle):
|
||||||
|
"""PIDs whose /proc/<pid>/cmdline contains needle (a script run by an
|
||||||
|
interpreter has the interpreter's comm, so pidof cannot see it)."""
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
for pid in os.listdir("/proc"):
|
||||||
|
if not pid.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open("/proc/%s/cmdline" % pid, "rb") as f:
|
||||||
|
if needle.encode() in f.read():
|
||||||
|
out.append(int(pid))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, timeout=60):
|
||||||
|
"""Run a command list; returns (rc, combined output)."""
|
||||||
|
try:
|
||||||
|
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as e:
|
||||||
|
return 127, str(e)
|
||||||
|
return p.returncode, p.stdout.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ grbl
|
||||||
|
|
||||||
|
def grbl_port_open(timeout=5):
|
||||||
|
"""True when the Grbl port accepts a connection (closed again at once)."""
|
||||||
|
try:
|
||||||
|
s = socket.create_connection((os.environ.get("GRBL_HOST") or "127.0.0.1",
|
||||||
|
int(os.environ.get("GRBL_PORT") or 23)), timeout=timeout)
|
||||||
|
s.close()
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Grbl:
|
||||||
|
"""Minimal Grbl-over-TCP client. The suite is the only client while a
|
||||||
|
motion test runs; nothing here is used to poll status when a sender
|
||||||
|
may be attached (position for display comes from forgectrl)."""
|
||||||
|
|
||||||
|
def __init__(self, host=None, port=None, timeout=5.0):
|
||||||
|
self.host = host or os.environ.get("GRBL_HOST") or "127.0.0.1"
|
||||||
|
self.port = int(port or os.environ.get("GRBL_PORT") or 23)
|
||||||
|
self.timeout = timeout
|
||||||
|
self.sock = None
|
||||||
|
self.buf = b""
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.connect()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
||||||
|
# Drain the greeting.
|
||||||
|
time.sleep(0.3)
|
||||||
|
self.drain()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.sock:
|
||||||
|
try:
|
||||||
|
self.sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self.sock = None
|
||||||
|
|
||||||
|
def _recv(self):
|
||||||
|
"""Pull whatever is waiting into the buffer; never blocks long."""
|
||||||
|
self.sock.settimeout(0.05)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
d = self.sock.recv(4096)
|
||||||
|
if not d:
|
||||||
|
break
|
||||||
|
self.buf += d
|
||||||
|
except (socket.timeout, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _take_report(self):
|
||||||
|
"""Remove every complete <...> report from the buffer and return the
|
||||||
|
last one, keeping the text around them. Status reports are the only
|
||||||
|
thing consumed here: the driver's [MSG:] lines stay for drain()."""
|
||||||
|
last = None
|
||||||
|
while True:
|
||||||
|
i = self.buf.find(b"<")
|
||||||
|
j = self.buf.find(b">", i + 1) if i >= 0 else -1
|
||||||
|
if i < 0 or j <= i:
|
||||||
|
return last
|
||||||
|
last = self.buf[i + 1:j].decode("utf-8", "replace")
|
||||||
|
self.buf = self.buf[:i] + self.buf[j + 1:]
|
||||||
|
|
||||||
|
def drain(self):
|
||||||
|
self._recv()
|
||||||
|
out, self.buf = self.buf, b""
|
||||||
|
return out.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
def send_raw(self, data):
|
||||||
|
self.sock.settimeout(self.timeout)
|
||||||
|
self.sock.sendall(data)
|
||||||
|
|
||||||
|
def command(self, line, timeout=None):
|
||||||
|
"""Send one line, return the response lines up to ok/error."""
|
||||||
|
self.send_raw((line.strip() + "\n").encode())
|
||||||
|
return self.wait_response(timeout or self.timeout)
|
||||||
|
|
||||||
|
def wait_response(self, timeout):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
lines = []
|
||||||
|
self.sock.settimeout(0.2)
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
d = self.sock.recv(4096)
|
||||||
|
if not d:
|
||||||
|
break
|
||||||
|
self.buf += d
|
||||||
|
except socket.timeout:
|
||||||
|
pass
|
||||||
|
while b"\n" in self.buf:
|
||||||
|
raw, self.buf = self.buf.split(b"\n", 1)
|
||||||
|
s = raw.decode("utf-8", "replace").strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
lines.append(s)
|
||||||
|
if s == "ok" or s.startswith("error:"):
|
||||||
|
return lines
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def realtime(self, byte):
|
||||||
|
self.send_raw(bytes([byte]))
|
||||||
|
|
||||||
|
def status_report(self):
|
||||||
|
"""One '?' report, parsed: {'state': 'Idle', 'MPos': (x,y,z), ...}.
|
||||||
|
The '?' is re-sent every 0.5 s until a report arrives: a soft
|
||||||
|
reset (^X) flushes the controller's read buffer and eats a '?'
|
||||||
|
that lands in it. Reports already buffered are stale and dropped -
|
||||||
|
but only the reports: anything else the controller said is left in
|
||||||
|
the buffer, so a test that polls for a state does not lose the
|
||||||
|
[MSG:] line that explains it."""
|
||||||
|
self._recv()
|
||||||
|
self._take_report() # stale: predates this '?'
|
||||||
|
self.send_raw(b"?")
|
||||||
|
deadline = time.time() + self.timeout
|
||||||
|
resend = time.time() + 0.5
|
||||||
|
self.sock.settimeout(0.2)
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
d = self.sock.recv(4096)
|
||||||
|
if d:
|
||||||
|
self.buf += d
|
||||||
|
except socket.timeout:
|
||||||
|
pass
|
||||||
|
if time.time() >= resend:
|
||||||
|
self.send_raw(b"?")
|
||||||
|
resend = time.time() + 0.5
|
||||||
|
rep = self._take_report()
|
||||||
|
if rep is not None:
|
||||||
|
return parse_report(rep)
|
||||||
|
raise HwError("no status report from grbl")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_report(rep):
|
||||||
|
parts = rep.split("|")
|
||||||
|
out = {"state": parts[0]}
|
||||||
|
for p in parts[1:]:
|
||||||
|
if ":" in p:
|
||||||
|
k, v = p.split(":", 1)
|
||||||
|
if k in ("MPos", "WPos", "WCO"):
|
||||||
|
try:
|
||||||
|
out[k] = tuple(float(x) for x in v.split(","))
|
||||||
|
except ValueError:
|
||||||
|
out[k] = v
|
||||||
|
else:
|
||||||
|
out[k] = v
|
||||||
|
return out
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""The append-only result log (JSONL) and the export helpers.
|
||||||
|
|
||||||
|
One JSON object per line under the data directory (default
|
||||||
|
/data/forgetest/results.jsonl, override FORGETEST_DATA). Records:
|
||||||
|
|
||||||
|
campaign {"id","manifest_sha","catalog_hash","image"} a campaign opened
|
||||||
|
result {"campaign","test","result","fingerprint","manifest_sha",
|
||||||
|
"image","duration_s","evidence","answers","log","message"}
|
||||||
|
invalidate {"reason"} manual invalidate-all (full campaign required)
|
||||||
|
reset {"reason"} explicit campaign reset
|
||||||
|
export {"artifact_sha256","authorized","campaign"}
|
||||||
|
|
||||||
|
Every record carries "t" (type) and "ts" (UTC, ISO 8601, seconds).
|
||||||
|
The file is only ever appended; a corrupt line is skipped, counted, and
|
||||||
|
reported, never repaired in place.
|
||||||
|
|
||||||
|
Because the file only grows, `read` parses each line once and keeps what
|
||||||
|
it parsed; a later call reads only the bytes appended since. A result
|
||||||
|
record carries the whole run log, so the file reaches megabytes over a
|
||||||
|
campaign, and the page asks for the state every second or two.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
DEFAULT_DATA_DIR = "/data/forgetest"
|
||||||
|
|
||||||
|
|
||||||
|
def now_ts():
|
||||||
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
|
||||||
|
|
||||||
|
def data_dir():
|
||||||
|
return os.environ.get("FORGETEST_DATA") or DEFAULT_DATA_DIR
|
||||||
|
|
||||||
|
|
||||||
|
class Log:
|
||||||
|
def __init__(self, path=None):
|
||||||
|
self.path = path or os.path.join(data_dir(), "results.jsonl")
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.corrupt = 0
|
||||||
|
self._recs = [] # every record parsed so far, in file order
|
||||||
|
self._offset = 0 # bytes of the file already consumed
|
||||||
|
self._tail = b"" # bytes past the last newline: not a record yet
|
||||||
|
|
||||||
|
def append(self, rec):
|
||||||
|
rec = dict(rec)
|
||||||
|
rec.setdefault("ts", now_ts())
|
||||||
|
line = json.dumps(rec, sort_keys=True, separators=(",", ":"))
|
||||||
|
with self._lock:
|
||||||
|
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
||||||
|
with open(self.path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
return rec
|
||||||
|
|
||||||
|
def _forget(self):
|
||||||
|
"""Drop what was parsed: the next read starts from the top."""
|
||||||
|
self._recs = []
|
||||||
|
self._offset = 0
|
||||||
|
self._tail = b""
|
||||||
|
self.corrupt = 0
|
||||||
|
|
||||||
|
def _consume(self, chunk):
|
||||||
|
"""Parse the whole lines in `chunk`, holding back a partial tail."""
|
||||||
|
data = self._tail + chunk
|
||||||
|
cut = data.rfind(b"\n")
|
||||||
|
if cut < 0:
|
||||||
|
self._tail = data
|
||||||
|
return
|
||||||
|
self._tail = data[cut + 1:]
|
||||||
|
for line in data[:cut].split(b"\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rec = json.loads(line.decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
self.corrupt += 1
|
||||||
|
continue
|
||||||
|
if isinstance(rec, dict) and "t" in rec:
|
||||||
|
self._recs.append(rec)
|
||||||
|
else:
|
||||||
|
self.corrupt += 1
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
"""Every record, in file order. Only the bytes appended since the
|
||||||
|
last call are parsed; a file that shrank or was replaced is read
|
||||||
|
again from the top."""
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(self.path)
|
||||||
|
except OSError:
|
||||||
|
self._forget()
|
||||||
|
return []
|
||||||
|
if size < self._offset:
|
||||||
|
self._forget()
|
||||||
|
if size != self._offset:
|
||||||
|
with open(self.path, "rb") as f:
|
||||||
|
f.seek(self._offset)
|
||||||
|
chunk = f.read()
|
||||||
|
self._offset += len(chunk)
|
||||||
|
self._consume(chunk)
|
||||||
|
return list(self._recs)
|
||||||
|
|
||||||
|
def raw(self):
|
||||||
|
if not os.path.exists(self.path):
|
||||||
|
return ""
|
||||||
|
with self._lock:
|
||||||
|
with open(self.path, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""The image manifest and the domain fingerprint.
|
||||||
|
|
||||||
|
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
|
||||||
|
identifies the build's inputs: for every component the pinned revision and
|
||||||
|
one [path, blob-id] pair per source file, plus the platform identity
|
||||||
|
(machine, kernel modules directory, device tree hashes, layer content
|
||||||
|
hashes). This module loads it and computes a test's *domain fingerprint*:
|
||||||
|
the hash of the source files its coverage globs select, plus the platform,
|
||||||
|
plus the test's own implementation. A recorded PASS applies to a build
|
||||||
|
exactly when the fingerprint recomputed from that build's manifest is the
|
||||||
|
same - the same code runs on the board and in the release gate.
|
||||||
|
"""
|
||||||
|
import functools
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
DEFAULT_PATH = "/etc/forgefirm-manifest.json"
|
||||||
|
|
||||||
|
# Components that ship only on the dev image. They can never be part of a
|
||||||
|
# fingerprint (the release manifest lacks them, so the gate could not
|
||||||
|
# recompute it); the test implementation is folded in separately.
|
||||||
|
DEV_ONLY_COMPONENTS = ("forgetest",)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(obj):
|
||||||
|
"""Canonical JSON: sorted keys, no whitespace - the hashing form."""
|
||||||
|
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_text(text):
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=512)
|
||||||
|
def glob_to_regex(pattern):
|
||||||
|
"""Coverage glob -> anchored regex. '**' spans directories, '*' and '?'
|
||||||
|
stay inside one path segment. Paths use '/' (git paths). Cached: the
|
||||||
|
catalog matches the same few hundred globs over and over."""
|
||||||
|
out = []
|
||||||
|
i, n = 0, len(pattern)
|
||||||
|
while i < n:
|
||||||
|
c = pattern[i]
|
||||||
|
if c == "*":
|
||||||
|
if pattern[i:i + 2] == "**":
|
||||||
|
# '**/' also matches zero directories
|
||||||
|
if pattern[i:i + 3] == "**/":
|
||||||
|
out.append("(?:.*/)?")
|
||||||
|
i += 3
|
||||||
|
continue
|
||||||
|
out.append(".*")
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
out.append("[^/]*")
|
||||||
|
elif c == "?":
|
||||||
|
out.append("[^/]")
|
||||||
|
else:
|
||||||
|
out.append(re.escape(c))
|
||||||
|
i += 1
|
||||||
|
return re.compile("^" + "".join(out) + "$")
|
||||||
|
|
||||||
|
|
||||||
|
def match_files(files, pattern):
|
||||||
|
"""(path, blob) pairs from a component's file list that the glob selects."""
|
||||||
|
rx = glob_to_regex(pattern)
|
||||||
|
return [(p, b) for p, b in files if rx.match(p)]
|
||||||
|
|
||||||
|
|
||||||
|
# Paths that carry no target behavior: docs, CI, the components' own unit
|
||||||
|
# tests, licenses, editor setup. Outside every fingerprint (a README edit
|
||||||
|
# re-requires nothing) and outside the coverage lint (no test has to name
|
||||||
|
# them). Reviewed with the catalog: widening this list is a change like
|
||||||
|
# any other. "*" applies to every component.
|
||||||
|
NON_BEHAVIORAL = [
|
||||||
|
("*", ".github/**"),
|
||||||
|
("*", ".gitignore"),
|
||||||
|
("*", ".gitattributes"),
|
||||||
|
("*", ".gitmodules"),
|
||||||
|
("*", "**/*.md"),
|
||||||
|
("*", "LICENSE*"),
|
||||||
|
("*", "COPYING*"),
|
||||||
|
("*", "docs/**"),
|
||||||
|
("*", "tests/**"),
|
||||||
|
("*", "graphify-out/**"),
|
||||||
|
("*", "**/.gitkeep"),
|
||||||
|
("*", ".devcontainer/**"),
|
||||||
|
("*", ".vscode/**"),
|
||||||
|
("*", ".env.example"),
|
||||||
|
("forgectrl", "tools/**"), # host-side dev tools (panel dev server)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def non_behavioral(comp, path, allow=NON_BEHAVIORAL):
|
||||||
|
for c, pat in allow:
|
||||||
|
if c in ("*", comp) and glob_to_regex(pat).match(path):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Manifest:
|
||||||
|
def __init__(self, data):
|
||||||
|
self.data = data
|
||||||
|
self.components = data.get("components", {}) or {}
|
||||||
|
self.platform = data.get("platform", {}) or {}
|
||||||
|
self.image = data.get("image", {}) or {}
|
||||||
|
self.content_sha = data.get("content_sha256")
|
||||||
|
self._files = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path=None):
|
||||||
|
path = path or os.environ.get("FORGETEST_MANIFEST") or DEFAULT_PATH
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return cls(json.load(f))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, text):
|
||||||
|
return cls(json.loads(text))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def version(self):
|
||||||
|
return self.image.get("version") or "unknown"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def image_name(self):
|
||||||
|
return self.image.get("name") or "unknown"
|
||||||
|
|
||||||
|
def files(self, component):
|
||||||
|
"""The (path, blob) pairs of a component, or None if the component
|
||||||
|
is not in this manifest. Built once per component: the manifest is
|
||||||
|
immutable and every coverage glob asks for the same lists."""
|
||||||
|
if component in self._files:
|
||||||
|
return self._files[component]
|
||||||
|
c = self.components.get(component)
|
||||||
|
out = None if c is None else [tuple(x) for x in c.get("files", [])]
|
||||||
|
self._files[component] = out
|
||||||
|
return out
|
||||||
|
|
||||||
|
def component_names(self):
|
||||||
|
return sorted(self.components)
|
||||||
|
|
||||||
|
def identity_sha(self):
|
||||||
|
"""sha256 of the acceptance-relevant identity: every component
|
||||||
|
except the dev-only ones, plus the platform. Informational (the
|
||||||
|
gate decides per test, by fingerprint)."""
|
||||||
|
comps = {k: v for k, v in self.components.items() if k not in DEV_ONLY_COMPONENTS}
|
||||||
|
return sha256_text(canonical({"components": comps, "platform": self.platform}))
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(manifest, covers, extra=()):
|
||||||
|
"""The domain fingerprint of a coverage map on a manifest.
|
||||||
|
|
||||||
|
covers: iterable of (component, glob). extra: strings folded in after
|
||||||
|
the files (the test's own implementation hash). A component the
|
||||||
|
manifest lacks contributes a marker so the fingerprint is still
|
||||||
|
defined and distinct.
|
||||||
|
"""
|
||||||
|
parts = set()
|
||||||
|
for comp, pat in covers:
|
||||||
|
if comp in DEV_ONLY_COMPONENTS:
|
||||||
|
raise ValueError("coverage may not name the dev-only component %r" % comp)
|
||||||
|
files = manifest.files(comp)
|
||||||
|
if files is None:
|
||||||
|
parts.add((comp, "@missing", ""))
|
||||||
|
continue
|
||||||
|
for p, b in match_files(files, pat):
|
||||||
|
if non_behavioral(comp, p):
|
||||||
|
continue
|
||||||
|
parts.add((comp, p, b))
|
||||||
|
h = hashlib.sha256()
|
||||||
|
h.update(canonical(sorted(parts)).encode("utf-8"))
|
||||||
|
h.update(b"\n")
|
||||||
|
h.update(canonical(manifest.platform).encode("utf-8"))
|
||||||
|
for e in extra:
|
||||||
|
h.update(b"\n")
|
||||||
|
h.update(str(e).encode("utf-8"))
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def empty_covers(manifest, tests):
|
||||||
|
"""Coverage entries that select no file of their component: a glob
|
||||||
|
that never matched (paths anchor at the repository root, so a file
|
||||||
|
under a recipe's subdirectory needs that directory in the glob), or
|
||||||
|
a component not in the manifest. Such an entry covers nothing, and
|
||||||
|
the test's fingerprint would not move with the file it meant.
|
||||||
|
Returns [(test id, component, glob)]. A glob selecting only
|
||||||
|
non-behavioral paths is hollow too: nothing it names is fingerprinted."""
|
||||||
|
out = []
|
||||||
|
for t in tests:
|
||||||
|
for comp, pat in t.covers:
|
||||||
|
files = manifest.files(comp)
|
||||||
|
if files is None or not [p for p, _b in match_files(files, pat) if not non_behavioral(comp, p)]:
|
||||||
|
out.append((t.id, comp, pat))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
{component: [uncovered paths]} for the non-dev-only components.
|
||||||
|
"""
|
||||||
|
covered = {}
|
||||||
|
for t in tests:
|
||||||
|
for comp, pat in t.covers:
|
||||||
|
covered.setdefault(comp, []).append(glob_to_regex(pat))
|
||||||
|
allowed = {}
|
||||||
|
for comp, pat in allow:
|
||||||
|
allowed.setdefault(comp, []).append(glob_to_regex(pat))
|
||||||
|
report = {}
|
||||||
|
for comp in manifest.component_names():
|
||||||
|
if comp in DEV_ONLY_COMPONENTS:
|
||||||
|
continue
|
||||||
|
rxs = covered.get(comp, []) + allowed.get(comp, [])
|
||||||
|
star = allowed.get("*", [])
|
||||||
|
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):
|
||||||
|
continue
|
||||||
|
missing.append(p)
|
||||||
|
if missing:
|
||||||
|
report[comp] = sorted(missing)
|
||||||
|
return report
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""The single page: acceptance tab + bench tab, assembled from ui/ into
|
||||||
|
one self-contained response (no external assets). Bootstrap and the
|
||||||
|
OpenGlow theme are inlined; theme.css and the vendor/ files are the same
|
||||||
|
bytes forgectrl's panel carries, so the two pages look like one product
|
||||||
|
and follow the same light and dark themes (scripts/check-ui-vendor.py
|
||||||
|
holds them identical).
|
||||||
|
|
||||||
|
ui/ holds index.html, theme.css, page.css, help.js, app.js and vendor/.
|
||||||
|
A plain file is read when it is there (a checkout); otherwise its .gz
|
||||||
|
sibling is, which is what the recipe installs on the dev image: the
|
||||||
|
rootfs is raw ext4, so bytes in the package are bytes on the image, and
|
||||||
|
the page is inflated once at first request. The token placeholder is
|
||||||
|
spliced in per render."""
|
||||||
|
import gzip
|
||||||
|
import os
|
||||||
|
|
||||||
|
UI_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui")
|
||||||
|
# The files index.html links, in load order: one marker tag per file.
|
||||||
|
CSS_FILES = ("vendor/bootstrap.min.css", "theme.css", "page.css")
|
||||||
|
JS_FILES = ("vendor/bootstrap.bundle.min.js", "help.js", "app.js")
|
||||||
|
TOKEN_MARK = "__TOKEN__"
|
||||||
|
|
||||||
|
|
||||||
|
def read_ui(name, ui_dir=UI_DIR):
|
||||||
|
"""A ui/ file as text: the plain file, or its gzipped install."""
|
||||||
|
path = os.path.join(ui_dir, *name.split("/"))
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return f.read().decode("utf-8")
|
||||||
|
except FileNotFoundError:
|
||||||
|
with gzip.open(path + ".gz", "rb") as f:
|
||||||
|
return f.read().decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def assemble(ui_dir=UI_DIR):
|
||||||
|
"""index.html with every linked stylesheet and script inlined in place
|
||||||
|
of its tag. A missing marker is a broken page, not a silent one."""
|
||||||
|
html = read_ui("index.html", ui_dir)
|
||||||
|
for name in CSS_FILES:
|
||||||
|
tag = '<link rel="stylesheet" href="%s" />' % name
|
||||||
|
if tag not in html:
|
||||||
|
raise ValueError("index.html lacks the marker %s" % tag)
|
||||||
|
html = html.replace(tag, "<style>\n" + read_ui(name, ui_dir) + "</style>", 1)
|
||||||
|
for name in JS_FILES:
|
||||||
|
tag = '<script src="%s"></script>' % name
|
||||||
|
if tag not in html:
|
||||||
|
raise ValueError("index.html lacks the marker %s" % tag)
|
||||||
|
html = html.replace(tag, "<script>\n" + read_ui(name, ui_dir) + "</script>", 1)
|
||||||
|
if html.count(TOKEN_MARK) != 1:
|
||||||
|
raise ValueError("the page must carry %s exactly once" % TOKEN_MARK)
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
_html = None
|
||||||
|
|
||||||
|
|
||||||
|
def render(token):
|
||||||
|
global _html
|
||||||
|
if _html is None:
|
||||||
|
_html = assemble()
|
||||||
|
return _html.replace(TOKEN_MARK, token)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""Pulse files for the offline cloud service: a job the machine will run
|
||||||
|
without the service having cut it.
|
||||||
|
|
||||||
|
A pulse file is a header (`\\x80GF1`, a little-endian total length, then
|
||||||
|
8-byte records of a 4-character tag and a uint32) followed by the step
|
||||||
|
stream, one byte per tick of the header's STfr (the kernel feeder
|
||||||
|
contract: bit 0 X step, bit 1 X dir, bit 2 Y step, bit 3 Y dir, bit 4
|
||||||
|
LASER, bits 5-6 Z, bit 7 marks a power byte whose low 7 bits set the
|
||||||
|
duty). The header here is the one a factory print of this machine type
|
||||||
|
carries (134 tags, MCsn 0 = not locked to a serial, PDfm 0), so the
|
||||||
|
client's header check, its job limits (the coolant window, the air
|
||||||
|
assist floor), its fan duties and its stepper settings all come from the
|
||||||
|
same place a service job's do. The stream is a square traced at a steady
|
||||||
|
feed with the laser never commanded: a leading power byte of zero, no
|
||||||
|
LASER bit anywhere. Nothing in a file from here can put energy into the
|
||||||
|
tube; the arm and the latch are exercised, the beam is not.
|
||||||
|
|
||||||
|
A file longer than the kernel ring (32 MiB of ticks) is written gzip-
|
||||||
|
compressed, whole file in the stream, as the service serves one: the
|
||||||
|
client inflates it as the ring drains, and the gzip trailer gives it the
|
||||||
|
job's length for its progress report.
|
||||||
|
"""
|
||||||
|
import gzip
|
||||||
|
import struct
|
||||||
|
|
||||||
|
STEPS_PER_MM = 53.333 # x8 microstepping: the service's scale, which cloud mode runs at
|
||||||
|
|
||||||
|
# The header of a factory print for this machine type, as captured.
|
||||||
|
PRINT_HEADER = {
|
||||||
|
"AAfc": 3632880242, "AAid": 204, "AAin": 0, "AAix": 0, "AArd": 1023, "AArn": 0,
|
||||||
|
"AArx": 64500, "AAsn": 0, "AAsx": 0, "AAwd": 1023, "AAwn": 0, "AAwx": 64500,
|
||||||
|
"BDbs": 0, "BDpe": 0, "BDps": 0, "BTcx": 12800, "BTfc": 4294711296, "BTfo": 4294942296,
|
||||||
|
"BTin": 2147483648, "BTix": 2147483647, "BTrn": 2147483648, "BTrx": 9440,
|
||||||
|
"BTwn": 2147483648, "BTwx": 8928, "BTxb": 15, "CCrp": 10000, "CCup": 1, "CCwp": 5000,
|
||||||
|
"CFrh": 1, "CMin": 10000, "CMix": 50000, "CMrn": 5000, "CMrx": 33000, "CMts": 0,
|
||||||
|
"CMwn": 7000, "CMwx": 31000, "EFid": 0, "EFin": 0, "EFix": 0, "EFrd": 65535, "EFrn": 0,
|
||||||
|
"EFrx": 0, "EFwd": 65535, "EFwn": 0, "EFwx": 0, "FTcx": 30000, "FTin": 2147483648,
|
||||||
|
"FTix": 2147483647, "FTrn": 2147483648, "FTrx": 14575, "FTwn": 2147483648, "FTwx": 14075,
|
||||||
|
"FTxb": 15, "HAai": 0, "HAar": 0, "HAsi": 2, "HAsr": 4, "HAxi": 0, "HAxr": 132, "HAyi": 0,
|
||||||
|
"HAyr": 112, "HAzi": 0, "HAzr": 0, "HIix": 12, "HIrx": 1023, "HTcx": 12800, "HTfc": 51200,
|
||||||
|
"HTfo": 5000, "HTin": 2147483648, "HTix": 2147483647, "HTrn": 2147483648, "HTrx": 9440,
|
||||||
|
"HTwn": 2147483648, "HTwx": 8928, "HTxb": 15, "IFid": 0, "IFin": 0, "IFix": 0,
|
||||||
|
"IFrd": 43278, "IFrn": 0, "IFrx": 0, "IFwd": 43278, "IFwn": 0, "IFwx": 0, "IRpd": 1000,
|
||||||
|
"IRwb": 3, "IRwc": 688, "IRwx": 275, "IRxb": 3, "IRxc": 1022, "IRxx": 374, "IRyb": 3,
|
||||||
|
"IRyc": 0, "IRyx": 0, "IRzb": 3, "IRzc": 0, "IRzx": 0, "ITcx": 19200, "ITfc": 153600,
|
||||||
|
"ITfo": 0, "ITin": 2147483648, "ITix": 2147483647, "ITrn": 2147483648, "ITrx": 15840,
|
||||||
|
"ITwn": 2147483648, "ITwx": 15328, "ITxb": 15, "LTcx": 12800, "LTfc": 51200, "LTfo": 5000,
|
||||||
|
"LTin": 2147483648, "LTix": 2147483647, "LTrn": 2147483648, "LTrx": 9440,
|
||||||
|
"LTwn": 2147483648, "LTwx": 8928, "LTxb": 15, "MCsn": 0, "PCid": 19795, "PDct": 5,
|
||||||
|
"PDfm": 0, "PTmn": 0, "PTmx": 1023, "STfr": 10000, "TRuc": 0, "XSdm": 1, "XShc": 33,
|
||||||
|
"XSmm": 8, "XSrc": 135, "YSdm": 1, "YShc": 5, "YSmm": 8, "YSrc": 22, "ZSmd": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
X_STEP, X_DIR, Y_STEP, Y_DIR, LASER = 0x01, 0x02, 0x04, 0x08, 0x10
|
||||||
|
POWER = 0x80
|
||||||
|
|
||||||
|
|
||||||
|
def header_bytes(tags=None):
|
||||||
|
"""The header record block, the template with `tags` laid over it."""
|
||||||
|
h = dict(PRINT_HEADER)
|
||||||
|
if tags:
|
||||||
|
h.update(tags)
|
||||||
|
recs = b"".join(k.encode("ascii") + struct.pack("<I", int(v)) for k, v in sorted(h.items()))
|
||||||
|
total = 8 + len(recs)
|
||||||
|
return bytes([POWER]) + b"GF1" + struct.pack("<I", total) + recs
|
||||||
|
|
||||||
|
|
||||||
|
def leg(dx_steps, dy_steps, period):
|
||||||
|
"""One straight leg: Bresenham over the major axis, one step every
|
||||||
|
`period` ticks, laser never commanded. X: DIR set = negative; Y: DIR
|
||||||
|
set = positive (the hardware convention)."""
|
||||||
|
ax, ay = abs(dx_steps), abs(dy_steps)
|
||||||
|
major = max(ax, ay)
|
||||||
|
if major == 0:
|
||||||
|
return b""
|
||||||
|
xbit = X_STEP | (X_DIR if dx_steps < 0 else 0)
|
||||||
|
ybit = Y_STEP | (Y_DIR if dy_steps > 0 else 0)
|
||||||
|
out = bytearray()
|
||||||
|
gap = bytes(period - 1)
|
||||||
|
err = 0
|
||||||
|
x_major = ax >= ay
|
||||||
|
minor = ay if x_major else ax
|
||||||
|
for _ in range(major):
|
||||||
|
b = xbit if x_major else ybit
|
||||||
|
err += minor
|
||||||
|
if err >= major:
|
||||||
|
err -= major
|
||||||
|
b |= ybit if x_major else xbit
|
||||||
|
out.append(b)
|
||||||
|
out += gap
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def square_stream(side_mm=40.0, feed_mm_min=600.0, step_hz=10000, repeats=1):
|
||||||
|
"""A closed square traced `repeats` times at a steady feed: +X, +Y,
|
||||||
|
-X, -Y, the head back where it started. Leads with a power byte of
|
||||||
|
zero, as the feeder contract wants before any laser-on byte (there
|
||||||
|
is none)."""
|
||||||
|
steps = int(round(side_mm * STEPS_PER_MM))
|
||||||
|
period = max(1, int(round(step_hz / (feed_mm_min / 60.0 * STEPS_PER_MM))))
|
||||||
|
one = (leg(steps, 0, period) + leg(0, steps, period)
|
||||||
|
+ leg(-steps, 0, period) + leg(0, -steps, period))
|
||||||
|
return bytes([POWER]) + bytes(period) + one * repeats
|
||||||
|
|
||||||
|
|
||||||
|
def seconds_of(stream, step_hz=10000):
|
||||||
|
return len(stream) / float(step_hz)
|
||||||
|
|
||||||
|
|
||||||
|
def write_job(path, side_mm=40.0, feed_mm_min=600.0, seconds=None, step_hz=10000,
|
||||||
|
compress=None, tags=None):
|
||||||
|
"""Write a job to `path`: a square of `side_mm` at `feed_mm_min`,
|
||||||
|
repeated until the stream is at least `seconds` long (one square if
|
||||||
|
None). Compressed when `compress` is true, or, left None, when the
|
||||||
|
stream is longer than the kernel ring (32 MiB of ticks). Returns
|
||||||
|
(payload bytes, seconds, compressed)."""
|
||||||
|
one = square_stream(side_mm, feed_mm_min, step_hz, 1)
|
||||||
|
repeats = 1
|
||||||
|
if seconds:
|
||||||
|
per = seconds_of(one, step_hz)
|
||||||
|
repeats = max(1, int(seconds / per) + (1 if seconds % per else 0))
|
||||||
|
body = square_stream(side_mm, feed_mm_min, step_hz, repeats)
|
||||||
|
head = header_bytes(dict(tags or {}, STfr=step_hz))
|
||||||
|
if compress is None:
|
||||||
|
compress = len(body) > 32 * 1024 * 1024
|
||||||
|
data = head + body
|
||||||
|
if compress:
|
||||||
|
data = gzip.compress(data, compresslevel=6)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
return len(body), seconds_of(body, step_hz), compress
|
||||||
|
|
||||||
|
|
||||||
|
def parse(data):
|
||||||
|
"""(header dict, payload) of a raw or gzip-compressed pulse file."""
|
||||||
|
if data[:2] == b"\x1f\x8b":
|
||||||
|
data = gzip.decompress(data)
|
||||||
|
if data[1:4] != b"GF1":
|
||||||
|
raise ValueError("not a GF1 pulse file")
|
||||||
|
total = struct.unpack_from("<I", data, 4)[0]
|
||||||
|
tags = {data[p:p + 4].decode("ascii"): struct.unpack_from("<I", data, p + 4)[0]
|
||||||
|
for p in range(8, total - 7, 8)}
|
||||||
|
return tags, data[total:]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
|||||||
|
"""HTTP: the page, the JSON API, and the access rules.
|
||||||
|
|
||||||
|
Access follows forgectrl's panel (auth.c): the Host header must be an
|
||||||
|
address literal or localhost (no DNS names - the rebinding vehicle), a
|
||||||
|
cross-site Sec-Fetch-Site is refused, an Origin must itself be a literal,
|
||||||
|
and every state-changing call needs the bearer token that is generated on
|
||||||
|
first start, stored 0600 under the data directory, and embedded in the
|
||||||
|
page. Read-only calls need the origin checks only.
|
||||||
|
|
||||||
|
Routes
|
||||||
|
GET / the page
|
||||||
|
GET /state campaign + per-test state + running run.
|
||||||
|
Carries an ETag; If-None-Match on an
|
||||||
|
unchanged state gets a 304, which is what an
|
||||||
|
idle page polls for.
|
||||||
|
GET /catalog test definitions (title, steps, covers...)
|
||||||
|
GET /bench bench tool listing
|
||||||
|
GET /result?test&ts one full result record (log, evidence)
|
||||||
|
GET /log the raw JSONL
|
||||||
|
GET /journal the daemon's journal (daemon.log tail)
|
||||||
|
GET /export/acceptance.json | .md the last export
|
||||||
|
POST /start {test, ack_live, ignore_requires} start an acceptance test
|
||||||
|
POST /batch {group, ack_live, ignore_requires} run everything a queue
|
||||||
|
still owes, in prerequisite order
|
||||||
|
POST /batch/stop cancel what is still queued
|
||||||
|
POST /bench/start {tool, args, ack_live}
|
||||||
|
POST /answer {prompt_id, value}
|
||||||
|
POST /abort
|
||||||
|
POST /invalidate {reason}
|
||||||
|
POST /reset {reason}
|
||||||
|
POST /export build + save the artifact, returns it
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import socket
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
from . import artifact as _artifact
|
||||||
|
from . import page as _page
|
||||||
|
from .log import data_dir
|
||||||
|
|
||||||
|
JOURNAL_TAIL_BYTES = 256 << 10
|
||||||
|
|
||||||
|
|
||||||
|
def journal_tail(path=None, max_bytes=JOURNAL_TAIL_BYTES):
|
||||||
|
"""The last max_bytes of the daemon's journal (daemon.log, written by
|
||||||
|
the init script from the daemon's stderr), or a note that there is
|
||||||
|
none yet."""
|
||||||
|
path = path or os.path.join(data_dir(), "daemon.log")
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
if size > max_bytes:
|
||||||
|
f.seek(size - max_bytes)
|
||||||
|
f.readline()
|
||||||
|
return f.read()
|
||||||
|
except OSError:
|
||||||
|
return b"(no journal yet: %s)\n" % path.encode()
|
||||||
|
|
||||||
|
TOKEN_HEX = 32
|
||||||
|
_LITERAL_RX = re.compile(r"^[0-9.]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def load_token(path=None):
|
||||||
|
path = path or os.path.join(data_dir(), "token")
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
tok = f.read().strip()
|
||||||
|
if len(tok) == TOKEN_HEX and all(c in "0123456789abcdef" for c in tok):
|
||||||
|
return tok
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
tok = secrets.token_hex(TOKEN_HEX // 2)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
f.write(tok + "\n")
|
||||||
|
return tok
|
||||||
|
|
||||||
|
|
||||||
|
def host_is_literal(h):
|
||||||
|
if not h:
|
||||||
|
return False
|
||||||
|
if h.startswith("["):
|
||||||
|
return True
|
||||||
|
host = h.split(":", 1)[0]
|
||||||
|
if host == "localhost":
|
||||||
|
return True
|
||||||
|
return bool(_LITERAL_RX.match(host))
|
||||||
|
|
||||||
|
|
||||||
|
def origin_ok(headers):
|
||||||
|
if not host_is_literal(headers.get("Host")):
|
||||||
|
return False
|
||||||
|
sfs = headers.get("Sec-Fetch-Site")
|
||||||
|
if sfs and sfs not in ("same-origin", "none"):
|
||||||
|
return False
|
||||||
|
origin = headers.get("Origin")
|
||||||
|
if origin and origin != "null":
|
||||||
|
p = urllib.parse.urlsplit(origin)
|
||||||
|
if not host_is_literal(p.netloc):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class App:
|
||||||
|
"""What the handler needs: the runner, the token, the export dir."""
|
||||||
|
|
||||||
|
def __init__(self, runner, token, export_dir=None):
|
||||||
|
self.runner = runner
|
||||||
|
self.token = token
|
||||||
|
self.export_dir = export_dir or os.path.join(data_dir(), "export")
|
||||||
|
|
||||||
|
def token_ok(self, given):
|
||||||
|
return bool(given) and hmac.compare_digest(given, self.token)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "forgetest"
|
||||||
|
# Keep-alive: the page polls, so a handshake and a fresh thread per
|
||||||
|
# request is pure overhead. Idle connections are dropped after the
|
||||||
|
# timeout rather than holding a thread forever.
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
timeout = 30
|
||||||
|
app = None # set on the server class
|
||||||
|
|
||||||
|
# -- plumbing ---------------------------------------------------------
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
if os.environ.get("FORGETEST_HTTP_LOG"):
|
||||||
|
sys.stderr.write("forgetest: %s - %s\n" % (self.address_string(), fmt % args))
|
||||||
|
|
||||||
|
def _send(self, status, body, ctype="application/json", extra=None):
|
||||||
|
if isinstance(body, (dict, list)):
|
||||||
|
body = json.dumps(body, sort_keys=True).encode("utf-8")
|
||||||
|
elif isinstance(body, str):
|
||||||
|
body = body.encode("utf-8")
|
||||||
|
self.responded = True
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", ctype + ("; charset=utf-8" if ctype.startswith("text/") else ""))
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
for k, v in (extra or {}).items():
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.end_headers()
|
||||||
|
if self.command != "HEAD":
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_304(self, etag):
|
||||||
|
self.responded = True
|
||||||
|
self.send_response(304)
|
||||||
|
self.send_header("ETag", etag)
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def _deny(self, status, msg):
|
||||||
|
# One response per request: on a kept-alive connection a second
|
||||||
|
# one would desynchronize the stream.
|
||||||
|
if getattr(self, "responded", False):
|
||||||
|
return
|
||||||
|
# A refused POST still has its body in the socket. Leaving it
|
||||||
|
# there would make the next read on this connection take the
|
||||||
|
# body for a request line, so the connection ends here.
|
||||||
|
# send_header sets close_connection from this.
|
||||||
|
extra = None if getattr(self, "body_read", True) else {"Connection": "close"}
|
||||||
|
self._send(status, {"error": msg}, extra=extra)
|
||||||
|
|
||||||
|
def _read_ok(self):
|
||||||
|
if not origin_ok(self.headers):
|
||||||
|
self._deny(403, "request origin refused")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _write_ok(self, query):
|
||||||
|
if not origin_ok(self.headers):
|
||||||
|
self._deny(403, "request origin refused")
|
||||||
|
return False
|
||||||
|
tok = self.headers.get("X-ForgeFIRM-Token") or (query.get("token") or [None])[0]
|
||||||
|
if not self.app.token_ok(tok):
|
||||||
|
self._deny(403, "authentication required")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _body_json(self):
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
if n <= 0:
|
||||||
|
self.body_read = True
|
||||||
|
return {}
|
||||||
|
if n > 1 << 20:
|
||||||
|
raise ValueError("body too large")
|
||||||
|
raw = self.rfile.read(n)
|
||||||
|
self.body_read = True
|
||||||
|
ctype = self.headers.get("Content-Type", "")
|
||||||
|
if "json" in ctype:
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
# form-encoded fallback
|
||||||
|
return {k: v[0] for k, v in urllib.parse.parse_qs(raw.decode("utf-8")).items()}
|
||||||
|
|
||||||
|
# -- GET ---------------------------------------------------------------
|
||||||
|
def do_HEAD(self):
|
||||||
|
self.do_GET()
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
url = urllib.parse.urlsplit(self.path)
|
||||||
|
path = url.path
|
||||||
|
query = urllib.parse.parse_qs(url.query)
|
||||||
|
r = self.app.runner
|
||||||
|
self.responded = False
|
||||||
|
if not self._read_ok():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if path == "/":
|
||||||
|
self._send(200, _page.render(self.app.token), "text/html")
|
||||||
|
elif path == "/state":
|
||||||
|
# Conditional: an idle page polls an unchanged state, and
|
||||||
|
# a 304 spares both ends the payload and the re-render.
|
||||||
|
state, _ = r.state()
|
||||||
|
body = json.dumps(state, sort_keys=True).encode("utf-8")
|
||||||
|
etag = '"%s"' % hashlib.sha256(body).hexdigest()[:32]
|
||||||
|
if self.headers.get("If-None-Match") == etag:
|
||||||
|
self._send_304(etag)
|
||||||
|
else:
|
||||||
|
self._send(200, body, extra={"ETag": etag})
|
||||||
|
elif path == "/catalog":
|
||||||
|
self._send(200, {"tests": [t.describe() for t in r.tests()],
|
||||||
|
"catalog_hash": r.catalog_hash})
|
||||||
|
elif path == "/bench":
|
||||||
|
b = r.bench
|
||||||
|
self._send(200, {"tools": b.listing() if b else [], "tool_dir": b.tool_dir() if b else None})
|
||||||
|
elif path == "/result":
|
||||||
|
test = (query.get("test") or [""])[0]
|
||||||
|
ts = (query.get("ts") or [""])[0]
|
||||||
|
rec = None
|
||||||
|
for x in reversed(r.log.read()):
|
||||||
|
if x.get("t") == "result" and x.get("test") == test and (not ts or x.get("ts") == ts):
|
||||||
|
rec = x
|
||||||
|
break
|
||||||
|
if rec is None:
|
||||||
|
self._deny(404, "no such result")
|
||||||
|
else:
|
||||||
|
self._send(200, rec)
|
||||||
|
elif path == "/log":
|
||||||
|
self._send(200, r.log.raw(), "text/plain")
|
||||||
|
elif path == "/journal":
|
||||||
|
self._send(200, journal_tail(), "text/plain")
|
||||||
|
elif path in ("/export/acceptance.json", "/export/acceptance.md"):
|
||||||
|
fn = os.path.join(self.app.export_dir, os.path.basename(path))
|
||||||
|
if not os.path.exists(fn):
|
||||||
|
self._deny(404, "nothing exported yet")
|
||||||
|
return
|
||||||
|
with open(fn, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
ctype = "application/json" if path.endswith(".json") else "text/markdown"
|
||||||
|
self._send(200, data, ctype, {"Content-Disposition": "attachment; filename=%s"
|
||||||
|
% os.path.basename(path)})
|
||||||
|
else:
|
||||||
|
self._deny(404, "not found")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
self._deny(500, "%s: %s" % (type(e).__name__, e))
|
||||||
|
|
||||||
|
# -- POST -----------------------------------------------------------------
|
||||||
|
def do_POST(self):
|
||||||
|
url = urllib.parse.urlsplit(self.path)
|
||||||
|
path = url.path
|
||||||
|
query = urllib.parse.parse_qs(url.query)
|
||||||
|
r = self.app.runner
|
||||||
|
self.responded = False
|
||||||
|
self.body_read = False
|
||||||
|
if not self._write_ok(query):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
body = self._body_json()
|
||||||
|
except ValueError as e:
|
||||||
|
self._deny(400, "bad body: %s" % e)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if path == "/start":
|
||||||
|
ok, msg = r.start_test(str(body.get("test", "")), ack_live=bool(body.get("ack_live")),
|
||||||
|
ignore_requires=bool(body.get("ignore_requires")))
|
||||||
|
self._send(200 if ok else 409, {"ok": ok, "message": msg})
|
||||||
|
elif path == "/batch":
|
||||||
|
ok, msg, order = r.start_batch(str(body.get("group", "")),
|
||||||
|
ack_live=bool(body.get("ack_live")),
|
||||||
|
ignore_requires=bool(body.get("ignore_requires")))
|
||||||
|
self._send(200 if ok else 409, {"ok": ok, "message": msg, "order": order})
|
||||||
|
elif path == "/batch/stop":
|
||||||
|
ok, msg = r.stop_batch()
|
||||||
|
self._send(200 if ok else 409, {"ok": ok, "message": msg})
|
||||||
|
elif path == "/bench/start":
|
||||||
|
args = body.get("args") or {}
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
self._deny(400, "args must be an object")
|
||||||
|
return
|
||||||
|
ok, msg = r.start_bench(str(body.get("tool", "")), args, ack_live=bool(body.get("ack_live")))
|
||||||
|
self._send(200 if ok else 409, {"ok": ok, "message": msg})
|
||||||
|
elif path == "/answer":
|
||||||
|
ok, msg = r.answer(str(body.get("prompt_id", "")), str(body.get("value", "")))
|
||||||
|
self._send(200 if ok else 409, {"ok": ok, "message": msg})
|
||||||
|
elif path == "/abort":
|
||||||
|
ok, msg = r.abort()
|
||||||
|
self._send(200 if ok else 409, {"ok": ok, "message": msg})
|
||||||
|
elif path == "/invalidate":
|
||||||
|
ok, msg = r.invalidate(str(body.get("reason", "")))
|
||||||
|
self._send(200 if ok else 400, {"ok": ok, "message": msg})
|
||||||
|
elif path == "/reset":
|
||||||
|
ok, msg = r.reset(str(body.get("reason", "")))
|
||||||
|
self._send(200 if ok else 409, {"ok": ok, "message": msg})
|
||||||
|
elif path == "/export":
|
||||||
|
art = r.export()
|
||||||
|
os.makedirs(self.app.export_dir, exist_ok=True)
|
||||||
|
with open(os.path.join(self.app.export_dir, "acceptance.json"), "w", encoding="utf-8") as f:
|
||||||
|
f.write(_artifact.to_json(art))
|
||||||
|
with open(os.path.join(self.app.export_dir, "acceptance.md"), "w", encoding="utf-8") as f:
|
||||||
|
f.write(_artifact.to_markdown(art))
|
||||||
|
self._send(200, {"ok": True, "authorized": art["authorized"], "sha256": art["sha256"],
|
||||||
|
"counts": art["counts"]})
|
||||||
|
else:
|
||||||
|
self._deny(404, "not found")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
self._deny(500, "%s: %s" % (type(e).__name__, e))
|
||||||
|
|
||||||
|
|
||||||
|
def make_server(app, host="::", port=8090):
|
||||||
|
handler = type("ForgetestHandler", (Handler,), {"app": app})
|
||||||
|
ThreadingHTTPServer.allow_reuse_address = True
|
||||||
|
# An IPv6 bind address (the default "::") gives one dual-stack socket
|
||||||
|
# that serves IPv4 clients too; a dotted address keeps the IPv4 family.
|
||||||
|
server_cls = ThreadingHTTPServer
|
||||||
|
if ":" in host:
|
||||||
|
server_cls = type("ForgetestServer6", (ThreadingHTTPServer,),
|
||||||
|
{"address_family": socket.AF_INET6})
|
||||||
|
srv = server_cls((host, port), handler)
|
||||||
|
srv.daemon_threads = True
|
||||||
|
return srv
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""The acceptance suite: one module per subsystem, imported in display
|
||||||
|
order. Each module registers its tests with @catalog.test."""
|
||||||
|
|
||||||
|
from . import image # noqa: F401,E402
|
||||||
|
from . import kernel # noqa: F401,E402
|
||||||
|
from . import forgectrl # noqa: F401,E402
|
||||||
|
from . import commission # noqa: F401,E402
|
||||||
|
from . import commission_dark # noqa: F401,E402
|
||||||
|
from . import commission_sheet # noqa: F401,E402
|
||||||
|
from . import logs # noqa: F401,E402
|
||||||
|
from . import motion # noqa: F401,E402
|
||||||
|
from . import cooling # noqa: F401,E402
|
||||||
|
from . import laser # noqa: F401,E402
|
||||||
|
from . import camera # noqa: F401,E402
|
||||||
|
from . import update # noqa: F401,E402
|
||||||
|
from . import cloud # noqa: F401,E402
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
"""camera.* - the lid camera pipeline through forgectrl."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .. import hw
|
||||||
|
from ..baseline import LID_LAMP_ATTR
|
||||||
|
|
||||||
|
_CAM_COVERS = [("forgectrl", "src/cam.*"), ("forgectrl", "src/camhealth.*"),
|
||||||
|
("forgectrl", "src/debayer.*"), ("forgectrl", "src/vpu_jpeg.*"),
|
||||||
|
("forgectrl", "src/vpu_h264.*"), ("forgectrl", "src/mp4mux.*"),
|
||||||
|
("forgectrl", "src/gpu_debayer.*"), ("forgectrl", "src/ipu_copy.*"),
|
||||||
|
("forgectrl", "src/main.c"),
|
||||||
|
("python3-gfhardware", "gfhardware/src/**"), ("python3-gfhardware", "gfhardware/cam*")]
|
||||||
|
|
||||||
|
# The privacy gate spans the camera path and the lid read it depends on,
|
||||||
|
# in both processes that can reach a sensor.
|
||||||
|
_PRIVACY_COVERS = _CAM_COVERS + [("forgectrl", "src/status.*"),
|
||||||
|
("python3-gfhardware", "gfhardware/switches.py"),
|
||||||
|
("forgefirm-app", "forgefirm-app/ffmachine.py")]
|
||||||
|
|
||||||
|
# The sensors the machine ships with, and the geometry each one implies.
|
||||||
|
# A machine reports exactly one of these; anything else means the sensor
|
||||||
|
# bound to a driver the capture path has no profile for.
|
||||||
|
_SENSOR_GEOMETRY = {
|
||||||
|
"OV5648": (2592, 1944),
|
||||||
|
"OV8856": (3264, 2448),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _jpeg_size(data):
|
||||||
|
"""(width, height) from a JPEG's first SOFn marker, or None."""
|
||||||
|
i = 2
|
||||||
|
n = len(data)
|
||||||
|
while i + 3 < n:
|
||||||
|
if data[i] != 0xFF:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
marker = data[i + 1]
|
||||||
|
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
seglen = (data[i + 2] << 8) | data[i + 3]
|
||||||
|
# SOFn, excluding the non-frame markers in the same range
|
||||||
|
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
|
||||||
|
if i + 9 > n:
|
||||||
|
return None
|
||||||
|
return ((data[i + 7] << 8) | data[i + 8],
|
||||||
|
(data[i + 5] << 8) | data[i + 6])
|
||||||
|
i += 2 + seglen
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
|
||||||
|
kind="auto", est_min=2,
|
||||||
|
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
||||||
|
steps=["Setup: lid closed."],
|
||||||
|
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
|
||||||
|
"far smaller), a second snapshot with the lid lamp switched off differs from it "
|
||||||
|
"and is smaller (the capture is live, not a frame the pipeline is re-serving, "
|
||||||
|
"and the lamp lights what the camera sees), and the MJPEG stream starts and "
|
||||||
|
"stops.")
|
||||||
|
def snapshot(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
st, body = fc.get("/cam/status")
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
|
||||||
|
ev["cam_status"] = body
|
||||||
|
ctx.log("cam status: %s", body)
|
||||||
|
|
||||||
|
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
|
||||||
|
ctx.log("GET /cam/snapshot?cam=lid&res=half -> %s (%d bytes)", st, len(data) if data else 0)
|
||||||
|
ctx.check(st == 200, "snapshot -> %s %s", st, data[:120] if data else "")
|
||||||
|
ctx.check(data[:2] == b"\xff\xd8" and data[-2:] == b"\xff\xd9", "snapshot is not a complete JPEG")
|
||||||
|
ev["snapshot_bytes"] = len(data)
|
||||||
|
ctx.check(len(data) > 20000, "snapshot is only %d bytes - a dark or empty frame?", len(data))
|
||||||
|
|
||||||
|
# Live, and lit: the same frame with the lid lamp off must be a
|
||||||
|
# different and smaller picture. The lamp is put back where it was.
|
||||||
|
lamp0 = hw.sysfs_read(LID_LAMP_ATTR)
|
||||||
|
ev["lid_lamp"] = lamp0
|
||||||
|
ctx.check(lamp0 is not None, "the lid lamp (%s) is not readable", LID_LAMP_ATTR)
|
||||||
|
try:
|
||||||
|
hw.sysfs_write(LID_LAMP_ATTR, "0")
|
||||||
|
ctx.sleep(1.5)
|
||||||
|
st, dark = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
|
||||||
|
finally:
|
||||||
|
hw.sysfs_write(LID_LAMP_ATTR, lamp0)
|
||||||
|
ctx.check(st == 200 and dark and dark[:2] == b"\xff\xd8", "snapshot with the lamp off -> %s", st)
|
||||||
|
ev["snapshot_dark_bytes"] = len(dark)
|
||||||
|
ctx.log("lamp %s -> %d bytes; lamp off -> %d bytes", lamp0, len(data), len(dark))
|
||||||
|
ctx.check(dark != data, "the snapshot with the lamp off is byte-identical to the lit one: the "
|
||||||
|
"capture is not live")
|
||||||
|
ctx.check(len(dark) < len(data), "the frame with the lamp off is not smaller than the lit one "
|
||||||
|
"(%d vs %d bytes): the lamp is not lighting what the camera "
|
||||||
|
"sees", len(dark), len(data))
|
||||||
|
|
||||||
|
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "full"}, raw=True)
|
||||||
|
ctx.log("GET /cam/snapshot?cam=lid&res=full -> %s (%d bytes)", st, len(data) if data else 0)
|
||||||
|
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "full-resolution snapshot -> %s", st)
|
||||||
|
ev["snapshot_full_bytes"] = len(data)
|
||||||
|
|
||||||
|
# the stream: fetch a little of it and let it close
|
||||||
|
import urllib.request
|
||||||
|
req = urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
ctype = r.headers.get("Content-Type", "")
|
||||||
|
chunk = r.read(65536)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
ctx.fail("stream did not open: %s", e)
|
||||||
|
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")
|
||||||
|
ctx.sleep(2)
|
||||||
|
st, body = fc.get("/cam/status")
|
||||||
|
ev["cam_status_after"] = body
|
||||||
|
ctx.log("cam status after: %s", body)
|
||||||
|
ctx.log("PASS: half-res snapshot %d bytes, %d with the lamp off; full-res %d; stream %s",
|
||||||
|
ev["snapshot_bytes"], len(dark), len(data), ctype)
|
||||||
|
|
||||||
|
|
||||||
|
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera",
|
||||||
|
kind="auto", est_min=1,
|
||||||
|
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
||||||
|
description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an "
|
||||||
|
"8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it "
|
||||||
|
"actually returns are that size. A machine that reports 'unknown', or whose frames "
|
||||||
|
"do not match what it advertises, is running the wrong capture profile.")
|
||||||
|
def sensor_profile(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
|
||||||
|
st, body = fc.get("/cam/status")
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
|
||||||
|
sensor = body.get("sensor")
|
||||||
|
ev["sensor"] = sensor
|
||||||
|
ctx.log("sensor: %s", sensor)
|
||||||
|
ctx.check(sensor in _SENSOR_GEOMETRY,
|
||||||
|
"sensor %r is not one this build has a capture profile for", sensor)
|
||||||
|
|
||||||
|
want_w, want_h = _SENSOR_GEOMETRY[sensor]
|
||||||
|
snap = body.get("snapshot") or {}
|
||||||
|
stream = body.get("stream") or {}
|
||||||
|
ev["geometry"] = {"snapshot": snap, "stream": stream}
|
||||||
|
ctx.log("advertised: snapshot %sx%s, stream %sx%s",
|
||||||
|
snap.get("width"), snap.get("height"), stream.get("width"), stream.get("height"))
|
||||||
|
ctx.check(snap.get("width") == want_w and snap.get("height") == want_h,
|
||||||
|
"%s should advertise %dx%d snapshots, got %sx%s",
|
||||||
|
sensor, want_w, want_h, snap.get("width"), snap.get("height"))
|
||||||
|
ctx.check(stream.get("width") == want_w // 2 and stream.get("height") == want_h // 2,
|
||||||
|
"%s should advertise %dx%d stream frames, got %sx%s",
|
||||||
|
sensor, want_w // 2, want_h // 2, stream.get("width"), stream.get("height"))
|
||||||
|
|
||||||
|
# What it advertises is what it delivers.
|
||||||
|
for res, (w, h) in (("full", (want_w, want_h)), ("half", (want_w // 2, want_h // 2))):
|
||||||
|
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": res}, raw=True)
|
||||||
|
ctx.check(st == 200 and data and data[:2] == b"\xff\xd8",
|
||||||
|
"%s snapshot -> %s", res, st)
|
||||||
|
got = _jpeg_size(data)
|
||||||
|
ev["jpeg_%s" % res] = got
|
||||||
|
ctx.log("%s snapshot: %s (%d bytes)", res, got, len(data))
|
||||||
|
ctx.check(got == (w, h), "%s snapshot is %s, not %dx%d", res, got, w, h)
|
||||||
|
|
||||||
|
|
||||||
|
@test("camera.h264-stream", title="H.264 live stream", subsystem="camera",
|
||||||
|
kind="auto", est_min=2,
|
||||||
|
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
|
||||||
|
steps=["Setup: lid closed."],
|
||||||
|
description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an "
|
||||||
|
"init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few "
|
||||||
|
"seconds, and /cam/status reporting the H.264 encoder active with this client "
|
||||||
|
"counted. Also records which demosaic path (GPU or CPU) served the stream. On a "
|
||||||
|
"machine whose image lacks Mesa or whose encoder refused, the endpoint must "
|
||||||
|
"answer 503 rather than hang - that is a pass for the endpoint but is recorded "
|
||||||
|
"in the evidence.")
|
||||||
|
def h264_stream(ctx):
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
|
||||||
|
req = urllib.request.Request(fc.base + "/cam/h264?cam=lid",
|
||||||
|
headers={"Host": fc.host_header()})
|
||||||
|
try:
|
||||||
|
r = urllib.request.urlopen(req, timeout=20)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
ev["status"] = e.code
|
||||||
|
ev["body"] = e.read()[:200].decode("utf-8", "replace")
|
||||||
|
ctx.check(e.code == 503, "H.264 refused with %s, not 503: %s", e.code, ev["body"])
|
||||||
|
ctx.log("H.264 unavailable on this machine (503): %s", ev["body"])
|
||||||
|
return
|
||||||
|
with r:
|
||||||
|
codec = r.headers.get("X-H264-Codec", "")
|
||||||
|
ctype = r.headers.get("Content-Type", "")
|
||||||
|
ev["codec"] = codec
|
||||||
|
ev["content_type"] = ctype
|
||||||
|
ctx.log("codec %s, content-type %s", codec, ctype)
|
||||||
|
ctx.check(ctype == "video/mp4", "content type is %r", ctype)
|
||||||
|
ctx.check(codec.startswith("avc1."), "codec header is %r", codec)
|
||||||
|
|
||||||
|
# Init segment, then at least one media fragment.
|
||||||
|
data = b""
|
||||||
|
while len(data) < 512 * 1024:
|
||||||
|
chunk = r.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
data += chunk
|
||||||
|
if b"moof" in data and b"mdat" in data:
|
||||||
|
break
|
||||||
|
ev["bytes"] = len(data)
|
||||||
|
ctx.check(data[4:8] == b"ftyp", "stream does not start with ftyp")
|
||||||
|
ctx.check(b"moov" in data, "no moov (init segment)")
|
||||||
|
ctx.check(b"avcC" in data, "no avcC in the init segment")
|
||||||
|
ctx.check(b"moof" in data and b"mdat" in data,
|
||||||
|
"no media fragment arrived in %d bytes", len(data))
|
||||||
|
|
||||||
|
st, body = fc.get("/cam/status")
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
|
||||||
|
ev["cam_status"] = body
|
||||||
|
ctx.log("convert=%s h264=%s", body.get("convert"), body.get("h264"))
|
||||||
|
h = body.get("h264") or {}
|
||||||
|
ctx.check(h.get("active") is True, "/cam/status does not report the H.264 encoder active: %s",
|
||||||
|
body)
|
||||||
|
|
||||||
|
|
||||||
|
@test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera",
|
||||||
|
kind="auto", est_min=1,
|
||||||
|
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.")
|
||||||
|
def frame_health(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
|
||||||
|
def health():
|
||||||
|
st, body = fc.get("/cam/status")
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
|
||||||
|
h = body.get("health")
|
||||||
|
ctx.check(isinstance(h, dict), "/cam/status carries no health block: %s", body)
|
||||||
|
return h
|
||||||
|
|
||||||
|
before = health()
|
||||||
|
ev["health_before"] = before
|
||||||
|
ctx.log("before: %s", before)
|
||||||
|
|
||||||
|
# A burst, so the count covers a run of frames rather than a single grab.
|
||||||
|
for _ in range(3):
|
||||||
|
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
|
||||||
|
ctx.check(st == 200 and data and data[:2] == b"\xff\xd8", "snapshot -> %s", st)
|
||||||
|
|
||||||
|
after = health()
|
||||||
|
ev["health_after"] = after
|
||||||
|
ctx.log("after: %s", after)
|
||||||
|
|
||||||
|
captured = (after.get("captured") or 0) - (before.get("captured") or 0)
|
||||||
|
corrupt = (after.get("corrupt") or 0) - (before.get("corrupt") or 0)
|
||||||
|
restarts = (after.get("restarts") or 0) - (before.get("restarts") or 0)
|
||||||
|
ev["captured"] = captured
|
||||||
|
ev["corrupt"] = corrupt
|
||||||
|
ev["restarts"] = restarts
|
||||||
|
ctx.log("captured %d frames, %d corrupt, %d stream restarts", captured, corrupt, restarts)
|
||||||
|
|
||||||
|
ctx.check(captured > 0, "no frames were dequeued during three snapshots")
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@test("camera.lid-privacy", title="Cameras capture only with the lid closed", subsystem="camera",
|
||||||
|
kind="operator", est_min=3,
|
||||||
|
covers=_PRIVACY_COVERS, 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.")
|
||||||
|
def lid_privacy(ctx):
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
|
||||||
|
def status():
|
||||||
|
st, body = fc.get("/cam/status")
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
|
||||||
|
return body
|
||||||
|
|
||||||
|
def snapshot():
|
||||||
|
"""(status, body) for a half-res lid snapshot."""
|
||||||
|
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
|
||||||
|
return st, data or b""
|
||||||
|
|
||||||
|
# --- lid closed: the baseline the rest is measured against ----------
|
||||||
|
ctx.act("lid", "close")
|
||||||
|
body = status()
|
||||||
|
ctx.check(body.get("capture_allowed") is True,
|
||||||
|
"with the lid closed /cam/status should allow capture, got %r",
|
||||||
|
body.get("capture_allowed"))
|
||||||
|
st, data = snapshot()
|
||||||
|
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
|
||||||
|
"snapshot with the lid closed -> %s", st)
|
||||||
|
ev["closed_snapshot_bytes"] = len(data)
|
||||||
|
ctx.log("lid closed: snapshot %d bytes", len(data))
|
||||||
|
|
||||||
|
# --- a live stream must die when the lid opens ----------------------
|
||||||
|
req = urllib.request.Request(fc.base + "/cam/stream?cam=lid",
|
||||||
|
headers={"Host": fc.host_header()})
|
||||||
|
stream = urllib.request.urlopen(req, timeout=15)
|
||||||
|
try:
|
||||||
|
first = stream.read(4096)
|
||||||
|
ctx.check(b"\xff\xd8" in first, "the stream did not start before the lid test")
|
||||||
|
ctx.act("lid", "open", text="The stream is running.")
|
||||||
|
# The engine tears the pipeline down on the next frame and the
|
||||||
|
# stream ends. Draining now returns whatever was buffered before
|
||||||
|
# the lid opened and then EOF; what must not happen is frames
|
||||||
|
# continuing to arrive indefinitely.
|
||||||
|
drained, ended = 0, False
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = stream.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
ended = True
|
||||||
|
break
|
||||||
|
drained += len(chunk)
|
||||||
|
ctx.check(drained < 8 * 1024 * 1024,
|
||||||
|
"the stream was still delivering after %d bytes with the lid open",
|
||||||
|
drained)
|
||||||
|
except Exception as e: # noqa: BLE001 - a reset connection ends it too
|
||||||
|
ended = True
|
||||||
|
ctx.log("stream ended with %s", type(e).__name__)
|
||||||
|
ev["stream_bytes_after_lid_open"] = drained
|
||||||
|
ctx.log("stream ended after %d further buffered bytes", drained)
|
||||||
|
ctx.check(ended, "the stream never ended after the lid opened")
|
||||||
|
finally:
|
||||||
|
stream.close()
|
||||||
|
|
||||||
|
# --- lid open: everything is refused, and says why ------------------
|
||||||
|
body = status()
|
||||||
|
ev["status_lid_open"] = body
|
||||||
|
ctx.check(body.get("capture_allowed") is False,
|
||||||
|
"with the lid open /cam/status should refuse capture, got %r",
|
||||||
|
body.get("capture_allowed"))
|
||||||
|
ctx.check(body.get("stopped_by_lid") is True,
|
||||||
|
"the engine should record that the lid stopped it, got %r",
|
||||||
|
body.get("stopped_by_lid"))
|
||||||
|
ctx.check(body.get("running") is False,
|
||||||
|
"the capture engine should not still be running with the lid open")
|
||||||
|
|
||||||
|
st, data = snapshot()
|
||||||
|
ev["snapshot_lid_open"] = [st, data[:120].decode("utf-8", "replace")]
|
||||||
|
ctx.log("lid open: snapshot -> %s %s", st, data[:120])
|
||||||
|
ctx.check(st == 409, "snapshot with the lid open should be refused with 409, got %s", st)
|
||||||
|
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")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
ctx.fail("the stream opened with the lid open (%s)", r.status)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
ctx.log("lid open: stream -> %s", e.code)
|
||||||
|
ctx.check(e.code == 409, "the stream should be refused with 409, got %s", e.code)
|
||||||
|
|
||||||
|
# --- closing the lid restores it -----------------------------------
|
||||||
|
ctx.act("lid", "close")
|
||||||
|
body = status()
|
||||||
|
ctx.check(body.get("capture_allowed") is True,
|
||||||
|
"closing the lid should allow capture again, got %r",
|
||||||
|
body.get("capture_allowed"))
|
||||||
|
st, data = snapshot()
|
||||||
|
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
|
||||||
|
"snapshot after closing the lid -> %s", st)
|
||||||
|
ctx.log("lid closed again: snapshot %d bytes", len(data))
|
||||||
|
|
||||||
|
|
||||||
|
@test("camera.key-read", title="The camera key reads without a login", subsystem="camera",
|
||||||
|
kind="auto", est_min=1,
|
||||||
|
covers=[("forgectrl", "src/camkey.*"), ("forgectrl", "src/auth.c"),
|
||||||
|
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/panel.js"),
|
||||||
|
("forgectrl", "src/ui/index.html"), ("forgectrl", "src/ui/help.js")],
|
||||||
|
requires=["forgectrl.auth"],
|
||||||
|
description="With the read routes closed to the network (panel_open_reads=0), a camera "
|
||||||
|
"status read from the board's LAN address is refused without the key and "
|
||||||
|
"served with it, on HTTP and on HTTPS; a wrong key is refused; the key never "
|
||||||
|
"authorizes a write; a rotation from the panel route stops the old key and "
|
||||||
|
"the new one reads. The setting is put back after.")
|
||||||
|
def key_read(ctx):
|
||||||
|
from .commission import request
|
||||||
|
from .forgectrl import lan_ip
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
ip = lan_ip()
|
||||||
|
ctx.check(ip, "cannot determine the board's LAN address")
|
||||||
|
http_base = "http://%s" % ip
|
||||||
|
tls_base = "https://%s" % ip
|
||||||
|
token = {"X-ForgeFIRM-Token": fc.token}
|
||||||
|
|
||||||
|
st, body, _ = request(fc.base, "GET", "/system/camera-key", headers=token)
|
||||||
|
ctx.check(st == 200, "GET /system/camera-key from loopback -> %s", st)
|
||||||
|
key = json.loads(body)["key"]
|
||||||
|
ctx.check(len(key) == 32, "the key is not 32 characters: %r", key)
|
||||||
|
ev["key_len"] = len(key)
|
||||||
|
|
||||||
|
before = fc.settings().get("panel_open_reads", "")
|
||||||
|
st, _ = fc.post("/settings", params={"panel_open_reads": "0"})
|
||||||
|
ctx.check(st == 200, "closing the reads -> %s", st)
|
||||||
|
try:
|
||||||
|
for base in (http_base, tls_base):
|
||||||
|
st, _, _ = request(base, "GET", "/cam/status")
|
||||||
|
ctx.log("GET %s/cam/status (no key) -> %s", base, st)
|
||||||
|
ctx.check(st == 403, "a closed read without the key -> %s, expected 403", st)
|
||||||
|
st, _, _ = request(base, "GET", "/cam/status?key=" + key)
|
||||||
|
ctx.log("GET %s/cam/status?key=... -> %s", base, st)
|
||||||
|
ctx.check(st == 200, "a read with the key -> %s, expected 200", st)
|
||||||
|
st, _, _ = request(base, "GET", "/cam/status", headers={"X-ForgeFIRM-Camera-Key": key})
|
||||||
|
ctx.check(st == 200, "a read with the key header -> %s, expected 200", st)
|
||||||
|
wrong = ("0" if key[0] != "0" else "1") + key[1:]
|
||||||
|
st, _, _ = request(base, "GET", "/cam/status?key=" + wrong)
|
||||||
|
ctx.check(st == 403, "a wrong key -> %s, expected 403", st)
|
||||||
|
# the key is a read credential only
|
||||||
|
st, _, _ = request(tls_base, "POST", "/settings", data={"panel_open_reads": "0"},
|
||||||
|
headers={"X-ForgeFIRM-Camera-Key": key})
|
||||||
|
ctx.log("POST /settings with the camera key -> %s", st)
|
||||||
|
ctx.check(st == 403, "the camera key authorized a write (%s)", st)
|
||||||
|
# rotation
|
||||||
|
st, body, _ = request(fc.base, "POST", "/system/camera-key?rotate=1", headers=token)
|
||||||
|
ctx.check(st == 200, "rotate -> %s", st)
|
||||||
|
new = json.loads(body)["key"]
|
||||||
|
ctx.check(new != key, "rotation kept the same key")
|
||||||
|
st, _, _ = request(http_base, "GET", "/cam/status?key=" + key)
|
||||||
|
ctx.check(st == 403, "the old key still reads after rotation (%s)", st)
|
||||||
|
st, _, _ = request(http_base, "GET", "/cam/status?key=" + new)
|
||||||
|
ctx.check(st == 200, "the new key does not read (%s)", st)
|
||||||
|
ev["rotated"] = True
|
||||||
|
finally:
|
||||||
|
fc.post("/settings", params={"panel_open_reads": before})
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,369 @@
|
|||||||
|
"""commission.check-* - the setup's checks (the dark wizards), driven the
|
||||||
|
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
|
||||||
|
the prompts answered from here (the bench fixture works the lid and the
|
||||||
|
button; a confirmation is answered yes once the snapshot exists), the
|
||||||
|
result judged, and every setting a check wrote put back as found. Each
|
||||||
|
check re-runs on the real record: a completed check completes again at
|
||||||
|
the same version, so the record reads as before.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from ..catalog import test
|
||||||
|
from .. import hw
|
||||||
|
from ..baseline import read_position
|
||||||
|
from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
|
||||||
|
|
||||||
|
POLL_S = 1.0
|
||||||
|
# What the machine's own press prompt says, used when it sends no text.
|
||||||
|
PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your press."
|
||||||
|
# How long the purge fan's current is given to come up after the airflow
|
||||||
|
# check switches it back on. The bench reference draws its off current
|
||||||
|
# (74) for the first seconds and settles near 630.
|
||||||
|
PURGE_SPINUP_S = 30
|
||||||
|
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
|
||||||
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
||||||
|
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
||||||
|
|
||||||
|
|
||||||
|
def dark(fc):
|
||||||
|
st, body = fc.get("/wiz/dark")
|
||||||
|
if st != 200 or not isinstance(body, dict):
|
||||||
|
raise hw.HwError("GET /wiz/dark -> %s" % st)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def start(ctx, fc, wid):
|
||||||
|
ev = ctx.evidence
|
||||||
|
st, body = fc.post("/wiz/%s/start" % wid)
|
||||||
|
if st == 409 and dark(fc).get("id") == wid and dark(fc).get("running"):
|
||||||
|
# The same check left running by an earlier run of this test (an
|
||||||
|
# abort mid-wait): this run owns the machine, so it ends that one
|
||||||
|
# and starts its own.
|
||||||
|
ctx.log("the %s check is still running from an earlier run: aborting it", wid)
|
||||||
|
fc.post("/wiz/%s/abort" % wid)
|
||||||
|
ctx.wait_for(lambda: not dark(fc).get("running"), 30)
|
||||||
|
st, body = fc.post("/wiz/%s/start" % wid)
|
||||||
|
ctx.log("POST /wiz/%s/start -> %s %s", wid, st, body if isinstance(body, dict) else "")
|
||||||
|
ctx.check(st == 200, "the %s check did not start (%s %s)", wid, st, body)
|
||||||
|
ev["started"] = st == 200
|
||||||
|
|
||||||
|
|
||||||
|
def answer(fc, wid, prompt, value):
|
||||||
|
return fc.post("/wiz/%s/answer" % wid, data={"seq": str(prompt["seq"]), "value": value})
|
||||||
|
|
||||||
|
|
||||||
|
def run_check(ctx, wid, on_prompt, timeout_s):
|
||||||
|
"""Start `wid` and follow it to its end. on_prompt(prompt) is called
|
||||||
|
once per prompt (by sequence number) and returns the value to answer
|
||||||
|
with, or None when it handled the prompt another way (a fixture
|
||||||
|
action). Returns the final status document."""
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
start(ctx, fc, wid)
|
||||||
|
t0 = time.time()
|
||||||
|
seen = set()
|
||||||
|
phases = []
|
||||||
|
last = None
|
||||||
|
try:
|
||||||
|
while time.time() - t0 < timeout_s:
|
||||||
|
ctx.checkpoint()
|
||||||
|
d = dark(fc)
|
||||||
|
if d.get("id") != wid:
|
||||||
|
raise hw.HwError("another check took the slot: %s" % d.get("id"))
|
||||||
|
if d.get("phase") and (not phases or phases[-1] != d["phase"]):
|
||||||
|
phases.append(d["phase"])
|
||||||
|
ctx.log("%s: %s", wid, d["phase"])
|
||||||
|
p = d.get("prompt")
|
||||||
|
if p and p.get("seq") not in seen:
|
||||||
|
seen.add(p["seq"])
|
||||||
|
ctx.log("prompt %s (%s): %s", p.get("id"), p.get("kind"), p.get("text"))
|
||||||
|
value = on_prompt(p)
|
||||||
|
if value is not None:
|
||||||
|
st, body = answer(fc, wid, p, value)
|
||||||
|
ctx.check(st == 200, "the answer to %s -> %s %s", p.get("id"), st, body)
|
||||||
|
elif p.get("id") == "press":
|
||||||
|
# The arm press, asked for by the machine itself: the
|
||||||
|
# check opens this prompt when the button is lit and
|
||||||
|
# the tube is still dark. Nothing answers it - the
|
||||||
|
# press does, and the discharge closes it.
|
||||||
|
ctx.press_now(p.get("text") or PRESS_TEXT)
|
||||||
|
if not d.get("running"):
|
||||||
|
last = d
|
||||||
|
break
|
||||||
|
time.sleep(POLL_S)
|
||||||
|
except BaseException:
|
||||||
|
# An abort or a failure on the way out leaves no check running
|
||||||
|
# behind this test: the next run must be able to start its own.
|
||||||
|
fc.post("/wiz/%s/abort" % wid)
|
||||||
|
raise
|
||||||
|
ev["elapsed_s"] = round(time.time() - t0, 1)
|
||||||
|
ev["phases"] = phases[-12:]
|
||||||
|
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
|
||||||
|
if last is None:
|
||||||
|
fc.post("/wiz/%s/abort" % wid)
|
||||||
|
return {}
|
||||||
|
ev["log"] = last.get("log", [])[-12:]
|
||||||
|
ev["error"] = last.get("error")
|
||||||
|
ev["result"] = last.get("result")
|
||||||
|
ctx.check(not last.get("error"), "the %s check failed: %s", wid, last.get("error"))
|
||||||
|
ctx.check(isinstance(last.get("result"), dict), "the %s check ended without a result", wid)
|
||||||
|
w = wiz(fc)
|
||||||
|
ctx.check((w.get("versions") or {}).get(wid) == 1, "the record does not carry %s at version 1", wid)
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
|
@test("commission.check-switches", title="The switches check follows the lid and the button",
|
||||||
|
subsystem="commission", kind="operator", est_min=3,
|
||||||
|
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
||||||
|
requires=["forgectrl.auth"], actions=["lid", "button"],
|
||||||
|
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 "
|
||||||
|
"satisfied without a prompt. The result says lid, button, head present, and "
|
||||||
|
"whether the HV enable readback followed the lid; the record carries switches at "
|
||||||
|
"version 1.")
|
||||||
|
def check_switches(ctx):
|
||||||
|
def on_prompt(p):
|
||||||
|
pid = p.get("id", "")
|
||||||
|
if pid in ("lid-open", "lid-close-first", "lid-close"):
|
||||||
|
ctx.act("lid", "open" if pid == "lid-open" else "close",
|
||||||
|
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
|
||||||
|
text=p.get("text", ""))
|
||||||
|
elif pid == "button-press":
|
||||||
|
ctx.act("button", "press",
|
||||||
|
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("id") != "button-press",
|
||||||
|
text=p.get("text", ""))
|
||||||
|
elif pid == "button-release":
|
||||||
|
pass # the fixture's press releases on its own
|
||||||
|
elif pid.startswith("interlock-"):
|
||||||
|
ctx.act("interlock", "open" if pid == "interlock-open" else "close",
|
||||||
|
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
|
||||||
|
text=p.get("text", ""))
|
||||||
|
return None
|
||||||
|
last = run_check(ctx, "switches", on_prompt, 600)
|
||||||
|
r = last.get("result") or {}
|
||||||
|
ctx.check(r.get("lid") is True and r.get("button") is True, "lid or button not proven: %s", r)
|
||||||
|
ctx.check(r.get("head_present") is True, "the head did not read present: %s", r)
|
||||||
|
ctx.log("switches: %s", json.dumps(r))
|
||||||
|
|
||||||
|
|
||||||
|
@test("commission.check-sensors", title="The sensors check reads a plausible machine at rest",
|
||||||
|
subsystem="commission", kind="auto", est_min=2,
|
||||||
|
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
|
||||||
|
("forgectrl", "src/cool.c")],
|
||||||
|
requires=["forgectrl.auth"],
|
||||||
|
description="POST /wiz/sensors/start; ten seconds of readings, then the room-temperature "
|
||||||
|
"prompt, answered Skip so no offset is written. The result carries both coolant "
|
||||||
|
"temperatures, the chassis and SoC, the lid IR maxima, the accelerometer event "
|
||||||
|
"count, the supply power-good, the HV current, and the idle fan speeds; the "
|
||||||
|
"record carries sensors at version 1 and cool_temp_offset_c reads as before.")
|
||||||
|
def check_sensors(ctx):
|
||||||
|
with Restore(ctx, ["cool_temp_offset_c"]):
|
||||||
|
last = run_check(ctx, "sensors", lambda p: "Skip" if p.get("id") == "room-temp" else None, 120)
|
||||||
|
r = last.get("result") or {}
|
||||||
|
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
||||||
|
"exhaust_rpm_idle", "intake_rpm_idle"):
|
||||||
|
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
||||||
|
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
|
||||||
|
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
|
||||||
|
r.get("lid_ir_max"), r.get("hv_current_max"))
|
||||||
|
|
||||||
|
|
||||||
|
@test("commission.check-airflow", title="The airflow check measures the fans and sets the floors",
|
||||||
|
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
|
||||||
|
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
||||||
|
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
||||||
|
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
||||||
|
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.")
|
||||||
|
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):
|
||||||
|
last = run_check(ctx, "airflow", lambda p: None, 240)
|
||||||
|
r = last.get("result") or {}
|
||||||
|
fans = r.get("fans") or {}
|
||||||
|
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
||||||
|
f = fans.get(name) or {}
|
||||||
|
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 {}
|
||||||
|
for k in keys:
|
||||||
|
ctx.check(k in floors, "no floor for %s: %s", k, floors)
|
||||||
|
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.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
|
||||||
|
# the very floor written above, and nothing puts it back until the
|
||||||
|
# daemon restarts: the engine's idle phase never re-applies its own
|
||||||
|
# duties. Prove the machine is whole, not just measured.
|
||||||
|
purge_on = ctx.sysfs("head/purge_air")
|
||||||
|
ctx.check(purge_on == "1", "purge air reads %r after the check, expected 1 (on)", purge_on)
|
||||||
|
|
||||||
|
def purge_gate():
|
||||||
|
return ((ctx.forgectrl.get("/cool/status")[1] or {}).get("fan_gates") or {}).get("purge") or {}
|
||||||
|
|
||||||
|
# The check read its off current with the fan off, and the stand-down
|
||||||
|
# commands it back on. The current follows the command, it does not
|
||||||
|
# arrive with it: read in the same second and what comes back is the
|
||||||
|
# off current the check just measured. Wait for the draw the way the
|
||||||
|
# controller below is waited for.
|
||||||
|
took = ctx.wait_for(lambda: (purge_gate().get("reading") or 0) >= (purge_gate().get("floor") or 0),
|
||||||
|
PURGE_SPINUP_S)
|
||||||
|
gate = purge_gate()
|
||||||
|
ctx.evidence["purge_after"] = {"purge_air": purge_on, "gate": gate, "took_s": took}
|
||||||
|
ctx.log("purge after the check: commanded %s, drawing %s against the %s floor (%s)",
|
||||||
|
purge_on, gate.get("reading"), gate.get("floor"),
|
||||||
|
"after %.1f s" % took if took is not None else "never reached the floor")
|
||||||
|
ctx.check(took is not None,
|
||||||
|
"purge draws %s after %d s, under the %s floor the check just wrote: the next job "
|
||||||
|
"would be held", gate.get("reading"), PURGE_SPINUP_S, gate.get("floor"))
|
||||||
|
ok = ctx.wait_for(lambda: (ctx.forgectrl.get("/mode")[1] or {}).get("controller") == "running", 60)
|
||||||
|
ctx.check(ok is not None, "the controller did not come back after the check")
|
||||||
|
|
||||||
|
|
||||||
|
@test("commission.check-cameras", title="The cameras check captures both cameras",
|
||||||
|
subsystem="commission", kind="operator", est_min=2,
|
||||||
|
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
|
||||||
|
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
|
||||||
|
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
|
||||||
|
"head snapshot, the question. Each question is answered yes once GET /wiz/shot "
|
||||||
|
"serves the snapshot as image/jpeg. The result names the sensor and says both "
|
||||||
|
"views were accepted.")
|
||||||
|
def check_cameras(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
ctx.act("lid", "close")
|
||||||
|
shots = {}
|
||||||
|
|
||||||
|
def on_prompt(p):
|
||||||
|
pid = p.get("id", "")
|
||||||
|
if pid in ("lid-view", "head-view"):
|
||||||
|
cam = "lid" if pid == "lid-view" else "head"
|
||||||
|
st, body = fc.get("/wiz/shot", params={"cam": cam}, raw=True)
|
||||||
|
shots[cam] = {"status": st, "bytes": len(body) if body else 0,
|
||||||
|
"jpeg": bool(body) and body[:2] == b"\xff\xd8"}
|
||||||
|
ctx.check(st == 200 and shots[cam]["jpeg"], "no %s snapshot to judge: %s", cam, shots[cam])
|
||||||
|
return "Yes"
|
||||||
|
if pid == "lid-close":
|
||||||
|
ctx.act("lid", "close", until=lambda: (dark(fc).get("prompt") or {}).get("seq") != p["seq"])
|
||||||
|
return None
|
||||||
|
last = run_check(ctx, "cameras", on_prompt, 180)
|
||||||
|
ev["shots"] = shots
|
||||||
|
r = last.get("result") or {}
|
||||||
|
ctx.check(r.get("lid_ok") is True and r.get("head_ok") is True, "a view was not accepted: %s", r)
|
||||||
|
ctx.check(bool(r.get("sensor")) and r.get("sensor") != "unknown", "no sensor named: %s", r)
|
||||||
|
ctx.log("cameras: %s, lid %d bytes, head %d bytes", r.get("sensor"),
|
||||||
|
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
|
||||||
|
|
||||||
|
|
||||||
|
@test("commission.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
||||||
|
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
||||||
|
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
||||||
|
("forgectrl", "src/lenshome.*"),
|
||||||
|
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
|
||||||
|
requires=["forgectrl.auth", "motion.pacing"],
|
||||||
|
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
|
||||||
|
"lens takes its hall-edge reference behind it (a lens that cannot reach its "
|
||||||
|
"edge is a motion fault, and no controller starts), the lens "
|
||||||
|
"finds the hall reference in five agreeing passes, the controller comes back in "
|
||||||
|
"loopback posture, and the head jogs 50 mm each way on X and Y with the "
|
||||||
|
"accelerometer as the witness. The one prompt (the jogs are about to move) is "
|
||||||
|
"answered Continue. The result carries the probe line, the passes, and a "
|
||||||
|
"witnessed reading for each jog; the controller must be back in its normal "
|
||||||
|
"posture, and the kernel position counters must read what they read before: "
|
||||||
|
"every move played to its end (the probe's return leg under the cooling "
|
||||||
|
"engine's dead-man, the last jog under the controller stop).")
|
||||||
|
def check_motion(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
before = read_position()
|
||||||
|
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()
|
||||||
|
r = last.get("result") or {}
|
||||||
|
ctx.check(r.get("z_referenced") is True, "the lens was not referenced: %s", r)
|
||||||
|
ctx.check(len(r.get("z_passes") or []) == 5, "not five reference passes: %s", r.get("z_passes"))
|
||||||
|
moves = r.get("moves") or {}
|
||||||
|
for name in ("+X", "-X", "+Y", "-Y"):
|
||||||
|
m = moves.get(name) or {}
|
||||||
|
ctx.check(m.get("witnessed") is True, "the %s jog was not witnessed: %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")
|
||||||
|
after = read_position()
|
||||||
|
if before is not None and after is not None:
|
||||||
|
ctx.check(all(abs(a - b) <= 2 for a, b in zip(after[:2], before[:2])),
|
||||||
|
"the check lost motion: position %s before, %s after (steps)", before, after)
|
||||||
|
ctx.log("motion: probe '%s', passes %s, rest %s, moves %s", r.get("probe"), r.get("z_passes"),
|
||||||
|
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
|
||||||
|
|
||||||
|
|
||||||
|
@test("commission.check-flow-verify", title="The flow check runs as a setup check",
|
||||||
|
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
||||||
|
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
||||||
|
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
||||||
|
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
||||||
|
"check runner: the diagnostic's phases show as the check's, its result becomes the "
|
||||||
|
"check's result with pass true, and the record carries cooling.flow-verify at "
|
||||||
|
"version 1. A thin margin recommends the calibration in the record's flags. The "
|
||||||
|
"air-assist offset calibration is named first among the prerequisites so a queue "
|
||||||
|
"keeps it ahead of every heater tool this check pulls forward.")
|
||||||
|
def check_flow_verify(ctx):
|
||||||
|
last = run_check(ctx, "cooling.flow-verify", lambda p: None, 600)
|
||||||
|
r = last.get("result") or {}
|
||||||
|
ctx.check(r.get("pass") is True, "the flow check did not pass: %s", r)
|
||||||
|
ctx.log("flow check: threshold %s, flow %s, no-flow %s, thin %s", r.get("threshold"),
|
||||||
|
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
|
||||||
|
|
||||||
|
|
||||||
|
@test("commission.cloud-header-capture", title="The cloud header check takes one print's envelope",
|
||||||
|
subsystem="commission", kind="operator", hardware="takeover", mode="grbl", est_min=8,
|
||||||
|
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
|
||||||
|
("python3-gfhardware", "gfhardware/machine.py"),
|
||||||
|
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
|
||||||
|
("python3-gfhardware", "forgefirm-app/ffmachine.py")],
|
||||||
|
requires=["forgectrl.auth", "cloud.mode-switch"], hands=["workstation"],
|
||||||
|
steps=["The Glowforge app open on the workstation, signed in to this machine. The test "
|
||||||
|
"turns cloud mode on itself and puts it back.",
|
||||||
|
"Once the app shows the machine online, place any small design on the bed image "
|
||||||
|
"and press Print. That is the only action: the check takes the print's header and "
|
||||||
|
"cancels the print before it arms. Do not press the machine's button.",
|
||||||
|
"The machine returns to GRBL mode on its own."],
|
||||||
|
description="POST /wiz/cloud.header/start with cloud mode on: the wizard writes the "
|
||||||
|
"client's one-start capture marker, starts cloud mode in the wizard's posture, "
|
||||||
|
"asks for a print, and takes the header the client writes to the run directory "
|
||||||
|
"before it cancels the print. The result carries the tag count, the "
|
||||||
|
"calibration-bearing tags, the limits, and the machine's own numbers; the log "
|
||||||
|
"has the tagged block; the machine is back in GRBL mode with the marker gone.")
|
||||||
|
def cloud_header_capture(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
with Restore(ctx, ["cloud_enabled", "homing_mode", "controller_mode"]):
|
||||||
|
s = fc.settings() or {}
|
||||||
|
if s.get("cloud_enabled") != "1":
|
||||||
|
st, body = fc.post("/settings", data={"cloud_enabled": "1"})
|
||||||
|
ctx.check(st == 200, "cloud_enabled=1 -> %s %s", st, body)
|
||||||
|
ctx.log("cloud mode turned on for the test")
|
||||||
|
ctx.notice("Print any small design from the Glowforge app once it shows the machine "
|
||||||
|
"online. Do not press the machine's button.")
|
||||||
|
last = run_check(ctx, "cloud.header",
|
||||||
|
lambda p: "Continue" if p.get("id") == "print" else None, 900)
|
||||||
|
ctx.clear_notice()
|
||||||
|
r = last.get("result") or {}
|
||||||
|
ctx.check(isinstance(r.get("tag_count"), int) and r["tag_count"] > 100,
|
||||||
|
"the header carried %s tags", r.get("tag_count"))
|
||||||
|
kept = r.get("calibration_tags") or {}
|
||||||
|
ctx.check(len(kept) > 10, "only %d calibration-bearing tags kept", len(kept))
|
||||||
|
ev["kept"] = sorted(kept)[:40]
|
||||||
|
ctx.check(not os.path.exists("/run/gfcloud-capture"), "the capture marker is still down")
|
||||||
|
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("mode") == "grbl"
|
||||||
|
and (fc.get("/mode")[1] or {}).get("controller") == "running", 120)
|
||||||
|
ctx.check(ok is not None, "the machine did not return to GRBL mode")
|
||||||
|
ctx.log("header: %d tags, %d kept; job %s", r.get("tag_count", 0), len(kept), r.get("job_id"))
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""commission.sheet - the setup's sheet (the live wizards) in one run,
|
||||||
|
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
|
||||||
|
polled, the prompts answered from here, each burn armed by a press, the
|
||||||
|
result judged, and every setting a wizard wrote put back as found. One
|
||||||
|
piece of wood at least 200 x 150 mm (8 x 6 in) carries the placement,
|
||||||
|
the frame, and the five cards. Each wizard re-runs on the real record,
|
||||||
|
so a completed one completes again at the same version.
|
||||||
|
|
||||||
|
The operator's part is one press on the machine at the start (the
|
||||||
|
presence check of the ready gate; with the bench actuator up it then
|
||||||
|
makes every arm press) and nothing else. The judgments a person makes
|
||||||
|
by eye on the sheet (the thinnest line, the faintest rung, the best
|
||||||
|
corner, the frame's fit) are answered here with a middle option or the
|
||||||
|
witnesses: the test proves each wizard's mechanics and the emission
|
||||||
|
witnesses, not the operator's eye, and the settings it writes are
|
||||||
|
restored. The page is where the real numbers go in.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .commission_dark import run_check, Restore
|
||||||
|
|
||||||
|
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
||||||
|
("forgectrl", "src/sheet.*"),
|
||||||
|
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
||||||
|
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
||||||
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
||||||
|
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
||||||
|
("forgectrl", "src/super.c"),
|
||||||
|
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
||||||
|
("forgectrl", "src/accel.*"), ("forgectrl", "src/wizcalc.*"),
|
||||||
|
("grblhal-glowforge", "src/glowforge_laser.c"),
|
||||||
|
("grblhal-glowforge", "src/glowforge_status.c"),
|
||||||
|
("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||||
|
("grblhal-glowforge", "src/glowforge_io.*")]
|
||||||
|
|
||||||
|
# The live wizards in the order the sheet burns them: the wizard id, the
|
||||||
|
# answers to its prompts (a middle pick where a person would look at the
|
||||||
|
# sheet), and the keys its result must carry. The arm press is not timed
|
||||||
|
# from here: each card opens a press prompt of its own when the button is
|
||||||
|
# lit and the tube still dark, and run_check presses on that.
|
||||||
|
CARDS = [
|
||||||
|
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"]),
|
||||||
|
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"},
|
||||||
|
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
|
||||||
|
"focus_range_mm", "stops"]),
|
||||||
|
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"]),
|
||||||
|
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"]),
|
||||||
|
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"]),
|
||||||
|
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"]),
|
||||||
|
]
|
||||||
|
# Every setting a card writes, restored as found when the run ends.
|
||||||
|
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
|
||||||
|
"laser_floor_density", "laser_dose_curve", "laser_corner_gamma",
|
||||||
|
"cool_laser_heat_density", "cool_laser_heat_cw"]
|
||||||
|
# The evidence run_check and the witnesses write at the top level, moved
|
||||||
|
# under the card's own key once it is done.
|
||||||
|
CARD_EVIDENCE = ("started", "elapsed_s", "phases", "log", "error", "result",
|
||||||
|
"preview_bytes", "program_lines", "emission")
|
||||||
|
|
||||||
|
|
||||||
|
def preview_ok(ctx, wid):
|
||||||
|
"""The page's preview and the program for the wizard are served."""
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
|
||||||
|
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
|
||||||
|
ctx.evidence["preview_bytes"] = len(body)
|
||||||
|
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
|
||||||
|
# The text and the frame are M4 at the mark dose; the patterns bring
|
||||||
|
# their own M3 or M4 lines. A program is one with a laser-on command.
|
||||||
|
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
||||||
|
"no program for %s (%s)", wid, st)
|
||||||
|
ctx.evidence["program_lines"] = body.count(b"\n")
|
||||||
|
|
||||||
|
|
||||||
|
def emission_ok(ctx, last):
|
||||||
|
"""The three witnesses of the burn, from the wizard's result."""
|
||||||
|
e = (last.get("result") or {}).get("emission") or {}
|
||||||
|
ctx.evidence["emission"] = e
|
||||||
|
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
|
||||||
|
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
|
||||||
|
ctx.check(e.get("thermopile_delta", 0) >= 100, "the thermopile did not see the beam (%s)",
|
||||||
|
e.get("thermopile_delta"))
|
||||||
|
|
||||||
|
|
||||||
|
def file_card(ctx, wid):
|
||||||
|
"""Move the card's evidence under its own key."""
|
||||||
|
ev = ctx.evidence
|
||||||
|
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
||||||
|
|
||||||
|
|
||||||
|
def thickness_answer(ctx):
|
||||||
|
"""The thickness the placement is answered with, in the machine's
|
||||||
|
units (the number prompts read ui_units), and the millimeters the
|
||||||
|
record must carry for it."""
|
||||||
|
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
|
||||||
|
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
|
||||||
|
|
||||||
|
|
||||||
|
def place(ctx):
|
||||||
|
"""The placement: the lens reference, two jogs, the origin at the
|
||||||
|
head's position, the full sheet, a thickness. Nothing fires."""
|
||||||
|
jogs = ["X+10", "X-10"]
|
||||||
|
answer, want_mm = thickness_answer(ctx)
|
||||||
|
ctx.evidence["thickness_answer"] = answer
|
||||||
|
|
||||||
|
def on_prompt(p):
|
||||||
|
# Each jog answer closes the prompt and the wizard asks again with
|
||||||
|
# the next sequence number, so the pad is answered one move at a
|
||||||
|
# time until the origin is set.
|
||||||
|
if p.get("id") == "place":
|
||||||
|
return jogs.pop(0) if jogs else "Set origin"
|
||||||
|
if p.get("id") == "sheet-kind":
|
||||||
|
return "Full sheet"
|
||||||
|
if p.get("id") == "thickness":
|
||||||
|
return answer
|
||||||
|
return None
|
||||||
|
|
||||||
|
last = run_check(ctx, "sheet.place", on_prompt, 300)
|
||||||
|
r = last.get("result") or {}
|
||||||
|
ctx.check("origin_x" in r and "origin_y" in r, "no origin in the result: %s", r)
|
||||||
|
ctx.check((r.get("steps_per_mm") or {}).get("x", 0) > 0, "no steps per mm: %s", r)
|
||||||
|
ctx.check(r.get("z_referenced") is True, "the lens was not referenced")
|
||||||
|
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
|
||||||
|
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
|
||||||
|
ctx.check(r.get("alone") is False, "alone %s", r.get("alone"))
|
||||||
|
ctx.log("placed: datum at %s, %s; steps per mm %s", r.get("origin_x"), r.get("origin_y"),
|
||||||
|
r.get("steps_per_mm"))
|
||||||
|
file_card(ctx, "sheet.place")
|
||||||
|
|
||||||
|
|
||||||
|
def burn(ctx, wid, answers, want):
|
||||||
|
"""One live card: the preview, the burn after the press with the
|
||||||
|
prompts answered from `answers`, the witnesses, the keys of the
|
||||||
|
result in `want`. run_check makes the arm press when the card asks
|
||||||
|
for it."""
|
||||||
|
preview_ok(ctx, wid)
|
||||||
|
|
||||||
|
def on_prompt(p):
|
||||||
|
return answers.get(p.get("id"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
last = run_check(ctx, wid, on_prompt, 900)
|
||||||
|
finally:
|
||||||
|
ctx.clear_notice()
|
||||||
|
emission_ok(ctx, last)
|
||||||
|
r = last.get("result") or {}
|
||||||
|
missing = [k for k in want if k not in r]
|
||||||
|
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
|
||||||
|
ctx.log("%s: %s, emission %s", wid, {k: r.get(k) for k in want}, ctx.evidence.get("emission"))
|
||||||
|
file_card(ctx, wid)
|
||||||
|
|
||||||
|
|
||||||
|
@test("commission.sheet", title="The commissioning sheet: the placement, the frame, and the five cards",
|
||||||
|
subsystem="commission", kind="live", hardware="takeover", mode="grbl", est_min=35,
|
||||||
|
covers=SHEET_COVERS,
|
||||||
|
requires=["forgectrl.auth", "commission.check-motion", "laser.emission-witness", "cooling.flow-verify"],
|
||||||
|
actions=["button"], hands=["scrap"],
|
||||||
|
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
|
||||||
|
"it goes with its top edge at the top of the cut area (the head's home corner); "
|
||||||
|
"lid closed. Eye protection on, exhaust on, extinguisher in reach.",
|
||||||
|
"Start the test and press the machine's button once. That press says you are at the "
|
||||||
|
"machine, and the bench actuator makes every arm press after it, each one when the "
|
||||||
|
"card itself asks for it. With no actuator the start is the Ready answer instead, "
|
||||||
|
"and you press the button each time the page asks: six times, the frame and the "
|
||||||
|
"five cards.",
|
||||||
|
"Nothing else: the test answers every prompt itself and puts back every setting the "
|
||||||
|
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
|
||||||
|
"tails."],
|
||||||
|
description="One run over the sheet's seven wizards on one piece of wood. "
|
||||||
|
"POST /wiz/sheet.place/start references the lens, takes the controller in "
|
||||||
|
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
|
||||||
|
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
|
||||||
|
"0.125 in). Then, each after the press and each with its preview "
|
||||||
|
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode: the frame "
|
||||||
|
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
|
||||||
|
"lens homed on its bottom stop, the hall edge counted, twelve lines down the "
|
||||||
|
"travel; the pick is line 11, the thickness kept); the floor card (twelve "
|
||||||
|
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
|
||||||
|
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
|
||||||
|
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
|
||||||
|
"through M102 inside one armed job; the pick is 1.50); and the flow-load card "
|
||||||
|
"(the coolant loop settled, the box and the patch at 60 percent, the "
|
||||||
|
"downstream peak over 100 s of dark as the coefficient). Every burn must be "
|
||||||
|
"seen by the three witnesses (the tube current, the kernel's LASER_ON samples, "
|
||||||
|
"the thermopile rise); every result must carry its keys; the record must "
|
||||||
|
"carry each wizard at version 1; the seven settings the cards write are "
|
||||||
|
"restored at the end.")
|
||||||
|
def sheet(ctx):
|
||||||
|
ctx.ready("The sheet: the placement, then the frame and the five cards burn after a press "
|
||||||
|
"each. The bench presses when the button lights.")
|
||||||
|
place(ctx)
|
||||||
|
with Restore(ctx, CARD_SETTINGS):
|
||||||
|
for wid, answers, want in CARDS:
|
||||||
|
burn(ctx, wid, answers, want)
|
||||||
|
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,413 @@
|
|||||||
|
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .. import hw
|
||||||
|
|
||||||
|
# The write guard reads the account and the session store (a machine with
|
||||||
|
# an account needs a session from the LAN; this host and the dev image
|
||||||
|
# write with the token), so both are the guard's domain.
|
||||||
|
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
||||||
|
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
||||||
|
("forgectrl", "src/ui/login.js"), ("forgectrl", "src/ui/wizard.js")]
|
||||||
|
|
||||||
|
|
||||||
|
def lan_ip():
|
||||||
|
"""The board's own non-loopback IPv4 (the address a LAN client would
|
||||||
|
use), or None."""
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
s.connect(("10.255.255.255", 9))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
except OSError:
|
||||||
|
ip = None
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
if ip and not ip.startswith("127."):
|
||||||
|
return ip
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@test("forgectrl.auth", title="API access control", subsystem="forgectrl", kind="auto", est_min=1,
|
||||||
|
covers=_COVERS_AUTH,
|
||||||
|
description="Every state-changing endpoint refuses an unauthenticated write (the factory "
|
||||||
|
"return, the SSH switch and the wizard's own routes included); a non-literal "
|
||||||
|
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused, while "
|
||||||
|
"the machine's own hostname passes and that name with a domain on it does not; "
|
||||||
|
"the cooling report channel accepts the loopback peer and refuses a non-loopback "
|
||||||
|
"one (over HTTP the write is sent to HTTPS first, 302; over HTTPS the route "
|
||||||
|
"answers 403 loopback only); the fuse view is two-factor "
|
||||||
|
"(token and the physical button) and refused without either; "
|
||||||
|
"the flash and factory-restore chain is refused unauthenticated; a page "
|
||||||
|
"asked for without a session is sent to the login carrying its path.")
|
||||||
|
def auth(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
|
||||||
|
st, body = fc.get("/status")
|
||||||
|
ctx.log("GET /status -> %s", st)
|
||||||
|
ctx.check(st == 200, "GET /status -> %s", st)
|
||||||
|
|
||||||
|
# unauthenticated writes: every one must be refused before it acts
|
||||||
|
for path, params in (("/controller/stop", None), ("/controller/start", None),
|
||||||
|
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
|
||||||
|
("/diag/flow-verify", None), ("/diag/abort", None),
|
||||||
|
("/update/apply", None), ("/boot", {"target": "a"}),
|
||||||
|
("/system/reboot", None), ("/restore/factory", None),
|
||||||
|
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
|
||||||
|
("/wiz/advisories/accept", None), ("/wiz/account", None),
|
||||||
|
("/wiz/complete", None)):
|
||||||
|
st, body = fc.post(path, params=params, auth=False)
|
||||||
|
ctx.log("POST %s (no token) -> %s %s", path, st, body if isinstance(body, dict) else "")
|
||||||
|
ev["noauth " + path] = st
|
||||||
|
ctx.check(st == 403, "POST %s without a token -> %s, expected 403", path, st)
|
||||||
|
ctx.check(isinstance(body, dict) and body.get("error") == "authentication required",
|
||||||
|
"POST %s without a token: unexpected body %r", path, body)
|
||||||
|
|
||||||
|
# the upload sink refuses during body parse; only the status is asserted
|
||||||
|
st, body = fc.post("/update/upload", data=b"not a firmware archive", auth=False,
|
||||||
|
headers={"Content-Type": "application/octet-stream"})
|
||||||
|
ev["noauth /update/upload"] = st
|
||||||
|
ctx.log("POST /update/upload (no token) -> %s", st)
|
||||||
|
ctx.check(st in (400, 403), "POST /update/upload without a token -> %s", st)
|
||||||
|
|
||||||
|
# An oversized body from an unauthenticated client must not be
|
||||||
|
# accumulated in memory before the token check. The framework buffers
|
||||||
|
# every POST body ahead of the callback, and its default is no limit,
|
||||||
|
# so without a ceiling this is a way to take the daemon and any job
|
||||||
|
# with it from the network, unauthenticated. The body here is far over
|
||||||
|
# the cap; what matters is that the daemon answers and is still
|
||||||
|
# serving afterwards, having refused the write.
|
||||||
|
big = b"ui_units=mm&pad=" + (b"x" * (4 * 1024 * 1024))
|
||||||
|
st, body = fc.post("/settings", data=big, auth=False,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||||
|
ev["oversize_body_status"] = st
|
||||||
|
ctx.log("POST /settings (no token, %d MiB body) -> %s", len(big) // (1024 * 1024), st)
|
||||||
|
ctx.check(st == 403, "an oversized unauthenticated body -> %s, expected 403", st)
|
||||||
|
st, body = fc.get("/status")
|
||||||
|
ev["alive_after_oversize"] = st
|
||||||
|
ctx.log("GET /status after the oversized body -> %s", st)
|
||||||
|
ctx.check(st == 200, "the daemon did not survive an oversized body (%s)", st)
|
||||||
|
|
||||||
|
# origin checks (read endpoint, so only the origin layer decides)
|
||||||
|
st, body = fc.get("/status", headers={"Host": "evil.example.net"})
|
||||||
|
ev["host_name"] = st
|
||||||
|
ctx.log("GET /status Host=evil.example.net -> %s", st)
|
||||||
|
ctx.check(st == 403, "a DNS-name Host was accepted (%s)", st)
|
||||||
|
# the machine's own name passes; the same name with a domain on it
|
||||||
|
# does not, because anyone can register one
|
||||||
|
own = socket.gethostname()
|
||||||
|
st, body = fc.get("/status", headers={"Host": own})
|
||||||
|
ev["host_own_name"] = st
|
||||||
|
ctx.log("GET /status Host=%s -> %s", own, st)
|
||||||
|
ctx.check(st == 200, "the machine's own hostname was refused as a Host (%s)", st)
|
||||||
|
st, body = fc.get("/status", headers={"Host": own + ".example.net"})
|
||||||
|
ev["host_own_name_domain"] = st
|
||||||
|
ctx.log("GET /status Host=%s.example.net -> %s", own, st)
|
||||||
|
ctx.check(st == 403, "a domain name built on the machine's name was accepted (%s)", st)
|
||||||
|
st, body = fc.get("/status", headers={"Origin": "http://evil.example.net"})
|
||||||
|
ev["origin_name"] = st
|
||||||
|
ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
|
||||||
|
ctx.check(st == 403, "a DNS-name Origin was accepted (%s)", st)
|
||||||
|
st, body = fc.get("/status", headers={"Sec-Fetch-Site": "cross-site"})
|
||||||
|
ev["sfs_cross"] = st
|
||||||
|
ctx.log("GET /status Sec-Fetch-Site=cross-site -> %s", st)
|
||||||
|
ctx.check(st == 403, "a cross-site fetch was accepted (%s)", st)
|
||||||
|
st, body = fc.get("/status", headers={"Sec-Fetch-Site": "same-origin", "Origin": "http://127.0.0.1"})
|
||||||
|
ctx.check(st == 200, "same-origin literal Origin refused (%s)", st)
|
||||||
|
|
||||||
|
# the fuse view is two-factor: the token AND the physical button held.
|
||||||
|
# Without the token: authentication refused; with the token and nobody
|
||||||
|
# at the button: refused with the button message. The identity itself is
|
||||||
|
# never fetched (it would land in this log).
|
||||||
|
st, body = fc.get("/fuse-identity", auth=False)
|
||||||
|
ev["fuse_noauth"] = st
|
||||||
|
ctx.log("GET /fuse-identity (no token) -> %s %s", st, body if isinstance(body, dict) else "")
|
||||||
|
ctx.check(st == 403 and isinstance(body, dict) and body.get("error") == "authentication required",
|
||||||
|
"GET /fuse-identity without token -> %s %r", st, body)
|
||||||
|
st, body = fc.get("/fuse-identity")
|
||||||
|
ev["fuse_token_no_button"] = st
|
||||||
|
ctx.log("GET /fuse-identity (token, button not held) -> %s %s", st, body if isinstance(body, dict) else "")
|
||||||
|
msg = body.get("error", "") if isinstance(body, dict) else str(body)
|
||||||
|
ctx.check(st == 403 and "button" in msg,
|
||||||
|
"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"})
|
||||||
|
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 "
|
||||||
|
"reports never reach the engine", st, body)
|
||||||
|
|
||||||
|
# ...and a non-loopback peer is refused, even with a token. Over HTTP
|
||||||
|
# the write is sent to HTTPS first (302, the listener's rule); over
|
||||||
|
# HTTPS the route itself refuses the peer (403 loopback only). Neither
|
||||||
|
# request follows the redirect, and the self-signed certificate is
|
||||||
|
# not verified.
|
||||||
|
from .commission import request, decode
|
||||||
|
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"}
|
||||||
|
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}
|
||||||
|
ctx.log("POST http://%s/cool/state -> %s %s", ip, st, loc)
|
||||||
|
ctx.check(st == 302 and loc.startswith("https://"),
|
||||||
|
"a LAN write over HTTP -> %s %r, expected a 302 to HTTPS", st, loc)
|
||||||
|
st, body, hdrs = request("https://%s" % ip, "POST", "/cool/state", data=report, headers=token)
|
||||||
|
b = decode(body)
|
||||||
|
ev["cool_state_from_lan"] = st
|
||||||
|
ctx.log("POST https://%s/cool/state -> %s %s", ip, st, b if isinstance(b, dict) else "")
|
||||||
|
ctx.check(st == 403 and isinstance(b, dict) and b.get("error") == "loopback only",
|
||||||
|
"/cool/state accepted a non-loopback peer (%s %r)", st, b)
|
||||||
|
|
||||||
|
# the login return path: a page asked for from the LAN without a
|
||||||
|
# session is sent to the login carrying the path it asked for (with
|
||||||
|
# its query), so the login can come back to it. Before the setup has
|
||||||
|
# made the account the page is served instead (200).
|
||||||
|
st, body, hdrs = request("https://%s" % ip, "GET", "/setup?step=laser.focus")
|
||||||
|
loc = hdrs.get("location", "")
|
||||||
|
ev["setup_from_lan"] = {"status": st, "location": loc}
|
||||||
|
ctx.log("GET https://%s/setup?step=laser.focus (no session) -> %s %s", ip, st, loc)
|
||||||
|
ctx.check(st in (200, 302), "the setup page from the LAN -> %s", st)
|
||||||
|
if st == 302:
|
||||||
|
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
|
||||||
|
"the login redirect does not carry the path: %r", loc)
|
||||||
|
|
||||||
|
|
||||||
|
@test("forgectrl.settings-bounds", title="Settings validation and restore", subsystem="forgectrl",
|
||||||
|
kind="auto", est_min=1,
|
||||||
|
covers=[("forgectrl", "src/settings.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/cam.c")],
|
||||||
|
description="An over-length value and an out-of-range value are refused (400) and leave the "
|
||||||
|
"settings byte-identical; an in-range value is accepted (200). The lid lamp "
|
||||||
|
"idles at lid_lamp_idle (unset = 236), an out-of-range level is refused, a new "
|
||||||
|
"level applies to the lamp at once, and clearing it returns the default.")
|
||||||
|
def settings_bounds(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
before = fc.settings()
|
||||||
|
ev["keys"] = len(before)
|
||||||
|
|
||||||
|
st, body = fc.post("/settings", data={"gf_serial": "X" * 300})
|
||||||
|
ev["overlong"] = st
|
||||||
|
ctx.log("POST /settings gf_serial=<300 chars> -> %s %s", st, body if isinstance(body, dict) else "")
|
||||||
|
ctx.check(st == 400, "over-length value -> %s, expected 400", st)
|
||||||
|
|
||||||
|
st, body = fc.post("/settings", data={"laser_disarm_s": "99999"})
|
||||||
|
ev["out_of_range"] = st
|
||||||
|
ctx.log("POST /settings laser_disarm_s=99999 -> %s", st)
|
||||||
|
ctx.check(st == 400, "out-of-range value -> %s, expected 400", st)
|
||||||
|
|
||||||
|
# The cloud download guard is bytes, so its range is far wider than the
|
||||||
|
# other numeric keys: check the far end is still a wall.
|
||||||
|
st, body = fc.post("/settings", data={"pulse_reject_threshold_bytes": "2000000000"})
|
||||||
|
ev["pulse_bytes_out_of_range"] = st
|
||||||
|
ctx.log("POST /settings pulse_reject_threshold_bytes=2000000000 -> %s", st)
|
||||||
|
ctx.check(st == 400, "out-of-range byte limit -> %s, expected 400", st)
|
||||||
|
|
||||||
|
st, body = fc.post("/settings", data={"no_such_key_forgetest": "1"})
|
||||||
|
ev["unknown_key"] = st
|
||||||
|
ctx.log("POST /settings no_such_key_forgetest=1 -> %s", st)
|
||||||
|
ctx.check(st in (400, 404), "unknown key -> %s, expected 400", st)
|
||||||
|
|
||||||
|
after = fc.settings()
|
||||||
|
ctx.check(json.dumps(after, sort_keys=True) == json.dumps(before, sort_keys=True),
|
||||||
|
"settings changed after refused writes")
|
||||||
|
ctx.log("settings unchanged after the refused writes")
|
||||||
|
|
||||||
|
# an accepted in-range write: rewrite a present key with its own value
|
||||||
|
key = None
|
||||||
|
for k in ("ui_units", "laser_disarm_s", "cool_flow_rise", "rail_settle_s"):
|
||||||
|
v = before.get(k)
|
||||||
|
if isinstance(v, str) and v != "":
|
||||||
|
key = k
|
||||||
|
break
|
||||||
|
if key is None:
|
||||||
|
key, val = "ui_units", "metric"
|
||||||
|
ctx.log("no settable key is present; writing %s=%s (recorded in evidence)", key, val)
|
||||||
|
else:
|
||||||
|
val = before[key]
|
||||||
|
st, body = fc.post("/settings", data={key: val})
|
||||||
|
ev["accepted"] = {"key": key, "value": val, "status": st}
|
||||||
|
ctx.log("POST /settings %s=%s -> %s", key, val, st)
|
||||||
|
ctx.check(st == 200, "in-range write -> %s, expected 200", st)
|
||||||
|
final = fc.settings()
|
||||||
|
others_before = {k: v for k, v in before.items() if k != key}
|
||||||
|
others_after = {k: v for k, v in final.items() if k != key}
|
||||||
|
ctx.check(others_before == others_after, "other settings changed by the write")
|
||||||
|
ctx.check(final.get(key) == val, "%s reads back %r, wrote %r", key, final.get(key), val)
|
||||||
|
|
||||||
|
# the lid lamp's idle level: resting at the setting, bounded, applied live
|
||||||
|
lamp_was = (before.get("lid_lamp_idle") or "").strip()
|
||||||
|
want = lamp_was or "236"
|
||||||
|
got = ctx.sysfs("pic/lid_led")
|
||||||
|
ev["lid_lamp"] = {"setting": lamp_was, "resting": got}
|
||||||
|
ctx.log("lid lamp: setting %r, pic/lid_led=%s (expected %s)", lamp_was, got, want)
|
||||||
|
ctx.check(got == want, "lid lamp rests at %s, lid_lamp_idle is %s", got, want)
|
||||||
|
for bad in ("256", "-1", "bright"):
|
||||||
|
st, body = fc.post("/settings", data={"lid_lamp_idle": bad})
|
||||||
|
ctx.check(st == 400, "lid_lamp_idle=%s -> %s, expected 400", bad, st)
|
||||||
|
ctx.log("lid_lamp_idle 256 / -1 / bright refused")
|
||||||
|
try_level = "100" if want != "100" else "120"
|
||||||
|
st, body = fc.post("/settings", data={"lid_lamp_idle": try_level})
|
||||||
|
ctx.check(st == 200, "lid_lamp_idle=%s -> %s, expected 200", try_level, st)
|
||||||
|
applied = None
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < 5:
|
||||||
|
applied = ctx.sysfs("pic/lid_led")
|
||||||
|
if applied == try_level:
|
||||||
|
break
|
||||||
|
ctx.sleep(0.2)
|
||||||
|
ctx.log("lid_lamp_idle=%s -> pic/lid_led=%s after %.1f s", try_level, applied, time.time() - t0)
|
||||||
|
# an empty value clears the key: the query-string form carries it
|
||||||
|
st, body = (fc.post("/settings", params={"lid_lamp_idle": ""}) if not lamp_was
|
||||||
|
else fc.post("/settings", data={"lid_lamp_idle": lamp_was}))
|
||||||
|
ctx.check(st == 200, "restoring lid_lamp_idle=%r -> %s", lamp_was, st)
|
||||||
|
t0 = time.time()
|
||||||
|
back = None
|
||||||
|
while time.time() - t0 < 5:
|
||||||
|
back = ctx.sysfs("pic/lid_led")
|
||||||
|
if back == want:
|
||||||
|
break
|
||||||
|
ctx.sleep(0.2)
|
||||||
|
ev["lid_lamp"].update({"applied": applied, "restored": back})
|
||||||
|
ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied)
|
||||||
|
ctx.check(back == want, "lamp did not return to %s after the restore (reads %s)", want, back)
|
||||||
|
ctx.log("lid lamp follows the setting live and returns to %s", want)
|
||||||
|
|
||||||
|
|
||||||
|
@test("forgectrl.panel-serves", title="Control panel and status endpoints", subsystem="forgectrl",
|
||||||
|
kind="auto", est_min=1,
|
||||||
|
covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"),
|
||||||
|
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
|
||||||
|
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
|
||||||
|
("forgectrl", "src/curverec.*")],
|
||||||
|
description="The panel page is served, /status carries the machine telemetry the panel and "
|
||||||
|
"the acceptance tool read (including the sys block: CPU busy percent over the "
|
||||||
|
"interval since the previous read, memory used percent; and homed_axes, "
|
||||||
|
"the axes that carry a reference, Z alone once the lens has taken its "
|
||||||
|
"own at the controller's start), and /cam/status "
|
||||||
|
"answers. In GRBL mode with a live controller, /status also echoes the "
|
||||||
|
"controller's published state file as the grbl block (fresh age, machine "
|
||||||
|
"state, sender session, laser window and dose model, modal report), "
|
||||||
|
"GET /grbl/settings serves the published $$ view, and the controller's "
|
||||||
|
"settings store is /data/forgefirm/EEPROM-glowforge.DAT with nothing of "
|
||||||
|
"it at the top of /data.")
|
||||||
|
def panel_serves(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
st, body = fc.get("/", raw=True)
|
||||||
|
ev["panel_status"] = st
|
||||||
|
ctx.log("GET / -> %s (%d bytes)", st, len(body) if body else 0)
|
||||||
|
ctx.check(st == 200, "GET / -> %s", st)
|
||||||
|
text = body.decode("utf-8", "replace")
|
||||||
|
ctx.check("<html" in text.lower() and "ForgeFIRM" in text, "the panel does not look like the panel")
|
||||||
|
ctx.check(fc.token and fc.token in text, "the panel does not embed the bearer token")
|
||||||
|
ctx.check("<link " not in text and "<script src=" not in text,
|
||||||
|
"the panel references an external asset (the build did not bundle src/ui/)")
|
||||||
|
# The daemon stores the page gzipped and inflates it once at first
|
||||||
|
# request; what it serves is the plain page with the theme attribute
|
||||||
|
# the head script sets and the one save bar every settings tab shares.
|
||||||
|
ctx.check("data-bs-theme" in text, "the panel lacks the theme attribute (inflate failed?)")
|
||||||
|
ctx.check('id="savebar"' in text, "the panel lacks the save bar")
|
||||||
|
|
||||||
|
s = fc.status()
|
||||||
|
for key in ("state", "switches", "coolant", "fans"):
|
||||||
|
ctx.check(key in s, "/status lacks %r", key)
|
||||||
|
ev["state"] = s.get("state")
|
||||||
|
ev["switches"] = s.get("switches")
|
||||||
|
ctx.log("/status state=%s switches=%s", s.get("state"), s.get("switches"))
|
||||||
|
for key in ("lid", "button", "interlock_ok", "head", "hv_enable"):
|
||||||
|
ctx.check(key in (s.get("switches") or {}), "/status switches lacks %r", key)
|
||||||
|
|
||||||
|
# SoC utilization rides /status next to the temperatures. The CPU
|
||||||
|
# number is a delta over the interval since the previous read, so
|
||||||
|
# the read above primes it; after a beat both percents must be
|
||||||
|
# numbers in range.
|
||||||
|
ctx.sleep(1)
|
||||||
|
sys_ = ctx.forgectrl.status().get("sys") or {}
|
||||||
|
ev["sys"] = sys_
|
||||||
|
ctx.log("/status sys=%s", sys_)
|
||||||
|
ctx.check(isinstance(sys_.get("cpu_pct"), (int, float)) and 0.0 <= sys_["cpu_pct"] <= 100.0,
|
||||||
|
"/status sys.cpu_pct is not a percent: %s", sys_)
|
||||||
|
ctx.check(isinstance(sys_.get("mem_pct"), (int, float)) and 0.0 < sys_["mem_pct"] < 100.0,
|
||||||
|
"/status sys.mem_pct is not a percent: %s", sys_)
|
||||||
|
|
||||||
|
# The lens takes its own reference on the hall edge at every
|
||||||
|
# controller start, so with a controller running Z is referenced on
|
||||||
|
# its own while X and Y wait for a home: homed_axes names the axes
|
||||||
|
# that carry one, and Z reads inside the lens reach rather than the
|
||||||
|
# zero an unreferenced axis would show.
|
||||||
|
axes = s.get("homed_axes")
|
||||||
|
ev["homed_axes"] = axes
|
||||||
|
ctx.check(isinstance(axes, int) and 0 <= axes <= 7,
|
||||||
|
"/status homed_axes is not an axis mask: %s", axes)
|
||||||
|
ctx.check(bool(s.get("homed")) == (axes == 7),
|
||||||
|
"/status homed (%s) disagrees with homed_axes (%s)",
|
||||||
|
s.get("homed"), axes)
|
||||||
|
st, mode0 = fc.get("/mode")
|
||||||
|
if isinstance(mode0, dict) and mode0.get("controller") == "running":
|
||||||
|
lens = s.get("lens") or {}
|
||||||
|
pos = s.get("pos") or {}
|
||||||
|
ctx.log("/status homed_axes=%s pos.z=%s reach=%s..%s", axes,
|
||||||
|
pos.get("z"), lens.get("reach_min"), lens.get("reach_max"))
|
||||||
|
ctx.check(axes is not None and axes & 4,
|
||||||
|
"Z is not referenced with a controller running: %s", axes)
|
||||||
|
ctx.check(isinstance(pos.get("z"), (int, float))
|
||||||
|
and lens.get("reach_min") is not None
|
||||||
|
and lens["reach_min"] <= pos["z"] <= lens["reach_max"],
|
||||||
|
"Z %s is outside the lens reach %s..%s", pos.get("z"),
|
||||||
|
lens.get("reach_min"), lens.get("reach_max"))
|
||||||
|
|
||||||
|
st, cam = fc.get("/cam/status")
|
||||||
|
ev["cam_status"] = st
|
||||||
|
ctx.log("GET /cam/status -> %s %s", st, cam)
|
||||||
|
ctx.check(st == 200 and isinstance(cam, dict) and "running" in cam, "GET /cam/status -> %s", st)
|
||||||
|
|
||||||
|
# The controller's published state, echoed only while a live GRBL
|
||||||
|
# controller runs (glowforge_status.c -> /run/forgefirm -> /status).
|
||||||
|
st, mode = fc.get("/mode")
|
||||||
|
if isinstance(mode, dict) and mode.get("mode") == "grbl" and mode.get("controller") == "running":
|
||||||
|
g = ctx.forgectrl.status().get("grbl") or {}
|
||||||
|
ev["grbl"] = g
|
||||||
|
ctx.log("/status grbl=%s", g)
|
||||||
|
ctx.check(isinstance(g.get("age_s"), (int, float)) and g["age_s"] < 30,
|
||||||
|
"/status grbl block missing or stale: %s", g)
|
||||||
|
rep = g.get("report") or {}
|
||||||
|
for key in ("state", "sender", "laser", "modals"):
|
||||||
|
ctx.check(key in rep, "/status grbl.report lacks %r", key)
|
||||||
|
ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"),
|
||||||
|
"grbl.report.laser carries no model: %s", rep.get("laser"))
|
||||||
|
ctx.check((rep.get("laser") or {}).get("curve"),
|
||||||
|
"grbl.report.laser carries no dose curve: %s", rep.get("laser"))
|
||||||
|
st, text = fc.get("/grbl/settings", raw=True)
|
||||||
|
ev["grbl_settings_status"] = st
|
||||||
|
ctx.check(st == 200 and b"$35=" in (text or b""),
|
||||||
|
"GET /grbl/settings -> %s without the $$ view", st)
|
||||||
|
# The $-settings persist in the data directory, never loose in /data.
|
||||||
|
nvs = "/data/forgefirm/EEPROM-glowforge.DAT"
|
||||||
|
ev["grbl_nvs"] = os.path.isfile(nvs)
|
||||||
|
ctx.check(os.path.isfile(nvs), "the controller's settings store is not at %s", nvs)
|
||||||
|
ctx.check(not os.path.exists("/data/EEPROM-glowforge.DAT"),
|
||||||
|
"a settings store remains at the top of /data")
|
||||||
|
else:
|
||||||
|
ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode)
|
||||||
|
|
||||||
|
# The dose-curve recorder's read surface answers in any mode.
|
||||||
|
st, cs = fc.get("/curve/status")
|
||||||
|
ev["curve_status"] = cs
|
||||||
|
ctx.check(st == 200 and isinstance(cs, dict) and cs.get("state") in
|
||||||
|
("idle", "waiting", "recording", "done", "failed"),
|
||||||
|
"GET /curve/status -> %s %s", st, cs)
|
||||||
|
st, text = fc.get("/curve/ladder.gcode", raw=True)
|
||||||
|
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
|
||||||
|
"GET /curve/ladder.gcode -> %s without the ladder", st)
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""image.* - the post-flash health of the running image (always-required)."""
|
||||||
|
import glob
|
||||||
|
import gzip
|
||||||
|
import mmap
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .. import hw
|
||||||
|
|
||||||
|
|
||||||
|
WDOG1_BASE = 0x020BC000 # i.MX6 WDOG1; WCR is the 16-bit word at offset 0
|
||||||
|
|
||||||
|
|
||||||
|
def _wdog1_wcr():
|
||||||
|
"""WDOG1's control register, read through /dev/mem (MMIO stays readable
|
||||||
|
under STRICT_DEVMEM), or None."""
|
||||||
|
try:
|
||||||
|
with open("/dev/mem", "r+b") as f:
|
||||||
|
m = mmap.mmap(f.fileno(), 4096, offset=WDOG1_BASE)
|
||||||
|
try:
|
||||||
|
return struct.unpack_from("<H", m, 0)[0]
|
||||||
|
finally:
|
||||||
|
m.close()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _plain(line):
|
||||||
|
"""A line without its ANSI color sequences: what a terminal shows,
|
||||||
|
counted in columns rather than in bytes."""
|
||||||
|
return re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path, default=None):
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
return f.read()
|
||||||
|
except OSError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def kernel_config():
|
||||||
|
"""The running kernel's config as a dict, from /proc/config.gz or
|
||||||
|
/boot/config-<release>. None when neither is available."""
|
||||||
|
try:
|
||||||
|
with gzip.open("/proc/config.gz", "rb") as f:
|
||||||
|
text = f.read().decode("utf-8", "replace")
|
||||||
|
except OSError:
|
||||||
|
text = None
|
||||||
|
if text is None:
|
||||||
|
rel = _read("/proc/sys/kernel/osrelease", "").strip()
|
||||||
|
text = _read("/boot/config-%s" % rel) if rel else None
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
cfg = {}
|
||||||
|
for line in text.splitlines():
|
||||||
|
m = re.match(r"^(CONFIG_[A-Z0-9_]+)=(.*)$", line)
|
||||||
|
if m:
|
||||||
|
cfg[m.group(1)] = m.group(2)
|
||||||
|
m = re.match(r"^# (CONFIG_[A-Z0-9_]+) is not set$", line)
|
||||||
|
if m:
|
||||||
|
cfg[m.group(1)] = "n"
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def kernel_ident(name):
|
||||||
|
"""A kernel release or modules-directory name less its LOCALVERSION_AUTO
|
||||||
|
hash (+g<hash> or -g<hash>). The hash does not reproduce across a
|
||||||
|
re-patch of the same source; the manifest lists modules directories
|
||||||
|
without it, and the kernel's identity is its @srcrev and @config."""
|
||||||
|
return re.sub(r"[+-]g[0-9a-f]{7,}$", "", name)
|
||||||
|
|
||||||
|
|
||||||
|
def kernel_matches(release, modules_dirs):
|
||||||
|
"""True when the running release names one of the manifest's modules
|
||||||
|
directories, hashes aside (a manifest without them constrains nothing)."""
|
||||||
|
return not modules_dirs or kernel_ident(release) in {kernel_ident(m) for m in modules_dirs}
|
||||||
|
|
||||||
|
|
||||||
|
def _dt_u32(path):
|
||||||
|
"""A device-tree cell as an int (big-endian), or None."""
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
raw = f.read(4)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return int.from_bytes(raw, "big") if len(raw) == 4 else None
|
||||||
|
|
||||||
|
|
||||||
|
def fds_of(pid):
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
for fd in os.listdir("/proc/%d/fd" % pid):
|
||||||
|
try:
|
||||||
|
out.append(os.readlink("/proc/%d/fd/%s" % (pid, fd)))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@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"),
|
||||||
|
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
|
||||||
|
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
|
||||||
|
("linux-fslc", "**")],
|
||||||
|
description="The image that is running is the image the manifest describes, with the "
|
||||||
|
"version stamped in /etc/forgefirm-version and under the machine mark in the "
|
||||||
|
"console banner and the motd, "
|
||||||
|
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
|
||||||
|
"the daemon ownership, "
|
||||||
|
"the init ordering, the file modes the release depends on, and the mounts: the "
|
||||||
|
"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):
|
||||||
|
ev = ctx.evidence
|
||||||
|
manifest = ctx.runner.manifest
|
||||||
|
|
||||||
|
# 1. version stamp: the machine-readable file, and the two a person
|
||||||
|
# reads - the console banner (/etc/issue, which forgefirm-banner
|
||||||
|
# renders from the image's own text) and the motd a login prints.
|
||||||
|
# Each carries the machine mark with the version on the mark's own
|
||||||
|
# last line, right-justified to the mark's last column.
|
||||||
|
ver = (_read("/etc/forgefirm-version", "") or "").strip()
|
||||||
|
ev["forgefirm_version"] = ver
|
||||||
|
ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version)
|
||||||
|
ctx.check(ver == manifest.version, "/etc/forgefirm-version %r != manifest %r", ver, manifest.version)
|
||||||
|
for path in ("/etc/issue", "/etc/motd"):
|
||||||
|
text = _read(path, "") or ""
|
||||||
|
ev[path] = text
|
||||||
|
lines = text.splitlines()
|
||||||
|
at = [i for i, line in enumerate(lines) if line.rstrip().endswith(ver)]
|
||||||
|
ctx.check(at, "%s carries no line ending in the version %r", path, ver)
|
||||||
|
if not at:
|
||||||
|
continue
|
||||||
|
i = at[0]
|
||||||
|
ctx.check(lines[i].rstrip() == lines[i], "%s pads the version line: %r", path, lines[i])
|
||||||
|
ctx.check(_plain(lines[i]).strip() != ver,
|
||||||
|
"%s puts the version on a line of its own, not on the mark's last line", path)
|
||||||
|
# Right-justified to the mark's last column: the stamped line is
|
||||||
|
# exactly as wide as the widest line above it. Only the motd is
|
||||||
|
# measured, because /etc/issue holds the mark with its
|
||||||
|
# backslashes doubled for the getty that reads it, so its bytes
|
||||||
|
# are wider than the columns a person sees.
|
||||||
|
if path == "/etc/motd":
|
||||||
|
above = [len(_plain(line)) for line in lines[:i] if line.strip()]
|
||||||
|
width = max(above) if above else 0
|
||||||
|
ev["motd_columns"] = [len(_plain(lines[i])), width]
|
||||||
|
ctx.check(len(_plain(lines[i])) == width,
|
||||||
|
"/etc/motd stamped line is %d columns, the mark above is %d",
|
||||||
|
len(_plain(lines[i])), width)
|
||||||
|
|
||||||
|
# 2. kernel options
|
||||||
|
cfg = kernel_config()
|
||||||
|
ctx.check(cfg is not None, "kernel config unavailable (/proc/config.gz, /boot/config-*)")
|
||||||
|
for opt, want in (("CONFIG_PREEMPT", ("y",)), ("CONFIG_IMX2_WDT", ("y", "m")),
|
||||||
|
("CONFIG_PANIC_ON_OOPS", ("y",))):
|
||||||
|
val = cfg.get(opt, "n")
|
||||||
|
ev[opt] = val
|
||||||
|
ctx.log("%s=%s", opt, val)
|
||||||
|
ctx.check(val in want, "%s=%s, expected one of %s", opt, val, want)
|
||||||
|
# The boot-armed hardware watchdog: U-Boot arms WDOG1 at 60 s and the
|
||||||
|
# kernel's core keeps it fed (no userspace opens it, so the sysfs state
|
||||||
|
# reads "inactive": that attribute says whether a process holds the
|
||||||
|
# device, not whether the hardware runs). The hardware's own word is
|
||||||
|
# WCR: WDE set, and WT giving the period. The sysfs view needs
|
||||||
|
# CONFIG_WATCHDOG_SYSFS; bootstatus carries the last reset's cause (32 =
|
||||||
|
# the watchdog's timeout).
|
||||||
|
wd = {k: (_read("/sys/class/watchdog/watchdog0/" + k) or "").strip()
|
||||||
|
for k in ("identity", "state", "timeout", "bootstatus")}
|
||||||
|
wcr = _wdog1_wcr()
|
||||||
|
wd["wcr"] = ("%#06x" % wcr) if wcr is not None else None
|
||||||
|
wd["wde"] = bool(wcr & 0x4) if wcr is not None else None
|
||||||
|
wd["period_s"] = ((wcr >> 8) + 1) / 2.0 if wcr is not None else None
|
||||||
|
ev["watchdog"] = wd
|
||||||
|
ctx.log("watchdog0: %s", wd)
|
||||||
|
ctx.check(wd["identity"], "watchdog0 has no sysfs view (CONFIG_WATCHDOG_SYSFS)")
|
||||||
|
ctx.check(wd["timeout"] == "60", "watchdog0 timeout %r, expected 60 s", wd["timeout"] or None)
|
||||||
|
ctx.check(wcr is not None, "WDOG1 WCR unreadable through /dev/mem")
|
||||||
|
ctx.check(wd["wde"], "WDOG1 is not enabled (WCR %s): nothing resets a hung kernel", wd["wcr"])
|
||||||
|
ctx.check(wd["period_s"] == 60.0, "WDOG1 period %s s (WCR %s), expected 60", wd["period_s"], wd["wcr"])
|
||||||
|
ctx.check(wd["state"] == "inactive", "watchdog0 is %r: a process holds the device, which the image "
|
||||||
|
"does not do", wd["state"] or None)
|
||||||
|
rel = _read("/proc/sys/kernel/osrelease", "").strip()
|
||||||
|
ev["kernel_release"] = rel
|
||||||
|
mods = manifest.platform.get("kernel_modules") or []
|
||||||
|
ctx.log("kernel release %s (manifest modules dirs: %s)", rel, ",".join(mods))
|
||||||
|
ctx.check(kernel_matches(rel, mods), "running kernel %r is not the manifest's %s", rel, mods)
|
||||||
|
|
||||||
|
# 3. the module and its sysfs
|
||||||
|
ctx.check(os.path.isdir("/sys/module/glowforge"), "glowforge.ko is not loaded")
|
||||||
|
state = hw.sysfs_read("cnc/state")
|
||||||
|
ev["cnc_state"] = state
|
||||||
|
ctx.check(state is not None, "/sys/glowforge/cnc/state unreadable")
|
||||||
|
ctx.log("cnc/state: %s", state)
|
||||||
|
free = hw.sysfs_int("cnc/free")
|
||||||
|
ev["cnc_free"] = free
|
||||||
|
ctx.check(free is not None and free > 0, "cnc/free unreadable or zero (ring not mapped?)")
|
||||||
|
ctx.log("cnc/free: %s bytes", free)
|
||||||
|
|
||||||
|
# The ring the platform ships: the DT pool is the promise, ring_mb is what
|
||||||
|
# the module took. They must agree - a pool the parameter does not use is
|
||||||
|
# no-map RAM burned for nothing, and a parameter the pool cannot back would
|
||||||
|
# have failed the probe. free can only ever be size less the 32 KiB gap.
|
||||||
|
pool = _dt_u32("/proc/device-tree/reserved-memory/cnc-pulsebuf/size")
|
||||||
|
try:
|
||||||
|
ring = int((_read("/sys/module/glowforge/parameters/ring_mb", "") or "").strip()) << 20
|
||||||
|
except ValueError:
|
||||||
|
ring = None
|
||||||
|
ev["cnc_pool_bytes"] = pool
|
||||||
|
ev["cnc_ring_bytes"] = ring
|
||||||
|
ctx.log("cnc-pulsebuf pool: %s bytes, ring_mb: %s bytes", pool, ring)
|
||||||
|
ctx.check(pool is not None, "no cnc-pulsebuf reserved-memory node (the ring fell back to CMA?)")
|
||||||
|
ctx.check(ring is not None, "/sys/module/glowforge/parameters/ring_mb unreadable")
|
||||||
|
ctx.check(pool is None or ring is None or pool == ring,
|
||||||
|
"DT pool %s and ring_mb %s disagree", pool, ring)
|
||||||
|
ctx.check(ring is None or free is None or free <= ring - 32 * 1024,
|
||||||
|
"cnc/free %s exceeds the ring less its 32 KiB gap (%s)", free, ring)
|
||||||
|
ctx.check(hw.sysfs_read("cnc/interlock_circuit") is not None, "cnc/interlock_circuit unreadable")
|
||||||
|
|
||||||
|
# The SDMA engine runs only while a channel holder keeps its ipg/ahb
|
||||||
|
# clocks enabled; imx-sdma leaves them off after probe and only a channel
|
||||||
|
# allocation turns them on. glowforge.ko takes its channel outside
|
||||||
|
# dmaengine and holds the clocks itself. With the block gated every
|
||||||
|
# channel-0 transfer is a silent no-op: the probe cannot start, and the
|
||||||
|
# ring reads back a stale bounce page (free above the ring size, a
|
||||||
|
# position counter that never moved).
|
||||||
|
clk = (_read("/sys/kernel/debug/clk/sdma/clk_enable_count", "") or "").strip()
|
||||||
|
ev["sdma_clk_enable_count"] = clk
|
||||||
|
ctx.log("sdma clk_enable_count: %s", clk or "(unreadable)")
|
||||||
|
ctx.check(clk.isdigit() and int(clk) >= 1,
|
||||||
|
"SDMA clk_enable_count %r: the engine's ipg/ahb clocks are not held", clk)
|
||||||
|
|
||||||
|
# 4. forgectrl holds /dev/glowforge and supervises the controller
|
||||||
|
pids = hw.pidof("forgectrl")
|
||||||
|
ev["forgectrl_pids"] = pids
|
||||||
|
ctx.check(pids, "forgectrl is not running")
|
||||||
|
holders = [p for p in pids if any(l == "/dev/glowforge" for l in fds_of(p))]
|
||||||
|
ev["pulse_device_holders"] = holders
|
||||||
|
ctx.log("forgectrl pids %s, holding /dev/glowforge: %s", pids, holders)
|
||||||
|
ctx.check(holders, "no forgectrl process holds /dev/glowforge")
|
||||||
|
st, mode = ctx.forgectrl.get("/mode")
|
||||||
|
ev["mode"] = mode
|
||||||
|
ctx.check(st == 200 and isinstance(mode, dict), "GET /mode -> %s", st)
|
||||||
|
ctx.log("mode: %s", mode)
|
||||||
|
ctx.check(mode.get("controller") in ("running", "standby"),
|
||||||
|
"controller is %r (expected running or standby)", mode.get("controller"))
|
||||||
|
ctx.check(mode.get("motion") != "fault", "supervisor reports a motion fault")
|
||||||
|
|
||||||
|
# 5. init ordering: controllers stop before the daemon
|
||||||
|
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*"))
|
||||||
|
ev["rc6_kill"] = k
|
||||||
|
kg = [x for x in k if "grblhal" in x]
|
||||||
|
kf = [x for x in k if x.endswith("forgectrl")]
|
||||||
|
ctx.log("rc6.d: %s", " ".join(k))
|
||||||
|
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links")
|
||||||
|
ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[0])
|
||||||
|
|
||||||
|
# 6. logging lever present, no userspace watchdog daemon
|
||||||
|
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
|
||||||
|
ev["logging_init"] = logging
|
||||||
|
ctx.check(logging, "no ForgeFIRM logging/logrotate init script")
|
||||||
|
ctx.check(not os.path.exists("/etc/init.d/watchdog"), "a userspace watchdog init script is present")
|
||||||
|
# kernel threads (the imx2_wdt kthread is expected) have an empty cmdline
|
||||||
|
user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")]
|
||||||
|
ev["userspace_watchdog_pids"] = user_wd
|
||||||
|
ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", user_wd)
|
||||||
|
|
||||||
|
# 7. file modes and space
|
||||||
|
for path in ("/data/forgefirm/panel.token", "/data/forgefirm/forgefirm.conf"):
|
||||||
|
if os.path.exists(path):
|
||||||
|
m = stat.S_IMODE(os.stat(path).st_mode)
|
||||||
|
ev[path] = "%o" % m
|
||||||
|
ctx.log("%s mode %o", path, m)
|
||||||
|
ctx.check(m == 0o600, "%s mode %o, expected 600", path, m)
|
||||||
|
if os.path.isdir("/data"):
|
||||||
|
s = os.statvfs("/data")
|
||||||
|
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
|
||||||
|
ev["data_free_mb"] = free_mb
|
||||||
|
ctx.log("/data free: %d MiB", free_mb)
|
||||||
|
ctx.check(free_mb >= 20, "/data has only %d MiB free", free_mb)
|
||||||
|
|
||||||
|
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
||||||
|
# the state a read-only rootfs hands off (the read-only-rootfs image
|
||||||
|
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
|
||||||
|
# sshd host keys). The
|
||||||
|
# dev image alone mounts the factory slots under /factory.
|
||||||
|
mounts = {}
|
||||||
|
for line in (_read("/proc/mounts", "") or "").splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 4:
|
||||||
|
mounts[parts[1]] = {"source": parts[0], "type": parts[2], "opts": parts[3].split(",")}
|
||||||
|
|
||||||
|
def mount_opts(path):
|
||||||
|
return (mounts.get(path) or {}).get("opts") or []
|
||||||
|
|
||||||
|
ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd",
|
||||||
|
"/etc/hostname", "/etc/issue") if p in mounts}
|
||||||
|
ctx.log("/ mounted %s; /data %s; /var/lib %s", ",".join(mount_opts("/")) or "(absent)",
|
||||||
|
",".join(mount_opts("/data")) or "(absent)", ",".join(mount_opts("/var/lib")) or "(absent)")
|
||||||
|
ctx.check("ro" in mount_opts("/"), "the rootfs is not mounted read-only: %s", mounts.get("/"))
|
||||||
|
ctx.check("rw" in mount_opts("/data"), "/data is not mounted read-write: %s", mounts.get("/data"))
|
||||||
|
ctx.check("rw" in mount_opts("/var/lib"),
|
||||||
|
"/var/lib is not a writable copy (read-only-rootfs-hook.sh): %s", mounts.get("/var/lib"))
|
||||||
|
rcs = _read("/etc/default/rcS", "") or ""
|
||||||
|
ctx.check("ROOTFS_READ_ONLY=yes" in rcs.splitlines(), "/etc/default/rcS lacks ROOTFS_READ_ONLY=yes")
|
||||||
|
factory = sorted(p for p in mounts if p.startswith("/factory/"))
|
||||||
|
ev["factory_mounts"] = factory
|
||||||
|
dev_image = os.path.exists("/etc/forgefirm-dev")
|
||||||
|
ev["dev_image"] = dev_image
|
||||||
|
ctx.log("dev image %s; /factory mounts: %s", dev_image, factory or "none")
|
||||||
|
if dev_image:
|
||||||
|
for n in (1, 2):
|
||||||
|
if os.path.exists("/dev/mmcblk2p%d" % n):
|
||||||
|
p = "/factory/img%d" % n
|
||||||
|
ctx.check("ro" in mount_opts(p), "%s is not mounted read-only on the dev image: %s", p, mounts.get(p))
|
||||||
|
else:
|
||||||
|
ctx.check(not factory, "a release image mounts the factory slots: %s", factory)
|
||||||
|
if os.path.isfile("/data/forgefirm/users"):
|
||||||
|
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
|
||||||
|
if l.strip() and not l.startswith("#")]
|
||||||
|
passwd_names = {l.split(":")[0] for l in (_read("/etc/passwd", "") or "").splitlines()}
|
||||||
|
ev["record_accounts"] = names
|
||||||
|
for f in ("/etc/passwd", "/etc/shadow", "/etc/group"):
|
||||||
|
ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f)
|
||||||
|
for n in names:
|
||||||
|
ctx.check(n in passwd_names, "record account %r is missing from /etc/passwd", n)
|
||||||
|
ctx.check("/etc/hostname" in mounts, "/etc/hostname is not the bind-mounted name copy")
|
||||||
|
ctx.check("/etc/issue" in mounts, "/etc/issue is not the bind-mounted banner copy")
|
||||||
|
if hw.pidof("sshd"):
|
||||||
|
key = "/data/forgefirm/ssh/ssh_host_ed25519_key"
|
||||||
|
ctx.check(os.path.isfile(key), "sshd runs but %s is missing", key)
|
||||||
|
|
||||||
|
# 9. the manifest itself is coherent
|
||||||
|
ctx.check(manifest.content_sha and len(manifest.content_sha) == 64, "manifest content_sha256 missing")
|
||||||
|
ctx.check("kernel-module-glowforge" in manifest.components, "manifest lacks kernel-module-glowforge")
|
||||||
|
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
|
||||||
|
ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components))
|
||||||
|
|
||||||
|
|
||||||
|
@test("image.license-bundle", title="The license texts travel with the image",
|
||||||
|
subsystem="image", kind="auto", est_min=1,
|
||||||
|
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
|
||||||
|
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/login.html")],
|
||||||
|
requires=["forgectrl.panel-serves"],
|
||||||
|
description="The image carries /usr/share/forgefirm/licenses.tar.gz, a gzip tar with "
|
||||||
|
"the license manifest (every installed package with its license) and the "
|
||||||
|
"license texts, and no loose common-licenses tree; the panel serves the "
|
||||||
|
"bundle, the manifest as text, and the Licenses page every footer links, "
|
||||||
|
"each without a login.")
|
||||||
|
def license_bundle(ctx):
|
||||||
|
import io
|
||||||
|
import tarfile
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
path = "/usr/share/forgefirm/licenses.tar.gz"
|
||||||
|
ctx.check(os.path.isfile(path), "%s is missing", path)
|
||||||
|
ctx.check(not os.path.isdir("/usr/share/common-licenses"),
|
||||||
|
"the loose common-licenses tree is still on the rootfs")
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
ev["bundle_bytes"] = size
|
||||||
|
ctx.log("%s: %d bytes", path, size)
|
||||||
|
with tarfile.open(path, "r:gz") as t:
|
||||||
|
names = t.getnames()
|
||||||
|
ctx.check("common-licenses/license.manifest" in names, "no license.manifest in the bundle")
|
||||||
|
manifest = t.extractfile("common-licenses/license.manifest").read().decode("utf-8", "replace")
|
||||||
|
packages = manifest.count("PACKAGE NAME: ")
|
||||||
|
texts = sum(1 for n in names if "/generic_" in n and not n.endswith("/"))
|
||||||
|
ev["packages"] = packages
|
||||||
|
ev["generic_texts"] = texts
|
||||||
|
ctx.log("manifest lists %d packages; %d generic license entries", packages, texts)
|
||||||
|
ctx.check(packages >= 40, "only %d packages in the manifest", packages)
|
||||||
|
ctx.check(texts >= 5, "only %d generic license entries", texts)
|
||||||
|
for name in ("forgectrl", "grblhal-glowforge", "kernel-module-glowforge"):
|
||||||
|
ctx.check("RECIPE NAME: %s\n" % name in manifest, "%s is not in the manifest", name)
|
||||||
|
|
||||||
|
st, body = fc.get("/system/licenses/manifest", raw=True)
|
||||||
|
ctx.check(st == 200, "GET /system/licenses/manifest -> %s", st)
|
||||||
|
ctx.check(isinstance(body, (bytes, bytearray)) and b"PACKAGE NAME: " in body,
|
||||||
|
"the served manifest is not the manifest")
|
||||||
|
st, body = fc.get("/system/licenses", raw=True)
|
||||||
|
ctx.check(st == 200, "GET /system/licenses -> %s", st)
|
||||||
|
ctx.check(isinstance(body, (bytes, bytearray)) and len(body) == size,
|
||||||
|
"the served bundle is %s bytes, the file %d", len(body) if body else None, size)
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(bytes(body)), mode="r:gz") as t:
|
||||||
|
ctx.check("common-licenses/license.manifest" in t.getnames(),
|
||||||
|
"the served bundle holds no manifest")
|
||||||
|
st, body = fc.get("/licenses", raw=True)
|
||||||
|
ctx.check(st == 200, "GET /licenses -> %s", st)
|
||||||
|
text = bytes(body).decode("utf-8", "replace") if body else ""
|
||||||
|
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
|
||||||
|
"the Licenses page lacks the download link or the manifest")
|
||||||
@@ -0,0 +1,919 @@
|
|||||||
|
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
|
||||||
|
|
||||||
|
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
|
||||||
|
(K1/K2/K3) and `scripts/bench/fire_test.py` (A/B/U) with their proven
|
||||||
|
sequences kept intact; they run under a hardware takeover (forgectrl and
|
||||||
|
the controller stopped, the pulse device free) and judge the software
|
||||||
|
witnesses: cnc/state, laser_enable (the FIRE line), laser_on and
|
||||||
|
laser_on_sampled (the gated LASER_ON output), interlock_circuit bit 3
|
||||||
|
(the commanded latch), faults and underruns. Every drill forces duty to
|
||||||
|
zero before any FIRE bit, keeps motor_lock=15 (no axis moves), and
|
||||||
|
re-locks the latch on every exit path. K3 and fire B/U unlock the latch
|
||||||
|
for their run and therefore refuse to proceed unless the safety chain
|
||||||
|
holds HV off: the charge-pump watchdog dead and the pulse engine idle
|
||||||
|
(HV_ENABLE follows the pump; laser_pgood is the supply's power-good and
|
||||||
|
says nothing about HV).
|
||||||
|
"""
|
||||||
|
import errno
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
except ImportError: # host unit tests import the suite off-target
|
||||||
|
fcntl = None
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .. import hw
|
||||||
|
from ..runner import Failed
|
||||||
|
|
||||||
|
# interlock_circuit bits (docs.forgefirm.org, the kernel module page): bit 3 = the driven latch line, set = locked.
|
||||||
|
LATCH_BIT = 1 << 3
|
||||||
|
|
||||||
|
TICK_HZ = 10000
|
||||||
|
FIRE = b"\x10"
|
||||||
|
PAD = b"\x00"
|
||||||
|
XSTEP = b"\x01" # +X (DIR clear)
|
||||||
|
XSTEP_BACK = b"\x03" # -X (DIR set)
|
||||||
|
POWER0 = bytes([0x80]) # power byte, duty 0
|
||||||
|
|
||||||
|
_KERNEL_COVERS = [("kernel-module-glowforge", "**"), ("linux-fslc", "**")]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
def wr(attr, val):
|
||||||
|
hw.sysfs_write(attr, val)
|
||||||
|
|
||||||
|
|
||||||
|
def rd(attr):
|
||||||
|
v = hw.sysfs_read(attr)
|
||||||
|
if v is None:
|
||||||
|
raise Failed("cannot read %s" % attr)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def rd_pos():
|
||||||
|
with open(hw.sysfs_root() + "cnc/position", "rb") as f:
|
||||||
|
raw = f.read(32)
|
||||||
|
return struct.unpack("<5i", raw[:20])
|
||||||
|
|
||||||
|
|
||||||
|
def snap(ctx, tag):
|
||||||
|
line = ("%s: state=%s laser_enable=%s laser_on=%s laser_on_sampled=%s interlock=%s"
|
||||||
|
% (tag, rd("cnc/state"), rd("cnc/laser_enable"), rd("cnc/laser_on"),
|
||||||
|
rd("cnc/laser_on_sampled"), rd("cnc/interlock_circuit")))
|
||||||
|
ctx.log(line)
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def wait_state(ctx, want, timeout, poll=0.05):
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < timeout:
|
||||||
|
ctx.checkpoint()
|
||||||
|
s = rd("cnc/state")
|
||||||
|
if s == want:
|
||||||
|
return s
|
||||||
|
time.sleep(poll)
|
||||||
|
return rd("cnc/state")
|
||||||
|
|
||||||
|
|
||||||
|
def watch_laser_until_idle(ctx, timeout):
|
||||||
|
"""Tight-loop laser_enable/laser_on watch; returns (hits, end_state)."""
|
||||||
|
hits = []
|
||||||
|
t0 = time.time()
|
||||||
|
state = "running"
|
||||||
|
n = 0
|
||||||
|
while time.time() - t0 < timeout:
|
||||||
|
en = rd("cnc/laser_enable")
|
||||||
|
on = rd("cnc/laser_on")
|
||||||
|
if en != "0" or on != "0":
|
||||||
|
hits.append((round(time.time() - t0, 4), en, on))
|
||||||
|
state = rd("cnc/state")
|
||||||
|
if state != "running":
|
||||||
|
break
|
||||||
|
n += 1
|
||||||
|
if n % 200 == 0:
|
||||||
|
ctx.checkpoint()
|
||||||
|
return hits, state
|
||||||
|
|
||||||
|
|
||||||
|
class PulseDevice:
|
||||||
|
"""Exclusive hold of /dev/glowforge for one drill."""
|
||||||
|
|
||||||
|
def __init__(self, ctx):
|
||||||
|
self.ctx = ctx
|
||||||
|
self.fd = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
try:
|
||||||
|
self.fd = os.open("/dev/glowforge", os.O_WRONLY)
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.EBUSY:
|
||||||
|
raise Failed("/dev/glowforge is busy - the takeover did not free the pulse device")
|
||||||
|
raise
|
||||||
|
fcntl.flock(self.fd, fcntl.LOCK_EX)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
os.write(self.fd, data)
|
||||||
|
|
||||||
|
def rewind(self):
|
||||||
|
os.lseek(self.fd, 1, os.SEEK_SET)
|
||||||
|
|
||||||
|
def drop(self):
|
||||||
|
"""Close the device with the lock still held: the dead man's switch."""
|
||||||
|
fd, self.fd = self.fd, None
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
try:
|
||||||
|
wr("cnc/laser_latch", 1) # re-lock unconditionally
|
||||||
|
finally:
|
||||||
|
if self.fd is not None:
|
||||||
|
fcntl.flock(self.fd, fcntl.LOCK_UN)
|
||||||
|
os.close(self.fd)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def hv_off_reason():
|
||||||
|
"""Why the safety chain is NOT holding HV off right now, or None. The
|
||||||
|
chain asserts HV_ENABLE only while a run feeds the charge-pump
|
||||||
|
watchdog, so a dead watchdog and an idle pulse engine mean HV off.
|
||||||
|
laser_pgood is the supply's power-good, high whenever the supply is
|
||||||
|
healthy, and says nothing about HV, so it plays no part here."""
|
||||||
|
alive = rd("cnc/charge_pump_alive")
|
||||||
|
state = rd("cnc/state")
|
||||||
|
if alive is None or state is None:
|
||||||
|
return "cnc/charge_pump_alive or cnc/state unreadable"
|
||||||
|
if alive != "0" or state != "idle":
|
||||||
|
return ("charge_pump_alive=%s state=%s: the chain may be holding HV_ENABLE up" % (alive, state))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def hv_off():
|
||||||
|
"""Precheck for the drills that unlock the latch with a zero-duty
|
||||||
|
stream: they run only while the chain holds HV off. At idle nothing
|
||||||
|
feeds the watchdog, so this refuses a start only on a machine that is
|
||||||
|
not idle the way it should be."""
|
||||||
|
why = hv_off_reason()
|
||||||
|
if why:
|
||||||
|
return ("%s; this drill unlocks the latch with a zero-duty stream and needs HV off "
|
||||||
|
"(let the machine go idle, then start again)" % why)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
HV_RELEASE_S = 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def wait_hv_off(ctx, timeout_s=HV_RELEASE_S):
|
||||||
|
"""hv_off_reason() once the chain has released, or the reason that still
|
||||||
|
stands after timeout_s. A run feeds the charge-pump watchdog every
|
||||||
|
200 ms and the one-shot holds ALIVE for 0.45 s after the last feed (the
|
||||||
|
feed's soft timer can add one more feed after the state leaves
|
||||||
|
running), so a phase that follows a run finds the chain still up for
|
||||||
|
under a second. The wait covers that release and nothing more; the
|
||||||
|
time it took is logged when it was not immediate and kept in the
|
||||||
|
evidence."""
|
||||||
|
t0 = time.time()
|
||||||
|
why = hv_off_reason()
|
||||||
|
while why is not None and time.time() - t0 < timeout_s:
|
||||||
|
ctx.sleep(0.05)
|
||||||
|
why = hv_off_reason()
|
||||||
|
dt = round(time.time() - t0, 2)
|
||||||
|
ctx.evidence.setdefault("hv_release_s", []).append(dt)
|
||||||
|
if why is not None or dt >= 0.1:
|
||||||
|
ctx.log("hv off: %s after %.2f s", "released" if why is None else "still held", dt)
|
||||||
|
return why
|
||||||
|
|
||||||
|
|
||||||
|
def require_hv_off(ctx):
|
||||||
|
"""The same rule at the start of the run (the precheck ran a moment
|
||||||
|
earlier; the machine must still agree)."""
|
||||||
|
why = wait_hv_off(ctx)
|
||||||
|
ctx.evidence["charge_pump_alive"] = rd("cnc/charge_pump_alive")
|
||||||
|
ctx.evidence["kernel_state"] = rd("cnc/state")
|
||||||
|
ctx.check(why is None, "%s - refusing the latch unlock", why)
|
||||||
|
|
||||||
|
|
||||||
|
def check_hv_off(ctx):
|
||||||
|
"""The hard check right before an unlock (no prompt: forgectrl is down)."""
|
||||||
|
why = wait_hv_off(ctx)
|
||||||
|
ctx.check(why is None, "%s - refusing the latch unlock", why)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- readbacks
|
||||||
|
|
||||||
|
@test("kernel.latch-locked-idle", title="Laser latch locked at idle", subsystem="kernel",
|
||||||
|
kind="auto", always=True, est_min=1,
|
||||||
|
covers=_KERNEL_COVERS + [("forgectrl", "src/super.c"),
|
||||||
|
("grblhal-glowforge", "src/glowforge_laser.c"),
|
||||||
|
("grblhal-glowforge", "src/driver.c")],
|
||||||
|
description="With the machine idle the kernel latch reads locked, the FIRE line is not "
|
||||||
|
"driven, no LASER_ON sample is seen, and no stepper fault is pending; forgectrl "
|
||||||
|
"agrees.")
|
||||||
|
def latch_locked_idle(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
state = hw.sysfs_read("cnc/state")
|
||||||
|
ev["cnc_state"] = state
|
||||||
|
ctx.log("cnc/state: %s", state)
|
||||||
|
ctx.check(state is not None, "cnc/state unreadable")
|
||||||
|
ctx.check(state in ("idle", "disabled"), "machine is %r, run this test at idle", state)
|
||||||
|
|
||||||
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
|
ev["interlock_circuit"] = ilk
|
||||||
|
ctx.check(ilk is not None, "cnc/interlock_circuit unreadable")
|
||||||
|
ctx.log("interlock_circuit: %d (0x%x)", ilk, ilk)
|
||||||
|
ctx.check(ilk & LATCH_BIT, "latch line reads unlocked at idle (bit 3 clear)")
|
||||||
|
|
||||||
|
fire = hw.sysfs_int("cnc/laser_enable")
|
||||||
|
ev["laser_enable"] = fire
|
||||||
|
ctx.log("laser_enable (FIRE line): %s", fire)
|
||||||
|
ctx.check(fire == 0, "FIRE line driven at idle (laser_enable=%s)", fire)
|
||||||
|
|
||||||
|
on = hw.sysfs_int("cnc/laser_on")
|
||||||
|
on_s = hw.sysfs_int("cnc/laser_on_sampled")
|
||||||
|
ev["laser_on"] = on
|
||||||
|
ev["laser_on_sampled"] = on_s
|
||||||
|
ctx.log("laser_on: %s, laser_on_sampled: %s", on, on_s)
|
||||||
|
ctx.check(on == 0, "LASER_ON active at idle")
|
||||||
|
ctx.check(on_s == 0, "LASER_ON samples seen at idle (%s)", on_s)
|
||||||
|
|
||||||
|
faults = hw.sysfs_int("cnc/faults")
|
||||||
|
ev["faults"] = faults
|
||||||
|
ctx.log("faults: %s", faults)
|
||||||
|
ctx.check(faults == 0, "stepper faults pending: %s", faults)
|
||||||
|
|
||||||
|
st = ctx.forgectrl.status()
|
||||||
|
ev["forgectrl_laser_locked"] = st.get("laser_locked")
|
||||||
|
ctx.log("forgectrl /status laser_locked=%s state=%s", st.get("laser_locked"), st.get("state"))
|
||||||
|
ctx.check(st.get("laser_locked") is True, "forgectrl reports the latch unlocked")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- K1 + K2
|
||||||
|
|
||||||
|
@test("kernel.k1-k2", title="Controlled-stop floor and resume honors the locked latch",
|
||||||
|
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=2,
|
||||||
|
covers=_KERNEL_COVERS,
|
||||||
|
requires=["kernel.latch-locked-idle"],
|
||||||
|
description="K1: a controlled stop mid-run ramps the step frequency down (tens of ms), "
|
||||||
|
"never consumes the tail as a burst or hangs. K2: with the latch locked, a "
|
||||||
|
"stop inside the leading pads and a resume with a positive waypoint replays "
|
||||||
|
"a 2 s FIRE window with laser_enable/laser_on at 0 throughout. Motors locked; "
|
||||||
|
"duty zero.")
|
||||||
|
def k1_k2(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
with ctx.takeover():
|
||||||
|
# ---- K1
|
||||||
|
stream = POWER0 + PAD * (6 * TICK_HZ)
|
||||||
|
ctx.log("K1: %d bytes = %.1f s of pads at %d Hz, ramp 125000 Hz/s",
|
||||||
|
len(stream), len(stream) / TICK_HZ, TICK_HZ)
|
||||||
|
snap(ctx, "K1 pre")
|
||||||
|
wr("cnc/motor_lock", 15)
|
||||||
|
wr("cnc/laser_latch", 1)
|
||||||
|
wr("cnc/ramp_rate", 125000)
|
||||||
|
wr("cnc/step_freq", TICK_HZ)
|
||||||
|
with PulseDevice(ctx) as dev:
|
||||||
|
dev.rewind()
|
||||||
|
wr("cnc/enable", 1)
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
dev.write(stream)
|
||||||
|
wr("cnc/run", 1)
|
||||||
|
ctx.sleep(1.5) # well past the accel ramp
|
||||||
|
st = rd("cnc/state")
|
||||||
|
ctx.check(st == "running", "K1: expected running before the stop, got %s", st)
|
||||||
|
t0 = time.time()
|
||||||
|
wr("cnc/stop", 1)
|
||||||
|
while time.time() - t0 < 5:
|
||||||
|
if rd("cnc/state") != "running":
|
||||||
|
break
|
||||||
|
dt = time.time() - t0
|
||||||
|
state = rd("cnc/state")
|
||||||
|
faults = rd("cnc/faults")
|
||||||
|
ev["k1"] = {"stop_to_idle_s": round(dt, 4), "state": state, "faults": faults}
|
||||||
|
ctx.log("K1 controlled stop: state=%s after %.4f s, faults=%s", state, dt, faults)
|
||||||
|
# drain the paused remainder laser-less so the device ends clean
|
||||||
|
wr("cnc/resume", 0)
|
||||||
|
wait_state(ctx, "running", 2, poll=0.005)
|
||||||
|
wait_state(ctx, "idle", 10)
|
||||||
|
ctx.check(state == "idle", "K1: state %s after the stop", state)
|
||||||
|
ctx.check(dt >= 0.02, "K1: stop consumed the tail as a burst (%.4f s) - decel floor broken", dt)
|
||||||
|
ctx.check(dt <= 3.0, "K1: stop took %.4f s", dt)
|
||||||
|
ctx.check(faults == "0", "K1: faults=%s", faults)
|
||||||
|
ctx.log("K1 PASS: decelerating tail %.4f s, no burst, no fault", dt)
|
||||||
|
|
||||||
|
# ---- K2
|
||||||
|
# 1000 X steps out and 1000 back at 2 kHz (masked by motor_lock):
|
||||||
|
# the position counters end where they started
|
||||||
|
step_sec = (XSTEP + PAD * 4) * 1000 + (XSTEP_BACK + PAD * 4) * 1000
|
||||||
|
stream = (POWER0 + PAD * TICK_HZ + step_sec + PAD * (TICK_HZ // 2)
|
||||||
|
+ FIRE * (2 * TICK_HZ) + PAD * TICK_HZ)
|
||||||
|
ctx.log("K2: %d bytes = %.1f s; latch stays LOCKED; waypoint +200; motor_lock=15",
|
||||||
|
len(stream), len(stream) / TICK_HZ)
|
||||||
|
snap(ctx, "K2 pre")
|
||||||
|
wr("cnc/motor_lock", 15)
|
||||||
|
wr("cnc/laser_latch", 1)
|
||||||
|
wr("cnc/ramp_rate", 125000)
|
||||||
|
wr("cnc/step_freq", TICK_HZ)
|
||||||
|
with PulseDevice(ctx) as dev:
|
||||||
|
dev.rewind()
|
||||||
|
wr("cnc/enable", 1)
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
dev.write(stream)
|
||||||
|
pos_before = rd_pos()
|
||||||
|
wr("cnc/run", 1)
|
||||||
|
ctx.sleep(0.4) # inside the initial pads
|
||||||
|
wr("cnc/stop", 1)
|
||||||
|
state = wait_state(ctx, "idle", 5, poll=0.01)
|
||||||
|
ctx.check(state == "idle", "K2: controlled stop did not reach idle (state=%s)", state)
|
||||||
|
ctx.log("K2: paused inside the pads; resuming with waypoint +200 (latch LOCKED)")
|
||||||
|
wr("cnc/resume", 200)
|
||||||
|
wait_state(ctx, "running", 2, poll=0.005)
|
||||||
|
hits, state = watch_laser_until_idle(ctx, 20)
|
||||||
|
pos_after = rd_pos()
|
||||||
|
snap(ctx, "K2 post")
|
||||||
|
ev["k2"] = {"hits": hits[:10], "end_state": state, "pos_before": pos_before,
|
||||||
|
"pos_after": pos_after, "laser_on_sampled": rd("cnc/laser_on_sampled"),
|
||||||
|
"underruns": rd("cnc/underruns"), "faults": rd("cnc/faults")}
|
||||||
|
ctx.log("K2 done: state=%s pos before=%s after=%s", state, pos_before, pos_after)
|
||||||
|
ctx.check(not hits, "K2: laser asserted with the latch locked: %s", hits[:10])
|
||||||
|
ctx.check(pos_before[:3] == pos_after[:3],
|
||||||
|
"K2: position counters did not return to start (%s -> %s)", pos_before[:3], pos_after[:3])
|
||||||
|
ctx.log("K2 PASS: FIRE window replayed after the resume waypoint with "
|
||||||
|
"laser_enable/laser_on at 0 throughout")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- dead man
|
||||||
|
|
||||||
|
@test("kernel.deadman-close", title="The final close of a running pulse device halts the engine and safes the head",
|
||||||
|
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=1,
|
||||||
|
covers=_KERNEL_COVERS,
|
||||||
|
requires=["kernel.k1-k2"],
|
||||||
|
description="The kernel dead man's switch: a pulse device closed while locked and running "
|
||||||
|
"halts the engine at once, locks the laser latch, and puts the head in its safe "
|
||||||
|
"state - lens driver disabled at low current, measure laser and UV LED off. The "
|
||||||
|
"lens driver is enabled at low current before the close, so the safe state has "
|
||||||
|
"something to undo. Motors locked; duty zero.")
|
||||||
|
def deadman_close(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
with ctx.takeover():
|
||||||
|
stream = POWER0 + PAD * (6 * TICK_HZ)
|
||||||
|
snap(ctx, "deadman pre")
|
||||||
|
wr("cnc/motor_lock", 15)
|
||||||
|
wr("cnc/laser_latch", 1)
|
||||||
|
wr("cnc/ramp_rate", 125000)
|
||||||
|
wr("cnc/step_freq", TICK_HZ)
|
||||||
|
wr("head/z_current", 1)
|
||||||
|
wr("head/z_enable", 0)
|
||||||
|
before = {"z_enable": rd("head/z_enable"), "z_current": rd("head/z_current")}
|
||||||
|
ev["head_before"] = before
|
||||||
|
ctx.check(before["z_enable"] == "0", "the lens driver did not enable (z_enable %s)", before["z_enable"])
|
||||||
|
with PulseDevice(ctx) as dev:
|
||||||
|
dev.rewind()
|
||||||
|
wr("cnc/enable", 1)
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
dev.write(stream)
|
||||||
|
wr("cnc/run", 1)
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
st = rd("cnc/state")
|
||||||
|
ctx.check(st == "running", "expected running before the close, got %s", st)
|
||||||
|
t0 = time.time()
|
||||||
|
dev.drop()
|
||||||
|
while time.time() - t0 < 5:
|
||||||
|
if rd("cnc/state") != "running":
|
||||||
|
break
|
||||||
|
dt = time.time() - t0
|
||||||
|
state = rd("cnc/state")
|
||||||
|
after = {"z_enable": rd("head/z_enable"), "z_current": rd("head/z_current"),
|
||||||
|
"measure_laser": rd("head/measure_laser"), "uv_led": rd("head/uv_led")}
|
||||||
|
latch = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
|
ev["close"] = {"halt_s": round(dt, 4), "state": state, "faults": rd("cnc/faults"),
|
||||||
|
"interlock_circuit": latch}
|
||||||
|
ev["head_after"] = after
|
||||||
|
snap(ctx, "deadman post")
|
||||||
|
ctx.log("final close: state=%s after %.4f s; head %s", state, dt, after)
|
||||||
|
ctx.check(state == "idle", "the engine did not halt on the final close (state %s)", state)
|
||||||
|
ctx.check(dt < 1.0, "the halt took %.3f s", dt)
|
||||||
|
ctx.check(latch is not None and latch & (1 << 3), "the latch is not locked after the close")
|
||||||
|
ctx.check(after["z_enable"] == "1" and after["z_current"] == "1",
|
||||||
|
"the head was not safed: z_enable %s z_current %s (want 1, 1: driver disabled, low current)",
|
||||||
|
after["z_enable"], after["z_current"])
|
||||||
|
ctx.check(after["measure_laser"] == "0" and after["uv_led"] == "0",
|
||||||
|
"measure laser %s, UV LED %s after the close", after["measure_laser"], after["uv_led"])
|
||||||
|
wr("cnc/stop", 1)
|
||||||
|
ctx.log("PASS: the final close halted the engine in %.3f s, latch locked, head safed", dt)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- backtrack
|
||||||
|
|
||||||
|
@test("kernel.backtrack-bounds", title="A backward run is bounded by the history the ring still holds",
|
||||||
|
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=2,
|
||||||
|
covers=_KERNEL_COVERS,
|
||||||
|
requires=["kernel.k1-k2"],
|
||||||
|
description="A pause walks the program backward to put the beam back over ground the job "
|
||||||
|
"already cut, and the ring is what remembers that ground. cnc/max_backtrack is "
|
||||||
|
"the distance still available: what has played, less the tail the deceleration "
|
||||||
|
"spends. This drills the boundary on real hardware - the readback matches the "
|
||||||
|
"bytes played, a step beyond it is refused rather than quietly shortened, and "
|
||||||
|
"the run at the boundary plays out and returns to idle. Motors locked, latch "
|
||||||
|
"locked, duty zero: nothing moves and nothing fires.")
|
||||||
|
def backtrack_bounds(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
tail = TICK_HZ * TICK_HZ // (2 * 125000) # v^2/2a: 400 steps at the print tick
|
||||||
|
with ctx.takeover():
|
||||||
|
stream = POWER0 + PAD * (6 * TICK_HZ)
|
||||||
|
ctx.log("%d bytes = %.1f s of pads at %d Hz; decel tail %d steps",
|
||||||
|
len(stream), len(stream) / TICK_HZ, TICK_HZ, tail)
|
||||||
|
snap(ctx, "pre")
|
||||||
|
wr("cnc/motor_lock", 15)
|
||||||
|
wr("cnc/laser_latch", 1)
|
||||||
|
wr("cnc/ramp_rate", 125000)
|
||||||
|
wr("cnc/step_freq", TICK_HZ)
|
||||||
|
with PulseDevice(ctx) as dev:
|
||||||
|
dev.rewind()
|
||||||
|
wr("cnc/enable", 1)
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
dev.write(stream)
|
||||||
|
wr("cnc/run", 1)
|
||||||
|
ctx.sleep(1.5) # well past the accel ramp
|
||||||
|
wr("cnc/stop", 1)
|
||||||
|
state = wait_state(ctx, "idle", 5, poll=0.01)
|
||||||
|
ctx.check(state == "idle", "the controlled stop did not reach idle (state=%s)", state)
|
||||||
|
|
||||||
|
played = rd_pos()[3]
|
||||||
|
budget = int(rd("cnc/max_backtrack"))
|
||||||
|
ev["played"] = played
|
||||||
|
ev["max_backtrack"] = budget
|
||||||
|
ctx.log("played %d bytes; max_backtrack %d", played, budget)
|
||||||
|
ctx.check(budget > 0, "max_backtrack is %d after %d bytes played", budget, played)
|
||||||
|
# The two numbers come from different SDMA registers (the byte
|
||||||
|
# counter and the ring head), so allow a few bytes of skew - but
|
||||||
|
# not the whole gap, and not the whole played span.
|
||||||
|
ctx.check(abs(budget - (played - tail)) <= 8,
|
||||||
|
"max_backtrack %d is not the %d bytes played less the %d-step decel tail",
|
||||||
|
budget, played, tail)
|
||||||
|
|
||||||
|
# One step past the boundary: refused, and the device stays idle.
|
||||||
|
refused = None
|
||||||
|
try:
|
||||||
|
wr("cnc/resume", -(budget + 1))
|
||||||
|
except OSError as e:
|
||||||
|
refused = e.errno
|
||||||
|
ev["over_long_errno"] = refused
|
||||||
|
ctx.check(refused == errno.EPERM,
|
||||||
|
"a backtrack one step past the boundary was not refused with EPERM (%s)",
|
||||||
|
refused)
|
||||||
|
state = rd("cnc/state")
|
||||||
|
ctx.check(state == "idle", "the refused backtrack left the device %s", state)
|
||||||
|
|
||||||
|
# At the boundary: runs, decelerates inside genuine data, ends idle.
|
||||||
|
wr("cnc/resume", -budget)
|
||||||
|
wait_state(ctx, "running", 2, poll=0.005)
|
||||||
|
hits, state = watch_laser_until_idle(ctx, 30)
|
||||||
|
after = int(rd("cnc/max_backtrack"))
|
||||||
|
ev["after"] = {"state": state, "max_backtrack": after,
|
||||||
|
"faults": rd("cnc/faults"), "underruns": rd("cnc/underruns")}
|
||||||
|
ctx.log("backtrack of %d done: state=%s max_backtrack now %d", budget, state, after)
|
||||||
|
# Leave the program drained so the device ends where the other
|
||||||
|
# drills expect it.
|
||||||
|
wr("cnc/resume", 0)
|
||||||
|
wait_state(ctx, "running", 2, poll=0.005)
|
||||||
|
wait_state(ctx, "idle", 30)
|
||||||
|
ctx.check(not hits, "the laser asserted during the backward run: %s", hits[:10])
|
||||||
|
ctx.check(state == "idle", "the backward run ended in %s", state)
|
||||||
|
ctx.check(ev["after"]["faults"] == "0", "faults=%s after the backward run",
|
||||||
|
ev["after"]["faults"])
|
||||||
|
ctx.log("PASS: max_backtrack tracks the played history, the boundary is enforced, "
|
||||||
|
"and the run at it plays out clean")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- K3
|
||||||
|
|
||||||
|
def _k3_phase(ctx):
|
||||||
|
"""K3: the latch unlocked during the accel ramp must not restore the FIRE
|
||||||
|
drive while the run is in flight. Runs inside the caller's takeover."""
|
||||||
|
ev = ctx.evidence
|
||||||
|
check_hv_off(ctx)
|
||||||
|
stream = POWER0 + FIRE * (3 * TICK_HZ) + PAD * (TICK_HZ // 2)
|
||||||
|
ctx.log("K3: %d bytes = %.1f s of FIRE bits; ramp_rate 10000 Hz/s (~0.9 s accel "
|
||||||
|
"window); unlock at t=+0.15 s", len(stream), len(stream) / TICK_HZ)
|
||||||
|
snap(ctx, "K3 pre")
|
||||||
|
wr("cnc/motor_lock", 15)
|
||||||
|
wr("cnc/laser_latch", 1)
|
||||||
|
wr("cnc/step_freq", TICK_HZ)
|
||||||
|
wr("cnc/ramp_rate", 10000)
|
||||||
|
try:
|
||||||
|
with PulseDevice(ctx) as dev:
|
||||||
|
dev.rewind()
|
||||||
|
wr("cnc/enable", 1)
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
dev.write(stream)
|
||||||
|
wr("cnc/run", 1)
|
||||||
|
time.sleep(0.15) # inside the accel ramp
|
||||||
|
wr("cnc/laser_latch", 0)
|
||||||
|
ilk = rd("cnc/interlock_circuit")
|
||||||
|
ctx.log("K3: latch UNLOCKED mid-ramp; interlock=%s (bit 3 should read 0)", ilk)
|
||||||
|
hits, state = watch_laser_until_idle(ctx, 20)
|
||||||
|
snap(ctx, "K3 post")
|
||||||
|
ev["k3"] = {"interlock_after_unlock": ilk, "hits": hits[:10], "end_state": state,
|
||||||
|
"laser_on_sampled": rd("cnc/laser_on_sampled"),
|
||||||
|
"underruns": rd("cnc/underruns"), "faults": rd("cnc/faults")}
|
||||||
|
finally:
|
||||||
|
wr("cnc/laser_latch", 1)
|
||||||
|
try:
|
||||||
|
wr("cnc/ramp_rate", 125000)
|
||||||
|
except OSError:
|
||||||
|
ctx.log("WARNING: could not restore ramp_rate=125000")
|
||||||
|
ctx.check((int(ilk) & LATCH_BIT) == 0, "K3: the unlock did not drive the latch pin (interlock=%s)", ilk)
|
||||||
|
ctx.check(not hits, "K3: FIRE drive re-armed by a mid-run unlock: %s", hits[:10])
|
||||||
|
ctx.log("K3 PASS: laser_enable stayed 0 for the entire run after the mid-ramp unlock")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- FIRE A/B/U
|
||||||
|
|
||||||
|
def _fire_stream():
|
||||||
|
return (POWER0 + # duty zero before any FIRE bit
|
||||||
|
PAD * TICK_HZ + # 1 s baseline
|
||||||
|
FIRE * (2 * TICK_HZ) + # 2.000 s FIRE window (bounded by pads)
|
||||||
|
PAD * TICK_HZ + # 1 s gap
|
||||||
|
FIRE * (2 * TICK_HZ)) # 2.000 s FIRE window ending AT end-of-data
|
||||||
|
|
||||||
|
|
||||||
|
def _fire_phase(ctx, mode):
|
||||||
|
"""One phase of fire_test.py; returns the evidence dict."""
|
||||||
|
unlock = mode in ("B", "U")
|
||||||
|
underrun_mode = mode == "U"
|
||||||
|
stream = _fire_stream()
|
||||||
|
ctx.log("fire %s: stream %d bytes = %.3f s", mode, len(stream), len(stream) / TICK_HZ)
|
||||||
|
if unlock:
|
||||||
|
check_hv_off(ctx)
|
||||||
|
snap(ctx, "fire %s pre" % mode)
|
||||||
|
wr("cnc/motor_lock", 15)
|
||||||
|
wr("cnc/step_freq", TICK_HZ)
|
||||||
|
wr("cnc/laser_latch", 1)
|
||||||
|
underruns_before = int(rd("cnc/underruns"))
|
||||||
|
mid = None
|
||||||
|
tail = None
|
||||||
|
with PulseDevice(ctx) as dev:
|
||||||
|
dev.rewind()
|
||||||
|
wr("cnc/enable", 1)
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
dev.write(stream)
|
||||||
|
pos_before = rd_pos()
|
||||||
|
if underrun_mode:
|
||||||
|
wr("cnc/streaming", 1) # end-of-data mid-run = true underrun
|
||||||
|
ctx.log("fire U: streaming=1, the terminal end-of-data will be a TRUE UNDERRUN")
|
||||||
|
try:
|
||||||
|
if unlock:
|
||||||
|
wr("cnc/laser_latch", 0)
|
||||||
|
ctx.log("fire %s: latch UNLOCKED for this run", mode)
|
||||||
|
wr("cnc/run", 1)
|
||||||
|
t0 = time.time()
|
||||||
|
state = ""
|
||||||
|
samples = []
|
||||||
|
while time.time() - t0 < 20:
|
||||||
|
ctx.checkpoint()
|
||||||
|
state = rd("cnc/state")
|
||||||
|
dt = time.time() - t0
|
||||||
|
en, on, ons = rd("cnc/laser_enable"), rd("cnc/laser_on"), rd("cnc/laser_on_sampled")
|
||||||
|
samples.append((round(dt, 2), state, en, on, ons))
|
||||||
|
if mid is None and 1.5 < dt < 3.0:
|
||||||
|
mid = {"t": round(dt, 2), "laser_enable": en, "laser_on": on,
|
||||||
|
"laser_on_sampled": ons, "interlock": rd("cnc/interlock_circuit")}
|
||||||
|
ctx.log("fire %s mid (inside FIRE window): laser_enable=%s laser_on=%s "
|
||||||
|
"laser_on_sampled=%s interlock=%s", mode, en, on, ons, mid["interlock"])
|
||||||
|
if state != "running":
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
end_dt = time.time() - t0
|
||||||
|
tail = {"state": state, "after_s": round(end_dt, 2), "laser_enable": rd("cnc/laser_enable"),
|
||||||
|
"laser_on": rd("cnc/laser_on")}
|
||||||
|
ctx.log("fire %s done: state=%s after %.1f s (laser_enable=%s)", mode, state, end_dt,
|
||||||
|
tail["laser_enable"])
|
||||||
|
if underrun_mode:
|
||||||
|
if state == "underrun":
|
||||||
|
ctx.log("fire U: underrun state reached as EXPECTED; acking via stop")
|
||||||
|
else:
|
||||||
|
ctx.log("fire U: WARNING expected underrun state, got %s", state)
|
||||||
|
wr("cnc/stop", 1)
|
||||||
|
wr("cnc/streaming", 0)
|
||||||
|
tail["acked_state"] = rd("cnc/state")
|
||||||
|
ctx.log("fire U: acked: state=%s", tail["acked_state"])
|
||||||
|
finally:
|
||||||
|
wr("cnc/laser_latch", 1)
|
||||||
|
if underrun_mode:
|
||||||
|
try:
|
||||||
|
wr("cnc/streaming", 0)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
pos_after = rd_pos()
|
||||||
|
snap(ctx, "fire %s post" % mode)
|
||||||
|
ev = {"mid": mid, "tail": tail, "moved": pos_before[:3] != pos_after[:3],
|
||||||
|
"underruns_before": underruns_before, "underruns_after": int(rd("cnc/underruns")),
|
||||||
|
"faults": rd("cnc/faults"),
|
||||||
|
"any_laser_on": any(s[3] != "0" or s[4] != "0" for s in samples),
|
||||||
|
"any_fire_driven": any(s[2] != "0" for s in samples)}
|
||||||
|
ctx.log("fire %s: moved=%s underruns %d->%d faults=%s", mode, ev["moved"],
|
||||||
|
ev["underruns_before"], ev["underruns_after"], ev["faults"])
|
||||||
|
return ev
|
||||||
|
|
||||||
|
|
||||||
|
@test("kernel.fire-line", title="FIRE line: latch locked, unlocked-unarmed, true underrun, and a "
|
||||||
|
"mid-run unlock",
|
||||||
|
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=4,
|
||||||
|
covers=_KERNEL_COVERS,
|
||||||
|
requires=["kernel.k1-k2"], precheck=hv_off,
|
||||||
|
steps=["Phases B, U and K3 unlock the latch with a zero-duty stream, so the drill starts only "
|
||||||
|
"while the HV supply does not report good (true at idle; if it is refused, open the "
|
||||||
|
"lid - the safety chain holds HV off - and start it again)."],
|
||||||
|
description="Four phases behind one takeover of the pulse device, all zero duty. A: latch "
|
||||||
|
"locked, 40 000 streamed FIRE bits, nothing on the FIRE/LASER_ON nets. B: latch "
|
||||||
|
"unlocked with the chain unarmed, the FIRE line is driven mid-window and "
|
||||||
|
"LASER_ON stays off (the safety AND-gate holds), FIRE clear at end-of-data. U: "
|
||||||
|
"streaming declared, the terminal end-of-data is a true underrun, the backstop "
|
||||||
|
"drops FIRE and stop acks it. K3: the latch unlocked during the accel ramp "
|
||||||
|
"drives the latch pin but never restores the FIRE drive to a run already in "
|
||||||
|
"flight - laser_enable stays 0 for the whole run.")
|
||||||
|
def fire_line(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
require_hv_off(ctx)
|
||||||
|
with ctx.takeover():
|
||||||
|
try:
|
||||||
|
a = _fire_phase(ctx, "A")
|
||||||
|
ev["A"] = a
|
||||||
|
ctx.check(a["mid"] is not None, "A: no mid-window sample")
|
||||||
|
ctx.check(not a["any_fire_driven"], "A: FIRE line driven with the latch locked")
|
||||||
|
ctx.check(not a["any_laser_on"], "A: LASER_ON seen with the latch locked")
|
||||||
|
ctx.check(a["tail"]["state"] == "idle", "A: ended in %s", a["tail"]["state"])
|
||||||
|
ctx.check(not a["moved"], "A: position moved with motors locked")
|
||||||
|
ctx.log("fire A PASS: latch locked, no FIRE drive, no LASER_ON")
|
||||||
|
|
||||||
|
b = _fire_phase(ctx, "B")
|
||||||
|
ev["B"] = b
|
||||||
|
ctx.check(b["mid"] is not None, "B: no mid-window sample")
|
||||||
|
ctx.check(b["mid"]["laser_enable"] != "0",
|
||||||
|
"B: FIRE line not driven mid-window with the latch unlocked (%s)", b["mid"])
|
||||||
|
ctx.check(not b["any_laser_on"], "B: LASER_ON active with the chain unarmed - the AND-gate did not hold")
|
||||||
|
ctx.check(b["tail"]["state"] == "idle", "B: ended in %s", b["tail"]["state"])
|
||||||
|
ctx.check(b["tail"]["laser_enable"] == "0", "B: FIRE still driven after end-of-data")
|
||||||
|
ctx.check(b["underruns_after"] == b["underruns_before"], "B: underrun counted on a normal completion")
|
||||||
|
ctx.log("fire B PASS: FIRE driven mid-window, LASER_ON off, FIRE clear at end-of-data")
|
||||||
|
|
||||||
|
u = _fire_phase(ctx, "U")
|
||||||
|
ev["U"] = u
|
||||||
|
ctx.check(u["tail"]["state"] == "underrun", "U: expected the underrun state, got %s", u["tail"]["state"])
|
||||||
|
ctx.check(u["tail"]["laser_enable"] == "0", "U: FIRE still driven after the underrun")
|
||||||
|
ctx.check(not u["any_laser_on"], "U: LASER_ON active with the chain unarmed")
|
||||||
|
ctx.check(u["tail"].get("acked_state") == "idle", "U: stop did not ack the underrun (state %s)",
|
||||||
|
u["tail"].get("acked_state"))
|
||||||
|
ctx.check(u["underruns_after"] == u["underruns_before"] + 1,
|
||||||
|
"U: underrun counter %d -> %d", u["underruns_before"], u["underruns_after"])
|
||||||
|
ctx.log("fire U PASS: true underrun, backstop dropped FIRE, stop acked")
|
||||||
|
|
||||||
|
_k3_phase(ctx)
|
||||||
|
finally:
|
||||||
|
wr("cnc/laser_latch", 1)
|
||||||
|
try:
|
||||||
|
wr("cnc/disable", 1) # the script's safe state
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
ctx.log("safe state restored: state=%s latch=LOCKED", rd("cnc/state"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- resume lead
|
||||||
|
|
||||||
|
@test("kernel.resume-lead", title="A resume's laser-off lead, and an end-of-data before the waypoint",
|
||||||
|
subsystem="kernel", kind="auto", hardware="takeover", est_min=2,
|
||||||
|
covers=_KERNEL_COVERS,
|
||||||
|
requires=["kernel.k1-k2"], precheck=hv_off,
|
||||||
|
steps=["Phase L unlocks the latch with a zero-duty stream, so the drill starts only while "
|
||||||
|
"the HV supply does not report good (true at idle; if it is refused, open the lid - "
|
||||||
|
"the safety chain holds HV off - and start it again)."],
|
||||||
|
description="Two phases behind one takeover, zero duty, motors locked, a 1 kHz tick. E: a "
|
||||||
|
"resume whose lead is longer than the data (the ring drains before the waypoint) "
|
||||||
|
"ends the run at end-of-data within a few ticks, not one re-notify period (255 "
|
||||||
|
"ticks) later: the script publishes end-of-data in its mailbox and the host "
|
||||||
|
"decodes on that, not on the waypoint it was still waiting for. L: with the "
|
||||||
|
"latch unlocked and the chain unarmed, a resume with a 1000-byte lead over a "
|
||||||
|
"stream of FIRE bits keeps the FIRE line low through the lead and drives it from "
|
||||||
|
"the waypoint byte on. The script masks the laser bits itself, so no host write "
|
||||||
|
"reaches the GPIO data register while the script runs; LASER_ON stays off (the "
|
||||||
|
"safety AND-gate holds).")
|
||||||
|
def resume_lead(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
require_hv_off(ctx)
|
||||||
|
tick = 1000
|
||||||
|
with ctx.takeover():
|
||||||
|
try:
|
||||||
|
# ---- E: end-of-data before the waypoint
|
||||||
|
stream = POWER0 + PAD * (tick // 2) # 0.5 s of pads
|
||||||
|
expected = len(stream) / tick
|
||||||
|
ctx.log("E: %d bytes = %.1f s of pads at %d Hz; resume with a %d-byte lead (never reached)",
|
||||||
|
len(stream), expected, tick, 10 * tick)
|
||||||
|
wr("cnc/motor_lock", 15)
|
||||||
|
wr("cnc/laser_latch", 1)
|
||||||
|
wr("cnc/ramp_rate", 125000)
|
||||||
|
wr("cnc/step_freq", tick)
|
||||||
|
snap(ctx, "E pre")
|
||||||
|
with PulseDevice(ctx) as dev:
|
||||||
|
dev.rewind()
|
||||||
|
wr("cnc/enable", 1)
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
dev.write(stream)
|
||||||
|
underruns_before = int(rd("cnc/underruns"))
|
||||||
|
t0 = time.time()
|
||||||
|
wr("cnc/resume", 10 * tick)
|
||||||
|
state = wait_state(ctx, "running", 2, poll=0.002)
|
||||||
|
ctx.check(state == "running", "E: the resume did not start (state=%s)", state)
|
||||||
|
state = wait_state(ctx, "idle", 5, poll=0.002)
|
||||||
|
dt = time.time() - t0
|
||||||
|
ev["E"] = {"run_to_idle_s": round(dt, 3), "data_s": expected, "state": state,
|
||||||
|
"underruns_before": underruns_before,
|
||||||
|
"underruns_after": int(rd("cnc/underruns")), "faults": rd("cnc/faults")}
|
||||||
|
ctx.log("E: %s after %.3f s (the data is %.3f s; a lost end-of-data would add %.3f s)",
|
||||||
|
state, dt, expected, 255.0 / tick)
|
||||||
|
ctx.check(state == "idle", "E: state %s after the data ran out", state)
|
||||||
|
ctx.check(dt < expected + 0.15,
|
||||||
|
"E: the run ended %.3f s after its data: end-of-data decoded late", dt - expected)
|
||||||
|
ctx.check(ev["E"]["underruns_after"] == underruns_before,
|
||||||
|
"E: an underrun was counted on a normal completion")
|
||||||
|
ctx.check(ev["E"]["faults"] == "0", "E: faults=%s", ev["E"]["faults"])
|
||||||
|
ctx.log("E PASS: end-of-data before the waypoint ended the run on time")
|
||||||
|
|
||||||
|
# ---- L: the lead, latch unlocked, chain unarmed
|
||||||
|
check_hv_off(ctx)
|
||||||
|
lead = tick # 1.0 s of FIRE bits held off
|
||||||
|
lead_s = lead / tick
|
||||||
|
stream = POWER0 + FIRE * (2 * tick) + PAD * (tick // 5)
|
||||||
|
ctx.log("L: %d bytes = %.1f s (%.1f s of FIRE bits then pads); lead %d bytes = %.1f s; "
|
||||||
|
"latch UNLOCKED, chain unarmed", len(stream), len(stream) / tick,
|
||||||
|
2 * tick / tick, lead, lead_s)
|
||||||
|
wr("cnc/motor_lock", 15)
|
||||||
|
wr("cnc/laser_latch", 1)
|
||||||
|
wr("cnc/step_freq", tick)
|
||||||
|
snap(ctx, "L pre")
|
||||||
|
samples = []
|
||||||
|
state = ""
|
||||||
|
with PulseDevice(ctx) as dev:
|
||||||
|
dev.rewind()
|
||||||
|
wr("cnc/enable", 1)
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
dev.write(stream)
|
||||||
|
try:
|
||||||
|
wr("cnc/laser_latch", 0)
|
||||||
|
t0 = time.time()
|
||||||
|
wr("cnc/resume", lead)
|
||||||
|
n = 0
|
||||||
|
while time.time() - t0 < 10:
|
||||||
|
en, on = rd("cnc/laser_enable"), rd("cnc/laser_on")
|
||||||
|
samples.append((round(time.time() - t0, 4), en, on))
|
||||||
|
state = rd("cnc/state")
|
||||||
|
if state != "running" and time.time() - t0 > 0.2:
|
||||||
|
break
|
||||||
|
n += 1
|
||||||
|
if n % 200 == 0:
|
||||||
|
ctx.checkpoint()
|
||||||
|
finally:
|
||||||
|
wr("cnc/laser_latch", 1)
|
||||||
|
snap(ctx, "L post")
|
||||||
|
driven = [s for s in samples if s[1] != "0"]
|
||||||
|
first = driven[0][0] if driven else None
|
||||||
|
last = driven[-1][0] if driven else None
|
||||||
|
inside_lead = [s for s in driven if s[0] < lead_s - 0.02]
|
||||||
|
ev["L"] = {"samples": len(samples), "driven_samples": len(driven),
|
||||||
|
"first_driven_s": first, "last_driven_s": last, "end_state": state,
|
||||||
|
"any_laser_on": any(s[2] != "0" for s in samples),
|
||||||
|
"inside_lead": inside_lead[:5], "laser_on_sampled": rd("cnc/laser_on_sampled"),
|
||||||
|
"faults": rd("cnc/faults")}
|
||||||
|
ctx.log("L: %d samples, FIRE driven in %d, first at %s s, last at %s s, end state %s",
|
||||||
|
len(samples), len(driven), first, last, state)
|
||||||
|
ctx.check(state == "idle", "L: ended in %s", state)
|
||||||
|
ctx.check(not inside_lead, "L: FIRE driven inside the lead: %s", inside_lead[:5])
|
||||||
|
ctx.check(driven, "L: FIRE never driven after the lead (the inhibit never cleared)")
|
||||||
|
ctx.check(first is not None and first < lead_s + 0.3,
|
||||||
|
"L: FIRE first driven at %s s; the lead ends at %.1f s", first, lead_s)
|
||||||
|
ctx.check(not ev["L"]["any_laser_on"],
|
||||||
|
"L: LASER_ON active with the chain unarmed - the AND-gate did not hold")
|
||||||
|
ctx.check(rd("cnc/laser_enable") == "0", "L: FIRE still driven after end-of-data")
|
||||||
|
ctx.log("L PASS: FIRE low through the %.1f s lead, driven from the waypoint on, LASER_ON off",
|
||||||
|
lead_s)
|
||||||
|
finally:
|
||||||
|
wr("cnc/laser_latch", 1)
|
||||||
|
try:
|
||||||
|
wr("cnc/ramp_rate", 125000)
|
||||||
|
wr("cnc/disable", 1) # the script's safe state
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
ctx.log("safe state restored: state=%s latch=LOCKED", rd("cnc/state"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- PIC and the SoC's load
|
||||||
|
|
||||||
|
PIC_SETTLE_PARAM = "/sys/module/glowforge/parameters/pic_settle_us"
|
||||||
|
|
||||||
|
|
||||||
|
def _param_read(path):
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _param_write(path, value):
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write("%s\n" % value)
|
||||||
|
|
||||||
|
|
||||||
|
def _level_stats(xs):
|
||||||
|
s = sorted(xs)
|
||||||
|
n = len(s)
|
||||||
|
return {"n": n, "mean": round(sum(s) / float(n), 2), "med": s[n // 2],
|
||||||
|
"iqr": s[(3 * n) // 4] - s[n // 4], "min": s[0], "max": s[-1]}
|
||||||
|
|
||||||
|
|
||||||
|
def _spin(sec):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
x = 1.0
|
||||||
|
while time.perf_counter() - t0 < sec:
|
||||||
|
x = x * 1.000001 + 0.5
|
||||||
|
return x
|
||||||
|
|
||||||
|
|
||||||
|
@test("kernel.pic-soc-load", title="A PIC reading does not depend on what the reader was doing",
|
||||||
|
subsystem="kernel", kind="auto", est_min=1,
|
||||||
|
covers=_KERNEL_COVERS + [("forgectrl", "src/diag.c")],
|
||||||
|
description="The PIC converts its sensors in a free-running loop and a read returns the "
|
||||||
|
"last conversion of that channel; the count depends on the SoC's load when the "
|
||||||
|
"conversion was made (the PIC converts against its own supply, the dividers hang "
|
||||||
|
"on the board's reference), about 6 counts between an idle and a busy CPU on the "
|
||||||
|
"coolant thermistors. The module keeps the CPU busy for pic_settle_us before every "
|
||||||
|
"transaction, longer than one PIC loop, so the value read was converted under the "
|
||||||
|
"same load whoever reads. The drill reads a coolant thermistor 200 times each after "
|
||||||
|
"3 ms of sleep and after 3 ms of spinning, with the settle off (the control, "
|
||||||
|
"reported) and on (the claim): settled, the two agree.")
|
||||||
|
def pic_soc_load(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
saved = _param_read(PIC_SETTLE_PARAM)
|
||||||
|
ctx.check(saved is not None, "the module has no pic_settle_us parameter (%s)", PIC_SETTLE_PARAM)
|
||||||
|
ev["pic_settle_us_before"] = saved
|
||||||
|
fd = os.open(hw.sysfs_root() + "pic/water_temp_1", os.O_RDONLY)
|
||||||
|
|
||||||
|
def reads(regime, n=200):
|
||||||
|
xs = []
|
||||||
|
for i in range(n):
|
||||||
|
if regime == "idle":
|
||||||
|
time.sleep(0.003)
|
||||||
|
else:
|
||||||
|
_spin(0.003)
|
||||||
|
xs.append(int(os.pread(fd, 32, 0).strip()))
|
||||||
|
if i % 100 == 99:
|
||||||
|
ctx.checkpoint()
|
||||||
|
return _level_stats(xs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_param_write(PIC_SETTLE_PARAM, 0)
|
||||||
|
ctx.sleep(0.05)
|
||||||
|
control = {"idle": reads("idle"), "busy": reads("busy")}
|
||||||
|
_param_write(PIC_SETTLE_PARAM, 500)
|
||||||
|
ctx.sleep(0.05)
|
||||||
|
settled = {"idle": reads("idle"), "busy": reads("busy")}
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
_param_write(PIC_SETTLE_PARAM, saved if saved not in (None, "0") else 500)
|
||||||
|
ev["control"] = control
|
||||||
|
ev["settled"] = settled
|
||||||
|
ev["pic_settle_us_after"] = _param_read(PIC_SETTLE_PARAM)
|
||||||
|
for name, r in (("control (settle off)", control), ("settled (500 us)", settled)):
|
||||||
|
ctx.log("%s: after sleep median %d (iqr %d, %d..%d); after spin median %d (iqr %d, %d..%d)",
|
||||||
|
name, r["idle"]["med"], r["idle"]["iqr"], r["idle"]["min"], r["idle"]["max"],
|
||||||
|
r["busy"]["med"], r["busy"]["iqr"], r["busy"]["min"], r["busy"]["max"])
|
||||||
|
ev["control_split"] = control["busy"]["med"] - control["idle"]["med"]
|
||||||
|
ev["settled_split"] = settled["busy"]["med"] - settled["idle"]["med"]
|
||||||
|
ctx.log("split busy minus idle: control %+d, settled %+d", ev["control_split"], ev["settled_split"])
|
||||||
|
ctx.check(abs(ev["settled_split"]) <= 2,
|
||||||
|
"settled reads still split by %+d counts between an idle and a busy reader", ev["settled_split"])
|
||||||
|
ctx.check(settled["idle"]["iqr"] <= 4 and settled["busy"]["iqr"] <= 4,
|
||||||
|
"settled reads spread %d / %d counts (interquartile)", settled["idle"]["iqr"], settled["busy"]["iqr"])
|
||||||
|
# The kernel's spin is its own load level, a couple of counts under a
|
||||||
|
# Python spin, so a settled reader lands above the idle regime without
|
||||||
|
# reaching the busy one. The failure this guards is a settle that
|
||||||
|
# overshoots and lets the conversion fall back to idle, which reads as
|
||||||
|
# no move at all; the split collapsing above is the primary proof.
|
||||||
|
#
|
||||||
|
# The bound is a third of the control split, not half. Measured over
|
||||||
|
# eleven runs on this bench the move is 3 counts (once 2, once 4)
|
||||||
|
# against a control split of 6 (once 7), so a half-split bound sits
|
||||||
|
# exactly on the median and decides on one count of noise: two of those
|
||||||
|
# eleven runs failed with the machine reading identically to the nine
|
||||||
|
# that passed. A third keeps the guard against no move at all and
|
||||||
|
# leaves a count of margin either way.
|
||||||
|
moved = settled["idle"]["med"] - control["idle"]["med"]
|
||||||
|
ev["settled_idle_moved"] = moved
|
||||||
|
ctx.check(ev["control_split"] <= 2 or moved * 3 >= ev["control_split"],
|
||||||
|
"a settled idle reader moved %+d counts off the idle regime, the control split is %+d: "
|
||||||
|
"the settle did not land the conversion under load", moved, ev["control_split"])
|
||||||
|
ctx.check(ev["pic_settle_us_after"] == "500", "pic_settle_us left at %s", ev["pic_settle_us_after"])
|
||||||
|
ctx.log("PASS: settled, an idle reader and a busy reader read the same (split %+d); control split %+d",
|
||||||
|
ev["settled_split"], ev["control_split"])
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,306 @@
|
|||||||
|
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
|
||||||
|
the routing path (emitters, relays, rendered rules); the level settings."""
|
||||||
|
import gzip
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tarfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .. import hw
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path, default=None):
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
return f.read()
|
||||||
|
except OSError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
|
||||||
|
("forgectrl", "src/main.c")]
|
||||||
|
|
||||||
|
|
||||||
|
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
|
||||||
|
kind="auto", est_min=1,
|
||||||
|
covers=_LOG_COVERS, requires=["forgectrl.auth"],
|
||||||
|
description="/logs lists the loggers with their levels and files, /logs/tail returns the "
|
||||||
|
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
|
||||||
|
"bundle that contains neither the panel token nor the camera key.")
|
||||||
|
def tree_tail_export(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
st, body = fc.get("/logs")
|
||||||
|
ctx.log("GET /logs -> %s", st)
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
|
||||||
|
loggers = body.get("loggers") or []
|
||||||
|
names = [l.get("name") for l in loggers] if isinstance(loggers, list) else list(loggers)
|
||||||
|
ev["loggers"] = names
|
||||||
|
ctx.log("loggers: %s", names)
|
||||||
|
ctx.check("forgectrl" in names, "/logs lacks the forgectrl logger: %s", names)
|
||||||
|
|
||||||
|
st, tail = fc.get("/logs/tail", params={"name": "forgectrl", "lines": "20"})
|
||||||
|
ctx.log("GET /logs/tail?name=forgectrl -> %s", st)
|
||||||
|
ctx.check(st == 200, "GET /logs/tail -> %s %s", st, tail if isinstance(tail, dict) else "")
|
||||||
|
st, tail = fc.get("/logs/tail", params={"name": "no-such-logger"})
|
||||||
|
ctx.check(st == 404, "unknown logger -> %s, expected 404", st)
|
||||||
|
|
||||||
|
# the sanitizer walks every log file; a full tree takes tens of
|
||||||
|
# seconds on the target, so this call gets its own client timeout
|
||||||
|
slow = hw.Forgectrl(token=fc.token, timeout=300.0)
|
||||||
|
t0 = time.time()
|
||||||
|
st, data = slow.post("/logs/export", raw=True)
|
||||||
|
ev["export_s"] = round(time.time() - t0, 1)
|
||||||
|
ctx.log("POST /logs/export -> %s (%d bytes, %.1f s)", st, len(data) if data else 0, ev["export_s"])
|
||||||
|
ctx.check(st == 200, "POST /logs/export -> %s", st)
|
||||||
|
ev["export_bytes"] = len(data)
|
||||||
|
try:
|
||||||
|
raw = gzip.decompress(data)
|
||||||
|
tf = tarfile.open(fileobj=io.BytesIO(raw))
|
||||||
|
members = tf.getnames()
|
||||||
|
except (OSError, tarfile.TarError, EOFError) as e:
|
||||||
|
ctx.fail("export is not a readable tar.gz: %s", e)
|
||||||
|
ev["members"] = len(members)
|
||||||
|
ctx.log("bundle: %d members, e.g. %s", len(members), members[:5])
|
||||||
|
ctx.check(members, "empty bundle")
|
||||||
|
ctx.check(any(m.endswith("README.txt") for m in members), "sanitized bundle lacks README.txt")
|
||||||
|
# the two secrets the sanitizer knows by value and a bundle could
|
||||||
|
# carry: the panel token and the camera key (a URL parameter that
|
||||||
|
# lands in sender logs)
|
||||||
|
secrets = {}
|
||||||
|
if fc.token:
|
||||||
|
secrets["panel token"] = fc.token
|
||||||
|
st, body = fc.get("/system/camera-key")
|
||||||
|
if st == 200 and isinstance(body, dict) and body.get("key"):
|
||||||
|
secrets["camera key"] = body["key"]
|
||||||
|
ctx.check("camera key" in secrets, "GET /system/camera-key -> %s: no key to check for", st)
|
||||||
|
contents = [(m.name, tf.extractfile(m).read()) for m in tf.getmembers() if m.isfile()]
|
||||||
|
for what, value in secrets.items():
|
||||||
|
leaked = [name for name, content in contents if value.encode() in content]
|
||||||
|
ev[what.replace(" ", "_") + "_leaks"] = leaked
|
||||||
|
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
|
||||||
|
|
||||||
|
|
||||||
|
# The routing test proves the whole path every logger takes: emitter (or
|
||||||
|
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
|
||||||
|
# logger's own file in the ff_line format. It stands for the emitters in
|
||||||
|
# every component (fflog in forgectrl and grblHAL, the SysLogHandler in the
|
||||||
|
# Python apps), the supervisor's per-controller relay, the daemon's fifo
|
||||||
|
# relay in its init script, and the render step.
|
||||||
|
_ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/super.c"),
|
||||||
|
("forgectrl", "init/**"),
|
||||||
|
("grblhal-glowforge", "src/fflog.*"), ("grblhal-glowforge", "src/main.c"),
|
||||||
|
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
|
||||||
|
("forgefirm-app", "forgefirm-app/gfhome.py")]
|
||||||
|
|
||||||
|
LOGS_ROOT = "/data/log/forgefirm"
|
||||||
|
LOGGERS = ("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
|
||||||
|
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
|
||||||
|
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
|
||||||
|
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
|
||||||
|
|
||||||
|
|
||||||
|
def _tail_lines(path, n=400):
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
f.seek(0, 2)
|
||||||
|
size = f.tell()
|
||||||
|
f.seek(max(0, size - 256 * 1024))
|
||||||
|
data = f.read()
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
return data.decode("utf-8", "replace").splitlines()[-n:]
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_line(path, needle, timeout=6.0):
|
||||||
|
"""Poll a log file for a line containing needle; the matched line or None."""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
for line in _tail_lines(path, 200):
|
||||||
|
if needle in line:
|
||||||
|
return line
|
||||||
|
time.sleep(0.25)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@test("logs.routing", title="Log routing, relays, and the rendered rules", subsystem="logs",
|
||||||
|
kind="auto", est_min=1,
|
||||||
|
covers=_ROUTING_COVERS, requires=["logs.tree-tail-export"],
|
||||||
|
description="rsyslog is the only logger (no busybox syslogd/klogd), the rules rendered from "
|
||||||
|
"the settings and the effective-levels record exist and agree with /logs, every "
|
||||||
|
"logger has its directory, the daemon's own emitter and the per-controller "
|
||||||
|
"`logger` relay both land in the right file in the ff_line format, a stray "
|
||||||
|
"program's line goes to system/ and nowhere else, kernel lines reach kernel/, "
|
||||||
|
"and nothing of ours is written outside the tree.")
|
||||||
|
def routing(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
|
||||||
|
# 1. one logger daemon
|
||||||
|
rs = hw.pidof("rsyslogd")
|
||||||
|
ev["rsyslogd_pids"] = rs
|
||||||
|
ctx.check(rs, "rsyslogd is not running")
|
||||||
|
for legacy in ("syslogd", "klogd"):
|
||||||
|
pids = hw.pidof(legacy)
|
||||||
|
ctx.check(not pids, "busybox %s is running (%s) next to rsyslog", legacy, pids)
|
||||||
|
ctx.log("rsyslogd %s; no syslogd/klogd", rs)
|
||||||
|
|
||||||
|
# 2. the render step: rules + effective levels, consistent with what
|
||||||
|
# the API reports as effective
|
||||||
|
ctx.check(os.path.isfile("/data/forgefirm/rsyslog-forgefirm.conf"),
|
||||||
|
"rendered rules missing: /data/forgefirm/rsyslog-forgefirm.conf")
|
||||||
|
rules = _read("/data/forgefirm/rsyslog-forgefirm.conf") or ""
|
||||||
|
for name in LOGGERS:
|
||||||
|
ctx.check(("# %s:" % name) in rules, "rendered rules carry no block for %s", name)
|
||||||
|
ctx.check(os.path.isfile("/var/run/forgefirm-loglevels"), "effective-levels record missing")
|
||||||
|
st, body = fc.get("/logs")
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
|
||||||
|
ctx.check(body.get("effective_known") is True, "/logs does not know the effective levels")
|
||||||
|
eff = {}
|
||||||
|
for l in body.get("loggers") or []:
|
||||||
|
eff[l["name"]] = (l.get("effective_disk"), l.get("effective_remote"))
|
||||||
|
ev["effective"] = eff
|
||||||
|
ctx.log("effective levels: %s", eff)
|
||||||
|
ctx.check(set(eff) == set(LOGGERS), "loggers reported: %s", sorted(eff))
|
||||||
|
levels = _read("/var/run/forgefirm-loglevels") or ""
|
||||||
|
for name, (disk, _remote) in eff.items():
|
||||||
|
ctx.check(("log_%s_disk=%s" % (name, disk)) in levels,
|
||||||
|
"effective record and /logs disagree on %s (%s)", name, disk)
|
||||||
|
|
||||||
|
# 3. the tree
|
||||||
|
for name in LOGGERS:
|
||||||
|
ctx.check(os.path.isdir(os.path.join(LOGS_ROOT, name)), "no directory for %s", name)
|
||||||
|
|
||||||
|
# 4. the daemon's own emitter (fflog): a line from the running pid, if
|
||||||
|
# its level admits info
|
||||||
|
pids = hw.pidof("forgectrl")
|
||||||
|
ctx.check(pids, "forgectrl is not running")
|
||||||
|
disk_rank = _SEV_RANK.get(eff.get("forgectrl", ("info", "off"))[0], 6)
|
||||||
|
fpath = os.path.join(LOGS_ROOT, "forgectrl", "forgectrl.log")
|
||||||
|
if disk_rank >= 6:
|
||||||
|
own = [l for l in _tail_lines(fpath, 2000)
|
||||||
|
if any((" forgectrl[%d] " % p) in l for p in pids)]
|
||||||
|
ev["own_lines"] = len(own)
|
||||||
|
ctx.log("forgectrl[%s] lines in its file: %d", pids, len(own))
|
||||||
|
ctx.check(own, "no line from the running daemon in %s (emitter path broken?)", fpath)
|
||||||
|
m = _LINE_RE.match(own[-1])
|
||||||
|
ctx.check(m and m.group("prog") == "forgectrl", "not in the ff_line format: %r", own[-1])
|
||||||
|
else:
|
||||||
|
ctx.log("forgectrl disk level %s: own-emitter check skipped", eff["forgectrl"][0])
|
||||||
|
|
||||||
|
# 5. the relay path: a `logger` probe under a controller's name lands
|
||||||
|
# in that controller's file with the ff_line format
|
||||||
|
nonce = "forgetest-routing-%d" % int(time.time())
|
||||||
|
for name in ("grblhal", "gfhome"):
|
||||||
|
rank = _SEV_RANK.get(eff.get(name, ("info", "off"))[0], 6)
|
||||||
|
if rank < 3:
|
||||||
|
ctx.log("%s disk level off: relay probe skipped", name)
|
||||||
|
continue
|
||||||
|
rc, out = hw.run(["logger", "-t", name, "-p", "daemon.err", "%s %s" % (nonce, name)])
|
||||||
|
ctx.check(rc == 0, "logger -t %s failed: %s", name, out)
|
||||||
|
path = os.path.join(LOGS_ROOT, name, name + ".log")
|
||||||
|
line = _wait_line(path, nonce)
|
||||||
|
ev["probe_" + name] = line
|
||||||
|
ctx.check(line, "%s probe did not reach %s", name, path)
|
||||||
|
m = _LINE_RE.match(line)
|
||||||
|
ctx.check(m and m.group("prog") == name and m.group("sev") == "ERR",
|
||||||
|
"%s probe line not in the ff_line format: %r", name, line)
|
||||||
|
ctx.log("%s: %s", name, line)
|
||||||
|
# a probe under another name must NOT leak into these files
|
||||||
|
rc, out = hw.run(["logger", "-t", "forgetest-stray", "-p", "daemon.err", "%s stray" % nonce])
|
||||||
|
time.sleep(1.5)
|
||||||
|
for name in ("grblhal", "gfhome", "forgectrl"):
|
||||||
|
path = os.path.join(LOGS_ROOT, name, name + ".log")
|
||||||
|
ctx.check(not any((nonce + " stray") in l for l in _tail_lines(path, 100)),
|
||||||
|
"a stray program's line landed in %s", path)
|
||||||
|
sys_rank = _SEV_RANK.get(eff.get("system", ("info", "off"))[0], 6)
|
||||||
|
if sys_rank >= 3:
|
||||||
|
line = _wait_line(os.path.join(LOGS_ROOT, "system", "system.log"), nonce + " stray")
|
||||||
|
ctx.check(line, "the stray program's line did not reach system/")
|
||||||
|
|
||||||
|
# 6. relay processes: the supervisor spawns one `logger` per controller
|
||||||
|
# (its stdin is the controller's output pipe); the daemon's init
|
||||||
|
# script keeps one on the fifo
|
||||||
|
st, mode = fc.get("/mode")
|
||||||
|
relays = hw.pidof("logger")
|
||||||
|
ev["logger_relays"] = relays
|
||||||
|
ev["controller"] = mode.get("controller") if isinstance(mode, dict) else None
|
||||||
|
ctx.log("logger relays: %s (controller %s)", relays, ev["controller"])
|
||||||
|
if isinstance(mode, dict) and mode.get("controller") == "running":
|
||||||
|
ctx.check(relays, "controller running but no `logger` relay process")
|
||||||
|
if os.path.exists("/var/run/forgectrl.stderr"):
|
||||||
|
ctx.check(len(relays) >= 2 or ev["controller"] != "running",
|
||||||
|
"fifo relay present but only %s logger process(es)", relays)
|
||||||
|
|
||||||
|
# 7. the kernel reaches kernel/ (unless filtered off)
|
||||||
|
if _SEV_RANK.get(eff.get("kernel", ("info", "off"))[0], 6) >= 6:
|
||||||
|
klines = _tail_lines(os.path.join(LOGS_ROOT, "kernel", "kernel.log"), 50)
|
||||||
|
ev["kernel_lines"] = len(klines)
|
||||||
|
ctx.check(klines and any(" kernel[" in l for l in klines), "no kernel lines in kernel/")
|
||||||
|
|
||||||
|
# 8. nothing outside the tree
|
||||||
|
for stray in ("/data/forgectrl.log", "/data/gfcloud.log", "/data/gfhome.log",
|
||||||
|
"/data/log/gfcloud", "/data/log/gfhome", "/var/log/messages"):
|
||||||
|
ctx.check(not os.path.exists(stray), "pre-syslog log path still present: %s", stray)
|
||||||
|
ctx.log("no ForgeFIRM log outside %s", LOGS_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
@test("logs.level-settings", title="Log level and remote-target settings", subsystem="logs",
|
||||||
|
kind="auto", est_min=1,
|
||||||
|
covers=[("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/settings.*")],
|
||||||
|
requires=["logs.tree-tail-export", "forgectrl.settings-bounds"],
|
||||||
|
description="A log level outside off..debug, a bad remote port, a bad protocol and a bad "
|
||||||
|
"server name are refused (400); a valid remote level is accepted and /logs shows "
|
||||||
|
"it as configured but not effective (pending_reboot); the setting is restored to "
|
||||||
|
"its prior value.")
|
||||||
|
def level_settings(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||||
|
|
||||||
|
for key, val in (("log_grblhal_disk", "verbose"), ("log_kernel_remote", "7"),
|
||||||
|
("syslog_port", "70000"), ("syslog_proto", "tls"),
|
||||||
|
("syslog_server", "bad host name")):
|
||||||
|
st, body = fc.post("/settings", data={key: val})
|
||||||
|
ev["refused %s=%s" % (key, val)] = st
|
||||||
|
ctx.log("POST /settings %s=%s -> %s", key, val, st)
|
||||||
|
ctx.check(st == 400, "%s=%s -> %s, expected 400", key, val, st)
|
||||||
|
|
||||||
|
st, before = fc.get("/logs")
|
||||||
|
ctx.check(st == 200 and isinstance(before, dict), "GET /logs -> %s", st)
|
||||||
|
prior = {l["name"]: l for l in before.get("loggers") or []}
|
||||||
|
ctx.check("system" in prior, "/logs lacks the system logger")
|
||||||
|
was = prior["system"].get("remote") or "off"
|
||||||
|
was_pending = bool(before.get("pending_reboot"))
|
||||||
|
new = "warning" if was != "warning" else "error"
|
||||||
|
ev["system_remote_before"] = was
|
||||||
|
ev["pending_before"] = was_pending
|
||||||
|
try:
|
||||||
|
st, body = fc.post("/settings", data={"log_system_remote": new})
|
||||||
|
ctx.log("POST /settings log_system_remote=%s -> %s", new, st)
|
||||||
|
ctx.check(st == 200, "valid level refused: %s", st)
|
||||||
|
st, after = fc.get("/logs")
|
||||||
|
now = {l["name"]: l for l in after.get("loggers") or []}
|
||||||
|
ctx.check(now["system"].get("remote") == new, "configured level reads %r", now["system"].get("remote"))
|
||||||
|
ctx.check(now["system"].get("effective_remote") == prior["system"].get("effective_remote"),
|
||||||
|
"effective level changed without a reboot")
|
||||||
|
ctx.check(after.get("pending_reboot") is True, "pending_reboot not raised by a level change")
|
||||||
|
ctx.log("configured %s, effective %s, pending_reboot %s",
|
||||||
|
now["system"].get("remote"), now["system"].get("effective_remote"), after.get("pending_reboot"))
|
||||||
|
finally:
|
||||||
|
# restore: a value that was stored goes back as a value; a default
|
||||||
|
# is restored by clearing the key (a query parameter - empty
|
||||||
|
# values never reach the form body)
|
||||||
|
stored = fc.settings().get("log_system_remote", "")
|
||||||
|
if was == "off" and stored in ("", None, new):
|
||||||
|
st, body = fc.post("/settings", params={"log_system_remote": ""})
|
||||||
|
else:
|
||||||
|
st, body = fc.post("/settings", data={"log_system_remote": was})
|
||||||
|
ctx.log("restore log_system_remote -> %s", st)
|
||||||
|
st, final = fc.get("/logs")
|
||||||
|
fin = {l["name"]: l for l in final.get("loggers") or []}
|
||||||
|
ctx.check(fin["system"].get("remote") == was, "system remote level not restored (%r)", fin["system"].get("remote"))
|
||||||
|
ctx.check(bool(final.get("pending_reboot")) == was_pending,
|
||||||
|
"pending_reboot did not return to %s after the restore", was_pending)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
|||||||
|
"""update.* - the A/B slot inventory and the firmware verification path."""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from ..catalog import test
|
||||||
|
from .. import hw
|
||||||
|
|
||||||
|
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
|
||||||
|
("ffboot", "**")]
|
||||||
|
|
||||||
|
|
||||||
|
@test("update.slots-and-signature", title="Boot slots readable, unsigned and foreign-signed archives refused",
|
||||||
|
subsystem="update", kind="auto", est_min=2,
|
||||||
|
covers=_UPDATE_COVERS, requires=["forgectrl.auth"],
|
||||||
|
description="/slots reports the A/B inventory consistent with `ffboot -l`; /update/status "
|
||||||
|
"answers; `fwup` refuses a garbage archive. A tiny archive signed with a "
|
||||||
|
"throwaway key made on the spot verifies with its own key and fails against the "
|
||||||
|
"shipped release key, and an apply of it without confirm_unsigned is refused by "
|
||||||
|
"the update job before anything is written. Nothing is written to any slot.")
|
||||||
|
def slots_and_signature(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
st, slots = fc.get("/slots")
|
||||||
|
ctx.log("GET /slots -> %s %s", st, slots)
|
||||||
|
ctx.check(st == 200 and isinstance(slots, dict), "GET /slots -> %s", st)
|
||||||
|
ev["slots"] = slots
|
||||||
|
rc, out = hw.run(["ffboot", "-l"])
|
||||||
|
ev["ffboot_l_rc"] = rc
|
||||||
|
ctx.log("ffboot -l -> rc %s\n%s", rc, out.strip())
|
||||||
|
ctx.check(rc == 0, "ffboot -l failed (%s)", rc)
|
||||||
|
text = str(slots).lower()
|
||||||
|
ctx.check("forgefirm" in text or "slot" in text, "/slots does not look like a slot inventory")
|
||||||
|
|
||||||
|
st, us = fc.get("/update/status")
|
||||||
|
ctx.log("GET /update/status -> %s %s", st, us)
|
||||||
|
ctx.check(st == 200 and isinstance(us, dict) and "running" in us, "GET /update/status -> %s", st)
|
||||||
|
ctx.check(not us.get("running"), "an update is running")
|
||||||
|
|
||||||
|
key = "/etc/forgefirm/keys/forgefirm-release.pub"
|
||||||
|
ctx.check(os.path.exists(key), "release key %s missing", key)
|
||||||
|
with tempfile.NamedTemporaryFile(prefix="forgetest-", suffix=".fw", delete=False) as f:
|
||||||
|
f.write(b"this is not a firmware archive" * 64)
|
||||||
|
garbage = f.name
|
||||||
|
try:
|
||||||
|
rc, out = hw.run(["fwup", "-V", "-i", garbage, "-p", key], timeout=30)
|
||||||
|
ev["fwup_garbage_rc"] = rc
|
||||||
|
ctx.log("fwup -V garbage -> rc %s: %s", rc, out.strip()[:200])
|
||||||
|
ctx.check(rc != 0, "fwup accepted a garbage archive")
|
||||||
|
finally:
|
||||||
|
os.unlink(garbage)
|
||||||
|
|
||||||
|
# A foreign signature: a throwaway key pair signs a tiny archive. The
|
||||||
|
# shipped key must refuse it, its own key must accept it, and the
|
||||||
|
# apply job must refuse it without confirm_unsigned, before it
|
||||||
|
# touches the slot.
|
||||||
|
work = tempfile.mkdtemp(prefix="forgetest-fw-")
|
||||||
|
staged = "/data/forgefirm/upload.fw"
|
||||||
|
try:
|
||||||
|
with open(os.path.join(work, "note.txt"), "w") as f:
|
||||||
|
f.write("forgetest foreign-signature drill\n")
|
||||||
|
with open(os.path.join(work, "fwup.conf"), "w") as f:
|
||||||
|
f.write('meta-product = "forgetest"\nmeta-version = "0.0.0-test"\n'
|
||||||
|
'file-resource note.txt { host-path = "note.txt" }\n'
|
||||||
|
'task complete { on-resource note.txt { raw_write(0) } }\n')
|
||||||
|
rc, out = hw.run(["sh", "-c", "cd %s && fwup -g" % work], timeout=60)
|
||||||
|
ev["fwup_gen_rc"] = rc
|
||||||
|
ctx.check(rc == 0 and os.path.exists(os.path.join(work, "fwup-key.pub")),
|
||||||
|
"fwup -g did not make a key pair (rc %s): %s", rc, out.strip()[:200])
|
||||||
|
plain, signed = os.path.join(work, "plain.fw"), os.path.join(work, "signed.fw")
|
||||||
|
rc, out = hw.run(["sh", "-c", "cd %s && fwup -c -f fwup.conf -o plain.fw && "
|
||||||
|
"fwup -S -s fwup-key.priv -i plain.fw -o signed.fw" % work], timeout=60)
|
||||||
|
ctx.check(rc == 0 and os.path.exists(signed), "could not make the signed archive (rc %s): %s",
|
||||||
|
rc, out.strip()[:200])
|
||||||
|
rc_own, _ = hw.run(["fwup", "-V", "-i", signed, "-p", os.path.join(work, "fwup-key.pub")], timeout=30)
|
||||||
|
rc_ship, out = hw.run(["fwup", "-V", "-i", signed, "-p", key], timeout=30)
|
||||||
|
ev["fwup_foreign"] = {"own_key_rc": rc_own, "shipped_key_rc": rc_ship}
|
||||||
|
ctx.log("fwup -V signed: own key rc %s, shipped key rc %s", rc_own, rc_ship)
|
||||||
|
ctx.check(rc_own == 0, "the archive does not verify with the key that signed it")
|
||||||
|
ctx.check(rc_ship != 0, "the shipped release key accepted a foreign signature")
|
||||||
|
|
||||||
|
# The apply path. The target is the slot not booted; a slot already
|
||||||
|
# selected for the next boot is refused for its own reason, which
|
||||||
|
# this drill records and steps over.
|
||||||
|
target = None
|
||||||
|
for name, si in (slots.get("slots") or {}).items():
|
||||||
|
if name in ("a", "b") and isinstance(si, dict) and not si.get("booted"):
|
||||||
|
target = name
|
||||||
|
ctx.check(target is not None, "no inactive firmware slot in /slots: %s", slots)
|
||||||
|
shutil.copyfile(signed, staged)
|
||||||
|
st, body = fc.post("/update/apply", params={"slot": target, "file": "upload"})
|
||||||
|
ev["apply"] = {"status": st, "body": body}
|
||||||
|
ctx.log("POST /update/apply slot=%s file=upload (no confirm_unsigned) -> %s %s", target, st, body)
|
||||||
|
if st == 409 and isinstance(body, dict) and "next boot" in str(body.get("error", "")):
|
||||||
|
ctx.log("slot %s is selected for the next boot: the apply is refused before the "
|
||||||
|
"signature check, which is its own guard", target)
|
||||||
|
else:
|
||||||
|
ctx.check(st == 202 and isinstance(body, dict) and body.get("started") is True,
|
||||||
|
"the apply job did not start: %s %s", st, body)
|
||||||
|
result = None
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < 60:
|
||||||
|
ctx.sleep(1)
|
||||||
|
st, us = fc.get("/update/status")
|
||||||
|
if isinstance(us, dict) and not us.get("running"):
|
||||||
|
result = us.get("result")
|
||||||
|
break
|
||||||
|
ev["apply_result"] = result
|
||||||
|
ctx.log("apply result: %s", result)
|
||||||
|
ctx.check(isinstance(result, dict) and result.get("ok") is False
|
||||||
|
and "not signed" in str(result.get("error", "")),
|
||||||
|
"the apply of a foreign-signed archive was not refused for its signature: %s", result)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(staged)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
/*
|
||||||
|
* app.js - forgetest: the acceptance page
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Acceptance tab + bench tab. State comes from GET /state, polled every
|
||||||
|
* 2 s when the machine is idle and every second during a run; the catalog
|
||||||
|
* and the bench listing are fetched once per tab and refreshed after a
|
||||||
|
* run finishes.
|
||||||
|
*
|
||||||
|
* Rows, prompt buttons and tool entries are built once and thereafter
|
||||||
|
* only updated in place. A poll that rebuilt them would swallow the click
|
||||||
|
* it landed on: the button that took the mousedown would be gone before
|
||||||
|
* the mouseup, so no click event would ever be raised. Every action also
|
||||||
|
* grays its control out on the press and pulls the next poll forward, so
|
||||||
|
* the page answers the operator rather than the timer. The help popovers
|
||||||
|
* (help.js) sit on static elements only, so no rebuild ever orphans one.
|
||||||
|
*
|
||||||
|
* TOKEN is the bearer token page.py splices in when it serves the page.
|
||||||
|
*/
|
||||||
|
var TOKEN='__TOKEN__';
|
||||||
|
var state=null, catalog=null, catalogHash=null, bench=null, tab='acceptance', openDetails={};
|
||||||
|
var lastRunKey=null, stateEtag=null, curPrompt=null, promptKey=null, abortSent=false;
|
||||||
|
var pollTimer=null, polling=false, pending=0, pendingId=null, rowMsg={};
|
||||||
|
var rowEls=null, groupsKey=null, benchEls=null, benchKey=null, benchNeedsRefresh=false;
|
||||||
|
var PENDING_MS=6000;
|
||||||
|
/* A poll that never answers must not wedge the loop: it times out
|
||||||
|
and the next one is scheduled as usual. Actions carry no timeout,
|
||||||
|
because a POST the server did act on must not read as a failure. */
|
||||||
|
var POLL_TIMEOUT_MS=20000;
|
||||||
|
var ignoreReq=false;try{ignoreReq=window.localStorage.getItem('forgetest.ignoreReq')==='1'}catch(e){}
|
||||||
|
function $(id){return document.getElementById(id)}
|
||||||
|
function nowMs(){return (new Date()).getTime()}
|
||||||
|
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}
|
||||||
|
/* Touch the DOM only where the value really changed. Replacing a node
|
||||||
|
under the operator's finger loses the click: the button that took the
|
||||||
|
mousedown is gone before the mouseup, so no click event is ever
|
||||||
|
raised, and the press does nothing. Nothing on a poll may rebuild a
|
||||||
|
control that is only sitting there unchanged. */
|
||||||
|
function setHtml(e,h){if(e&&e.__h!==h){e.__h=h;e.innerHTML=h}}
|
||||||
|
function setText(e,t){if(e&&e.__t!==t){e.__t=t;e.textContent=t}}
|
||||||
|
function setDis(e,d){if(e&&e.disabled!==d)e.disabled=d}
|
||||||
|
function setProp(e,k,v){if(e&&e[k]!==v)e[k]=v}
|
||||||
|
function api(method,path,body,cb,hdrs,timeoutMs){var x=new XMLHttpRequest();x.open(method,path,true);
|
||||||
|
if(timeoutMs)x.timeout=timeoutMs;
|
||||||
|
x.setRequestHeader('X-ForgeFIRM-Token',TOKEN);
|
||||||
|
if(hdrs){for(var k in hdrs){if(hdrs.hasOwnProperty(k))x.setRequestHeader(k,hdrs[k])}}
|
||||||
|
if(body!==undefined&&body!==null){x.setRequestHeader('Content-Type','application/json')}
|
||||||
|
x.onreadystatechange=function(){if(x.readyState!==4)return;var d=null;try{d=JSON.parse(x.responseText)}catch(e){d={error:x.responseText}}cb(x.status,d,x)};
|
||||||
|
x.send(body===undefined||body===null?null:JSON.stringify(body))}
|
||||||
|
/* The state poll. It carries the last ETag, so an unchanged state costs
|
||||||
|
a 304 and no re-render; a run gets a faster tick, and every action
|
||||||
|
pulls the next poll forward instead of waiting out the interval. */
|
||||||
|
function pollDelay(){return (state&&(state.running||batchActive()))?1000:2000}
|
||||||
|
function schedule(ms){if(pollTimer)window.clearTimeout(pollTimer);pollTimer=window.setTimeout(poll,ms)}
|
||||||
|
function kick(){schedule(120)}
|
||||||
|
function poll(){if(polling){schedule(150);return}polling=true;
|
||||||
|
/* While an action is still in flight the state is asked for in full:
|
||||||
|
a 304 would skip the render that releases the grayed-out buttons. */
|
||||||
|
var h=(stateEtag&&!pending)?{'If-None-Match':stateEtag}:null;
|
||||||
|
api('GET','/state',null,function(s,d,x){polling=false;
|
||||||
|
if(s===200){stateEtag=x.getResponseHeader('ETag');state=d;
|
||||||
|
if(!catalog||catalogHash!==d.catalog_hash){loadCatalog(render)}else{render()}}
|
||||||
|
schedule(pollDelay())},h,POLL_TIMEOUT_MS)}
|
||||||
|
function showTab(t){tab=t;closeHelp();$('tab-acceptance').className=t==='acceptance'?'on':'';$('tab-bench').className=t==='bench'?'on':'';
|
||||||
|
$('pane-acceptance').style.display=t==='acceptance'?'':'none';$('pane-bench').style.display=t==='bench'?'':'none';
|
||||||
|
if(t==='bench'&&!bench)loadBench()}
|
||||||
|
function setMsg(id,txt,err){setHtml($(id),txt?"<div class='"+(err?'err':'msg')+"'>"+esc(txt)+"</div>":'')}
|
||||||
|
function loadCatalog(cb){api('GET','/catalog',null,function(s,d){if(s===200){catalog=d.tests;catalogHash=d.catalog_hash;if(cb)cb()}})}
|
||||||
|
function loadBench(){api('GET','/bench',null,function(s,d){if(s===200){bench=d;benchKey=null;renderBench()}})}
|
||||||
|
function setIgnoreReq(on){ignoreReq=!!on;try{window.localStorage.setItem('forgetest.ignoreReq',ignoreReq?'1':'0')}catch(e){}
|
||||||
|
$('ignreq').checked=ignoreReq;setHtml($('ignreqon'),ignoreReq?"<span class='on'>ON - prerequisites are not enforced</span>":'');
|
||||||
|
if(state&&catalog)renderGroups()}
|
||||||
|
/* A start already sent but not yet seen in the state counts as busy, so
|
||||||
|
the buttons gray out on the click rather than on the next poll. The
|
||||||
|
window is capped in case the answer never arrives. A queue holds the
|
||||||
|
machine between its tests as well as during them. */
|
||||||
|
function batchActive(){return !!(state&&state.batch&&!state.batch.finished)}
|
||||||
|
function isBusy(){if(state&&(state.running||batchActive()))return true;
|
||||||
|
return !!(pending&&(nowMs()-pending)<PENDING_MS)}
|
||||||
|
function fmtTs(t){return t?t.replace('T',' ').replace('Z',' UTC'):'-'}
|
||||||
|
function render(){if(!state||!catalog)return;
|
||||||
|
if(state.running||batchActive()){pending=0;pendingId=null}
|
||||||
|
var m=state.manifest||{};setText($('hdrver'),(m.version||'?'));setText($('hdrsub'),m.image||'');
|
||||||
|
var a=$('auth');setText(a,'Release authorized: '+(state.authorized?'YES':'NO'));setProp(a,'className','auth'+(state.authorized?' yes':''));
|
||||||
|
var c=state.campaign,cn=state.counts||{};var b='';
|
||||||
|
b+='<b>'+cn.satisfied+'</b> of <b>'+cn.total+'</b> satisfied ('+cn.inherited+' inherited), <b>'+cn.required+'</b> required<br>';
|
||||||
|
b+=c?('campaign <span class="mono">'+esc(c.id)+'</span> opened '+esc(fmtTs(c.ts))):('no open campaign'+(state.closed_by?(' (last one closed by '+esc(state.closed_by)+')'):'')+' - the first Start opens one');
|
||||||
|
b+='<br>manifest identity <span class="mono">'+esc((m.sha||'').slice(0,16))+'</span> · catalog <span class="mono">'+esc((state.catalog_hash||'').slice(0,12))+'</span>';
|
||||||
|
if(state.log_corrupt)b+='<br><span class="b-bad">'+state.log_corrupt+' corrupt log line(s) skipped</span>';
|
||||||
|
setHtml($('banner'),b);
|
||||||
|
var inv=state.invalidate;setHtml($('invnote'),inv?"<div class='note'>Full campaign required since "+esc(fmtTs(inv.ts))+": "+esc(inv.reason)+"</div>":'');
|
||||||
|
renderQueue();renderGroups();renderRun();renderSteps();if(bench)renderBench()}
|
||||||
|
/* The two queues: what each would run now, and how the running one is
|
||||||
|
getting on. Built from the state, so a reload picks the queue back up
|
||||||
|
exactly where it is - the queue lives in the runner, not in this tab. */
|
||||||
|
var QUEUES=[['unattended','Unattended'],['attended','Operator and live']];
|
||||||
|
function renderFixture(){var f=state.fixture,e=$('fixture');if(!e)return;
|
||||||
|
if(!f){setHtml(e,'');return}
|
||||||
|
var ch=(f.channels||[]).filter(function(c){return c!=='button'||f.button_enabled});
|
||||||
|
setHtml(e,'Bench fixture <b>'+esc(f.hostname)+'</b> at '+esc(f.ip||'?')+' (v'+esc(f.version||'?')+') covers <b>'+
|
||||||
|
(ch.length?esc(ch.join(', ')):'nothing')+'</b>'+
|
||||||
|
((f.channels||[]).indexOf('button')>=0&&!f.button_enabled?' (button disabled: enable jumper out)':'')+
|
||||||
|
'; arm press: '+(f.arm_press?'<b>the fixture</b>':'the operator'))}
|
||||||
|
function renderQueue(){var av=state.batch_available||{},b=state.batch,busy=isBusy();
|
||||||
|
renderFixture();
|
||||||
|
QUEUES.forEach(function(p){var e=$('q-'+p[0]);if(!e)return;
|
||||||
|
var ids=av[p[0]]||[];
|
||||||
|
setText(e,ids.length?(p[1]+' ('+ids.length+')'):(p[1]+' (none left)'));
|
||||||
|
setDis(e,busy||!ids.length);
|
||||||
|
setProp(e,'title',!ids.length?('nothing left: every '+p[0]+' test is satisfied')
|
||||||
|
:(busy?'a run is in progress':('in order: '+ids.join(', '))))});
|
||||||
|
setDis($('q-stop'),!batchActive()||!!(b&&b.stopping));
|
||||||
|
if(!b){setHtml($('qstate'),'');return}
|
||||||
|
var total=b.order.length||1,ok=0,bad=0;
|
||||||
|
b.done.forEach(function(x){if(x.result==='PASS')ok++;else bad++});
|
||||||
|
function seg(cls,n){return n?("<i class='"+cls+"' style='width:"+(100*n/total)+"%'></i>"):''}
|
||||||
|
var h="<div class='qbar'>"+seg('ok',ok)+seg('bad',bad)+seg('skip',b.skipped.length)+
|
||||||
|
(b.current?seg('now',1):'')+"</div><div class='qline'><b>"+esc(b.group)+"</b> queue, opened "+
|
||||||
|
esc(fmtTs(b.ts))+" · ";
|
||||||
|
h+=b.finished?('finished '+esc(fmtTs(b.finished))):(b.current?('running <b>'+esc(b.current)+'</b>'):
|
||||||
|
(b.stopping?'stopping':'starting'));
|
||||||
|
h+=" · <b>"+ok+"</b> passed, <b>"+bad+"</b> not, <b>"+b.skipped.length+
|
||||||
|
"</b> skipped, <b>"+b.pending.length+"</b> waiting";
|
||||||
|
if(b.stopped)h+="<br><span class='req'>stopped: "+esc(b.stopped)+"</span>";
|
||||||
|
if(b.skipped.length)h+="<br>skipped: "+esc(b.skipped.map(function(x){return x.test+' ('+x.reason+')'}).join('; '));
|
||||||
|
if(b.pending.length)h+="<br>waiting: <span class='tid'>"+esc(b.pending.join(', '))+"</span>";
|
||||||
|
setHtml($('qstate'),h+"</div>")}
|
||||||
|
/* The rows are built once for a given catalog and then only updated in
|
||||||
|
place: a poll never rewrites the table, so a Start button survives the
|
||||||
|
press that is landing on it. */
|
||||||
|
function renderGroups(){var ids=[];catalog.forEach(function(t){ids.push(t.id)});
|
||||||
|
var sig=catalogHash+'|'+ids.join(',');
|
||||||
|
if(sig!==groupsKey){groupsKey=sig;buildGroups()}
|
||||||
|
updateGroups()}
|
||||||
|
var COLS="<colgroup><col><col class='k'><col class='s'><col class='l'><col class='a'></colgroup>";
|
||||||
|
function buildGroups(){var groups={},order=[];
|
||||||
|
catalog.forEach(function(t){if(!groups[t.subsystem]){groups[t.subsystem]=[];order.push(t.subsystem)}groups[t.subsystem].push(t)});
|
||||||
|
var h='';
|
||||||
|
order.forEach(function(g){h+="<div class='card grp'><h2>"+esc(g)+"</h2><table>"+COLS+"<tr><th>Test</th><th>Kind</th><th>Status</th><th>Last result</th><th></th></tr>";
|
||||||
|
groups[g].forEach(function(t){var d=esc(t.id);var tags='';
|
||||||
|
if(t.always)tags+="<span class='tag core'>core</span>";tags+="<span class='tag "+esc(t.kind)+"'>"+esc(t.kind)+"</span>";
|
||||||
|
if(t.hardware==='takeover')tags+="<span class='tag takeover'>takeover</span>";
|
||||||
|
if(t.mode)tags+="<span class='tag mode'>"+esc(t.mode)+"</span>";
|
||||||
|
var det="<div class='details"+(openDetails[t.id]?' on':'')+"' id='det-"+d+"'>"+
|
||||||
|
(t.description?("<div class='dsc'>"+esc(t.description)+"</div>"):'')+
|
||||||
|
(t.steps&&t.steps.length?"<b>Operator steps:</b><ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>":'')+
|
||||||
|
(t.actions&&t.actions.length?"<b>Machine actions:</b> "+esc(t.actions.join(', '))+"<br>":'')+
|
||||||
|
"<b>Requires:</b> "+esc((t.requires||[]).join(', ')||'-')+"<br><b>Covers:</b> "+esc((t.covers||[]).map(function(c){return c[0]+':'+c[1]}).join(', ')||'-')+
|
||||||
|
"<br><span id='detdyn-"+d+"'></span></div>";
|
||||||
|
h+="<tr><td><div class='tsel' title='show what this test asks of you' onclick='selectTest(\""+d+"\")'>"+esc(t.title)+"</div><div class='tid'>"+d+" <a href='#' onclick='toggleDet(\""+d+"\");return false'>details</a></div>"+
|
||||||
|
"</td><td>"+tags+"</td>"+
|
||||||
|
"<td><div id='st-"+d+"'></div><div id='unmet-"+d+"'></div><div id='note-"+d+"'></div></td>"+
|
||||||
|
"<td id='last-"+d+"'></td>"+
|
||||||
|
"<td><button class='btn btn-sm btn-primary' id='btn-"+d+"' onclick='startTest(\""+d+"\")'>Start</button></td></tr>"+
|
||||||
|
"<tr class='detrow'><td colspan='5'>"+det+"</td></tr>"});
|
||||||
|
h+="</table></div>"});
|
||||||
|
$('groups').innerHTML=h;rowEls={};
|
||||||
|
catalog.forEach(function(t){rowEls[t.id]={st:$('st-'+t.id),last:$('last-'+t.id),btn:$('btn-'+t.id),
|
||||||
|
unmet:$('unmet-'+t.id),note:$('note-'+t.id),detdyn:$('detdyn-'+t.id)}})}
|
||||||
|
function updateGroups(){if(!rowEls||!state)return;var busy=isBusy();
|
||||||
|
var queued={};if(batchActive()){(state.batch.pending||[]).forEach(function(x){queued[x]=1})}
|
||||||
|
catalog.forEach(function(t){var e=rowEls[t.id];if(!e)return;var s=state.tests[t.id]||{};
|
||||||
|
var st;
|
||||||
|
if(pendingId===t.id&&isBusy()&&!state.running){st="<span class='st running'>starting…</span>"}
|
||||||
|
else{st="<span class='st "+esc(s.status)+"'>"+esc(s.status||'none')+"</span>";
|
||||||
|
if(s.required&&s.status!=='running')st+="<br><span class='req'>required: "+esc(s.reason)+"</span>"}
|
||||||
|
if(queued[t.id])st+="<br><span class='tag queued'>queued</span>";
|
||||||
|
setHtml(e.st,st);
|
||||||
|
var last=s.last?(esc(s.last.result)+' '+esc(fmtTs(s.last.ts))):'-';
|
||||||
|
if(s.status==='inherited'&&s.origin)last+="<br><span class='tid'>from "+esc(s.origin.campaign)+" on "+esc(s.origin.image)+"</span>";
|
||||||
|
setHtml(e.last,last);
|
||||||
|
var unmet=s.requires_met===false;var can=!busy&&(!unmet||ignoreReq);
|
||||||
|
setDis(e.btn,!can);
|
||||||
|
setProp(e.btn,'title',busy?'a run is in progress':(unmet?((ignoreReq?'prerequisites overridden - needs: ':'needs: ')+(s.missing_requires||[]).join(', ')):''));
|
||||||
|
setProp(e.unmet,'className','req'+(ignoreReq?' over':''));
|
||||||
|
setHtml(e.unmet,unmet?((ignoreReq?'unmet: ':'needs: ')+esc((s.missing_requires||[]).join(', '))):'');
|
||||||
|
setHtml(e.note,rowMsg[t.id]?"<div class='err'>"+esc(rowMsg[t.id])+"</div>":'');
|
||||||
|
setHtml(e.detdyn,"<b>Fingerprint:</b> <span class='mono'>"+esc((s.fingerprint||'').slice(0,16))+"</span> · est. "+esc(t.est_min)+" min"+
|
||||||
|
(s.last?"<br><a href='/result?test="+encodeURIComponent(t.id)+"&ts="+encodeURIComponent(s.last.ts)+"'>last result record</a>":''))})}
|
||||||
|
function toggleDet(id){openDetails[id]=!openDetails[id];var e=$('det-'+id);if(e)e.className='details'+(openDetails[id]?' on':'')}
|
||||||
|
function renderRun(){var r=state.running||state.last_run;var key=r?(r.kind+':'+r.id+':'+r.started):null;
|
||||||
|
if(key!==lastRunKey)abortSent=false;
|
||||||
|
document.querySelector('main').classList.toggle('running',!!state.running);
|
||||||
|
if(!r){setText($('runhead'),'idle');setText($('log'),'');$('prompt').style.display='none';$('notice').style.display='none';
|
||||||
|
curPrompt=null;promptKey=null;setDis($('abortbtn'),true);setText($('runtitle'),'Run');lastRunKey=null;return}
|
||||||
|
setText($('runtitle'),(r.kind==='bench'?'Bench: ':'Test: ')+r.title);
|
||||||
|
var hd=esc(r.id)+' · started '+esc(fmtTs(r.started))+' · '+r.elapsed_s+' s';
|
||||||
|
if(r.finished){hd+=' · <b class="st '+(r.finished.result==='PASS'||r.finished.result==='OK'?'pass':'fail')+'">'+esc(r.finished.result)+'</b>'+(r.finished.message?' - '+esc(r.finished.message):'')}
|
||||||
|
else if(r.aborting){hd+=' · <b class="st stale">aborting</b>'}
|
||||||
|
setHtml($('runhead'),hd);
|
||||||
|
var lg=$('log');var txt=(r.dropped?('... '+r.dropped+' earlier lines dropped\n'):'')+r.log.join('\n');
|
||||||
|
if(lg.__t!==txt){var atBottom=lg.scrollTop+lg.clientHeight>=lg.scrollHeight-20;
|
||||||
|
lg.__t=txt;lg.textContent=txt;if(atBottom||key!==lastRunKey)lg.scrollTop=lg.scrollHeight}
|
||||||
|
lastRunKey=key;
|
||||||
|
var nt=(r.notice&&!r.finished)?r.notice.text:'';
|
||||||
|
setText($('notice'),nt);$('notice').style.display=nt?'':'none';
|
||||||
|
/* The prompt buttons are rebuilt only when the prompt itself changes -
|
||||||
|
they are the ones the operator stares at before pressing. */
|
||||||
|
if(r.prompt&&!r.finished){var pk=r.prompt.id+'|'+r.prompt.question+'|'+r.prompt.options.length+':'+r.prompt.options.join(',');
|
||||||
|
if(pk!==promptKey){promptKey=pk;curPrompt=r.prompt;
|
||||||
|
$('promptq').textContent=r.prompt.question;var pb='';
|
||||||
|
r.prompt.options.forEach(function(o,i){pb+="<button class='btn btn-sm btn-primary' onclick='answerIdx("+i+")'>"+esc(o)+"</button>"});
|
||||||
|
$('promptb').innerHTML=pb;$('promptb').__h=pb;$('prompt').style.display=''}}
|
||||||
|
else if(promptKey!==null){promptKey=null;curPrompt=null;$('prompt').style.display='none'}
|
||||||
|
setDis($('abortbtn'),!!r.finished||abortSent||!!r.aborting);
|
||||||
|
if(r.finished&&tab==='bench'&&r.kind==='bench'&&benchNeedsRefresh){benchNeedsRefresh=false;loadBench()}}
|
||||||
|
function findTest(id){for(var i=0;i<catalog.length;i++)if(catalog[i].id===id)return catalog[i];return null}
|
||||||
|
/* What the operator will be asked to do, shown before it is asked: the
|
||||||
|
running test's steps for the whole run, the queue's attended tests
|
||||||
|
before the first one starts, or the test the operator clicked on. The
|
||||||
|
prompts and notices that follow are these steps, taken in turn. */
|
||||||
|
var selected=null;
|
||||||
|
function selectTest(id){selected=(selected===id)?null:id;renderSteps()}
|
||||||
|
function stepsOf(t,head){var h='';
|
||||||
|
var acts=(t.actions||[]);
|
||||||
|
if(head)h+="<div class='sub'>"+head+"</div>";
|
||||||
|
if(t.kind==='live')h+="<div>This test fires the laser: eye protection, fire watch, exhaust, scrap under the head.</div>";
|
||||||
|
if(acts.length)h+="<div>Machine actions: <b>"+esc(acts.join(', '))+"</b> (a standing instruction tells you when; the test watches the machine for it)</div>";
|
||||||
|
if(t.steps&&t.steps.length)h+="<ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>";
|
||||||
|
return h}
|
||||||
|
function renderSteps(){var e=$('steps');if(!e||!catalog)return;var h='',r=state.running;
|
||||||
|
if(r&&r.kind==='test'){var t=findTest(r.id);
|
||||||
|
if(t&&((t.steps&&t.steps.length)||(t.actions&&t.actions.length)||t.kind==='live'))h="<div class='sh'>What you will do</div>"+stepsOf(t,null)}
|
||||||
|
else if(batchActive()&&state.batch.pending&&state.batch.pending.length){var parts=[];
|
||||||
|
state.batch.pending.forEach(function(id){var t=findTest(id);if(t&&t.kind!=='auto')parts.push(stepsOf(t,esc(id)))});
|
||||||
|
if(parts.length)h="<div class='sh'>Coming up in this queue</div>"+parts.join('')}
|
||||||
|
else if(selected&&!r){var t=findTest(selected);
|
||||||
|
if(t)h="<div class='sh'>Before you start "+esc(t.id)+"</div>"+(t.kind==='auto'?"<div class='auto'>Nothing: this test needs nobody at the machine.</div>":stepsOf(t,null))}
|
||||||
|
setHtml(e,h);e.style.display=h?'':'none'}
|
||||||
|
function startTest(id){if(isBusy())return;var t=findTest(id);var body={test:id};
|
||||||
|
if(ignoreReq)body.ignore_requires=true;
|
||||||
|
if(t&&t.kind==='live'){if(!confirmLive())return;body.ack_live=true}
|
||||||
|
pending=nowMs();pendingId=id;rowMsg={};updateGroups();
|
||||||
|
setMsg('actmsg','starting '+id+'...');
|
||||||
|
api('POST','/start',body,function(s,d){
|
||||||
|
if(s!==200){pending=0;pendingId=null;rowMsg[id]=d.message||d.error;updateGroups()}
|
||||||
|
setMsg('actmsg',d.message||d.error,s!==200);kick()})}
|
||||||
|
/* The live-laser acknowledgment stays a blocking dialog on purpose: it
|
||||||
|
is the one thing on this page that must not be dismissed by a stray
|
||||||
|
click or a poll. */
|
||||||
|
function confirmLive(live){return window.confirm(
|
||||||
|
(live?('LIVE LASER QUEUE.\n\nThese fire the laser:\n - '+live.join('\n - ')+'\n'):'LIVE LASER TEST.\n')+
|
||||||
|
'\nConfirm before starting:\n - eye protection on, everyone in the room\n - fire watch present, extinguisher at hand\n - exhaust running, lid closed, scrap in place\n - you will press the physical button to arm when prompted\n\n'+
|
||||||
|
(live?'Start the queue?':'Start the test?'))}
|
||||||
|
/* A queue takes the machine for a long stretch, so both what it will run
|
||||||
|
and the acknowledgment it needs are put in front of the operator once,
|
||||||
|
before anything starts. */
|
||||||
|
function startBatch(group){if(isBusy())return;
|
||||||
|
var ids=(state&&state.batch_available&&state.batch_available[group])||[];
|
||||||
|
if(!ids.length)return;
|
||||||
|
var body={group:group};if(ignoreReq)body.ignore_requires=true;
|
||||||
|
var live=[];catalog.forEach(function(t){if(t.kind==='live'&&ids.indexOf(t.id)>=0)live.push(t.id)});
|
||||||
|
if(live.length){if(!confirmLive(live))return;body.ack_live=true}
|
||||||
|
else if(!window.confirm('Run these '+ids.length+' test(s), in this order?\n\n - '+ids.join('\n - ')))return;
|
||||||
|
pending=nowMs();pendingId=null;rowMsg={};updateGroups();renderQueue();
|
||||||
|
setMsg('qmsg','starting the '+group+' queue...');
|
||||||
|
api('POST','/batch',body,function(s,d){
|
||||||
|
if(s!==200){pending=0;updateGroups();renderQueue()}
|
||||||
|
setMsg('qmsg',d.message||d.error,s!==200);kick()})}
|
||||||
|
function stopBatch(){var e=$('q-stop');if(e.disabled)return;setDis(e,true);
|
||||||
|
setMsg('qmsg','stopping the queue...');
|
||||||
|
api('POST','/batch/stop',{},function(s,d){setMsg('qmsg',d.message||d.error,s!==200);kick()})}
|
||||||
|
function promptBusy(on){var b=$('promptb').getElementsByTagName('button');for(var i=0;i<b.length;i++)setDis(b[i],on)}
|
||||||
|
function answerIdx(i){if(!curPrompt)return;var p=curPrompt,v=p.options[i];
|
||||||
|
promptBusy(true);setMsg('actmsg','answer sent: '+v);
|
||||||
|
api('POST','/answer',{prompt_id:p.id,value:v},function(s,d){
|
||||||
|
if(s!==200){promptBusy(false);setMsg('actmsg',d.message||d.error,true)}kick()})}
|
||||||
|
function doAbort(){var b=$('abortbtn');if(b.disabled)return;abortSent=true;setDis(b,true);
|
||||||
|
setMsg('actmsg','aborting...');
|
||||||
|
api('POST','/abort',{},function(s,d){if(s!==200)abortSent=false;setMsg('actmsg',d.message||d.error,s!==200);kick()})}
|
||||||
|
function doExport(){var b=$('exportbtn');if(b.disabled)return;setDis(b,true);setMsg('actmsg','exporting...');
|
||||||
|
api('POST','/export',{},function(s,d){setDis(b,false);
|
||||||
|
if(s===200){setMsg('actmsg','exported: authorized='+d.authorized+', sha256 '+d.sha256.slice(0,16)+' - download below');$('dljson').style.display='';$('dlmd').style.display=''}
|
||||||
|
else setMsg('actmsg',d.message||d.error,true)})}
|
||||||
|
function toggleInv(){var f=$('invform');f.style.display=f.style.display==='none'?'':'none'}
|
||||||
|
function doInvalidate(){var r=$('invreason').value;if(!r){setMsg('actmsg','a reason is required',true);return}
|
||||||
|
api('POST','/invalidate',{reason:r},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);
|
||||||
|
if(s===200){$('invform').style.display='none';$('invreason').value=''}kick()})}
|
||||||
|
function doReset(){api('POST','/reset',{reason:'operator reset from the page'},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);kick()})}
|
||||||
|
/* Same rule as the acceptance rows: the tool list is built once and then
|
||||||
|
only updated, so a run starting never rewrites the arg fields the
|
||||||
|
operator has typed into or the button being pressed. */
|
||||||
|
function renderBench(){if(!bench)return;var ids=[];
|
||||||
|
bench.tools.forEach(function(t){ids.push(t.id+':'+(t.ported?1:0)+(t.installed?1:0))});
|
||||||
|
var sig=ids.join(',');
|
||||||
|
if(sig!==benchKey){benchKey=sig;buildBench()}
|
||||||
|
updateBench()}
|
||||||
|
function buildBench(){var groups={dry:[],takeover:[],live:[],scope:[]};
|
||||||
|
bench.tools.forEach(function(t){(groups[t.safety]||(groups[t.safety]=[])).push(t)});var h='';
|
||||||
|
['dry','takeover','scope','live'].forEach(function(g){if(!groups[g]||!groups[g].length)return;
|
||||||
|
h+="<div class='card grp'><h2>"+esc(g)+"</h2>";
|
||||||
|
groups[g].forEach(function(t){var d=esc(t.id);
|
||||||
|
h+="<div class='tool'><div><b>"+esc(t.title)+"</b> <span class='tid'>"+esc(t.script)+"</span> <span class='tag'>"+esc(t.where)+"</span>"+(t.ported?'':"<span class='tag'>unported</span>")+"</div>";
|
||||||
|
h+="<div class='hint' style='margin:2px 0 4px'>"+esc(t.desc)+"</div>";
|
||||||
|
if(t.args&&t.args.length){h+="<div class='argrow'>";t.args.forEach(function(a){var iid='arg-'+t.id+'-'+a.name;
|
||||||
|
if(a.type==='choice'){h+="<label>"+esc(a.name)+" <select class='form-select form-select-sm' id='"+iid+"'>"+a.choices.map(function(c){return "<option"+(c===a.default?' selected':'')+">"+esc(c)+"</option>"}).join('')+"</select></label>"}
|
||||||
|
else{h+="<label>"+esc(a.name)+" <input class='form-control form-control-sm' type='"+(a.type==='str'?'text':'number')+"' step='any' id='"+iid+"' value='"+esc(a.default==null?'':a.default)+"' title='"+esc(a.help)+"'></label>"}});h+="</div>"}
|
||||||
|
h+="<div class='actions'><button class='btn btn-sm btn-primary' id='tbtn-"+d+"' onclick='startTool(\""+d+"\")'>Start</button>";
|
||||||
|
h+="<span class='hint' id='tlast-"+d+"'></span></div></div>"});
|
||||||
|
h+="</div>"});
|
||||||
|
$('tools').innerHTML=h;benchEls={};
|
||||||
|
bench.tools.forEach(function(t){benchEls[t.id]={btn:$('tbtn-'+t.id),last:$('tlast-'+t.id)}})}
|
||||||
|
function updateBench(){if(!benchEls)return;var busy=isBusy();
|
||||||
|
bench.tools.forEach(function(t){var e=benchEls[t.id];if(!e)return;
|
||||||
|
var can=t.ported&&t.installed&&!busy;
|
||||||
|
setDis(e.btn,!can);
|
||||||
|
setProp(e.btn,'title',!t.ported?'not yet ported to the bench page':(!t.installed?'script not installed on this image':(busy?'a run is in progress':'')));
|
||||||
|
setHtml(e.last,t.last?("last: "+esc(t.last.result?t.last.result.result:'?')+" "+esc(fmtTs(t.last.ts))):'')})}
|
||||||
|
function startTool(id){if(isBusy())return;var t=null;bench.tools.forEach(function(x){if(x.id===id)t=x});if(!t)return;var args={};
|
||||||
|
(t.args||[]).forEach(function(a){var e=$('arg-'+id+'-'+a.name);if(e)args[a.name]=e.value});
|
||||||
|
var body={tool:id,args:args};if(t.safety==='live'){if(!confirmLive())return;body.ack_live=true}
|
||||||
|
pending=nowMs();pendingId=null;updateBench();updateGroups();
|
||||||
|
setMsg('benchmsg','starting '+id+'...');
|
||||||
|
api('POST','/bench/start',body,function(s,d){
|
||||||
|
if(s!==200){pending=0;updateBench();updateGroups()}
|
||||||
|
setMsg('benchmsg',d.message||d.error,s!==200);if(s===200)benchNeedsRefresh=true;kick()})}
|
||||||
|
|
||||||
|
/* ---- Theme ----------------------------------------------------------
|
||||||
|
Light, dark, or auto (the system preference); the choice lives in
|
||||||
|
localStorage under the key forgectrl's panel uses, so the two pages
|
||||||
|
agree. The head script in index.html applies it before first paint. */
|
||||||
|
var THEMES=['auto','light','dark'];
|
||||||
|
function themeChoice(){try{var s=localStorage.getItem('ff_theme');return s==='light'||s==='dark'?s:'auto'}catch(e){return 'auto'}}
|
||||||
|
function applyTheme(){var c=themeChoice(),t=c;
|
||||||
|
if(c==='auto')t=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';
|
||||||
|
document.documentElement.setAttribute('data-bs-theme',t);
|
||||||
|
$('themebtn').textContent=c==='auto'?'\u25d0 Auto':c==='dark'?'\u263e Dark':'\u2600 Light';
|
||||||
|
$('themebtn').title='Theme: '+c+' (click to change)'}
|
||||||
|
function cycleTheme(){var next=THEMES[(THEMES.indexOf(themeChoice())+1)%THEMES.length];
|
||||||
|
try{if(next==='auto')localStorage.removeItem('ff_theme');else localStorage.setItem('ff_theme',next)}catch(e){}
|
||||||
|
applyTheme()}
|
||||||
|
(function(){applyTheme();if(window.matchMedia){var mq=matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
if(mq.addEventListener)mq.addEventListener('change',applyTheme);else if(mq.addListener)mq.addListener(applyTheme)}})();
|
||||||
|
|
||||||
|
initHelp();
|
||||||
|
setIgnoreReq(ignoreReq);
|
||||||
|
poll();
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/*
|
||||||
|
* help.js - forgetest: the page's help text
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Every explanation the page offers lives here, keyed by the data-help
|
||||||
|
* attribute of the "?" button that opens it (index.html): a title, its
|
||||||
|
* paragraphs, and the page of the documentation site it deep-links to
|
||||||
|
* (DOC_BASE + d). The buttons open Bootstrap popovers, one at a time; a
|
||||||
|
* click anywhere else, Escape, or a tab change closes the open one. The
|
||||||
|
* buttons sit on static elements only: the rows, prompts and tool entries
|
||||||
|
* are rebuilt by app.js, and a popover on one of those would be orphaned.
|
||||||
|
* What the operator must read mid-run (the steps, prompts, notices and
|
||||||
|
* the live-laser acknowledgment) stays in the page and never moves here.
|
||||||
|
*/
|
||||||
|
var DOC_BASE = 'https://docs.forgefirm.org/';
|
||||||
|
var HELP = {
|
||||||
|
campaign: {
|
||||||
|
t: 'Campaign',
|
||||||
|
d: 'developers/acceptance/',
|
||||||
|
p: [
|
||||||
|
'A release is authorized when a campaign is open on this image and every catalog test is satisfied: by a PASS in the campaign, or (never for the core) by an earlier PASS whose domain fingerprint is unchanged. A FAIL ends the campaign.',
|
||||||
|
'The manifest identity is the image, the catalog hash is the test set; either changing starts the count over.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
queues: {
|
||||||
|
t: 'Run what is left',
|
||||||
|
d: 'developers/acceptance/',
|
||||||
|
p: [
|
||||||
|
'Each queue takes every test of its kind that the campaign does not already count as satisfied, runs them one at a time in prerequisite order, and stops on the first result that is not a PASS.',
|
||||||
|
'The unattended queue needs nobody in the room; with the bench fixture up, the operator tests it can perform by itself run there too. The other one does: it prompts, and it fires the laser. Stop-the-queue cancels what is still waiting and lets the run in progress finish; Abort ends that one too.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
t: 'Campaign actions',
|
||||||
|
d: 'developers/acceptance/',
|
||||||
|
p: [
|
||||||
|
'Export builds the release artifact the release gate reads (acceptance.json, with acceptance.md for people) from the campaign as it stands. The raw log is the append-only record; the journal is the daemon\'s own.',
|
||||||
|
'Invalidate all forces a full campaign; give the reason. Reset campaign closes the open campaign without invalidating anything: earlier passes stay inheritable under the campaign rules, and the next Start opens a new one.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ignore: {
|
||||||
|
t: 'Ignore prerequisites',
|
||||||
|
d: 'developers/acceptance/',
|
||||||
|
p: [
|
||||||
|
'A test\'s requires list only orders the runs. With the switch on, any test starts alone and its record notes which prerequisites were unmet; the release still needs every test satisfied.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
bench: {
|
||||||
|
t: 'Bench diagnostics',
|
||||||
|
d: 'developers/bench/',
|
||||||
|
p: [
|
||||||
|
'The bench tools under scripts/bench, run on the board with their output in the Run pane. Nothing here enters a campaign. Tools not yet ported are listed for completeness.',
|
||||||
|
'Live tools need the operator acknowledgment; takeover and scope tools stop forgectrl for the duration and start it again when they finish.'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var helpOpen = null;
|
||||||
|
function helpContent(h) {
|
||||||
|
var d = document.createElement('div'),
|
||||||
|
i,
|
||||||
|
p,
|
||||||
|
a;
|
||||||
|
for (i = 0; i < h.p.length; i++) {
|
||||||
|
p = document.createElement('p');
|
||||||
|
p.textContent = h.p[i];
|
||||||
|
d.appendChild(p);
|
||||||
|
}
|
||||||
|
a = document.createElement('a');
|
||||||
|
a.className = 'doclink';
|
||||||
|
a.href = DOC_BASE + h.d;
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener';
|
||||||
|
a.textContent = 'Documentation \u2192';
|
||||||
|
d.appendChild(a);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
function closeHelp() {
|
||||||
|
if (helpOpen) {
|
||||||
|
helpOpen.hide();
|
||||||
|
helpOpen = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function initHelp() {
|
||||||
|
var els = document.querySelectorAll('[data-help]'),
|
||||||
|
i;
|
||||||
|
for (i = 0; i < els.length; i++) {
|
||||||
|
(function (el) {
|
||||||
|
var h = HELP[el.getAttribute('data-help')];
|
||||||
|
if (!h) {
|
||||||
|
el.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var pop = new bootstrap.Popover(el, {
|
||||||
|
trigger: 'manual',
|
||||||
|
html: true,
|
||||||
|
placement: 'auto',
|
||||||
|
title: h.t,
|
||||||
|
content: helpContent(h),
|
||||||
|
container: 'body'
|
||||||
|
});
|
||||||
|
el.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (helpOpen === pop) {
|
||||||
|
closeHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeHelp();
|
||||||
|
pop.show();
|
||||||
|
helpOpen = pop;
|
||||||
|
});
|
||||||
|
el.addEventListener('hidden.bs.popover', function () {
|
||||||
|
if (helpOpen === pop) helpOpen = null;
|
||||||
|
});
|
||||||
|
})(els[i]);
|
||||||
|
}
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var tip = document.querySelector('.popover.show');
|
||||||
|
if (helpOpen && !(tip && tip.contains(e.target))) closeHelp();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') closeHelp();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<!--
|
||||||
|
index.html - forgetest: the acceptance page
|
||||||
|
Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
Written by Scott Wiederhold
|
||||||
|
SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
Served at "/" as one self-contained page: page.py inlines every
|
||||||
|
stylesheet and script linked below, in order, in place of its
|
||||||
|
link/script tag. No external assets. Bootstrap (vendor/) is the UI
|
||||||
|
library; theme.css is the OpenGlow theme forgectrl's panel uses, byte
|
||||||
|
for byte, in a light and a dark variant selected by the data-bs-theme
|
||||||
|
attribute the head script sets before first paint; page.css is this
|
||||||
|
page's own layout. Design notes are at the top of app.js.
|
||||||
|
-->
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>ForgeFIRM acceptance</title>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var t = 'light';
|
||||||
|
try {
|
||||||
|
var s = localStorage.getItem('ff_theme');
|
||||||
|
if (s === 'light' || s === 'dark') t = s;
|
||||||
|
else if (window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
t = 'dark';
|
||||||
|
} catch (e) {}
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', t);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="vendor/bootstrap.min.css" />
|
||||||
|
<link rel="stylesheet" href="theme.css" />
|
||||||
|
<link rel="stylesheet" href="page.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="hrow">
|
||||||
|
<span class="appname">ForgeFIRM acceptance</span><span class="sub" id="hdrsub"></span
|
||||||
|
><span class="ver" id="hdrver"></span
|
||||||
|
><button id="themebtn" type="button" onclick="cycleTheme()" title="Theme">Theme</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<nav>
|
||||||
|
<div class="nrow">
|
||||||
|
<a id="tab-acceptance" class="on" onclick='showTab("acceptance")'>Release acceptance</a
|
||||||
|
><a id="tab-bench" onclick='showTab("bench")'>Bench diagnostics</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<div id="left">
|
||||||
|
<div id="pane-acceptance">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Campaign<button class="hlp" data-help="campaign" type="button">?</button></h2>
|
||||||
|
<div class="banner">
|
||||||
|
<div class="auth" id="auth">?</div>
|
||||||
|
<div class="info" id="banner"></div>
|
||||||
|
</div>
|
||||||
|
<div id="invnote"></div>
|
||||||
|
<div class="card queue">
|
||||||
|
<h2>Run what is left<button class="hlp" data-help="queues" type="button">?</button></h2>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-sm btn-primary" id="q-unattended" onclick='startBatch("unattended")'>Unattended</button>
|
||||||
|
<button class="btn btn-sm btn-primary" id="q-attended" onclick='startBatch("attended")'>Operator and live</button>
|
||||||
|
<button class="btn btn-sm btn-danger" id="q-stop" onclick="stopBatch()" disabled>Stop the queue</button>
|
||||||
|
</div>
|
||||||
|
<div id="qstate"></div>
|
||||||
|
<div id="qmsg"></div>
|
||||||
|
<div id="fixture" class="hint"></div>
|
||||||
|
</div>
|
||||||
|
<div class="actions" style="margin-top: 10px">
|
||||||
|
<button class="btn btn-sm btn-primary" id="exportbtn" onclick="doExport()">Export release artifact</button>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" id="dljson" href="/export/acceptance.json" style="display: none">acceptance.json</a>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" id="dlmd" href="/export/acceptance.md" style="display: none">acceptance.md</a>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="/log">Raw log</a>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="/journal">Runner journal</a>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="toggleInv()">Invalidate all…</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="doReset()">Reset campaign</button>
|
||||||
|
<button class="hlp" data-help="actions" type="button">?</button>
|
||||||
|
</div>
|
||||||
|
<div id="invform" style="display: none">
|
||||||
|
<input type="text" class="form-control form-control-sm" id="invreason" placeholder="reason (required): what changed on the bench" />
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="doInvalidate()">Invalidate all results</button>
|
||||||
|
</div>
|
||||||
|
<div id="actmsg"></div>
|
||||||
|
<div class="switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="ignreq" onchange="setIgnoreReq(this.checked)" />
|
||||||
|
<label for="ignreq">Ignore prerequisites: start any test on its own, whatever its <i>requires</i> list says</label>
|
||||||
|
<span id="ignreqon"></span>
|
||||||
|
<button class="hlp" data-help="ignore" type="button">?</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="groups"></div>
|
||||||
|
</div>
|
||||||
|
<div id="pane-bench" style="display: none">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Bench diagnostics<button class="hlp" data-help="bench" type="button">?</button></h2>
|
||||||
|
<p class="hint">
|
||||||
|
The bench tools (scripts/bench), run on the board with their output in the Run
|
||||||
|
pane. Runs here never enter a campaign; live tools need the operator
|
||||||
|
acknowledgment, and takeover tools stop forgectrl for the duration.
|
||||||
|
</p>
|
||||||
|
<div id="benchmsg"></div>
|
||||||
|
</div>
|
||||||
|
<div id="tools"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="right">
|
||||||
|
<div class="card">
|
||||||
|
<h2 id="runtitle">Run</h2>
|
||||||
|
<div id="runhead" class="info">idle</div>
|
||||||
|
<div id="steps" style="display: none"></div>
|
||||||
|
<div id="notice" style="display: none"></div>
|
||||||
|
<div id="prompt" style="display: none">
|
||||||
|
<div class="q" id="promptq"></div>
|
||||||
|
<div class="actions" id="promptb"></div>
|
||||||
|
</div>
|
||||||
|
<pre id="log"></pre>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-sm btn-danger" id="abortbtn" onclick="doAbort()" disabled>Abort</button
|
||||||
|
><span class="hint" id="runfoot"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="vendor/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="help.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
/*
|
||||||
|
* page.css - forgetest: the acceptance page's own styles
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Everything specific to this page, on the tokens theme.css defines
|
||||||
|
* (theme.css is the same file forgectrl's panel uses, so the two look like
|
||||||
|
* one product and follow the same light and dark themes). This page is a
|
||||||
|
* wide two-pane layout, so the 900 px column theme.css gives the panel is
|
||||||
|
* lifted here.
|
||||||
|
*/
|
||||||
|
.hrow,
|
||||||
|
.nrow,
|
||||||
|
main {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
.hrow .appname {
|
||||||
|
border-left: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.hrow .sub {
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.hrow .ver {
|
||||||
|
margin-left: auto;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
#left {
|
||||||
|
flex: 1 1 640px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
#right {
|
||||||
|
flex: 0 0 420px;
|
||||||
|
position: sticky;
|
||||||
|
top: 10px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
main {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
#right {
|
||||||
|
position: static;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
/* A run in progress is what the operator is watching: on a phone the
|
||||||
|
run pane goes to the top for its duration. */
|
||||||
|
main.running #right {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.banner {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.auth {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--og-red);
|
||||||
|
}
|
||||||
|
.auth.yes {
|
||||||
|
background: var(--og-ok);
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
color: var(--og-dim);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.info b {
|
||||||
|
color: var(--og-txt);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--og-dim);
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-bottom: 1px solid var(--og-line);
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--og-line);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
/* One grid for every subsystem. The groups are separate tables, so
|
||||||
|
left to themselves each sizes its own columns from its own content
|
||||||
|
and no two line up. Fixed widths put them all on the same rails. */
|
||||||
|
#groups .grp {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
#groups table {
|
||||||
|
table-layout: fixed;
|
||||||
|
min-width: 700px;
|
||||||
|
}
|
||||||
|
#groups col.k {
|
||||||
|
width: 132px;
|
||||||
|
}
|
||||||
|
#groups col.s {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
#groups col.l {
|
||||||
|
width: 210px;
|
||||||
|
}
|
||||||
|
#groups col.a {
|
||||||
|
width: 96px;
|
||||||
|
}
|
||||||
|
/* Why a test stands where it does reads as one block under Status; a
|
||||||
|
requires list of three test ids would otherwise wrap into a ragged
|
||||||
|
stack under the Start button and drag the columns out of line. */
|
||||||
|
#groups .req {
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
/* The details sit in a full-width row of their own: inside the Test
|
||||||
|
cell they would have to be read through a column, and opening one
|
||||||
|
would push that group's grid out of step with the rest. */
|
||||||
|
#groups td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
#groups tr.detrow td {
|
||||||
|
padding: 0 8px;
|
||||||
|
border-bottom: 1px solid var(--og-line);
|
||||||
|
}
|
||||||
|
#groups tr.detrow:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
#groups .details {
|
||||||
|
margin: 2px 0 8px;
|
||||||
|
}
|
||||||
|
#groups .details .dsc {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.tid {
|
||||||
|
color: var(--og-dim);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10.5px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 9px;
|
||||||
|
margin-right: 4px;
|
||||||
|
background: var(--og-line-soft);
|
||||||
|
color: var(--og-txt);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.tag.live {
|
||||||
|
background: var(--og-bad-bg);
|
||||||
|
color: var(--og-red);
|
||||||
|
}
|
||||||
|
.tag.operator {
|
||||||
|
background: var(--og-note-bg);
|
||||||
|
color: var(--og-note-txt);
|
||||||
|
}
|
||||||
|
.tag.takeover {
|
||||||
|
background: var(--og-line-soft);
|
||||||
|
color: var(--og-title);
|
||||||
|
}
|
||||||
|
.tag.mode {
|
||||||
|
background: var(--og-line-soft);
|
||||||
|
color: var(--og-ok);
|
||||||
|
}
|
||||||
|
.tag.core {
|
||||||
|
background: var(--og-navy);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.tag.queued {
|
||||||
|
background: var(--og-line-soft);
|
||||||
|
color: var(--og-action);
|
||||||
|
}
|
||||||
|
.st {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.st.pass {
|
||||||
|
color: var(--og-ok);
|
||||||
|
}
|
||||||
|
.st.inherited {
|
||||||
|
color: var(--og-title);
|
||||||
|
}
|
||||||
|
.st.fail,
|
||||||
|
.st.error {
|
||||||
|
color: var(--og-red);
|
||||||
|
}
|
||||||
|
.st.stale,
|
||||||
|
.st.aborted {
|
||||||
|
color: var(--og-warn);
|
||||||
|
}
|
||||||
|
.st.none {
|
||||||
|
color: var(--og-dim);
|
||||||
|
}
|
||||||
|
.st.running {
|
||||||
|
color: var(--og-action);
|
||||||
|
}
|
||||||
|
.req {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--og-warn);
|
||||||
|
}
|
||||||
|
.req.over {
|
||||||
|
color: var(--og-dim);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.actions .hint {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
pre#log {
|
||||||
|
background: var(--og-term-bg);
|
||||||
|
color: var(--og-term-txt);
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 380px;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 8px 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
#prompt {
|
||||||
|
background: var(--og-note-bg);
|
||||||
|
border: 1px solid var(--og-note-line);
|
||||||
|
color: var(--og-note-txt);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
#prompt .q {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
#notice {
|
||||||
|
background: var(--og-line-soft);
|
||||||
|
border: 1px solid var(--og-action);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
#steps {
|
||||||
|
background: var(--og-line-soft);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
#steps .sh {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
#steps .sub {
|
||||||
|
color: var(--og-dim);
|
||||||
|
margin: 6px 0 2px;
|
||||||
|
}
|
||||||
|
#steps ol {
|
||||||
|
margin: 2px 0 4px 20px;
|
||||||
|
}
|
||||||
|
#steps .auto {
|
||||||
|
color: var(--og-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.tsel {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tsel:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.details {
|
||||||
|
display: none;
|
||||||
|
background: var(--og-line-soft);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.details.on {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
.err {
|
||||||
|
color: var(--og-red);
|
||||||
|
font-size: 12.5px;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
.switch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.switch input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.switch .on {
|
||||||
|
color: var(--og-warn);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.grp {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.queue {
|
||||||
|
background: var(--og-line-soft);
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.queue h2 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.qline {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--og-dim);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.qline b {
|
||||||
|
color: var(--og-txt);
|
||||||
|
}
|
||||||
|
.qbar {
|
||||||
|
display: flex;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--og-line);
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
.qbar i {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.qbar i.ok {
|
||||||
|
background: var(--og-ok);
|
||||||
|
}
|
||||||
|
.qbar i.bad {
|
||||||
|
background: var(--og-red);
|
||||||
|
}
|
||||||
|
.qbar i.skip {
|
||||||
|
background: var(--og-warn);
|
||||||
|
}
|
||||||
|
.qbar i.now {
|
||||||
|
background: var(--og-action);
|
||||||
|
}
|
||||||
|
.tool {
|
||||||
|
border-bottom: 1px solid var(--og-line);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.tool:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.tool .argrow {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 6px 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.tool .argrow label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--og-dim);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.tool .argrow input,
|
||||||
|
.tool .argrow select {
|
||||||
|
width: auto;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.tool .argrow input {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
#invform {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
#invform input {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,706 @@
|
|||||||
|
/*
|
||||||
|
* theme.css - forgectrl: the OpenGlow theme on top of Bootstrap
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Every color the panel uses is a token here, defined once for the light
|
||||||
|
* theme and once more for the dark one; Bootstrap's own component
|
||||||
|
* variables are pointed at the tokens, so the stock components follow the
|
||||||
|
* theme without a Sass build. The visual identity is OpenGlow's
|
||||||
|
* (community.openglow.org): navy header with the starburst wordmark, blue
|
||||||
|
* actions, laser red reserved for the brand accent and fault states. The
|
||||||
|
* header keeps its navy in both themes; the log viewers stay dark in both
|
||||||
|
* because they are terminals.
|
||||||
|
*
|
||||||
|
* Density: the panel is a machine control surface read at a glance, so
|
||||||
|
* everything runs a size tighter than Bootstrap's defaults (13.5 px body,
|
||||||
|
* small controls, compact cards).
|
||||||
|
*/
|
||||||
|
:root,
|
||||||
|
[data-bs-theme='light'] {
|
||||||
|
--og-navy: #2b2b5e;
|
||||||
|
--og-red: #e8262a;
|
||||||
|
--og-action: #0088cc;
|
||||||
|
--og-action-hover: #0077b3;
|
||||||
|
--og-ok: #3d854d;
|
||||||
|
--og-warn: #c7760a;
|
||||||
|
--og-dim: #767a82;
|
||||||
|
--og-bg: #f0f1f4;
|
||||||
|
--og-card: #ffffff;
|
||||||
|
--og-line: #dde0e6;
|
||||||
|
--og-line-soft: #eceef1;
|
||||||
|
--og-txt: #222222;
|
||||||
|
--og-field-bg: #ffffff;
|
||||||
|
--og-field-line: #c9cdd4;
|
||||||
|
--og-placeholder: #a6aab2;
|
||||||
|
--og-note-bg: #fdf3e3;
|
||||||
|
--og-note-line: #eccb90;
|
||||||
|
--og-note-txt: #7a5a12;
|
||||||
|
--og-warn-bg: #fff6e6;
|
||||||
|
--og-warn-txt: #7a4a06;
|
||||||
|
--og-bad-bg: #fdeeee;
|
||||||
|
--og-bad-txt: #7a1f1f;
|
||||||
|
--og-safety-bg: #fdeaea;
|
||||||
|
--og-safety-txt: #8a1518;
|
||||||
|
--og-cam-bg: #e8e9ec;
|
||||||
|
--og-term-bg: #23252b;
|
||||||
|
--og-term-txt: #cfd3da;
|
||||||
|
--og-title: var(--og-navy);
|
||||||
|
--og-nav-on: var(--og-navy);
|
||||||
|
}
|
||||||
|
[data-bs-theme='dark'] {
|
||||||
|
--og-navy: #2b2b5e;
|
||||||
|
--og-red: #f05a5e;
|
||||||
|
--og-action: #2ea3e0;
|
||||||
|
--og-action-hover: #46b3ec;
|
||||||
|
--og-ok: #5fb374;
|
||||||
|
--og-warn: #e09a3a;
|
||||||
|
--og-dim: #9296a2;
|
||||||
|
--og-bg: #15161c;
|
||||||
|
--og-card: #1e2028;
|
||||||
|
--og-line: #32353f;
|
||||||
|
--og-line-soft: #2a2d36;
|
||||||
|
--og-txt: #d9dce3;
|
||||||
|
--og-field-bg: #14151b;
|
||||||
|
--og-field-line: #3d404b;
|
||||||
|
--og-placeholder: #6b6f7a;
|
||||||
|
--og-note-bg: #2e2717;
|
||||||
|
--og-note-line: #6a5323;
|
||||||
|
--og-note-txt: #e3c584;
|
||||||
|
--og-warn-bg: #2f2415;
|
||||||
|
--og-warn-txt: #eab86a;
|
||||||
|
--og-bad-bg: #331c1e;
|
||||||
|
--og-bad-txt: #f0a3a6;
|
||||||
|
--og-safety-bg: #361a1c;
|
||||||
|
--og-safety-txt: #f3a7aa;
|
||||||
|
--og-cam-bg: #2a2d36;
|
||||||
|
--og-term-bg: #0f1014;
|
||||||
|
--og-term-txt: #cfd3da;
|
||||||
|
--og-title: #b9bcd8;
|
||||||
|
--og-nav-on: #e6e8f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Bootstrap variables pointed at the tokens ---------------------- */
|
||||||
|
:root,
|
||||||
|
[data-bs-theme='light'],
|
||||||
|
[data-bs-theme='dark'] {
|
||||||
|
--bs-body-bg: var(--og-bg);
|
||||||
|
--bs-body-color: var(--og-txt);
|
||||||
|
--bs-body-font-size: 13.5px;
|
||||||
|
--bs-body-font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--bs-border-color: var(--og-line);
|
||||||
|
--bs-secondary-color: var(--og-dim);
|
||||||
|
--bs-link-color: var(--og-action);
|
||||||
|
--bs-link-hover-color: var(--og-action-hover);
|
||||||
|
--bs-link-color-rgb: 0, 136, 204;
|
||||||
|
--bs-link-hover-color-rgb: 0, 119, 179;
|
||||||
|
--bs-primary: var(--og-action);
|
||||||
|
--bs-danger: var(--og-red);
|
||||||
|
--bs-success: var(--og-ok);
|
||||||
|
--bs-warning: var(--og-warn);
|
||||||
|
--bs-font-monospace: ui-monospace, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
--bs-btn-bg: var(--og-action);
|
||||||
|
--bs-btn-border-color: var(--og-action);
|
||||||
|
--bs-btn-hover-bg: var(--og-action-hover);
|
||||||
|
--bs-btn-hover-border-color: var(--og-action-hover);
|
||||||
|
--bs-btn-active-bg: var(--og-action-hover);
|
||||||
|
--bs-btn-active-border-color: var(--og-action-hover);
|
||||||
|
--bs-btn-disabled-bg: var(--og-action);
|
||||||
|
--bs-btn-disabled-border-color: var(--og-action);
|
||||||
|
--bs-btn-font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-outline-secondary {
|
||||||
|
--bs-btn-color: var(--og-txt);
|
||||||
|
--bs-btn-border-color: var(--og-field-line);
|
||||||
|
--bs-btn-bg: var(--og-field-bg);
|
||||||
|
--bs-btn-hover-color: var(--og-txt);
|
||||||
|
--bs-btn-hover-bg: var(--og-field-bg);
|
||||||
|
--bs-btn-hover-border-color: var(--og-nav-on);
|
||||||
|
--bs-btn-active-color: var(--og-txt);
|
||||||
|
--bs-btn-active-bg: var(--og-line-soft);
|
||||||
|
--bs-btn-active-border-color: var(--og-nav-on);
|
||||||
|
--bs-btn-disabled-color: var(--og-txt);
|
||||||
|
--bs-btn-disabled-bg: var(--og-field-bg);
|
||||||
|
--bs-btn-disabled-border-color: var(--og-field-line);
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
--bs-btn-bg: var(--og-red);
|
||||||
|
--bs-btn-border-color: var(--og-red);
|
||||||
|
--bs-btn-font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn:disabled,
|
||||||
|
.btn.disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
--bs-body-bg: var(--og-field-bg);
|
||||||
|
--bs-border-color: var(--og-field-line);
|
||||||
|
color: var(--og-txt);
|
||||||
|
}
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
border-color: var(--og-action);
|
||||||
|
box-shadow: 0 0 0 0.15rem rgba(0, 136, 204, 0.2);
|
||||||
|
}
|
||||||
|
.form-control::placeholder {
|
||||||
|
color: var(--og-placeholder);
|
||||||
|
}
|
||||||
|
.form-control.dirty,
|
||||||
|
.form-select.dirty {
|
||||||
|
border-color: var(--og-warn);
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
--bs-card-bg: var(--og-card);
|
||||||
|
--bs-card-border-color: var(--og-line);
|
||||||
|
--bs-card-border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: 12px 0;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.popover {
|
||||||
|
--bs-popover-max-width: 400px;
|
||||||
|
--bs-popover-font-size: 12.5px;
|
||||||
|
--bs-popover-bg: var(--og-card);
|
||||||
|
--bs-popover-border-color: var(--og-line);
|
||||||
|
--bs-popover-body-color: var(--og-txt);
|
||||||
|
--bs-popover-header-bg: var(--og-line-soft);
|
||||||
|
--bs-popover-header-color: var(--og-title);
|
||||||
|
--bs-popover-header-font-size: 12px;
|
||||||
|
}
|
||||||
|
.popover-header {
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
}
|
||||||
|
.popover-body p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.popover-body p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.popover-body .doclink {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
--bs-modal-bg: var(--og-card);
|
||||||
|
--bs-modal-border-color: var(--og-line);
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
--bs-toast-bg: var(--og-card);
|
||||||
|
--bs-toast-border-color: var(--og-line);
|
||||||
|
--bs-toast-color: var(--og-txt);
|
||||||
|
--bs-toast-font-size: 13px;
|
||||||
|
}
|
||||||
|
.toast.t-ok {
|
||||||
|
border-left: 4px solid var(--og-ok);
|
||||||
|
}
|
||||||
|
.toast.t-bad {
|
||||||
|
border-left: 4px solid var(--og-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Chrome: header, tabs, footer ---------------------------------- */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
body.has-savebar {
|
||||||
|
padding-bottom: 64px;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
background: var(--og-navy);
|
||||||
|
}
|
||||||
|
.hrow {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 10px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.appname {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
padding-left: 14px;
|
||||||
|
}
|
||||||
|
#host {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
font-size: 12.5px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
#themebtn,
|
||||||
|
#logsbtn,
|
||||||
|
#logoutbtn {
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#themebtn:hover,
|
||||||
|
#logsbtn:hover,
|
||||||
|
#logoutbtn:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
nav {
|
||||||
|
background: var(--og-card);
|
||||||
|
border-bottom: 1px solid var(--og-line);
|
||||||
|
}
|
||||||
|
.nrow {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
padding: 9px 13px;
|
||||||
|
color: var(--og-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
nav a.on {
|
||||||
|
color: var(--og-nav-on);
|
||||||
|
font-weight: 600;
|
||||||
|
border-color: var(--og-red);
|
||||||
|
}
|
||||||
|
nav a:hover {
|
||||||
|
color: var(--og-nav-on);
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 4px 20px 12px;
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
section.on {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 6px 20px 28px;
|
||||||
|
color: var(--og-dim);
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
footer a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Cards and settings rows --------------------------------------- */
|
||||||
|
.card h2 {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--og-title);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.9px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.card h3 {
|
||||||
|
font-size: 11.5px;
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
color: var(--og-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.card h3:first-of-type {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.grid2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0 24px;
|
||||||
|
}
|
||||||
|
.frow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr;
|
||||||
|
gap: 6px 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
.frow label {
|
||||||
|
color: var(--og-txt);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.frow .u {
|
||||||
|
color: var(--og-dim);
|
||||||
|
font-size: 11.5px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--og-dim);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
.btns {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--og-action);
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.brk {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
/* The help button: a small ring that opens the field's popover. */
|
||||||
|
.hlp {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--og-dim);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--og-dim);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.hlp:hover,
|
||||||
|
.hlp[aria-describedby] {
|
||||||
|
color: var(--og-action);
|
||||||
|
border-color: var(--og-action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Status widgets ------------------------------------------------ */
|
||||||
|
.kv {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 5px 2px;
|
||||||
|
border-bottom: 1px solid var(--og-line-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.kv:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.kv > span:first-child {
|
||||||
|
color: var(--og-dim);
|
||||||
|
}
|
||||||
|
.kv .btn {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.b-ok {
|
||||||
|
color: var(--og-ok);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.b-run {
|
||||||
|
color: var(--og-action);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.b-bad {
|
||||||
|
color: var(--og-red);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.b-dim {
|
||||||
|
color: var(--og-dim);
|
||||||
|
}
|
||||||
|
.b-warn {
|
||||||
|
color: var(--og-warn);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.bigpass {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--og-ok);
|
||||||
|
}
|
||||||
|
.bigfail {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--og-red);
|
||||||
|
}
|
||||||
|
.seg {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.segbtn {
|
||||||
|
padding: 7px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--og-field-bg);
|
||||||
|
color: var(--og-txt);
|
||||||
|
border: 1px solid var(--og-field-line);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.segbtn:hover {
|
||||||
|
border-color: var(--og-nav-on);
|
||||||
|
}
|
||||||
|
.segbtn.segon {
|
||||||
|
background: var(--og-navy);
|
||||||
|
border-color: var(--og-navy);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.segbtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
#cam,
|
||||||
|
#camvid {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--og-line);
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--og-cam-bg);
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
.camwrap {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.camctl {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.btn.on {
|
||||||
|
background: var(--og-navy);
|
||||||
|
border-color: var(--og-navy);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Banners ------------------------------------------------------- */
|
||||||
|
.banner {
|
||||||
|
background: var(--og-warn-bg);
|
||||||
|
border: 1px solid var(--og-warn);
|
||||||
|
color: var(--og-warn-txt);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.banner a {
|
||||||
|
color: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.banner-bad {
|
||||||
|
border-color: var(--og-red);
|
||||||
|
background: var(--og-bad-bg);
|
||||||
|
color: var(--og-bad-txt);
|
||||||
|
}
|
||||||
|
.safety {
|
||||||
|
background: var(--og-safety-bg);
|
||||||
|
border: 1px solid var(--og-red);
|
||||||
|
color: var(--og-safety-txt);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
margin: 8px 0 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
#locknote,
|
||||||
|
.note {
|
||||||
|
display: none;
|
||||||
|
background: var(--og-note-bg);
|
||||||
|
border: 1px solid var(--og-note-line);
|
||||||
|
color: var(--og-note-txt);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
display: block;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.gnotes {
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 4px 0 6px;
|
||||||
|
}
|
||||||
|
.gnotes .gn-warn {
|
||||||
|
color: var(--og-warn);
|
||||||
|
}
|
||||||
|
.gnotes .gn-off {
|
||||||
|
color: var(--og-red);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Terminals: the diagnostic log and the log viewer -------------- */
|
||||||
|
#diaglog,
|
||||||
|
#logview {
|
||||||
|
background: var(--og-term-bg);
|
||||||
|
color: var(--og-term-txt);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
#diaglog {
|
||||||
|
line-height: 1.6;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#logview {
|
||||||
|
line-height: 1.5;
|
||||||
|
height: 380px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.logtbl {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.logtbl th {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--og-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11.5px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-bottom: 1px solid var(--og-line);
|
||||||
|
}
|
||||||
|
.logtbl td {
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-bottom: 1px solid var(--og-line-soft);
|
||||||
|
}
|
||||||
|
.logtbl select {
|
||||||
|
width: auto;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- The fuse identity dialog -------------------------------------- */
|
||||||
|
#fusewrap {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(20, 22, 30, 0.55);
|
||||||
|
z-index: 1050;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#fusewrap.on {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.fusebox {
|
||||||
|
background: var(--og-card);
|
||||||
|
border: 1px solid var(--og-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
max-width: 560px;
|
||||||
|
width: 92%;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.fusebox h2 {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--og-title);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- The save bar -------------------------------------------------- */
|
||||||
|
#savebar {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1040;
|
||||||
|
background: var(--og-card);
|
||||||
|
border-top: 1px solid var(--og-line);
|
||||||
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
#savebar.on {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.sbrow {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 9px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
#sb-count {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--og-warn);
|
||||||
|
}
|
||||||
|
#sb-msg {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--og-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.frow {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2px 0;
|
||||||
|
}
|
||||||
|
.grid2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.hrow,
|
||||||
|
.nrow,
|
||||||
|
main,
|
||||||
|
.sbrow {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The setup's log-bundle line, under the header: the result of the
|
||||||
|
header's Download logs, or why the machine refused it. */
|
||||||
|
.wz-logs-msg {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 6px auto 0;
|
||||||
|
padding: 0 16px;
|
||||||
|
min-height: 16px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2011-2025 The Bootstrap Authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+41
@@ -0,0 +1,41 @@
|
|||||||
|
2026-08-17T09:44:00.417337+00:00 gfcloud[1927] INFO cnc:set_step_freq 28160
|
||||||
|
2026-08-17T09:44:00.418627+00:00 gfcloud[1927] INFO cnc:set_x_decay 1
|
||||||
|
2026-08-17T09:44:00.419858+00:00 gfcloud[1927] INFO cnc:set_x_mode 8
|
||||||
|
2026-08-17T09:44:00.421083+00:00 gfcloud[1927] INFO cnc:set_x_current 135
|
||||||
|
2026-08-17T09:44:00.422535+00:00 gfcloud[1927] INFO cnc:set_y_decay 1
|
||||||
|
2026-08-17T09:44:00.423794+00:00 gfcloud[1927] INFO cnc:set_y_mode 8
|
||||||
|
2026-08-17T09:44:00.425014+00:00 gfcloud[1927] INFO cnc:set_y_current 22
|
||||||
|
2026-08-17T09:44:00.432591+00:00 gfcloud[1927] INFO machine:_run_loop starting run
|
||||||
|
2026-08-17T09:44:00.434005+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:44:00.438277+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||||
|
2026-08-17T09:44:01.785230+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:44:01.797728+00:00 gfcloud[1927] INFO machine:_run_loop finished run
|
||||||
|
2026-08-17T09:44:01.800661+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:44:01.808504+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (actual/expected): X (-10313/-10313), Y (-649/-649), Z (0/0)
|
||||||
|
2026-08-17T09:44:01.809326+00:00 gfcloud[1927] INFO machine:_motion_locked motion bytes actual:34952, expected: 34952
|
||||||
|
2026-08-17T09:44:01.810066+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
|
||||||
|
2026-08-17T09:44:01.811352+00:00 gfcloud[1927] INFO cnc:set_x_current 33
|
||||||
|
2026-08-17T09:44:01.812874+00:00 gfcloud[1927] INFO cnc:set_y_current 5
|
||||||
|
2026-08-17T09:44:01.820631+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (-10313, -649, 0)
|
||||||
|
2026-08-17T09:44:01.822062+00:00 gfcloud[1927] INFO machine:_motion end motion
|
||||||
|
2026-08-17T09:44:01.822829+00:00 gfcloud[1927] INFO basemachine:_finish_action motion [1576550614]: finished with event ":completed"
|
||||||
|
2026-08-17T09:44:01.824200+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:44:07.993647+00:00 gfcloud[1927] INFO gfuiservice:run service action request: print (ready)
|
||||||
|
2026-08-17T09:44:07.998655+00:00 gfcloud[1927] INFO machine:_motion start motion
|
||||||
|
2026-08-17T09:44:07.999643+00:00 gfcloud[1927] INFO gfuiservice:run print (ready) service action print
|
||||||
|
2026-08-17T09:44:14.199666+00:00 gfcloud[1927] INFO cnc:laser_latch 0
|
||||||
|
2026-08-17T09:44:14.208291+00:00 gfcloud[1927] INFO machine:_button_wait waiting for button
|
||||||
|
2026-08-17T09:44:21.270846+00:00 gfcloud[1927] INFO machine:_switch_event lid opened
|
||||||
|
2026-08-17T09:44:21.275703+00:00 gfcloud[1927] WARNING machine:_button_wait button wait lid opened - relocking the laser
|
||||||
|
2026-08-17T09:44:21.278615+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:44:21.288305+00:00 gfcloud[1927] INFO machine:_return_home start return home
|
||||||
|
2026-08-17T09:44:21.291373+00:00 gfcloud[1927] INFO machine:_return_home return home: already at the job start
|
||||||
|
2026-08-17T09:44:21.292501+00:00 gfcloud[1927] INFO machine:_motion_locked start cool down
|
||||||
|
2026-08-17T09:44:21.305578+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
|
||||||
|
2026-08-17T09:44:21.307322+00:00 gfcloud[1927] INFO cnc:set_x_current 33
|
||||||
|
2026-08-17T09:44:21.309185+00:00 gfcloud[1927] INFO cnc:set_y_current 5
|
||||||
|
2026-08-17T09:44:21.316340+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (0, 0, 0)
|
||||||
|
2026-08-17T09:44:21.318139+00:00 gfcloud[1927] INFO machine:_motion end motion
|
||||||
|
2026-08-17T09:44:21.318860+00:00 gfcloud[1927] INFO basemachine:_finish_action print [1576550621]: finished with event ":cancelled"
|
||||||
|
2026-08-17T09:44:21.320214+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:44:34.058320+00:00 gfcloud[1927] INFO machine:_switch_event lid closed
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
2026-08-17T09:44:35.789556+00:00 gfcloud[1927] INFO websocket:img_upload START
|
||||||
|
2026-08-17T09:44:36.105516+00:00 gfcloud[1927] INFO websocket:img_upload COMPLETE
|
||||||
|
2026-08-17T09:44:36.109421+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:44:38.461442+00:00 gfcloud[1927] INFO gfcloud:_shutdown shutdown requested
|
||||||
|
2026-08-17T09:44:38.910494+00:00 gfcloud[1927] INFO machine:_shutdown shutting down
|
||||||
|
2026-08-17T09:44:38.913049+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:44:38.950354+00:00 gfcloud[1927] INFO machine:_shutdown joining switch thread
|
||||||
|
2026-08-17T09:44:38.956909+00:00 gfcloud[1927] INFO machine:_shutdown shut down complete
|
||||||
|
2026-08-17T09:44:38.986287+00:00 gfcloud[1927] INFO websocket:_on_close RX-EVENT: closed (None, None)
|
||||||
|
2026-08-17T09:44:38.989003+00:00 gfcloud[1927] INFO websocket:run CLOSING
|
||||||
|
2026-08-17T09:44:38.992724+00:00 gfcloud[1927] INFO gfcloud:main gfcloud exit
|
||||||
|
2026-08-17T09:45:08.449553+00:00 gfcloud[2278] INFO gfuiservice:__init__ INITIALIZED
|
||||||
|
2026-08-17T09:45:08.451929+00:00 gfcloud[2278] INFO authentication:authenticate_machine START
|
||||||
|
2026-08-17T09:45:08.965893+00:00 gfcloud[2278] INFO authentication:authenticate_machine SUCCESS
|
||||||
|
2026-08-17T09:45:08.967947+00:00 gfcloud[2278] INFO websocket:ws_connect CONNECTING
|
||||||
|
2026-08-17T09:45:09.421918+00:00 gfcloud[2278] INFO _logging:info Websocket connected
|
||||||
|
2026-08-17T09:45:09.424224+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready
|
||||||
|
2026-08-17T09:45:09.973615+00:00 gfcloud[2278] INFO websocket:ws_connect ESTABLISHED
|
||||||
|
2026-08-17T09:45:09.994287+00:00 gfcloud[2278] INFO cnc:set_ignored_faults 0
|
||||||
|
2026-08-17T09:45:09.996281+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||||
|
2026-08-17T09:45:09.998803+00:00 gfcloud[2278] INFO cnc:set_x_mode Microstep.M_8
|
||||||
|
2026-08-17T09:45:09.999688+00:00 gfcloud[2278] INFO cnc:set_y_mode Microstep.M_8
|
||||||
|
2026-08-17T09:45:10.000483+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||||
|
2026-08-17T09:45:10.001229+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||||
|
2026-08-17T09:45:10.002000+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||||
|
2026-08-17T09:45:10.007267+00:00 gfcloud[2278] INFO cnc:set_y_current 33
|
||||||
|
2026-08-17T09:45:10.008695+00:00 gfcloud[2278] INFO z_axis:reset resetting z
|
||||||
|
2026-08-17T09:45:10.009124+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: False, current: ZCur.LOW, mode: Microstep.HALF
|
||||||
|
2026-08-17T09:45:10.021592+00:00 gfcloud[2278] INFO gfuiservice:run service action request: settings (ready)
|
||||||
|
2026-08-17T09:45:10.024928+00:00 gfcloud[2278] INFO settings:send_report START
|
||||||
|
2026-08-17T09:45:10.039856+00:00 gfcloud[2278] INFO settings:send_report COMPLETE
|
||||||
|
2026-08-17T09:45:10.040650+00:00 gfcloud[2278] INFO gfuiservice:run settings (ready) service action dispatched
|
||||||
|
2026-08-17T09:45:10.591813+00:00 gfcloud[2278] INFO gfuiservice:run service action request: hunt (ready)
|
||||||
|
2026-08-17T09:45:10.596213+00:00 gfcloud[2278] INFO z_axis:home starting z homing cycle
|
||||||
|
2026-08-17T09:45:10.599109+00:00 gfcloud[2278] INFO gfuiservice:run hunt (ready) service action hunt
|
||||||
|
2026-08-17T09:45:10.600433+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: True, current: ZCur.HIGH, mode: Microstep.FULL
|
||||||
|
2026-08-17T09:45:10.607559+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||||
|
2026-08-17T09:45:11.160422+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||||
|
2026-08-17T09:45:11.161195+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||||
|
2026-08-17T09:45:11.714663+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||||
|
2026-08-17T09:45:11.717907+00:00 gfcloud[2278] INFO z_axis:home pass: 1
|
||||||
|
2026-08-17T09:45:11.719134+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||||
|
2026-08-17T09:45:12.272747+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||||
|
2026-08-17T09:45:12.274301+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||||
|
2026-08-17T09:45:12.829585+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||||
|
2026-08-17T09:45:12.831305+00:00 gfcloud[2278] INFO z_axis:home pass: 2
|
||||||
|
2026-08-17T09:45:12.832786+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||||
|
2026-08-17T09:45:13.385567+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||||
|
2026-08-17T09:45:13.397106+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||||
|
2026-08-17T09:45:13.947958+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||||
|
2026-08-17T09:45:13.949754+00:00 gfcloud[2278] INFO z_axis:home pass: 3
|
||||||
|
2026-08-17T09:45:13.951244+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||||
|
2026-08-17T09:45:14.504840+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||||
|
2026-08-17T09:45:14.506743+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||||
|
2026-08-17T09:45:15.060854+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||||
|
2026-08-17T09:45:15.062536+00:00 gfcloud[2278] INFO z_axis:home pass: 4
|
||||||
|
2026-08-17T09:45:15.063941+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||||
|
2026-08-17T09:45:15.617907+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||||
|
2026-08-17T09:45:15.619505+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||||
|
2026-08-17T09:45:16.175270+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||||
|
2026-08-17T09:45:16.177421+00:00 gfcloud[2278] INFO z_axis:home pass: 5
|
||||||
|
2026-08-17T09:45:16.179808+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
|
||||||
|
2026-08-17T09:45:16.733698+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
|
||||||
|
2026-08-17T09:45:16.735355+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
|
||||||
|
2026-08-17T09:45:17.289377+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
|
||||||
|
2026-08-17T09:45:17.291109+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: None, current: ZCur.LOW, mode: Microstep.HALF
|
||||||
|
2026-08-17T09:45:17.296301+00:00 gfcloud[2278] INFO z_axis:home homing cycle complete: [0, 0, 0, 0, 0]
|
||||||
|
2026-08-17T09:45:17.298209+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||||
|
2026-08-17T09:45:17.968494+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||||
|
2026-08-17T09:45:17.969759+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||||
|
2026-08-17T09:45:17.970993+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||||
|
2026-08-17T09:45:17.972200+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||||
|
2026-08-17T09:45:17.973616+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||||
|
2026-08-17T09:45:17.974870+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||||
|
2026-08-17T09:45:17.976077+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||||
|
2026-08-17T09:45:17.983820+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||||
|
2026-08-17T09:45:17.985159+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:45:17.989360+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||||
|
2026-08-17T09:45:19.630807+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:45:19.636283+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||||
|
2026-08-17T09:45:19.637979+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:45:19.651159+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (0/0), Y (0/0), Z (-4/-4)
|
||||||
|
2026-08-17T09:45:19.652103+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:15874, expected: 15874
|
||||||
|
2026-08-17T09:45:19.652852+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||||
|
2026-08-17T09:45:19.653964+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||||
|
2026-08-17T09:45:19.655305+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||||
|
2026-08-17T09:45:19.664032+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (0, 0, -4)
|
||||||
|
2026-08-17T09:45:19.664853+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||||
|
2026-08-17T09:45:19.665575+00:00 gfcloud[2278] INFO basemachine:_finish_action hunt [1576550660]: finished with event ":completed"
|
||||||
|
2026-08-17T09:45:19.667114+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:45:20.027338+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||||
|
2026-08-17T09:45:20.030024+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||||
|
2026-08-17T09:45:20.040337+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||||
|
2026-08-17T09:45:21.195560+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||||
|
2026-08-17T09:45:21.196529+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||||
|
2026-08-17T09:45:21.721382+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||||
|
2026-08-17T09:45:21.725394+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:45:23.732857+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||||
|
2026-08-17T09:45:23.736044+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||||
|
2026-08-17T09:45:23.737995+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||||
|
2026-08-17T09:45:23.738941+00:00 gfcloud[2278] INFO machine:_safe_to_move lid opened, unsafe to move
|
||||||
|
2026-08-17T09:45:23.739814+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||||
|
2026-08-17T09:45:23.740605+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550667]: finished with event ":cancelled"
|
||||||
|
2026-08-17T09:45:23.742169+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:45:42.629674+00:00 gfcloud[2278] INFO machine:_switch_event lid closed
|
||||||
|
2026-08-17T09:45:42.920194+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||||
|
2026-08-17T09:45:42.924152+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||||
|
2026-08-17T09:45:42.927934+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||||
|
2026-08-17T09:45:43.989152+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||||
|
2026-08-17T09:45:43.990022+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||||
|
2026-08-17T09:45:44.312060+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||||
|
2026-08-17T09:45:44.315952+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:45:46.652633+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||||
|
2026-08-17T09:45:46.654950+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||||
|
2026-08-17T09:45:46.657522+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||||
|
2026-08-17T09:45:48.089456+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||||
|
2026-08-17T09:45:48.090706+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||||
|
2026-08-17T09:45:48.091970+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||||
|
2026-08-17T09:45:48.093155+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||||
|
2026-08-17T09:45:48.094587+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||||
|
2026-08-17T09:45:48.095866+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||||
|
2026-08-17T09:45:48.097182+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||||
|
2026-08-17T09:45:48.104758+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||||
|
2026-08-17T09:45:48.106128+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:45:48.110477+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||||
|
2026-08-17T09:45:50.393091+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:45:50.398748+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||||
|
2026-08-17T09:45:50.399629+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:45:50.423949+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (12939/12939), Y (7402/7402), Z (0/0)
|
||||||
|
2026-08-17T09:45:50.425534+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:22740, expected: 22740
|
||||||
|
2026-08-17T09:45:50.426258+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||||
|
2026-08-17T09:45:50.427835+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||||
|
2026-08-17T09:45:50.429387+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||||
|
2026-08-17T09:45:50.437659+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (12939, 7402, 0)
|
||||||
|
2026-08-17T09:45:50.438474+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||||
|
2026-08-17T09:45:50.439167+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550679]: finished with event ":completed"
|
||||||
|
2026-08-17T09:45:50.440481+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:45:50.770056+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||||
|
2026-08-17T09:45:50.772800+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||||
|
2026-08-17T09:45:50.782017+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||||
|
2026-08-17T09:45:51.427848+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||||
|
2026-08-17T09:45:51.428456+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||||
|
2026-08-17T09:45:51.757479+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||||
|
2026-08-17T09:45:51.760692+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:45:54.222123+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||||
|
2026-08-17T09:45:54.224786+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||||
|
2026-08-17T09:45:54.226855+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||||
|
2026-08-17T09:45:54.557705+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
|
||||||
|
2026-08-17T09:45:54.562210+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||||
|
2026-08-17T09:45:54.571212+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||||
|
2026-08-17T09:45:54.573373+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||||
|
2026-08-17T09:45:54.596668+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||||
|
2026-08-17T09:45:54.597612+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||||
|
2026-08-17T09:45:54.598479+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||||
|
2026-08-17T09:45:54.622488+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||||
|
2026-08-17T09:45:54.624223+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:45:54.629307+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||||
|
2026-08-17T09:45:54.835600+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:45:54.838848+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||||
|
2026-08-17T09:45:54.839726+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:45:54.849011+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (159/159), Y (5/5), Z (0/0)
|
||||||
|
2026-08-17T09:45:54.849946+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:1222, expected: 1222
|
||||||
|
2026-08-17T09:45:54.850790+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||||
|
2026-08-17T09:45:54.852317+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||||
|
2026-08-17T09:45:54.854073+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||||
|
2026-08-17T09:45:54.880450+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (159, 5, 0)
|
||||||
|
2026-08-17T09:45:54.881925+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||||
|
2026-08-17T09:45:54.882641+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550683]: finished with event ":completed"
|
||||||
|
2026-08-17T09:45:54.884500+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:45:55.137070+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||||
|
2026-08-17T09:45:55.139449+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||||
|
2026-08-17T09:45:55.142346+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||||
|
2026-08-17T09:45:55.809276+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||||
|
2026-08-17T09:45:55.810155+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||||
|
2026-08-17T09:45:56.158888+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||||
|
2026-08-17T09:45:56.162644+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:45:58.467590+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
|
||||||
|
2026-08-17T09:45:58.469426+00:00 gfcloud[2278] INFO machine:_motion start motion
|
||||||
|
2026-08-17T09:45:58.471091+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
|
||||||
|
2026-08-17T09:46:00.721960+00:00 gfcloud[2278] INFO cnc:set_step_freq 28160
|
||||||
|
2026-08-17T09:46:00.725688+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
|
||||||
|
2026-08-17T09:46:00.727477+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
|
||||||
|
2026-08-17T09:46:00.728738+00:00 gfcloud[2278] INFO cnc:set_x_current 135
|
||||||
|
2026-08-17T09:46:00.730198+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
|
||||||
|
2026-08-17T09:46:00.731506+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
|
||||||
|
2026-08-17T09:46:00.732735+00:00 gfcloud[2278] INFO cnc:set_y_current 22
|
||||||
|
2026-08-17T09:46:00.740459+00:00 gfcloud[2278] INFO machine:_run_loop starting run
|
||||||
|
2026-08-17T09:46:00.741865+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:46:00.745983+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||||
|
2026-08-17T09:46:02.491897+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:46:02.494137+00:00 gfcloud[2278] INFO machine:_run_loop finished run
|
||||||
|
2026-08-17T09:46:02.495155+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:46:02.530565+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (-13096/-13096), Y (-7400/-7400), Z (0/0)
|
||||||
|
2026-08-17T09:46:02.531413+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:47243, expected: 47243
|
||||||
|
2026-08-17T09:46:02.538168+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
|
||||||
|
2026-08-17T09:46:02.539603+00:00 gfcloud[2278] INFO cnc:set_x_current 33
|
||||||
|
2026-08-17T09:46:02.541147+00:00 gfcloud[2278] INFO cnc:set_y_current 5
|
||||||
|
2026-08-17T09:46:02.549578+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (-13096, -7400, 0)
|
||||||
|
2026-08-17T09:46:02.550385+00:00 gfcloud[2278] INFO machine:_motion end motion
|
||||||
|
2026-08-17T09:46:02.551153+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550685]: finished with event ":completed"
|
||||||
|
2026-08-17T09:46:02.552496+00:00 gfcloud[2278] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:46:03.007623+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
|
||||||
|
2026-08-17T09:46:03.009488+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
|
||||||
|
2026-08-17T09:46:03.037155+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
|
||||||
|
2026-08-17T09:46:03.758520+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
|
||||||
|
2026-08-17T09:46:03.760049+00:00 gfcloud[2278] INFO websocket:img_upload START
|
||||||
|
2026-08-17T09:46:04.128126+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
2026-08-17T09:42:09.781500+00:00 gfcloud[1522] INFO gfuiservice:run motion (ready) service action motion
|
||||||
|
2026-08-17T09:42:11.281337+00:00 gfcloud[1522] INFO cnc:set_step_freq 28160
|
||||||
|
2026-08-17T09:42:11.282627+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
|
||||||
|
2026-08-17T09:42:11.283850+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
|
||||||
|
2026-08-17T09:42:11.285121+00:00 gfcloud[1522] INFO cnc:set_x_current 135
|
||||||
|
2026-08-17T09:42:11.286711+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
|
||||||
|
2026-08-17T09:42:11.287977+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
|
||||||
|
2026-08-17T09:42:11.289235+00:00 gfcloud[1522] INFO cnc:set_y_current 22
|
||||||
|
2026-08-17T09:42:11.297425+00:00 gfcloud[1522] INFO machine:_run_loop starting run
|
||||||
|
2026-08-17T09:42:11.299141+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:42:11.303264+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||||
|
2026-08-17T09:42:12.560917+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:42:12.572022+00:00 gfcloud[1522] INFO machine:_run_loop finished run
|
||||||
|
2026-08-17T09:42:12.572811+00:00 gfcloud[1522] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:42:12.580801+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (-10313/-10313), Y (-649/-649), Z (0/0)
|
||||||
|
2026-08-17T09:42:12.581620+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:34952, expected: 34952
|
||||||
|
2026-08-17T09:42:12.582366+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
|
||||||
|
2026-08-17T09:42:12.583540+00:00 gfcloud[1522] INFO cnc:set_x_current 33
|
||||||
|
2026-08-17T09:42:12.584928+00:00 gfcloud[1522] INFO cnc:set_y_current 5
|
||||||
|
2026-08-17T09:42:12.593681+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (-10313, -649, 0)
|
||||||
|
2026-08-17T09:42:12.604394+00:00 gfcloud[1522] INFO machine:_motion end motion
|
||||||
|
2026-08-17T09:42:12.604757+00:00 gfcloud[1522] INFO basemachine:_finish_action motion [1576550503]: finished with event ":completed"
|
||||||
|
2026-08-17T09:42:12.605824+00:00 gfcloud[1522] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:42:15.672518+00:00 gfcloud[1522] INFO gfuiservice:run service action request: print (ready)
|
||||||
|
2026-08-17T09:42:15.676330+00:00 gfcloud[1522] INFO machine:_motion start motion
|
||||||
|
2026-08-17T09:42:15.680419+00:00 gfcloud[1522] INFO gfuiservice:run print (ready) service action print
|
||||||
|
2026-08-17T09:42:21.975587+00:00 gfcloud[1522] INFO cnc:laser_latch 0
|
||||||
|
2026-08-17T09:42:22.001354+00:00 gfcloud[1522] INFO machine:_button_wait waiting for button
|
||||||
|
2026-08-17T09:42:24.774117+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
|
||||||
|
2026-08-17T09:42:24.778509+00:00 gfcloud[1522] INFO cnc:set_step_freq 10000
|
||||||
|
2026-08-17T09:42:24.781114+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
|
||||||
|
2026-08-17T09:42:24.783654+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
|
||||||
|
2026-08-17T09:42:24.786180+00:00 gfcloud[1522] INFO cnc:set_x_current 135
|
||||||
|
2026-08-17T09:42:24.789088+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
|
||||||
|
2026-08-17T09:42:24.791559+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
|
||||||
|
2026-08-17T09:42:24.794380+00:00 gfcloud[1522] INFO cnc:set_y_current 22
|
||||||
|
2026-08-17T09:42:24.815263+00:00 gfcloud[1522] INFO machine:_run_loop starting run
|
||||||
|
2026-08-17T09:42:24.816687+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:42:24.820823+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||||
|
2026-08-17T09:42:25.049489+00:00 gfcloud[1522] INFO machine:_switch_event button released
|
||||||
|
2026-08-17T09:42:30.832527+00:00 gfcloud[1522] INFO machine:_switch_event lid opened
|
||||||
|
2026-08-17T09:42:30.838627+00:00 gfcloud[1522] WARNING machine:_run_loop lid opened mid-run; stopping motion
|
||||||
|
2026-08-17T09:42:30.941553+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:42:30.947477+00:00 gfcloud[1522] INFO machine:_run_loop finished run
|
||||||
|
2026-08-17T09:42:30.949099+00:00 gfcloud[1522] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:42:30.964916+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (8454/7573), Y (684/684), Z (3/3)
|
||||||
|
2026-08-17T09:42:30.965704+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:60649, expected: 238043
|
||||||
|
2026-08-17T09:42:30.972180+00:00 gfcloud[1522] INFO machine:_return_home start return home
|
||||||
|
2026-08-17T09:42:31.847247+00:00 gfcloud[1522] INFO machine:_run_loop starting run
|
||||||
|
2026-08-17T09:42:31.848617+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:42:31.852977+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||||
|
2026-08-17T09:42:36.970521+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:42:36.976059+00:00 gfcloud[1522] INFO machine:_run_loop finished run
|
||||||
|
2026-08-17T09:42:36.977738+00:00 gfcloud[1522] INFO machine:_return_home return home complete
|
||||||
|
2026-08-17T09:42:36.979696+00:00 gfcloud[1522] INFO machine:_motion_locked start cool down
|
||||||
|
2026-08-17T09:42:36.998973+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
|
||||||
|
2026-08-17T09:42:37.000314+00:00 gfcloud[1522] INFO cnc:set_x_current 33
|
||||||
|
2026-08-17T09:42:37.001774+00:00 gfcloud[1522] INFO cnc:set_y_current 5
|
||||||
|
2026-08-17T09:42:37.010089+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (0, 0, 3)
|
||||||
|
2026-08-17T09:42:37.010885+00:00 gfcloud[1522] INFO machine:_motion end motion
|
||||||
|
2026-08-17T09:42:37.011563+00:00 gfcloud[1522] INFO basemachine:_finish_action print [1576550507]: finished with event ":cancelled"
|
||||||
|
2026-08-17T09:42:37.013071+00:00 gfcloud[1522] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:42:37.449180+00:00 gfcloud[1522] INFO gfuiservice:run service action request: hunt (ready)
|
||||||
|
2026-08-17T09:42:37.451286+00:00 gfcloud[1522] INFO z_axis:home starting z homing cycle
|
||||||
|
2026-08-17T09:42:37.452782+00:00 gfcloud[1522] INFO gfuiservice:run hunt (ready) service action hunt
|
||||||
|
2026-08-17T09:42:37.453691+00:00 gfcloud[1522] INFO z_axis:configure configuring z_enable: True, current: ZCur.HIGH, mode: Microstep.FULL
|
||||||
|
2026-08-17T09:42:37.458283+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: False
|
||||||
|
2026-08-17T09:42:37.827610+00:00 gfcloud[1522] INFO z_axis:_step_until took -2 z_steps
|
||||||
|
2026-08-17T09:42:37.829278+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: True
|
||||||
|
2026-08-17T09:42:38.200493+00:00 gfcloud[1522] INFO z_axis:_step_until took 2 z_steps
|
||||||
|
2026-08-17T09:42:38.202234+00:00 gfcloud[1522] INFO z_axis:home pass: 1
|
||||||
|
2026-08-17T09:42:38.203788+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: False
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
2026-08-17T09:42:15.672518+00:00 gfcloud[1522] INFO gfuiservice:run service action request: print (ready)
|
||||||
|
2026-08-17T09:42:15.676330+00:00 gfcloud[1522] INFO machine:_motion start motion
|
||||||
|
2026-08-17T09:42:15.680419+00:00 gfcloud[1522] INFO gfuiservice:run print (ready) service action print
|
||||||
|
2026-08-17T09:42:21.975587+00:00 gfcloud[1522] INFO cnc:laser_latch 0
|
||||||
|
2026-08-17T09:42:22.001354+00:00 gfcloud[1522] INFO machine:_button_wait waiting for button
|
||||||
|
2026-08-17T09:42:24.774117+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
|
||||||
|
2026-08-17T09:42:24.778509+00:00 gfcloud[1522] INFO cnc:set_step_freq 10000
|
||||||
|
2026-08-17T09:42:24.781114+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
|
||||||
|
2026-08-17T09:42:24.783654+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
|
||||||
|
2026-08-17T09:42:24.786180+00:00 gfcloud[1522] INFO cnc:set_x_current 135
|
||||||
|
2026-08-17T09:42:24.789088+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
|
||||||
|
2026-08-17T09:42:24.791559+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
|
||||||
|
2026-08-17T09:42:24.794380+00:00 gfcloud[1522] INFO cnc:set_y_current 22
|
||||||
|
2026-08-17T09:42:24.815263+00:00 gfcloud[1522] INFO machine:_run_loop starting run
|
||||||
|
2026-08-17T09:42:24.816687+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:42:24.820823+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||||
|
2026-08-17T09:42:25.049489+00:00 gfcloud[1522] INFO machine:_switch_event button released
|
||||||
|
2026-08-17T09:42:31.104201+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
|
||||||
|
2026-08-17T09:42:31.106877+00:00 gfcloud[1522] INFO machine:_run_loop button pressed mid-run; pausing
|
||||||
|
2026-08-17T09:42:31.288114+00:00 gfcloud[1522] INFO machine:_switch_event button released
|
||||||
|
2026-08-17T09:42:31.612040+00:00 gfcloud[1522] INFO machine:_run_loop paused at Position(x=AxisPosition(steps=1210, mm=22.68, inch=0.89), y=AxisPosition(steps=402, mm=7.54, inch=0.30), z=AxisPosition(steps=3, mm=0.0, inch=0.0), bytes=PulsPosition(total=238043, processed=61200))
|
||||||
|
2026-08-17T09:42:34.702311+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
|
||||||
|
2026-08-17T09:42:34.704560+00:00 gfcloud[1522] INFO machine:_run_loop button pressed while paused
|
||||||
|
2026-08-17T09:42:34.706100+00:00 gfcloud[1522] INFO machine:_resume_retraced resuming (laser lead 1950 ticks)
|
||||||
|
2026-08-17T09:42:34.901002+00:00 gfcloud[1522] INFO machine:_switch_event button released
|
||||||
|
2026-08-17T09:42:58.130807+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:42:58.136283+00:00 gfcloud[1522] INFO machine:_run_loop finished run
|
||||||
|
2026-08-17T09:42:58.137979+00:00 gfcloud[1522] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:42:58.151159+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (7573/7573), Y (684/684), Z (3/3)
|
||||||
|
2026-08-17T09:42:58.152103+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:238043, expected: 238043
|
||||||
|
2026-08-17T09:42:58.160180+00:00 gfcloud[1522] INFO machine:_return_home start return home
|
||||||
|
2026-08-17T09:42:59.047247+00:00 gfcloud[1522] INFO machine:_run_loop starting run
|
||||||
|
2026-08-17T09:42:59.048617+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:42:59.052977+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||||
|
2026-08-17T09:43:04.170521+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:43:04.176059+00:00 gfcloud[1522] INFO machine:_run_loop finished run
|
||||||
|
2026-08-17T09:43:04.177738+00:00 gfcloud[1522] INFO machine:_return_home return home complete
|
||||||
|
2026-08-17T09:43:04.179696+00:00 gfcloud[1522] INFO machine:_motion_locked start cool down
|
||||||
|
2026-08-17T09:43:04.198973+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
|
||||||
|
2026-08-17T09:43:04.200314+00:00 gfcloud[1522] INFO cnc:set_x_current 33
|
||||||
|
2026-08-17T09:43:04.201774+00:00 gfcloud[1522] INFO cnc:set_y_current 5
|
||||||
|
2026-08-17T09:43:04.210089+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (0, 0, 3)
|
||||||
|
2026-08-17T09:43:04.210885+00:00 gfcloud[1522] INFO machine:_motion end motion
|
||||||
|
2026-08-17T09:43:04.211563+00:00 gfcloud[1522] INFO basemachine:_finish_action print [1576550507]: finished with event ":completed"
|
||||||
|
2026-08-17T09:43:04.213071+00:00 gfcloud[1522] INFO cnc:laser_latch 1
|
||||||
|
2026-08-17T09:43:04.649180+00:00 gfcloud[1522] INFO gfuiservice:run service action request: hunt (ready)
|
||||||
|
2026-08-17T09:43:04.651286+00:00 gfcloud[1522] INFO z_axis:home starting z homing cycle
|
||||||
|
2026-08-17T09:43:04.652782+00:00 gfcloud[1522] INFO gfuiservice:run hunt (ready) service action hunt
|
||||||
|
2026-08-17T09:43:11.298209+00:00 gfcloud[1522] INFO machine:_motion start motion
|
||||||
|
2026-08-17T09:43:11.983820+00:00 gfcloud[1522] INFO machine:_run_loop starting run
|
||||||
|
2026-08-17T09:43:13.630807+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||||
|
2026-08-17T09:43:13.636283+00:00 gfcloud[1522] INFO machine:_run_loop finished run
|
||||||
|
2026-08-17T09:43:13.664853+00:00 gfcloud[1522] INFO machine:_motion end motion
|
||||||
|
2026-08-17T09:43:13.665575+00:00 gfcloud[1522] INFO basemachine:_finish_action hunt [1576550560]: finished with event ":completed"
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""Shared fixtures for the forgetest unit tests: synthetic manifests and
|
||||||
|
catalog entries that never touch hardware."""
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, os.path.dirname(HERE))
|
||||||
|
|
||||||
|
from forgetest import catalog as catalog_mod # noqa: E402
|
||||||
|
from forgetest import manifest as manifest_mod # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def blob(text):
|
||||||
|
"""A git-style blob id for text (what ls-tree would report)."""
|
||||||
|
data = text.encode()
|
||||||
|
return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def make_manifest(components=None, platform=None, version="20260101000000 (dev)", name="forgefirm-image-dev"):
|
||||||
|
comps = components if components is not None else {
|
||||||
|
"forgectrl": {"srcrev": "aaa", "files": [["src/main.c", blob("main")], ["src/ui.c", blob("ui")],
|
||||||
|
["src/auth.c", blob("auth")], ["src/cool.c", blob("cool")],
|
||||||
|
["README.md", blob("readme")]]},
|
||||||
|
"grblhal-glowforge": {"srcrev": "bbb", "files": [["src/driver.c", blob("drv")],
|
||||||
|
["src/grbl", "cccc"], ["src/grbl/core.c", blob("core")]]},
|
||||||
|
"kernel-module-glowforge": {"srcrev": "ddd", "files": [["src/cnc.c", blob("cnc")]]},
|
||||||
|
"linux-fslc": {"srcrev": "eee", "files": [["@srcrev", "eee"], ["@config", "cfg1"]]},
|
||||||
|
"forgetest": {"srcrev": None, "files": [["forgetest/x.py", blob("x")]]},
|
||||||
|
}
|
||||||
|
plat = platform if platform is not None else {
|
||||||
|
"machine": "glowforge", "kernel_modules": ["6.12.20-fslc+g0e01ec9f0d3f"],
|
||||||
|
"dtb": {"glowforge.dtb": "d" * 64},
|
||||||
|
"layers": {"meta-forgefirm": {"content_sha256": "1" * 64}, "poky": {"rev": "p" * 40}},
|
||||||
|
}
|
||||||
|
data = {"format": 1, "image": {"name": name, "version": version},
|
||||||
|
"components": comps, "platform": plat}
|
||||||
|
data["content_sha256"] = manifest_mod.sha256_text(
|
||||||
|
manifest_mod.canonical({"components": comps, "platform": plat}))
|
||||||
|
return manifest_mod.Manifest(data)
|
||||||
|
|
||||||
|
|
||||||
|
def with_file(manifest, component, path, text):
|
||||||
|
"""A copy of the manifest with one file's content changed/added."""
|
||||||
|
import copy
|
||||||
|
data = copy.deepcopy(manifest.data)
|
||||||
|
files = data["components"][component]["files"]
|
||||||
|
for f in files:
|
||||||
|
if f[0] == path:
|
||||||
|
f[1] = blob(text)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
files.append([path, blob(text)])
|
||||||
|
data["content_sha256"] = manifest_mod.sha256_text(
|
||||||
|
manifest_mod.canonical({"components": data["components"], "platform": data["platform"]}))
|
||||||
|
return manifest_mod.Manifest(data)
|
||||||
|
|
||||||
|
|
||||||
|
def with_platform(manifest, **changes):
|
||||||
|
import copy
|
||||||
|
data = copy.deepcopy(manifest.data)
|
||||||
|
data["platform"].update(changes)
|
||||||
|
data["content_sha256"] = manifest_mod.sha256_text(
|
||||||
|
manifest_mod.canonical({"components": data["components"], "platform": data["platform"]}))
|
||||||
|
return manifest_mod.Manifest(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _noop(ctx):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def make_test(id, covers, always=False, requires=(), kind="auto", fn=None, subsystem=None, mode=None,
|
||||||
|
actions=(), precheck=None, steps=(), hands=()):
|
||||||
|
return catalog_mod.Test(id, "Title " + id, subsystem or id.split(".")[0], kind, "api",
|
||||||
|
covers, requires, always, 1, steps, "desc", fn or _noop, mode=mode,
|
||||||
|
actions=actions, precheck=precheck, hands=hands)
|
||||||
|
|
||||||
|
|
||||||
|
def registry(*tests):
|
||||||
|
return {t.id: t for t in tests}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- fake forgectrl
|
||||||
|
|
||||||
|
class FakeForgectrl:
|
||||||
|
"""A stand-in for the machine-services daemon on localhost: canned
|
||||||
|
JSON for the endpoints the suite reads, a mutable `state` the test
|
||||||
|
scripts, every POST recorded, and an optional `on_post(path, form)`
|
||||||
|
hook returning (status, body) to script the daemon's reactions.
|
||||||
|
Point the suite at it with FORGECTRL_URL (see start/stop)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
import http.server
|
||||||
|
import json as _json
|
||||||
|
import threading as _threading
|
||||||
|
import urllib.parse as _up
|
||||||
|
self.state = {
|
||||||
|
"mode": {"mode": "grbl", "controller": "running", "pid": 100, "motion": "verified"},
|
||||||
|
"status": {"state": "idle", "homed": False, "diag": False, "laser_locked": True,
|
||||||
|
"pos": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||||
|
"switches": {"lid": True, "button": False, "interlock_ok": True,
|
||||||
|
"head": True, "hv_enable": False}},
|
||||||
|
"cool": {"phase": "idle", "armed": False, "hold": False},
|
||||||
|
"cam": {"running": False, "clients": 0},
|
||||||
|
"diag": {"running": False},
|
||||||
|
"settings": {"controller_mode": "grbl", "lid_lamp_idle": ""},
|
||||||
|
"logs_tail": {"name": "forgectrl", "text": "", "truncated": False, "exists": True},
|
||||||
|
}
|
||||||
|
self.posts = []
|
||||||
|
self.on_post = None
|
||||||
|
self.on_get = None # (path, query) -> (status, body) | (status, bytes, ctype) | None
|
||||||
|
fake = self
|
||||||
|
|
||||||
|
class H(http.server.BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _send(self, st, body):
|
||||||
|
data = _json.dumps(body).encode()
|
||||||
|
self.send_response(st)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path, _, query = self.path.partition("?")
|
||||||
|
if fake.on_get:
|
||||||
|
r = fake.on_get(path, dict(_up.parse_qsl(query, keep_blank_values=True)))
|
||||||
|
if r is not None:
|
||||||
|
if isinstance(r[1], (bytes, bytearray)):
|
||||||
|
self.send_response(r[0])
|
||||||
|
self.send_header("Content-Type", r[2] if len(r) > 2 else "application/octet-stream")
|
||||||
|
self.send_header("Content-Length", str(len(r[1])))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(r[1])
|
||||||
|
return
|
||||||
|
return self._send(r[0], r[1])
|
||||||
|
key = {"/mode": "mode", "/status": "status", "/cool/status": "cool", "/cam/status": "cam",
|
||||||
|
"/diag/status": "diag", "/settings": "settings",
|
||||||
|
"/logs/tail": "logs_tail"}.get(path)
|
||||||
|
if key is None:
|
||||||
|
return self._send(404, {"error": "no " + path})
|
||||||
|
self._send(200, fake.state[key])
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
path, _, query = self.path.partition("?")
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
raw = self.rfile.read(n).decode() if n else ""
|
||||||
|
form = dict(_up.parse_qsl(raw if raw else query, keep_blank_values=True))
|
||||||
|
fake.posts.append((path, form))
|
||||||
|
if fake.on_post:
|
||||||
|
r = fake.on_post(path, form)
|
||||||
|
if r is not None:
|
||||||
|
return self._send(*r)
|
||||||
|
if path == "/mode" and form.get("controller"):
|
||||||
|
fake.state["mode"] = dict(fake.state["mode"], mode=form["controller"], controller="running",
|
||||||
|
pid=fake.state["mode"].get("pid", 0) + 1)
|
||||||
|
fake.state["settings"]["controller_mode"] = form["controller"]
|
||||||
|
elif path == "/settings":
|
||||||
|
fake.state["settings"].update(form)
|
||||||
|
return self._send(200, fake.state["settings"])
|
||||||
|
self._send(200, {"ok": True})
|
||||||
|
|
||||||
|
self._srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
|
||||||
|
self._srv.daemon_threads = True
|
||||||
|
self._srv.block_on_close = False # never wait on a lingering connection
|
||||||
|
self.url = "http://127.0.0.1:%d" % self._srv.server_address[1]
|
||||||
|
self._th = _threading.Thread(target=self._srv.serve_forever, daemon=True)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._th.start()
|
||||||
|
os.environ["FORGECTRL_URL"] = self.url
|
||||||
|
os.environ["FORGECTRL_TOKEN_FILE"] = os.devnull
|
||||||
|
return self
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._srv.shutdown()
|
||||||
|
self._srv.server_close()
|
||||||
|
os.environ.pop("FORGECTRL_URL", None)
|
||||||
|
os.environ.pop("FORGECTRL_TOKEN_FILE", None)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ fake grbl
|
||||||
|
|
||||||
|
class FakeGrbl:
|
||||||
|
"""A Grbl-over-TCP stand-in for the controller: answers '?' with a
|
||||||
|
status report built from its mutable `state`/`mpos`, 'ok' to every
|
||||||
|
command line, and records what it was sent. Point the suite at it
|
||||||
|
with GRBL_HOST/GRBL_PORT (see start/stop)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
import socket as _socket
|
||||||
|
import threading as _threading
|
||||||
|
self.state = "Idle"
|
||||||
|
self.mpos = [0.0, 0.0, 0.0]
|
||||||
|
self.sent = []
|
||||||
|
self.extra = b"" # text pushed to the client on the next poll
|
||||||
|
self.reset_to = None # the state a soft reset (^X) lands in, when set
|
||||||
|
self.on_command = None # called with every command line received
|
||||||
|
self._sock = _socket.socket()
|
||||||
|
self._sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
|
||||||
|
self._sock.bind(("127.0.0.1", 0))
|
||||||
|
self._sock.listen(5)
|
||||||
|
self.port = self._sock.getsockname()[1]
|
||||||
|
self._stop = False
|
||||||
|
self._th = _threading.Thread(target=self._serve, daemon=True)
|
||||||
|
|
||||||
|
def _serve(self):
|
||||||
|
import socket as _socket
|
||||||
|
import threading as _threading
|
||||||
|
self._sock.settimeout(0.2)
|
||||||
|
while not self._stop:
|
||||||
|
try:
|
||||||
|
c, _ = self._sock.accept()
|
||||||
|
except (_socket.timeout, OSError):
|
||||||
|
continue
|
||||||
|
_threading.Thread(target=self._client, args=(c,), daemon=True).start()
|
||||||
|
|
||||||
|
def _client(self, c):
|
||||||
|
import socket as _socket
|
||||||
|
c.settimeout(0.1)
|
||||||
|
buf = b""
|
||||||
|
try:
|
||||||
|
c.sendall(b"\r\nGrblHAL 1.1f ['$' for help]\r\n")
|
||||||
|
while not self._stop:
|
||||||
|
try:
|
||||||
|
d = c.recv(4096)
|
||||||
|
if not d:
|
||||||
|
break
|
||||||
|
buf += d
|
||||||
|
except _socket.timeout:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
out = b""
|
||||||
|
if self.extra:
|
||||||
|
out += self.extra
|
||||||
|
self.extra = b""
|
||||||
|
while buf:
|
||||||
|
if buf[0:1] == b"?":
|
||||||
|
buf = buf[1:]
|
||||||
|
out += ("<%s|MPos:%.3f,%.3f,%.3f|FS:0,0>\r\n" % (self.state, *self.mpos)).encode()
|
||||||
|
elif buf[0] in (0x18, 0x85, 0x7E, 0x21):
|
||||||
|
if buf[0] == 0x18:
|
||||||
|
self.sent.append("^X")
|
||||||
|
if self.reset_to:
|
||||||
|
self.state = self.reset_to
|
||||||
|
buf = buf[1:]
|
||||||
|
elif b"\n" in buf:
|
||||||
|
line, buf = buf.split(b"\n", 1)
|
||||||
|
self.sent.append(line.strip().decode("utf-8", "replace"))
|
||||||
|
if self.on_command:
|
||||||
|
self.on_command(self.sent[-1])
|
||||||
|
out += b"ok\r\n"
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if out:
|
||||||
|
c.sendall(out)
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._th.start()
|
||||||
|
os.environ["GRBL_HOST"] = "127.0.0.1"
|
||||||
|
os.environ["GRBL_PORT"] = str(self.port)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop = True
|
||||||
|
try:
|
||||||
|
self._sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
for k in ("GRBL_HOST", "GRBL_PORT"):
|
||||||
|
os.environ.pop(k, None)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers
|
||||||
|
from forgetest import artifact, campaign, catalog
|
||||||
|
from forgetest import manifest as manifest_mod
|
||||||
|
from test_campaign import rec_campaign, rec_result
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.man = helpers.make_manifest()
|
||||||
|
self.core = helpers.make_test("image.health", [("linux-fslc", "**")], always=True)
|
||||||
|
self.ui = helpers.make_test("forgectrl.panel", [("forgectrl", "src/ui.c")])
|
||||||
|
self.cool = helpers.make_test("cooling.flow", [("forgectrl", "src/cool.c")])
|
||||||
|
self.reg = helpers.registry(self.core, self.ui, self.cool)
|
||||||
|
self.tests = list(self.reg.values())
|
||||||
|
self.chash = catalog.catalog_hash(self.reg)
|
||||||
|
# campaign 1 on the base image: everything passes
|
||||||
|
self.recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
|
||||||
|
for i, t in enumerate(self.tests):
|
||||||
|
self.recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
|
||||||
|
# image 2: cool.c changed; campaign 2 reruns core + cool
|
||||||
|
self.man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
|
||||||
|
self.recs.append(rec_campaign("c2", self.man2, self.chash, "2026-08-21T10:00:00Z"))
|
||||||
|
self.recs.append(rec_result("c2", self.core, self.man2, "PASS", "2026-08-21T10:01:00Z"))
|
||||||
|
self.recs.append(rec_result("c2", self.cool, self.man2, "PASS", "2026-08-21T10:02:00Z"))
|
||||||
|
|
||||||
|
def build(self, man=None):
|
||||||
|
man = man or self.man2
|
||||||
|
st = campaign.compute(self.recs, self.tests, man, self.chash)
|
||||||
|
return artifact.build(st, self.tests, man, self.recs, self.chash), st
|
||||||
|
|
||||||
|
def release_manifest(self, man=None):
|
||||||
|
"""The release build's manifest: same identity, no forgetest component."""
|
||||||
|
man = man or self.man2
|
||||||
|
data = copy.deepcopy(man.data)
|
||||||
|
data["components"].pop("forgetest", None)
|
||||||
|
data["image"] = {"name": "forgefirm-image", "version": "v0.1.0"}
|
||||||
|
return manifest_mod.Manifest(data)
|
||||||
|
|
||||||
|
def test_build_and_verify(self):
|
||||||
|
art, st = self.build()
|
||||||
|
self.assertTrue(art["authorized"])
|
||||||
|
by = {t["id"]: t for t in art["tests"]}
|
||||||
|
self.assertTrue(by["forgectrl.panel"]["inherited"])
|
||||||
|
self.assertEqual(by["forgectrl.panel"]["record"]["campaign"], "c1")
|
||||||
|
self.assertFalse(by["image.health"]["inherited"])
|
||||||
|
text = artifact.to_json(art)
|
||||||
|
art2 = json.loads(text)
|
||||||
|
ok, rows, problems = artifact.verify(art2, self.release_manifest(), self.tests, self.chash,
|
||||||
|
expect_machine="glowforge")
|
||||||
|
self.assertEqual(problems, [])
|
||||||
|
self.assertTrue(ok)
|
||||||
|
md = artifact.to_markdown(art)
|
||||||
|
self.assertIn("Release authorized: YES", md)
|
||||||
|
self.assertIn("forgectrl.panel", md)
|
||||||
|
|
||||||
|
def test_tamper_detected(self):
|
||||||
|
art, _ = self.build()
|
||||||
|
art2 = json.loads(artifact.to_json(art))
|
||||||
|
art2["tests"][1]["record"]["result"] = "PASS"
|
||||||
|
art2["counts"]["required"] = 0
|
||||||
|
art2["authorized"] = True
|
||||||
|
art2["tests"][2]["record"]["ts"] = "2026-08-21T10:02:01Z"
|
||||||
|
ok, rows, problems = artifact.verify(art2, self.release_manifest(), self.tests, self.chash)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIn("self-hash", problems[0])
|
||||||
|
|
||||||
|
def test_release_differs_in_covered_file(self):
|
||||||
|
art, _ = self.build()
|
||||||
|
rel = self.release_manifest(helpers.with_file(self.man2, "forgectrl", "src/ui.c", "ui v3"))
|
||||||
|
ok, rows, problems = artifact.verify(art, rel, self.tests, self.chash)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertTrue(any("forgectrl.panel" in p and "fingerprint" in p for p in problems), problems)
|
||||||
|
# an uncovered change is fine
|
||||||
|
rel2 = self.release_manifest(helpers.with_file(self.man2, "forgectrl", "README.md", "docs"))
|
||||||
|
ok, rows, problems = artifact.verify(art, rel2, self.tests, self.chash)
|
||||||
|
self.assertTrue(ok, problems)
|
||||||
|
|
||||||
|
def test_release_platform_differs(self):
|
||||||
|
art, _ = self.build()
|
||||||
|
rel = self.release_manifest(helpers.with_platform(self.man2, dtb={"glowforge.dtb": "0" * 64}))
|
||||||
|
ok, rows, problems = artifact.verify(art, rel, self.tests, self.chash)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(len([r for r in rows if not r["ok"]]), 3)
|
||||||
|
|
||||||
|
def test_unauthorized_artifact(self):
|
||||||
|
self.recs.append(rec_result("c2", self.ui, self.man2, "FAIL", "2026-08-21T10:03:00Z"))
|
||||||
|
art, st = self.build()
|
||||||
|
self.assertFalse(art["authorized"])
|
||||||
|
ok, rows, problems = artifact.verify(art, self.release_manifest(), self.tests, self.chash)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertTrue(any("does not claim authorization" in p for p in problems))
|
||||||
|
|
||||||
|
def test_core_inherited_refused(self):
|
||||||
|
art, _ = self.build()
|
||||||
|
# forge an artifact whose core record is marked inherited (self-hash recomputed)
|
||||||
|
body = json.loads(artifact.to_json(art))
|
||||||
|
body.pop("sha256")
|
||||||
|
for t in body["tests"]:
|
||||||
|
if t["id"] == "image.health":
|
||||||
|
t["inherited"] = True
|
||||||
|
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
|
||||||
|
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertTrue(any("always-required" in p for p in problems), problems)
|
||||||
|
|
||||||
|
def test_invalidate_epoch(self):
|
||||||
|
art, _ = self.build()
|
||||||
|
body = json.loads(artifact.to_json(art))
|
||||||
|
body.pop("sha256")
|
||||||
|
body["invalidate"] = {"t": "invalidate", "ts": "2026-08-20T12:00:00Z", "reason": "tube"}
|
||||||
|
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
|
||||||
|
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertTrue(any("predates the invalidate" in p for p in problems), problems)
|
||||||
|
|
||||||
|
def test_catalog_changed(self):
|
||||||
|
art, _ = self.build()
|
||||||
|
reg = helpers.registry(self.core, self.ui, self.cool, helpers.make_test("new.one", []))
|
||||||
|
tests = list(reg.values())
|
||||||
|
ok, rows, problems = artifact.verify(art, self.release_manifest(), tests, catalog.catalog_hash(reg))
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertTrue(any("catalog changed" in p for p in problems))
|
||||||
|
self.assertTrue(any("new.one" in p for p in problems))
|
||||||
|
|
||||||
|
def test_implementation_changed(self):
|
||||||
|
art, _ = self.build()
|
||||||
|
body = json.loads(artifact.to_json(art))
|
||||||
|
body.pop("sha256")
|
||||||
|
for t in body["tests"]:
|
||||||
|
if t["id"] == "cooling.flow":
|
||||||
|
t["source_sha"] = "0" * 64
|
||||||
|
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
|
||||||
|
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertTrue(any("implementation changed" in p for p in problems), problems)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
"""The baseline: fixed resting values are restored, preserved values are
|
||||||
|
handed back, every deviation is a recorded leftover. Runs against a fake
|
||||||
|
sysfs tree; forgectrl is unreachable (service-side checks skip)."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from forgetest import baseline
|
||||||
|
|
||||||
|
|
||||||
|
class BaselineTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix="forgetest-bl-")
|
||||||
|
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)
|
||||||
|
self._led(name, "0")
|
||||||
|
# a clean machine
|
||||||
|
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.lines = []
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
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 _read(self, attr):
|
||||||
|
with open(self.sysfs + attr) as f:
|
||||||
|
return f.read().strip()
|
||||||
|
|
||||||
|
def _led(self, name, val):
|
||||||
|
with open(self.leds + name + "/brightness", "w") as f:
|
||||||
|
f.write(val)
|
||||||
|
# the class interface writes 'target'; the fake mirrors it into brightness
|
||||||
|
# only when the test asks (see _sync_leds)
|
||||||
|
|
||||||
|
def _led2(self, name, target, brightness):
|
||||||
|
"""Both attributes of one button LED: the trigger's commanded
|
||||||
|
level and the fade that follows it."""
|
||||||
|
for a, v in (("target", target), ("brightness", brightness)):
|
||||||
|
with open(self.leds + name + "/" + a, "w") as f:
|
||||||
|
f.write(v)
|
||||||
|
|
||||||
|
def _sync_leds(self):
|
||||||
|
for name in baseline.BUTTON_LEDS:
|
||||||
|
p = self.leds + name + "/target"
|
||||||
|
if os.path.exists(p):
|
||||||
|
with open(p) as f:
|
||||||
|
v = f.read().strip()
|
||||||
|
with open(self.leds + name + "/brightness", "w") as f:
|
||||||
|
f.write(v)
|
||||||
|
|
||||||
|
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 bl(self):
|
||||||
|
return baseline.Baseline(self.lines.append)
|
||||||
|
|
||||||
|
def test_clean_machine_has_no_leftovers(self):
|
||||||
|
left = self.bl().enforce("pre", captured=None)
|
||||||
|
self.assertEqual(left, [])
|
||||||
|
self.assertTrue(any("pre: clean" in l for l in self.lines))
|
||||||
|
|
||||||
|
def test_fixed_values_are_restored_and_recorded(self):
|
||||||
|
self._attr("cnc/motor_lock", "15")
|
||||||
|
self._attr("cnc/step_freq", "10000")
|
||||||
|
self._attr("cnc/streaming", "1")
|
||||||
|
left = self.bl().enforce("post", captured=None)
|
||||||
|
items = {x.item: x for x in left}
|
||||||
|
self.assertEqual(set(items), {"cnc/motor_lock", "cnc/step_freq", "cnc/streaming"})
|
||||||
|
for x in left:
|
||||||
|
self.assertEqual(x.action, "restored", str(x))
|
||||||
|
self.assertEqual(self._read("cnc/motor_lock"), "0")
|
||||||
|
self.assertEqual(self._read("cnc/step_freq"), "28160")
|
||||||
|
self.assertEqual(self._read("cnc/streaming"), "0")
|
||||||
|
self.assertEqual(items["cnc/motor_lock"].found, "15")
|
||||||
|
self.assertEqual(items["cnc/motor_lock"].expected, "0")
|
||||||
|
|
||||||
|
def test_unlocked_latch_is_relocked(self):
|
||||||
|
self._attr("cnc/interlock_circuit", "5") # bit 3 clear = unlocked
|
||||||
|
left = self.bl().enforce("post", captured=None)
|
||||||
|
self.assertEqual([x.item for x in left], ["laser_latch"])
|
||||||
|
self.assertEqual(self._read("cnc/laser_latch"), "1")
|
||||||
|
|
||||||
|
def test_readonly_deviation_is_unrestorable(self):
|
||||||
|
self._attr("cnc/state", "disabled")
|
||||||
|
left = self.bl().enforce("post", captured=None)
|
||||||
|
self.assertEqual([(x.item, x.action) for x in left], [("cnc/state", "unrestorable")])
|
||||||
|
|
||||||
|
def test_button_leds_are_turned_off(self):
|
||||||
|
self._led("button_led_2", "255")
|
||||||
|
left = self.bl().enforce("post", captured=None)
|
||||||
|
self.assertEqual([x.item for x in left], ["leds/button_led_2"])
|
||||||
|
self._sync_leds()
|
||||||
|
self.assertEqual(baseline.read_led("button_led_2"), "0")
|
||||||
|
|
||||||
|
def test_preserved_position(self):
|
||||||
|
b = self.bl()
|
||||||
|
cap = b.capture()
|
||||||
|
self.assertEqual(cap["position"], [0, 0, 0])
|
||||||
|
# the run shifted the counters
|
||||||
|
self._pos(1000, 0, 0)
|
||||||
|
left = b.enforce("post", captured=cap)
|
||||||
|
items = {x.item: x for x in left}
|
||||||
|
self.assertEqual(set(items), {"position"})
|
||||||
|
# no GRBL controller on the host: the head cannot be jogged back
|
||||||
|
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
|
||||||
|
self.assertEqual(items["position"].found, [1000, 0, 0])
|
||||||
|
|
||||||
|
def test_a_held_controller_is_reset_before_the_return_jog(self):
|
||||||
|
# a pause test that failed while held leaves the controller in
|
||||||
|
# Hold, which refuses a jog: the baseline resets out of it first
|
||||||
|
import helpers
|
||||||
|
fc = helpers.FakeForgectrl().start()
|
||||||
|
dev = helpers.FakeGrbl().start()
|
||||||
|
try:
|
||||||
|
dev.state = "Hold:0"
|
||||||
|
dev.reset_to = "Idle"
|
||||||
|
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
|
||||||
|
b = self.bl()
|
||||||
|
cap = b.capture()
|
||||||
|
self._pos(221, 0, 0) # 4.144 mm into the held move
|
||||||
|
left = b.enforce("post", captured=cap)
|
||||||
|
items = {x.item: x for x in left}
|
||||||
|
self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
|
||||||
|
self.assertEqual(dev.sent[0], "^X")
|
||||||
|
jogs = [l for l in dev.sent if l.startswith("$J=")]
|
||||||
|
self.assertEqual(len(jogs), 1)
|
||||||
|
self.assertIn("X-4.144", jogs[0])
|
||||||
|
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
|
||||||
|
finally:
|
||||||
|
dev.stop()
|
||||||
|
fc.stop()
|
||||||
|
for k in ("GRBL_HOST", "GRBL_PORT"):
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
|
||||||
|
def _pos_bytes(self, x, y, z, processed, total):
|
||||||
|
with open(self.sysfs + "cnc/position", "wb") as f:
|
||||||
|
f.write(struct.pack("<3i2I", x, y, z, processed, total))
|
||||||
|
|
||||||
|
def test_ring_residue_is_a_leftover_and_blocks_the_return_jog(self):
|
||||||
|
b = self.bl()
|
||||||
|
cap = b.capture()
|
||||||
|
# the run left 40 unplayed bytes queued in the kernel ring and the
|
||||||
|
# head 1000 counts out: the hand-back stands the machine down to
|
||||||
|
# empty the ring, and only refuses the return jog when the bytes
|
||||||
|
# are still there afterwards (they are here: no daemon to restart
|
||||||
|
# a controller)
|
||||||
|
self._pos_bytes(1000, 0, 0, 100, 140)
|
||||||
|
left = b.enforce("post", captured=cap)
|
||||||
|
items = {x.item: x for x in left}
|
||||||
|
self.assertIn("pulse ring", items)
|
||||||
|
self.assertEqual(items["pulse ring"].found, "40 unplayed bytes")
|
||||||
|
self.assertIn("still queued", items["position"].action)
|
||||||
|
self.assertIn("after a controller restart", items["position"].action)
|
||||||
|
self.assertEqual(baseline.read_ring_residue(), 40)
|
||||||
|
|
||||||
|
def test_the_hand_back_stands_the_machine_down_for_residue(self):
|
||||||
|
"""It acts, it does not report: a run that left bytes in the ring
|
||||||
|
gets a controller restart out of the hand-back, because that is
|
||||||
|
what empties the ring."""
|
||||||
|
b = self.bl()
|
||||||
|
cap = b.capture()
|
||||||
|
called = []
|
||||||
|
b.stand_down = lambda why: called.append(why)
|
||||||
|
self._pos_bytes(1000, 0, 0, 100, 140)
|
||||||
|
b.enforce("post", captured=cap)
|
||||||
|
self.assertEqual(len(called), 1)
|
||||||
|
self.assertIn("unplayed bytes", called[0])
|
||||||
|
|
||||||
|
def test_clean_ring_reads_zero_residue(self):
|
||||||
|
self.assertEqual(baseline.read_ring_residue(), 0)
|
||||||
|
|
||||||
|
def test_lamp_needs_forgectrl(self):
|
||||||
|
# the lamp's idle level comes from forgectrl's settings: without the
|
||||||
|
# daemon there is nothing to compare against
|
||||||
|
self._attr("pic/lid_led", "77")
|
||||||
|
left = self.bl().enforce("pre", captured=None)
|
||||||
|
self.assertEqual(left, [])
|
||||||
|
self.assertEqual(self._read("pic/lid_led"), "77")
|
||||||
|
|
||||||
|
def test_no_sysfs_means_skip(self):
|
||||||
|
os.environ["GF_SYSFS_ROOT"] = os.path.join(self.tmp, "nope") + os.sep
|
||||||
|
left = self.bl().enforce("pre", captured=None)
|
||||||
|
self.assertEqual(left, [])
|
||||||
|
self.assertTrue(any("kernel sysfs not present" in l for l in self.lines))
|
||||||
|
|
||||||
|
def test_boot_reference_needs_a_recent_boot(self):
|
||||||
|
os.environ["FORGETEST_BOOT_ID"] = "test-boot"
|
||||||
|
try:
|
||||||
|
# no reference file, uptime unknown on a host without /proc/uptime,
|
||||||
|
# or too old: None, with the reason logged
|
||||||
|
ref = baseline.boot_reference(self.lines.append, self.tmp)
|
||||||
|
up = baseline.uptime_s()
|
||||||
|
if up is None or up > baseline.BOOT_MAX_AGE_S:
|
||||||
|
self.assertIsNone(ref)
|
||||||
|
self.assertTrue(any("no fresh-boot reference" in l for l in self.lines))
|
||||||
|
else:
|
||||||
|
# a young host: the reference is taken from the fake tree
|
||||||
|
self.assertIsNotNone(ref)
|
||||||
|
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(self.tmp, "boot-test-boot.json")))
|
||||||
|
# and loaded back the second time
|
||||||
|
self.lines[:] = []
|
||||||
|
ref2 = baseline.boot_reference(self.lines.append, self.tmp)
|
||||||
|
self.assertEqual(ref2["ts"], ref["ts"])
|
||||||
|
self.assertTrue(any("reference loaded" in l for l in self.lines))
|
||||||
|
finally:
|
||||||
|
os.environ.pop("FORGETEST_BOOT_ID", None)
|
||||||
|
|
||||||
|
def test_fixed_constants_checked_against_a_dump(self):
|
||||||
|
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
|
||||||
|
diffs = baseline.check_fixed_against(ref, self.lines.append)
|
||||||
|
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=28160"])
|
||||||
|
|
||||||
|
|
||||||
|
# -- the reference is taken after the controller applied its config -----
|
||||||
|
|
||||||
|
def _probe_state(self):
|
||||||
|
# what the kernel shows between the supervisor's motion probe and
|
||||||
|
# the GRBL controller's init writes
|
||||||
|
self._attr("cnc/motor_lock", "0")
|
||||||
|
self._attr("cnc/step_freq", "10000")
|
||||||
|
self._attr("cnc/y_mode", "1")
|
||||||
|
|
||||||
|
def test_wait_configured_returns_once_the_controller_wrote_its_config(self):
|
||||||
|
self._probe_state()
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def sleep(_s):
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] == 3: # the controller's init writes land
|
||||||
|
for attr, val in baseline.CONFIGURED_MARKERS:
|
||||||
|
self._attr(attr, val)
|
||||||
|
ok = baseline.wait_controller_configured(
|
||||||
|
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
|
||||||
|
timeout=5, sleep=sleep)
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertTrue(any("controller configured" in l for l in self.lines))
|
||||||
|
self.assertGreaterEqual(calls["n"], 4) # 3 polls + the settle
|
||||||
|
|
||||||
|
def test_wait_configured_times_out_and_says_so(self):
|
||||||
|
self._probe_state()
|
||||||
|
t = {"now": 0.0}
|
||||||
|
real_time = baseline.time.time
|
||||||
|
baseline.time.time = lambda: t["now"]
|
||||||
|
try:
|
||||||
|
def sleep(s):
|
||||||
|
t["now"] += s
|
||||||
|
ok = baseline.wait_controller_configured(
|
||||||
|
self.lines.append, {"controller": "running", "mode": "grbl"}, timeout=2, sleep=sleep)
|
||||||
|
finally:
|
||||||
|
baseline.time.time = real_time
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertTrue(any("did not apply its config" in l for l in self.lines))
|
||||||
|
|
||||||
|
def test_wait_configured_is_a_noop_outside_grbl_mode(self):
|
||||||
|
self._probe_state()
|
||||||
|
ok = baseline.wait_controller_configured(
|
||||||
|
self.lines.append, {"controller": "running", "mode": "cloud"}, timeout=1,
|
||||||
|
sleep=lambda s: self.fail("slept in cloud mode"))
|
||||||
|
self.assertTrue(ok)
|
||||||
|
ok = baseline.wait_controller_configured(
|
||||||
|
self.lines.append, {"controller": "stopped", "mode": "grbl"}, timeout=1,
|
||||||
|
sleep=lambda s: self.fail("slept with the controller stopped"))
|
||||||
|
self.assertTrue(ok)
|
||||||
|
|
||||||
|
def test_preconfig_reference_is_recognized(self):
|
||||||
|
self.assertTrue(baseline.reference_preconfig(
|
||||||
|
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
|
||||||
|
self.assertFalse(baseline.reference_preconfig(
|
||||||
|
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"}}))
|
||||||
|
# a genuinely different single constant is a machine fact, not pre-config
|
||||||
|
self.assertFalse(baseline.reference_preconfig(
|
||||||
|
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"}}))
|
||||||
|
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
|
||||||
|
|
||||||
|
# -- the XY microstep mode drives the fixed values ------------------------
|
||||||
|
|
||||||
|
def test_the_fixed_values_follow_the_microstep_mode(self):
|
||||||
|
x8 = dict(baseline.fixed_sysfs(8))
|
||||||
|
self.assertEqual(x8, dict(baseline.FIXED_SYSFS))
|
||||||
|
x16 = dict(baseline.fixed_sysfs(16))
|
||||||
|
self.assertEqual((x16["cnc/x_mode"], x16["cnc/y_mode"], x16["cnc/step_freq"], x16["cnc/ramp_rate"]),
|
||||||
|
("16", "16", "56320", "250000"))
|
||||||
|
x32 = dict(baseline.fixed_sysfs(32))
|
||||||
|
self.assertEqual((x32["cnc/x_mode"], x32["cnc/step_freq"], x32["cnc/ramp_rate"]),
|
||||||
|
("32", "112640", "500000"))
|
||||||
|
# everything else is the same list, in the same order
|
||||||
|
self.assertEqual([a for a, _ in baseline.fixed_sysfs(32)], [a for a, _ in baseline.FIXED_SYSFS])
|
||||||
|
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
|
||||||
|
|
||||||
|
def test_the_mode_is_read_from_the_settings_with_a_default(self):
|
||||||
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
|
||||||
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32)
|
||||||
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 8)
|
||||||
|
self.assertEqual(baseline.xy_mode_of({}), 8)
|
||||||
|
self.assertEqual(baseline.xy_mode_of(None), 8)
|
||||||
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 8)
|
||||||
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 8)
|
||||||
|
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
|
||||||
|
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 8)
|
||||||
|
self.assertEqual(baseline.ref_xy_mode({}), 8)
|
||||||
|
|
||||||
|
def test_fixed_constants_are_checked_at_the_reference_mode(self):
|
||||||
|
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
|
||||||
|
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}
|
||||||
|
self.assertEqual(baseline.check_fixed_against(ref, self.lines.append), [])
|
||||||
|
ref["forgectrl"]["/settings"]["xy_microsteps"] = "8"
|
||||||
|
diffs = baseline.check_fixed_against(ref, self.lines.append)
|
||||||
|
self.assertEqual(diffs, ["cnc/x_mode: boot=16 constant=8",
|
||||||
|
"cnc/step_freq: boot=56320 constant=28160",
|
||||||
|
"cnc/ramp_rate: boot=250000 constant=125000"])
|
||||||
|
|
||||||
|
def test_wait_configured_watches_the_mode_in_force(self):
|
||||||
|
self._probe_state()
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def sleep(_s):
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] == 2: # an x16 controller's init writes land
|
||||||
|
self._attr("cnc/step_freq", "56320")
|
||||||
|
self._attr("cnc/y_mode", "16")
|
||||||
|
ok = baseline.wait_controller_configured(
|
||||||
|
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
|
||||||
|
timeout=5, sleep=sleep, xy_mode=16)
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertTrue(any("controller configured" in l for l in self.lines))
|
||||||
|
# a reference taken under x16 is not pre-config
|
||||||
|
self.assertFalse(baseline.reference_preconfig(
|
||||||
|
{"sysfs": {"cnc/step_freq": "56320", "cnc/y_mode": "16"},
|
||||||
|
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}))
|
||||||
|
self.assertTrue(baseline.reference_preconfig(
|
||||||
|
{"sysfs": {"cnc/step_freq": "10000", "cnc/y_mode": "1"},
|
||||||
|
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}))
|
||||||
|
|
||||||
|
def test_stale_preconfig_reference_is_retaken_on_a_fresh_boot(self):
|
||||||
|
os.environ["FORGETEST_BOOT_ID"] = "test-boot-2"
|
||||||
|
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")
|
||||||
|
finally:
|
||||||
|
os.environ.pop("FORGETEST_BOOT_ID", None)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class TransientNotLeftoverTests(BaselineTests):
|
||||||
|
"""A leftover is what a run left behind, not the machine part-way
|
||||||
|
through its own work. Found on the bench reference, where
|
||||||
|
laser.arm-wait-lid passed every check it makes and failed its
|
||||||
|
hand-back on the post-job smoke clear."""
|
||||||
|
|
||||||
|
def test_a_fading_button_led_is_not_a_leftover(self):
|
||||||
|
# the trigger fades brightness toward target: target 0 with
|
||||||
|
# brightness still high is the fade from a level the machine ended
|
||||||
|
for name in baseline.BUTTON_LEDS:
|
||||||
|
self._led2(name, target="0", brightness="900")
|
||||||
|
left = self.bl().enforce("post", captured=None)
|
||||||
|
self.assertEqual([x for x in left if x.item.startswith("leds/")], [])
|
||||||
|
|
||||||
|
def test_a_lit_button_led_is_a_leftover(self):
|
||||||
|
# a target the run left standing is dirt, whatever the fade reads
|
||||||
|
self._led2(baseline.BUTTON_LEDS[0], target="1014", brightness="0")
|
||||||
|
left = self.bl().enforce("post", captured=None)
|
||||||
|
items = [x for x in left if x.item.startswith("leds/")]
|
||||||
|
self.assertEqual(len(items), 1)
|
||||||
|
self.assertEqual(items[0].found, "1014")
|
||||||
|
|
||||||
|
|
||||||
|
class CloudOwnedSysfsTests(unittest.TestCase):
|
||||||
|
"""In cloud mode the cloud client configures the machine from the
|
||||||
|
pulse header it is playing, the lens included: z_mode comes from
|
||||||
|
ZSmd through gfhardware's set_mode_from_puls. Handing the GRBL values
|
||||||
|
back under it would be the baseline configuring another controller's
|
||||||
|
machine. In GRBL mode the pair is checked as before."""
|
||||||
|
|
||||||
|
def test_the_z_pair_is_the_cloud_clients(self):
|
||||||
|
for attr in ("head/z_current", "head/z_mode"):
|
||||||
|
self.assertIn(attr, baseline.GRBL_CONTROLLER_SYSFS)
|
||||||
|
|
||||||
|
def test_the_z_pair_is_still_checked_in_grbl_mode(self):
|
||||||
|
fixed = dict(baseline.fixed_sysfs())
|
||||||
|
self.assertEqual(fixed.get("head/z_current"), "1")
|
||||||
|
self.assertEqual(fixed.get("head/z_mode"), "1")
|
||||||
|
|
||||||
|
|
||||||
|
class PositionDeadbandTests(unittest.TestCase):
|
||||||
|
"""The counters count steps and a controller's own return lands within
|
||||||
|
a few hundredths of a millimeter, not on the step: a difference that
|
||||||
|
small is quantization, not a leftover. Found on the bench reference,
|
||||||
|
where motion.lid-cancel-home returned the head twice, each within
|
||||||
|
0.04 mm, and failed the hand-back on the four steps left over."""
|
||||||
|
|
||||||
|
def test_a_few_steps_of_xy_are_the_quantization(self):
|
||||||
|
self.assertTrue(baseline.position_quantized([0, 0, 0], [4, 0, 0]))
|
||||||
|
self.assertTrue(baseline.position_quantized([0, 0, 0], [-4, 4, 0]))
|
||||||
|
self.assertTrue(baseline.position_quantized([10, 20, 30], [10, 20, 30]))
|
||||||
|
|
||||||
|
def test_past_the_dead_band_is_a_leftover(self):
|
||||||
|
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
|
||||||
|
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
|
||||||
|
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
|
||||||
|
|
||||||
|
def test_z_is_exact_because_the_return_never_moves_it(self):
|
||||||
|
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
|
||||||
|
|
||||||
|
def test_an_unreadable_reading_is_never_quantization(self):
|
||||||
|
self.assertFalse(baseline.position_quantized(None, [0, 0, 0]))
|
||||||
|
self.assertFalse(baseline.position_quantized([0, 0, 0], None))
|
||||||
|
|
||||||
|
|
||||||
|
class BaselineModeTests(BaselineTests):
|
||||||
|
"""The baseline against a fake forgectrl: what the mode in force
|
||||||
|
owns. Reuses the fake sysfs tree of BaselineTests; only the new
|
||||||
|
tests run here (the inherited ones are skipped)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
import helpers
|
||||||
|
self.fc = helpers.FakeForgectrl().start()
|
||||||
|
baseline.Baseline._unreachable_until = 0.0
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.fc.stop()
|
||||||
|
super().tearDown()
|
||||||
|
|
||||||
|
def run(self, result=None):
|
||||||
|
# only this class's own tests, not the base class's
|
||||||
|
if self._testMethodName not in BaselineModeTests.__dict__:
|
||||||
|
return
|
||||||
|
return super().run(result)
|
||||||
|
|
||||||
|
def cloud(self):
|
||||||
|
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
|
||||||
|
self.fc.state["settings"]["controller_mode"] = "cloud"
|
||||||
|
|
||||||
|
def nohunt_marker(self, refuse=False):
|
||||||
|
"""The no-hunt marker under the fake tree; what each POST found.
|
||||||
|
The fake client takes the marker down as gfcloud does, first thing
|
||||||
|
at its start; with refuse the POST is refused and no client starts."""
|
||||||
|
baseline.NOHUNT_MARKER = os.path.join(self.tmp, "nohunt-marker")
|
||||||
|
self.fc.state["settings"].setdefault("cloud_enabled", "1")
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def on_post(path, form):
|
||||||
|
seen.append((path, dict(form), os.path.exists(baseline.NOHUNT_MARKER)))
|
||||||
|
if refuse:
|
||||||
|
return 409, {"error": "busy"}
|
||||||
|
if os.path.exists(baseline.NOHUNT_MARKER):
|
||||||
|
os.remove(baseline.NOHUNT_MARKER)
|
||||||
|
if path == "/mode":
|
||||||
|
self.fc.state["mode"] = dict(self.fc.state["mode"], mode=form["controller"], controller="running")
|
||||||
|
elif path == "/controller/start":
|
||||||
|
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running")
|
||||||
|
return None
|
||||||
|
self.fc.on_post = on_post
|
||||||
|
return seen
|
||||||
|
|
||||||
|
def test_a_refused_switch_leaves_no_marker_for_the_next_start(self):
|
||||||
|
seen = self.nohunt_marker(refuse=True)
|
||||||
|
ok, detail = self.bl().switch_mode("cloud")
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(seen, [("/mode", {"controller": "cloud"}, True)])
|
||||||
|
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
|
||||||
|
|
||||||
|
def test_a_switch_to_cloud_starts_the_client_without_the_hunt(self):
|
||||||
|
seen = self.nohunt_marker()
|
||||||
|
ok, detail = self.bl().switch_mode("cloud")
|
||||||
|
self.assertTrue(ok, detail)
|
||||||
|
self.assertEqual(seen, [("/mode", {"controller": "cloud"}, True)])
|
||||||
|
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER)) # one start, never left behind
|
||||||
|
|
||||||
|
def test_a_switch_to_cloud_turns_cloud_mode_on_when_it_is_off(self):
|
||||||
|
# A test that declares cloud mode gets it: cloud_enabled goes to 1
|
||||||
|
# first, with the typed phrase the daemon asks for and a log line,
|
||||||
|
# and only then the mode switch.
|
||||||
|
lines = []
|
||||||
|
seen = self.nohunt_marker()
|
||||||
|
self.fc.state["settings"]["cloud_enabled"] = "0"
|
||||||
|
ok, detail = baseline.Baseline(lines.append).switch_mode("cloud")
|
||||||
|
self.assertTrue(ok, detail)
|
||||||
|
self.assertEqual(seen[0], ("/settings", {"cloud_enabled": "1", "phrase": "I UNDERSTAND"}, False))
|
||||||
|
self.assertEqual(seen[1], ("/mode", {"controller": "cloud"}, True))
|
||||||
|
self.assertTrue(any("cloud mode turned on for the test" in ln for ln in lines))
|
||||||
|
|
||||||
|
def test_a_switch_to_grbl_sets_no_marker(self):
|
||||||
|
seen = self.nohunt_marker()
|
||||||
|
self.cloud()
|
||||||
|
ok, detail = self.bl().switch_mode("grbl")
|
||||||
|
self.assertFalse(ok) # the fake has no Grbl port: the switch itself happened
|
||||||
|
self.assertEqual(seen, [("/mode", {"controller": "grbl"}, False)])
|
||||||
|
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
|
||||||
|
|
||||||
|
def test_the_mode_handed_back_is_cloud_without_the_hunt(self):
|
||||||
|
seen = self.nohunt_marker()
|
||||||
|
self.cloud()
|
||||||
|
b = self.bl()
|
||||||
|
cap = b.capture() # found in cloud
|
||||||
|
self.fc.state["mode"] = dict(self.fc.state["mode"], mode="grbl") # the run left it in grbl
|
||||||
|
b.enforce("post", captured=cap)
|
||||||
|
self.assertEqual(seen[0], ("/mode", {"controller": "cloud"}, True))
|
||||||
|
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
|
||||||
|
|
||||||
|
def test_a_standby_cloud_client_is_started_without_the_hunt(self):
|
||||||
|
seen = self.nohunt_marker()
|
||||||
|
self.cloud()
|
||||||
|
b = self.bl()
|
||||||
|
cap = b.capture()
|
||||||
|
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby")
|
||||||
|
b.enforce("post", captured=cap)
|
||||||
|
self.assertEqual(seen[0], ("/controller/start", {}, True))
|
||||||
|
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
|
||||||
|
|
||||||
|
def test_grbl_mode_restores_the_controller_values_and_the_lamp(self):
|
||||||
|
self._attr("cnc/step_freq", "10000")
|
||||||
|
self._attr("pic/lid_led", "77")
|
||||||
|
left = self.bl().enforce("pre", captured=None)
|
||||||
|
self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"])
|
||||||
|
self.assertEqual(self._read("cnc/step_freq"), "28160")
|
||||||
|
self.assertEqual(self._read("pic/lid_led"), "236")
|
||||||
|
|
||||||
|
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
|
||||||
|
self.cloud()
|
||||||
|
self._attr("cnc/step_freq", "10000") # the cloud client's tick
|
||||||
|
self._attr("pic/x_step_current", "135")
|
||||||
|
self._attr("pic/lid_led", "77") # its lid-image level
|
||||||
|
b = self.bl()
|
||||||
|
cap = b.capture()
|
||||||
|
self.assertEqual(cap["mode"], "cloud")
|
||||||
|
self.assertNotIn("controller_mode", cap["settings"])
|
||||||
|
self._pos(-13096, -7400, 0) # the service re-zeroed and moved
|
||||||
|
self._attr("cnc/streaming", "1") # NOT the client's: still restored
|
||||||
|
left = b.enforce("post", captured=cap)
|
||||||
|
self.assertEqual([x.item for x in left], ["cnc/streaming"])
|
||||||
|
self.assertEqual(self._read("cnc/step_freq"), "10000")
|
||||||
|
self.assertEqual(self._read("pic/x_step_current"), "135")
|
||||||
|
self.assertEqual(self._read("pic/lid_led"), "77")
|
||||||
|
self.assertEqual(self._read("cnc/streaming"), "0")
|
||||||
|
self.assertEqual(self.fc.posts, []) # no mode switch, no settings write
|
||||||
|
|
||||||
|
def test_cloud_mode_still_relocks_the_latch(self):
|
||||||
|
self.cloud()
|
||||||
|
self._attr("cnc/interlock_circuit", "37") # bit 3 clear: unlocked
|
||||||
|
left = self.bl().enforce("post", captured=None)
|
||||||
|
self.assertEqual([x.item for x in left], ["laser_latch"])
|
||||||
|
self.assertEqual(self._read("cnc/laser_latch"), "1")
|
||||||
|
|
||||||
|
def test_undeclared_mode_change_is_handed_back_through_the_switch(self):
|
||||||
|
b = self.bl()
|
||||||
|
cap = b.capture() # found in grbl
|
||||||
|
self.assertEqual(cap["mode"], "grbl")
|
||||||
|
self.cloud() # the run left it in cloud, silently
|
||||||
|
left = b.enforce("post", captured=cap)
|
||||||
|
items = {x.item: x for x in left}
|
||||||
|
self.assertIn("mode", items)
|
||||||
|
self.assertEqual(items["mode"].action, "restored")
|
||||||
|
self.assertEqual(self.fc.posts, [("/mode", {"controller": "grbl"})])
|
||||||
|
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
|
||||||
|
|
||||||
|
def test_declared_mode_change_is_kept(self):
|
||||||
|
b = self.bl()
|
||||||
|
cap = b.capture()
|
||||||
|
self.cloud()
|
||||||
|
cap["mode"] = "cloud" # Context.mode_changed("cloud")
|
||||||
|
left = b.enforce("post", captured=cap)
|
||||||
|
self.assertNotIn("mode", [x.item for x in left])
|
||||||
|
self.assertEqual(self.fc.posts, [])
|
||||||
|
self.assertEqual(self.fc.state["mode"]["mode"], "cloud")
|
||||||
|
|
||||||
|
def test_controller_mode_setting_is_never_written_back_bare(self):
|
||||||
|
b = self.bl()
|
||||||
|
cap = b.capture()
|
||||||
|
self.assertNotIn("controller_mode", cap["settings"])
|
||||||
|
self.fc.state["settings"]["controller_mode"] = "cloud" # a switch persisted it
|
||||||
|
self.fc.state["mode"]["mode"] = "cloud"
|
||||||
|
cap["mode"] = "cloud"
|
||||||
|
b.enforce("post", captured=cap)
|
||||||
|
self.assertEqual([p for p, _ in self.fc.posts if p == "/settings"], [])
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""The bench registry against scripts/bench: every python tool is
|
||||||
|
registered (or named as not-a-tool), every registered script exists,
|
||||||
|
every argument spec builds a command line, every script compiles, and
|
||||||
|
the shared helper resolves host and local mode as documented."""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import py_compile
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers # noqa: F401 (sys.path)
|
||||||
|
from forgetest import bench as bench_mod
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
BENCH = os.path.join(REPO, "scripts", "bench")
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.bench = bench_mod.Bench(tool_dir=BENCH, index_path=os.devnull)
|
||||||
|
self.scripts = sorted(os.path.basename(p) for p in glob.glob(os.path.join(BENCH, "*.py")))
|
||||||
|
|
||||||
|
def test_every_python_tool_is_registered(self):
|
||||||
|
registered = {t["script"] for t in bench_mod.TOOLS}
|
||||||
|
missing = [s for s in self.scripts if s not in registered and s not in bench_mod.NOT_TOOLS]
|
||||||
|
self.assertEqual(missing, [], "scripts/bench python files missing from the registry")
|
||||||
|
|
||||||
|
def test_every_registered_script_exists(self):
|
||||||
|
for t in bench_mod.TOOLS:
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(BENCH, t["script"])), t["script"])
|
||||||
|
|
||||||
|
def test_ids_unique_and_fields_valid(self):
|
||||||
|
ids = [t["id"] for t in bench_mod.TOOLS]
|
||||||
|
self.assertEqual(len(ids), len(set(ids)))
|
||||||
|
for t in bench_mod.TOOLS:
|
||||||
|
self.assertIn(t["safety"], ("dry", "takeover", "scope", "live"), t["id"])
|
||||||
|
self.assertIn(t["where"], ("board", "host"), t["id"])
|
||||||
|
self.assertIsInstance(t["ported"], bool, t["id"])
|
||||||
|
self.assertTrue(t["desc"], t["id"])
|
||||||
|
for a in t.get("args", []):
|
||||||
|
self.assertIn(a["type"], ("str", "int", "float", "choice"), (t["id"], a["name"]))
|
||||||
|
if a["type"] == "choice":
|
||||||
|
self.assertIn(a["default"], a["choices"], (t["id"], a["name"]))
|
||||||
|
|
||||||
|
def test_ported_tools_build_a_command_with_defaults(self):
|
||||||
|
for t in bench_mod.TOOLS:
|
||||||
|
if not t["ported"]:
|
||||||
|
continue
|
||||||
|
ok, argv, err = self.bench.command(t, {})
|
||||||
|
self.assertTrue(ok, "%s: %s" % (t["id"], err))
|
||||||
|
self.assertEqual(argv[1], os.path.join(BENCH, t["script"]))
|
||||||
|
|
||||||
|
def test_flagged_args_build_option_pairs(self):
|
||||||
|
tool = next(t for t in bench_mod.TOOLS if t["id"] == "resume-dark-lead")
|
||||||
|
ok, argv, err = self.bench.command(tool, {"run": "live", "feed": 900})
|
||||||
|
self.assertTrue(ok, err)
|
||||||
|
self.assertIn("--run", argv)
|
||||||
|
self.assertEqual(argv[argv.index("--run") + 1], "live")
|
||||||
|
self.assertEqual(argv[argv.index("--feed") + 1], "900.0")
|
||||||
|
# an empty optional with an empty default is left off entirely
|
||||||
|
self.assertNotIn("--auto", argv)
|
||||||
|
# a choice outside the list is refused before anything runs
|
||||||
|
ok, _argv, err = self.bench.command(tool, {"run": "sideways"})
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIn("run must be one of", err)
|
||||||
|
|
||||||
|
def test_unported_tools_are_host_only(self):
|
||||||
|
# what stays unported is what cannot run on the machine at all
|
||||||
|
for t in bench_mod.TOOLS:
|
||||||
|
if not t["ported"]:
|
||||||
|
self.assertEqual(t["where"], "host", t["id"])
|
||||||
|
self.assertIn("not a bench-page tool", t["desc"], t["id"])
|
||||||
|
|
||||||
|
def test_scripts_compile(self):
|
||||||
|
for s in self.scripts:
|
||||||
|
py_compile.compile(os.path.join(BENCH, s), doraise=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GfbenchTests(unittest.TestCase):
|
||||||
|
"""gfbench.py in a subprocess (module-level host resolution)."""
|
||||||
|
|
||||||
|
def run_snippet(self, code, env_extra):
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("GF_HOST", None)
|
||||||
|
env.pop("FORGETEST_BENCH_DATA", None)
|
||||||
|
env.update(env_extra)
|
||||||
|
r = subprocess.run([sys.executable, "-c", code], cwd=BENCH, env=env,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
|
||||||
|
return r.returncode, r.stdout.strip()
|
||||||
|
|
||||||
|
def test_host_mode(self):
|
||||||
|
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL, gfbench.SSH[0])",
|
||||||
|
{"GF_HOST": "192.0.2.7", "GF_SSH": "fakessh -x"})
|
||||||
|
self.assertEqual(rc, 0, out)
|
||||||
|
self.assertEqual(out, "192.0.2.7 False fakessh")
|
||||||
|
|
||||||
|
def test_local_when_named_local(self):
|
||||||
|
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL); "
|
||||||
|
"print(gfbench.board('echo hi').strip())",
|
||||||
|
{"GF_HOST": "127.0.0.1"})
|
||||||
|
self.assertEqual(rc, 0, out)
|
||||||
|
self.assertEqual(out.splitlines(), ["127.0.0.1 True", "hi"])
|
||||||
|
|
||||||
|
def test_refuses_without_a_machine(self):
|
||||||
|
if os.path.isdir("/sys/glowforge"):
|
||||||
|
self.skipTest("running on the machine")
|
||||||
|
rc, out = self.run_snippet("import gfbench", {})
|
||||||
|
self.assertNotEqual(rc, 0)
|
||||||
|
self.assertIn("GF_HOST", out)
|
||||||
|
|
||||||
|
def test_degc_and_data_dir(self):
|
||||||
|
import tempfile
|
||||||
|
d = tempfile.mkdtemp(prefix="forgetest-bench-")
|
||||||
|
rc, out = self.run_snippet(
|
||||||
|
"import gfbench, os; print(round(gfbench.degc(700), 2), gfbench.degc('x') != gfbench.degc('x'), "
|
||||||
|
"os.path.dirname(gfbench.data_path('f.json')) == os.environ['FORGETEST_BENCH_DATA'])",
|
||||||
|
{"GF_HOST": "127.0.0.1", "FORGETEST_BENCH_DATA": d})
|
||||||
|
self.assertEqual(rc, 0, out)
|
||||||
|
val, nan, indir = out.split()
|
||||||
|
# a plausible coolant reading (700 counts is room temperature territory)
|
||||||
|
self.assertTrue(15.0 < float(val) < 35.0, val)
|
||||||
|
self.assertEqual(nan, "True")
|
||||||
|
self.assertEqual(indir, "True")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers
|
||||||
|
from forgetest import campaign
|
||||||
|
from forgetest import catalog
|
||||||
|
|
||||||
|
|
||||||
|
def rec_campaign(cid, man, chash, ts):
|
||||||
|
return {"t": "campaign", "ts": ts, "id": cid, "manifest_sha": man.content_sha,
|
||||||
|
"catalog_hash": chash, "image": man.version}
|
||||||
|
|
||||||
|
|
||||||
|
def rec_result(cid, t, man, result, ts, fp=None):
|
||||||
|
return {"t": "result", "ts": ts, "campaign": cid, "test": t.id, "result": result,
|
||||||
|
"fingerprint": fp or t.fingerprint(man), "manifest_sha": man.content_sha,
|
||||||
|
"image": man.version, "duration_s": 1, "message": ""}
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.man = helpers.make_manifest()
|
||||||
|
self.core = helpers.make_test("image.health", [("linux-fslc", "**")], always=True)
|
||||||
|
self.ui = helpers.make_test("forgectrl.panel", [("forgectrl", "src/ui.c")])
|
||||||
|
self.cool = helpers.make_test("cooling.flow", [("forgectrl", "src/cool.c")])
|
||||||
|
self.live = helpers.make_test("laser.witness", [("grblhal-glowforge", "src/**")],
|
||||||
|
requires=("forgectrl.panel",), kind="live")
|
||||||
|
self.reg = helpers.registry(self.core, self.ui, self.cool, self.live)
|
||||||
|
self.tests = list(self.reg.values())
|
||||||
|
self.chash = catalog.catalog_hash(self.reg)
|
||||||
|
|
||||||
|
def compute(self, records, man=None, chash=None):
|
||||||
|
return campaign.compute(records, self.tests, man or self.man, chash or self.chash)
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
st = self.compute([])
|
||||||
|
self.assertIsNone(st["campaign"])
|
||||||
|
self.assertFalse(st["authorized"])
|
||||||
|
for t in self.tests:
|
||||||
|
self.assertTrue(st["tests"][t.id]["required"])
|
||||||
|
self.assertEqual(st["tests"][t.id]["reason"], "always" if t.always else "never-passed")
|
||||||
|
self.assertFalse(st["tests"][self.live.id]["requires_met"])
|
||||||
|
|
||||||
|
def test_full_campaign_authorizes(self):
|
||||||
|
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
|
||||||
|
for i, t in enumerate(self.tests):
|
||||||
|
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
|
||||||
|
st = self.compute(recs)
|
||||||
|
self.assertTrue(st["authorized"])
|
||||||
|
self.assertEqual(st["counts"], {"total": 4, "satisfied": 4, "inherited": 0, "required": 0})
|
||||||
|
self.assertEqual(st["tests"][self.core.id]["status"], "pass")
|
||||||
|
|
||||||
|
def test_fail_closes_campaign(self):
|
||||||
|
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
|
||||||
|
rec_result("c1", self.core, self.man, "PASS", "2026-08-20T10:01:00Z"),
|
||||||
|
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:02:00Z"),
|
||||||
|
rec_result("c1", self.cool, self.man, "FAIL", "2026-08-20T10:03:00Z")]
|
||||||
|
st = self.compute(recs)
|
||||||
|
self.assertIsNone(st["campaign"])
|
||||||
|
self.assertEqual(st["closed_by"], "fail")
|
||||||
|
self.assertFalse(st["authorized"])
|
||||||
|
# the core PASS in the closed campaign no longer counts; the ui PASS is inheritable
|
||||||
|
self.assertTrue(st["tests"][self.core.id]["required"])
|
||||||
|
self.assertEqual(st["tests"][self.core.id]["reason"], "always")
|
||||||
|
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
|
||||||
|
self.assertEqual(st["tests"][self.cool.id]["status"], "fail")
|
||||||
|
self.assertEqual(st["tests"][self.cool.id]["reason"], "never-passed")
|
||||||
|
|
||||||
|
def test_a_fail_after_a_pass_blocks_inheritance(self):
|
||||||
|
# The ui test passes in c1, then fails on the same image (an
|
||||||
|
# intermittent gate): the FAIL closes c1. In c2 the older PASS must
|
||||||
|
# not be inherited over the newer FAIL: the test is required again.
|
||||||
|
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
|
||||||
|
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:01:00Z"),
|
||||||
|
rec_result("c1", self.ui, self.man, "FAIL", "2026-08-20T10:02:00Z"),
|
||||||
|
rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z")]
|
||||||
|
st = self.compute(recs)
|
||||||
|
t = st["tests"][self.ui.id]
|
||||||
|
self.assertEqual(t["status"], "fail")
|
||||||
|
self.assertEqual(t["reason"], "failed-since")
|
||||||
|
self.assertTrue(t["required"])
|
||||||
|
self.assertFalse(t["satisfied"])
|
||||||
|
self.assertFalse(st["authorized"])
|
||||||
|
|
||||||
|
def test_an_abort_after_a_pass_does_not_block_inheritance(self):
|
||||||
|
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
|
||||||
|
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:01:00Z"),
|
||||||
|
rec_result("c1", self.ui, self.man, "ABORTED", "2026-08-20T10:02:00Z"),
|
||||||
|
rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z")]
|
||||||
|
st = self.compute(recs)
|
||||||
|
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
|
||||||
|
|
||||||
|
def test_a_pass_after_a_fail_is_inherited(self):
|
||||||
|
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
|
||||||
|
rec_result("c1", self.ui, self.man, "FAIL", "2026-08-20T10:01:00Z"),
|
||||||
|
rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z"),
|
||||||
|
rec_result("c2", self.ui, self.man, "PASS", "2026-08-20T11:01:00Z"),
|
||||||
|
rec_campaign("c3", self.man, self.chash, "2026-08-20T12:00:00Z")]
|
||||||
|
st = self.compute(recs)
|
||||||
|
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
|
||||||
|
|
||||||
|
def test_error_closes_aborted_does_not(self):
|
||||||
|
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
|
||||||
|
rec_result("c1", self.ui, self.man, "ABORTED", "2026-08-20T10:01:00Z")]
|
||||||
|
st = self.compute(recs)
|
||||||
|
self.assertIsNotNone(st["campaign"])
|
||||||
|
self.assertEqual(st["tests"][self.ui.id]["status"], "aborted")
|
||||||
|
recs.append(rec_result("c1", self.ui, self.man, "ERROR", "2026-08-20T10:02:00Z"))
|
||||||
|
st = self.compute(recs)
|
||||||
|
self.assertIsNone(st["campaign"])
|
||||||
|
self.assertEqual(st["closed_by"], "fail")
|
||||||
|
|
||||||
|
def test_inheritance_follows_fingerprint(self):
|
||||||
|
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
|
||||||
|
for i, t in enumerate(self.tests):
|
||||||
|
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
|
||||||
|
# a new image with only cool.c changed
|
||||||
|
man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
|
||||||
|
st = self.compute(recs, man=man2)
|
||||||
|
self.assertIsNone(st["campaign"])
|
||||||
|
self.assertEqual(st["closed_by"], "image")
|
||||||
|
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
|
||||||
|
self.assertEqual(st["tests"][self.live.id]["status"], "inherited")
|
||||||
|
self.assertEqual(st["tests"][self.cool.id]["status"], "stale")
|
||||||
|
self.assertEqual(st["tests"][self.cool.id]["reason"], "domain-changed")
|
||||||
|
self.assertEqual(st["tests"][self.core.id]["reason"], "always")
|
||||||
|
# open a campaign on the new image, run the core + cool -> authorized
|
||||||
|
recs.append(rec_campaign("c2", man2, self.chash, "2026-08-21T10:00:00Z"))
|
||||||
|
recs.append(rec_result("c2", self.core, man2, "PASS", "2026-08-21T10:01:00Z"))
|
||||||
|
st = self.compute(recs, man=man2)
|
||||||
|
self.assertFalse(st["authorized"])
|
||||||
|
recs.append(rec_result("c2", self.cool, man2, "PASS", "2026-08-21T10:02:00Z"))
|
||||||
|
st = self.compute(recs, man=man2)
|
||||||
|
self.assertTrue(st["authorized"])
|
||||||
|
self.assertEqual(st["counts"]["inherited"], 2)
|
||||||
|
self.assertEqual(st["tests"][self.ui.id]["origin"]["campaign"], "c1")
|
||||||
|
|
||||||
|
def test_platform_change_invalidates_everything(self):
|
||||||
|
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
|
||||||
|
for i, t in enumerate(self.tests):
|
||||||
|
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
|
||||||
|
man2 = helpers.with_platform(self.man, dtb={"glowforge.dtb": "f" * 64})
|
||||||
|
st = self.compute(recs, man=man2)
|
||||||
|
for t in self.tests:
|
||||||
|
self.assertTrue(st["tests"][t.id]["required"], t.id)
|
||||||
|
|
||||||
|
def test_invalidate_all(self):
|
||||||
|
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
|
||||||
|
for i, t in enumerate(self.tests):
|
||||||
|
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
|
||||||
|
recs.append({"t": "invalidate", "ts": "2026-08-22T09:00:00Z", "reason": "new tube"})
|
||||||
|
st = self.compute(recs)
|
||||||
|
self.assertIsNone(st["campaign"])
|
||||||
|
self.assertEqual(st["closed_by"], "invalidate")
|
||||||
|
self.assertEqual(st["invalidate"]["reason"], "new tube")
|
||||||
|
for t in self.tests:
|
||||||
|
self.assertTrue(st["tests"][t.id]["required"], t.id)
|
||||||
|
self.assertNotEqual(st["tests"][t.id]["status"], "inherited")
|
||||||
|
# after the invalidate, a new campaign's passes count and later ones inherit again
|
||||||
|
recs.append(rec_campaign("c2", self.man, self.chash, "2026-08-22T10:00:00Z"))
|
||||||
|
st = self.compute(recs)
|
||||||
|
# the campaign that started after it answers the invalidation: the
|
||||||
|
# page no longer announces it, while its epoch still holds
|
||||||
|
self.assertIsNone(st["invalidate"])
|
||||||
|
for t in self.tests:
|
||||||
|
self.assertTrue(st["tests"][t.id]["required"], t.id)
|
||||||
|
self.assertNotEqual(st["tests"][t.id]["status"], "inherited")
|
||||||
|
for i, t in enumerate(self.tests):
|
||||||
|
recs.append(rec_result("c2", t, self.man, "PASS", "2026-08-22T10:0%d:00Z" % (i + 1)))
|
||||||
|
st = self.compute(recs)
|
||||||
|
self.assertTrue(st["authorized"])
|
||||||
|
self.assertIsNone(st["invalidate"])
|
||||||
|
|
||||||
|
def test_reset_and_catalog_change(self):
|
||||||
|
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
|
||||||
|
rec_result("c1", self.core, self.man, "PASS", "2026-08-20T10:01:00Z"),
|
||||||
|
{"t": "reset", "ts": "2026-08-20T11:00:00Z", "reason": "x"}]
|
||||||
|
st = self.compute(recs)
|
||||||
|
self.assertIsNone(st["campaign"])
|
||||||
|
self.assertEqual(st["closed_by"], "reset")
|
||||||
|
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
|
||||||
|
st = self.compute(recs, chash="different")
|
||||||
|
self.assertIsNone(st["campaign"])
|
||||||
|
self.assertEqual(st["closed_by"], "catalog")
|
||||||
|
|
||||||
|
def test_requires_uses_satisfied(self):
|
||||||
|
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
|
||||||
|
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:01:00Z")]
|
||||||
|
st = self.compute(recs)
|
||||||
|
self.assertTrue(st["tests"][self.live.id]["requires_met"])
|
||||||
|
man2 = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
|
||||||
|
st = self.compute(recs, man=man2)
|
||||||
|
self.assertFalse(st["tests"][self.live.id]["requires_met"])
|
||||||
|
self.assertEqual(st["tests"][self.live.id]["missing_requires"], ["forgectrl.panel"])
|
||||||
|
|
||||||
|
def test_running_marker(self):
|
||||||
|
st = campaign.compute([], self.tests, self.man, self.chash, running=self.ui.id)
|
||||||
|
self.assertEqual(st["tests"][self.ui.id]["status"], "running")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""Every phrase the cloud suite greps for in the gfcloud log must be a
|
||||||
|
phrase the pinned cloud app can actually log.
|
||||||
|
|
||||||
|
The cloud tests judge a print on the machine's own log lines. When the
|
||||||
|
app's wording moves (a resume that used to log one line logs two), a
|
||||||
|
test keeps looking for the old line and fails a print that worked. The
|
||||||
|
replay fixtures cannot catch that: they are excerpts of the old wording.
|
||||||
|
This test reads the phrases straight out of suite/cloud.py and checks
|
||||||
|
each against the logger calls in the app sources at the revisions the
|
||||||
|
recipes pin (python3-gfhardware and python3-gfutilities, from the
|
||||||
|
manifest cache the tree manifest builds, or the sibling checkouts when
|
||||||
|
no cache is at hand), placeholder-aware: "paused at" is covered by
|
||||||
|
'paused at %s', "warm up: holding" by '%s: holding %.1f s', and
|
||||||
|
"authenticate_machine SUCCESS" by logger.info('SUCCESS') inside
|
||||||
|
def authenticate_machine (the log format is module:function message).
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers # noqa: F401 (sys.path)
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
FORGETEST = os.path.dirname(HERE)
|
||||||
|
REPO = os.path.dirname(FORGETEST)
|
||||||
|
CLOUD_PY = os.path.join(FORGETEST, "forgetest", "suite", "cloud.py")
|
||||||
|
APP_COMPONENTS = ("python3-gfhardware", "python3-gfutilities")
|
||||||
|
SIBLINGS = {"python3-gfhardware": "python3-gfhardware", "python3-gfutilities": "Glowforge-Utilities"}
|
||||||
|
|
||||||
|
# Phrases cloud.py builds at run time rather than writing out (a "%s: holding"
|
||||||
|
# with the phase filled in), listed here so they are checked too.
|
||||||
|
BUILT_PHRASES = ("warm up: holding", "cool down: holding", "warm up: skipped", "cool down: skipped",
|
||||||
|
'finished with event ":cancelled"', 'finished with event ":completed"',
|
||||||
|
"motion [", "print [")
|
||||||
|
# Phrases that are not the app's: forgetest's own log, a line prefix of
|
||||||
|
# the log format rather than a message, or a line of forgectrl's log (the
|
||||||
|
# engine's effective-limits line, judged from its own file).
|
||||||
|
NOT_APP = ("PASS", "gfcloud", "[", "effective limits: coolant ceiling", "header")
|
||||||
|
|
||||||
|
PLACEHOLDER = re.compile(r"%(?:\([^)]*\))?[-+ #0]*\d*(?:\.\d+)?[sdifrxXeEgGcu]|%%")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ the needles
|
||||||
|
|
||||||
|
def needles_in(path):
|
||||||
|
"""String constants cloud.py tests against log lines: the left side of
|
||||||
|
an `x in ln`, the arguments of wait_log, endswith() arguments, and the
|
||||||
|
names those resolve to (module- or function-level constants)."""
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
tree = ast.parse(f.read())
|
||||||
|
consts = {} # name -> list of str
|
||||||
|
|
||||||
|
def strings_of(node):
|
||||||
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||||
|
return [node.value]
|
||||||
|
if isinstance(node, (ast.List, ast.Tuple)):
|
||||||
|
return [s for e in node.elts for s in strings_of(e)]
|
||||||
|
if isinstance(node, ast.Name):
|
||||||
|
return consts.get(node.id, [])
|
||||||
|
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
||||||
|
return strings_of(node.left) + strings_of(node.right)
|
||||||
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "list":
|
||||||
|
return [s for a in node.args for s in strings_of(a)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
||||||
|
got = strings_of(node.value)
|
||||||
|
if got:
|
||||||
|
consts[node.targets[0].id] = got
|
||||||
|
found = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Compare) and any(isinstance(op, ast.In) for op in node.ops):
|
||||||
|
found.update(strings_of(node.left))
|
||||||
|
elif isinstance(node, ast.Call):
|
||||||
|
fn = node.func
|
||||||
|
if isinstance(fn, ast.Name) and fn.id == "wait_log" and len(node.args) >= 3:
|
||||||
|
found.update(strings_of(node.args[2]))
|
||||||
|
elif isinstance(fn, ast.Attribute) and fn.attr == "endswith":
|
||||||
|
for a in node.args:
|
||||||
|
found.update(strings_of(a))
|
||||||
|
# the mark tuples the suite scans lines against in comprehensions
|
||||||
|
# (`any(m in ln for m in SESSION_MARKS)`), where the name is a loop variable
|
||||||
|
for name, vals in consts.items():
|
||||||
|
if name.endswith(("_MARKS", "_LINES")):
|
||||||
|
found.update(vals)
|
||||||
|
found.update(BUILT_PHRASES)
|
||||||
|
return sorted(s.strip() for s in found if s.strip() and s.strip() not in NOT_APP and " " in s or s in BUILT_PHRASES)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ the app's lines
|
||||||
|
|
||||||
|
def log_literals(source, label):
|
||||||
|
"""(function name, fragments) for every logger call in a Python source:
|
||||||
|
the message's fixed text split at its placeholders."""
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
tree = ast.parse(source)
|
||||||
|
except SyntaxError:
|
||||||
|
return out
|
||||||
|
funcs = {}
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
for sub in ast.walk(node):
|
||||||
|
funcs.setdefault(id(sub), node.name)
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||||
|
and node.func.attr in ("debug", "info", "warning", "warn", "error", "critical", "exception")
|
||||||
|
and node.args):
|
||||||
|
continue
|
||||||
|
msg = node.args[0]
|
||||||
|
if isinstance(msg, ast.BinOp) and isinstance(msg.op, ast.Mod):
|
||||||
|
msg = msg.left
|
||||||
|
frags = None
|
||||||
|
if isinstance(msg, ast.Constant) and isinstance(msg.value, str):
|
||||||
|
frags = [p.replace("%%", "%") for p in PLACEHOLDER.split(msg.value)]
|
||||||
|
elif isinstance(msg, ast.JoinedStr):
|
||||||
|
frags = [""]
|
||||||
|
for v in msg.values:
|
||||||
|
if isinstance(v, ast.Constant):
|
||||||
|
frags[-1] += str(v.value)
|
||||||
|
else:
|
||||||
|
frags.append("")
|
||||||
|
if frags is not None:
|
||||||
|
out.append((funcs.get(id(node), ""), frags, label))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def covers(frags, needle):
|
||||||
|
"""True when `needle` is a substring of a rendering of the message whose
|
||||||
|
fixed text is `frags` (the gaps between fragments are placeholders; the
|
||||||
|
rendered line is `function message`, so frags[0] carries the function).
|
||||||
|
A placeholder is a value, never the phrase itself: the needle must lie
|
||||||
|
inside one fixed fragment, or run through the fragments in order and
|
||||||
|
contain at least one complete non-empty fragment."""
|
||||||
|
n = len(frags)
|
||||||
|
if any(needle in f for f in frags if f):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def in_gap(text, i, complete):
|
||||||
|
"""`text` starts in the gap before frags[i]."""
|
||||||
|
if i >= n:
|
||||||
|
return False
|
||||||
|
frag = frags[i]
|
||||||
|
if frag == "":
|
||||||
|
return complete if i == n - 1 else in_gap(text, i + 1, complete)
|
||||||
|
start = 0
|
||||||
|
while True:
|
||||||
|
j = text.find(frag, start)
|
||||||
|
if j < 0:
|
||||||
|
break
|
||||||
|
if after(text[j + len(frag):], i, True):
|
||||||
|
return True
|
||||||
|
start = j + 1
|
||||||
|
# the needle ends inside frags[i]: a proper prefix of it, which counts
|
||||||
|
# as the complete fragment the rule wants only when the gap before it
|
||||||
|
# was preceded by one, or when what is left of the fragment is a
|
||||||
|
# trailing separator (": holding " is covered by "warm up: holding")
|
||||||
|
for k in range(1, len(frag)):
|
||||||
|
if text.endswith(frag[:k]) and (complete or not frag[k:].strip()):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def after(text, i, complete):
|
||||||
|
"""`text` starts right after the whole of frags[i]."""
|
||||||
|
if text == "":
|
||||||
|
return complete
|
||||||
|
return in_gap(text, i + 1, complete)
|
||||||
|
|
||||||
|
for i, frag in enumerate(frags):
|
||||||
|
for off in range(len(frag)):
|
||||||
|
suffix = frag[off:]
|
||||||
|
if needle.startswith(suffix) and after(needle[len(suffix):], i, off == 0):
|
||||||
|
return True
|
||||||
|
if i >= 1 and in_gap(needle, i, False):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ sources
|
||||||
|
|
||||||
|
def pinned_sources():
|
||||||
|
"""{component: [(path, text)]} for the app components at their pinned
|
||||||
|
revisions, through the tree manifest's recipe parser and cache; the
|
||||||
|
sibling checkouts when the cache cannot be reached."""
|
||||||
|
spec = importlib.util.spec_from_file_location("mft", os.path.join(REPO, "scripts", "manifest-from-tree.py"))
|
||||||
|
mft = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mft)
|
||||||
|
meta = os.path.join(os.path.dirname(REPO), "meta-openglow")
|
||||||
|
cache = os.path.join(REPO, ".manifest-cache")
|
||||||
|
out = {}
|
||||||
|
for name, rel, layer in mft.RECIPES:
|
||||||
|
if name not in APP_COMPONENTS:
|
||||||
|
continue
|
||||||
|
base = REPO if layer == "forgefirm" else meta
|
||||||
|
files = []
|
||||||
|
try:
|
||||||
|
url, rev = mft.parse_recipe(os.path.join(base, rel))
|
||||||
|
repo = mft.fetch(url, rev, cache)
|
||||||
|
names = mft.git(["ls-tree", "-r", "--name-only", rev], cwd=repo).decode().split("\n")
|
||||||
|
for p in names:
|
||||||
|
if p.endswith(".py"):
|
||||||
|
files.append((p, mft.git(["show", "%s:%s" % (rev, p)], cwd=repo).decode("utf-8", "replace")))
|
||||||
|
out[name] = ("pinned %s" % rev[:12], files)
|
||||||
|
continue
|
||||||
|
except (OSError, subprocess.CalledProcessError, SystemExit):
|
||||||
|
pass
|
||||||
|
sib = os.path.join(os.path.dirname(REPO), SIBLINGS[name])
|
||||||
|
if os.path.isdir(sib):
|
||||||
|
for root, _dirs, fnames in os.walk(sib):
|
||||||
|
if ".git" in root:
|
||||||
|
continue
|
||||||
|
for fn in fnames:
|
||||||
|
if fn.endswith(".py"):
|
||||||
|
p = os.path.join(root, fn)
|
||||||
|
files.append((os.path.relpath(p, sib), open(p, encoding="utf-8", errors="replace").read()))
|
||||||
|
out[name] = ("sibling checkout", files)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class CloudNeedleTests(unittest.TestCase):
|
||||||
|
def test_every_needle_is_a_line_the_pinned_app_can_log(self):
|
||||||
|
needles = needles_in(CLOUD_PY)
|
||||||
|
self.assertGreaterEqual(len(needles), 30, needles)
|
||||||
|
self.assertIn("authenticate_machine SUCCESS", needles)
|
||||||
|
self.assertIn("RX-EVENT: ready", needles)
|
||||||
|
sources = pinned_sources()
|
||||||
|
missing = [c for c in APP_COMPONENTS if c not in sources]
|
||||||
|
if missing:
|
||||||
|
if os.environ.get("CI"):
|
||||||
|
self.fail("no source for %s (run scripts/manifest-from-tree.py first)" % ", ".join(missing))
|
||||||
|
self.skipTest("no source for %s" % ", ".join(missing))
|
||||||
|
literals = []
|
||||||
|
for comp, (where, files) in sources.items():
|
||||||
|
for path, text in files:
|
||||||
|
literals.extend(log_literals(text, "%s:%s" % (comp, path)))
|
||||||
|
self.assertGreater(len(literals), 100, "too few logger calls found: is the app source complete?")
|
||||||
|
uncovered = []
|
||||||
|
for needle in needles:
|
||||||
|
if not any(covers([fn + " " + frags[0]] + frags[1:], needle) for fn, frags, _ in literals):
|
||||||
|
uncovered.append(needle)
|
||||||
|
self.assertEqual(uncovered, [], "cloud.py greps for lines the pinned app never logs (sources: %s)"
|
||||||
|
% ", ".join("%s=%s" % (c, w) for c, (w, _) in sources.items()))
|
||||||
|
|
||||||
|
def test_cover_rules(self):
|
||||||
|
self.assertTrue(covers(["_run_loop paused at ", ""], "paused at"))
|
||||||
|
self.assertTrue(covers(["_dwell ", ": holding ", " s"], "warm up: holding"))
|
||||||
|
self.assertTrue(covers(["authenticate_machine SUCCESS"], "authenticate_machine SUCCESS"))
|
||||||
|
self.assertTrue(covers(["_resume_retraced resuming (laser lead ", " ticks)"], "resuming (laser lead"))
|
||||||
|
self.assertTrue(covers(["_finish_action ", " [", "]: finished with event \"", "\""],
|
||||||
|
'finished with event ":completed"'))
|
||||||
|
self.assertFalse(covers(["_run_loop button pressed while paused"], "button pressed while paused; resuming"))
|
||||||
|
self.assertFalse(covers(["_run_loop paused at ", ""], "paused it"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
|||||||
|
"""The setup's checks in the catalog: registered as automatic tests with
|
||||||
|
no precheck, the takeover ones marked so, every covers map naming the
|
||||||
|
check runner; and the check driver itself against the fake daemon: a
|
||||||
|
start, a prompt answered, the result taken, the record read at version
|
||||||
|
1, and the settings a check may write put back as found."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
import helpers # noqa: E402
|
||||||
|
from forgetest import catalog # noqa: E402
|
||||||
|
from forgetest.runner import Context, Run # noqa: E402
|
||||||
|
|
||||||
|
IDS = ["commission.check-switches", "commission.check-sensors", "commission.check-airflow",
|
||||||
|
"commission.check-cameras", "commission.check-motion", "commission.check-flow-verify"]
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.reg = catalog.load_suite()
|
||||||
|
|
||||||
|
def test_every_check_runs_unattended_with_the_fixture(self):
|
||||||
|
# The lid and button checks name their machine actions, which the
|
||||||
|
# bench fixture performs; the rest need no hands at all.
|
||||||
|
for tid in IDS:
|
||||||
|
self.assertIn(tid, self.reg)
|
||||||
|
for tid in ("commission.check-switches", "commission.check-cameras"):
|
||||||
|
self.assertEqual(self.reg[tid].kind, "operator", tid)
|
||||||
|
self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid)
|
||||||
|
for tid in ("commission.check-sensors", "commission.check-airflow", "commission.check-motion",
|
||||||
|
"commission.check-flow-verify"):
|
||||||
|
self.assertEqual(self.reg[tid].kind, "auto", tid)
|
||||||
|
|
||||||
|
def test_no_check_asks_the_operator_to_prepare_anything(self):
|
||||||
|
for tid in IDS:
|
||||||
|
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
|
||||||
|
|
||||||
|
def test_the_checks_that_take_the_machine_say_so(self):
|
||||||
|
for tid in ("commission.check-airflow", "commission.check-motion", "commission.check-flow-verify"):
|
||||||
|
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||||
|
|
||||||
|
def test_every_check_covers_the_runner_and_the_page(self):
|
||||||
|
for tid in IDS:
|
||||||
|
covers = set(self.reg[tid].covers)
|
||||||
|
self.assertIn(("forgectrl", "src/wizdark.*"), covers, tid)
|
||||||
|
self.assertIn(("forgectrl", "src/ui/wizard.*"), covers, tid)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorsCheckTests(unittest.TestCase):
|
||||||
|
"""The check driver against a scripted daemon."""
|
||||||
|
|
||||||
|
RESULT = {"coolant_down_c": 22.4, "coolant_up_c": 21.9, "chassis_c": 28.0, "soc_c": 45.2,
|
||||||
|
"lid_ir_max": [110, 120, 105, 118], "accel_events": 0, "laser_pgood": 1,
|
||||||
|
"hv_current_max": 0, "exhaust_rpm_idle": 3400, "intake_rpm_idle": 2200}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.fake = helpers.FakeForgectrl().start()
|
||||||
|
self.fake.state["settings"].update({"cool_temp_offset_c": "0.5"})
|
||||||
|
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
|
||||||
|
"log": [], "prompt": None, "result": None, "error": "",
|
||||||
|
"shots": {"lid": False, "head": False}}
|
||||||
|
self.versions = {}
|
||||||
|
self.answers = []
|
||||||
|
|
||||||
|
def on_get(path, q):
|
||||||
|
if path == "/wiz/dark":
|
||||||
|
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/sensors/start":
|
||||||
|
self.dark.update({"id": "sensors", "running": True, "phase": "sampling",
|
||||||
|
"prompt": {"seq": 7, "id": "room-temp", "kind": "number",
|
||||||
|
"text": "Optional: the room temperature.",
|
||||||
|
"options": ["Set", "Skip"]}})
|
||||||
|
return 200, {"started": True, "id": "sensors"}
|
||||||
|
if path == "/wiz/sensors/answer":
|
||||||
|
self.answers.append(dict(form))
|
||||||
|
if form.get("seq") != "7":
|
||||||
|
return 409, {"error": "no such prompt is open"}
|
||||||
|
if form.get("value") == "Skip":
|
||||||
|
self.dark.update({"running": False, "prompt": None, "progress": 100,
|
||||||
|
"result": dict(self.RESULT)})
|
||||||
|
self.versions["sensors"] = 1
|
||||||
|
return 200, {"ok": True}
|
||||||
|
if path == "/settings":
|
||||||
|
for k, v in form.items():
|
||||||
|
if v == "":
|
||||||
|
s.pop(k, None)
|
||||||
|
else:
|
||||||
|
s[k] = v
|
||||||
|
return 200, dict(s)
|
||||||
|
return None
|
||||||
|
self.fake.on_get = on_get
|
||||||
|
self.fake.on_post = on_post
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.fake.stop()
|
||||||
|
|
||||||
|
def test_start_prompt_answer_result_and_restore(self):
|
||||||
|
t = catalog.load_suite()["commission.check-sensors"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
self.assertIn(("/wiz/sensors/start", {}), [(p, f) for p, f in self.fake.posts])
|
||||||
|
self.assertEqual(self.answers, [{"seq": "7", "value": "Skip"}])
|
||||||
|
self.assertEqual(run.evidence["result"]["laser_pgood"], 1)
|
||||||
|
self.assertEqual(run.evidence["settings_before"], {"cool_temp_offset_c": "0.5"})
|
||||||
|
self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""The setup's sheet in the catalog: one live test on the button with a
|
||||||
|
piece of wood at hand, its covers map naming the live runner, the
|
||||||
|
renderer, the sender, the page, and the driver's laser module; and the
|
||||||
|
whole run against a scripted daemon: the placement's jogs and origin,
|
||||||
|
then each card's preview and program read, its start, the arm prompt and
|
||||||
|
the pick answered, the witnesses judged, the record read at version 1,
|
||||||
|
every card's evidence filed under its own key, and the settings the cards
|
||||||
|
wrote put back as found at the end."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
import helpers # noqa: E402
|
||||||
|
from forgetest import catalog # noqa: E402
|
||||||
|
from forgetest.runner import Context, Run # noqa: E402
|
||||||
|
from forgetest.suite import commission_sheet # noqa: E402
|
||||||
|
|
||||||
|
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
|
||||||
|
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
|
||||||
|
|
||||||
|
# Each wizard as the daemon runs it: its prompts in order (id, kind,
|
||||||
|
# options) and the result it ends with. A "jog" prompt repeats until the
|
||||||
|
# origin is set.
|
||||||
|
SCRIPT = {
|
||||||
|
"sheet.place": ([("place", "jog", ["X-10", "X+10", "Set origin"]),
|
||||||
|
("sheet-kind", "choice", ["Full sheet", "One card"]),
|
||||||
|
("thickness", "number", ["Set", "Skip"])],
|
||||||
|
{"origin_x": 100, "origin_y": 200, "origin_z": 0, "steps_per_mm": {"x": 40.0, "y": 40.0},
|
||||||
|
"z_referenced": True, "thickness_mm": 3.2, "alone": False}),
|
||||||
|
"sheet.frame": ([("frame-arm", "continue", ["Continue"]), ("frame-ok", "choice", ["Yes", "No", "Again"])],
|
||||||
|
{"mark_s": 400, "mark_feed": 3000, "emission": EMISSION}),
|
||||||
|
"laser.focus": ([("focus-arm", "continue", ["Continue"]),
|
||||||
|
("focus-pick", "multichoice", [str(i) for i in range(1, 13)]),
|
||||||
|
("thickness", "number", ["Keep", "Set"])],
|
||||||
|
{"pick": 11, "thickness_mm": 3.2, "pick_half_steps": -11, "edge_z_mm": 6.96,
|
||||||
|
"steps_per_mm": 2.922, "max_height_mm": 13.81,
|
||||||
|
"focus_range_mm": {"min": 2.17, "max": 13.81},
|
||||||
|
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
|
||||||
|
"contact_above": 22, "why": ""}, "emission": EMISSION}),
|
||||||
|
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
|
||||||
|
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
|
||||||
|
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
|
||||||
|
"laser.dose-curve": ([("dose-arm", "continue", ["Continue"])],
|
||||||
|
{"points": [[10, 0.44], [100, 100]], "curve": "10:0.44,100:100", "emission": EMISSION}),
|
||||||
|
"laser.corner": ([("corner-arm", "continue", ["Continue"]),
|
||||||
|
("corner-pick", "choice", ["1.00", "1.25", "1.50", "1.75", "2.00"])],
|
||||||
|
{"gamma": 1.5, "emission": EMISSION}),
|
||||||
|
"cooling.flow-load": ([("load-arm", "continue", ["Continue"])],
|
||||||
|
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
|
||||||
|
"k_cw": 2.78e-5, "emission": EMISSION}),
|
||||||
|
}
|
||||||
|
# What each card writes when it ends.
|
||||||
|
WRITES = {
|
||||||
|
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14",
|
||||||
|
"lens_stop_above_steps": "20"},
|
||||||
|
"laser.floor": {"laser_floor_density": "10"},
|
||||||
|
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
|
||||||
|
"laser.corner": {"laser_corner_gamma": "1.5"},
|
||||||
|
"cooling.flow-load": {"cool_laser_heat_density": "2.14e-5", "cool_laser_heat_cw": "2.78e-5"},
|
||||||
|
}
|
||||||
|
FOUND = {"lens_hall_edge_z_mm": "3.35", "lens_stop_below_steps": "10", "lens_stop_above_steps": "12",
|
||||||
|
"laser_floor_density": "12",
|
||||||
|
"laser_dose_curve": "off", "laser_corner_gamma": "2", "cool_laser_heat_density": "1e-5",
|
||||||
|
"cool_laser_heat_cw": "1e-5"}
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.t = catalog.load_suite()["commission.sheet"]
|
||||||
|
|
||||||
|
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
|
||||||
|
t = self.t
|
||||||
|
self.assertEqual(t.kind, "live")
|
||||||
|
self.assertEqual(t.mode, "grbl")
|
||||||
|
self.assertEqual(t.hardware, "takeover")
|
||||||
|
self.assertIn("button", t.actions)
|
||||||
|
self.assertTrue(t.hands)
|
||||||
|
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
|
||||||
|
self.assertIsNone(getattr(t, "precheck", None))
|
||||||
|
for want in ("commission.check-motion", "laser.emission-witness", "cooling.flow-verify"):
|
||||||
|
self.assertIn(want, t.requires)
|
||||||
|
|
||||||
|
def test_no_other_sheet_test_remains(self):
|
||||||
|
ids = [i for i in catalog.load_suite() if i.startswith("commission.sheet")]
|
||||||
|
self.assertEqual(ids, ["commission.sheet"])
|
||||||
|
|
||||||
|
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
|
||||||
|
covers = set(self.t.covers)
|
||||||
|
for want in (("forgectrl", "src/wizlive.*"), ("forgectrl", "src/sheet.*"), ("forgectrl", "src/jobstream.*"),
|
||||||
|
("forgectrl", "src/curverec.*"), ("forgectrl", "src/font_hershey.*"),
|
||||||
|
("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/wizrun.h"),
|
||||||
|
("grblhal-glowforge", "src/glowforge_laser.c")):
|
||||||
|
self.assertIn(want, covers, "lacks %s" % (want,))
|
||||||
|
|
||||||
|
def test_the_steps_ask_for_one_press_and_no_page(self):
|
||||||
|
text = " ".join(self.t.steps).lower()
|
||||||
|
self.assertIn("press the machine's button once", text)
|
||||||
|
self.assertNotIn("when the page", text)
|
||||||
|
self.assertEqual(len(commission_sheet.CARDS), 6)
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptedDaemon:
|
||||||
|
"""The wizard runner as the suite sees it: /wiz/<id>/start opens the
|
||||||
|
first prompt, each answer opens the next or ends the wizard with its
|
||||||
|
result and its writes, /wiz/dark reports it, GET /wiz carries the
|
||||||
|
versions, the sheet preview and program are served per card."""
|
||||||
|
|
||||||
|
def __init__(self, fake):
|
||||||
|
self.fake = fake
|
||||||
|
fake.state["settings"].update(FOUND)
|
||||||
|
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
|
||||||
|
"log": [], "prompt": None, "result": None, "error": "",
|
||||||
|
"shots": {"lid": False, "head": False}}
|
||||||
|
self.versions = {}
|
||||||
|
self.answers = {} # wid -> [values]
|
||||||
|
self.order = [] # the wizards as started
|
||||||
|
self.previews = []
|
||||||
|
self.queue = []
|
||||||
|
self.seq = 0
|
||||||
|
fake.on_get = self.on_get
|
||||||
|
fake.on_post = self.on_post
|
||||||
|
|
||||||
|
def open_next(self):
|
||||||
|
if not self.queue:
|
||||||
|
return False
|
||||||
|
pid, kind, options = self.queue[0]
|
||||||
|
self.seq += 1
|
||||||
|
self.dark["prompt"] = {"seq": self.seq, "id": pid, "kind": kind, "text": pid, "options": options}
|
||||||
|
return True
|
||||||
|
|
||||||
|
def on_get(self, path, q):
|
||||||
|
if path == "/wiz/dark":
|
||||||
|
return 200, dict(self.dark)
|
||||||
|
if path == "/wiz":
|
||||||
|
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
|
||||||
|
if path == "/wiz/sheet.svg":
|
||||||
|
self.previews.append(q.get("card"))
|
||||||
|
if q.get("card") not in LIVE:
|
||||||
|
return 404, {"error": "no such card"}
|
||||||
|
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
|
||||||
|
if path == "/wiz/sheet.gcode":
|
||||||
|
# the frame and the text are M4 at the mark dose, as the daemon serves them
|
||||||
|
return 200, b"; mock\nM4 S400\nG0 X60 Y42\nG1 X120 Y42 F3000\nM5\n", "text/plain"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def on_post(self, path, form):
|
||||||
|
s = self.fake.state["settings"]
|
||||||
|
if path.startswith("/wiz/") and path.endswith("/start"):
|
||||||
|
wid = path[len("/wiz/"):-len("/start")]
|
||||||
|
if wid not in SCRIPT:
|
||||||
|
return 404, {"error": "no such wizard"}
|
||||||
|
self.order.append(wid)
|
||||||
|
self.queue = list(SCRIPT[wid][0])
|
||||||
|
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
|
||||||
|
self.open_next()
|
||||||
|
return 200, {"started": True, "id": wid}
|
||||||
|
if path.startswith("/wiz/") and path.endswith("/answer"):
|
||||||
|
wid = path[len("/wiz/"):-len("/answer")]
|
||||||
|
p = self.dark.get("prompt")
|
||||||
|
if not p or form.get("seq") != str(p["seq"]):
|
||||||
|
return 409, {"error": "no such prompt is open"}
|
||||||
|
self.answers.setdefault(wid, []).append(form.get("value"))
|
||||||
|
if not (p["kind"] == "jog" and form.get("value") != "Set origin"):
|
||||||
|
self.queue.pop(0)
|
||||||
|
if not self.open_next():
|
||||||
|
s.update(WRITES.get(wid, {}))
|
||||||
|
self.dark.update({"running": False, "prompt": None, "progress": 100,
|
||||||
|
"result": dict(SCRIPT[wid][1])})
|
||||||
|
self.versions[wid] = 1
|
||||||
|
return 200, {"ok": True}
|
||||||
|
if path == "/settings":
|
||||||
|
for k, v in form.items():
|
||||||
|
if v == "":
|
||||||
|
s.pop(k, None)
|
||||||
|
else:
|
||||||
|
s[k] = v
|
||||||
|
return 200, dict(s)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SheetRunTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.fake = helpers.FakeForgectrl().start()
|
||||||
|
self.daemon = ScriptedDaemon(self.fake)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.fake.stop()
|
||||||
|
|
||||||
|
def test_the_whole_sheet_on_one_piece(self):
|
||||||
|
t = catalog.load_suite()["commission.sheet"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
run.unattended = True # the ready gate passes; the arm cue is a notice
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
d = self.daemon
|
||||||
|
self.assertEqual(d.order, ["sheet.place"] + LIVE)
|
||||||
|
self.assertEqual(d.answers["sheet.place"], ["X+10", "X-10", "Set origin", "Full sheet", "3.2"])
|
||||||
|
self.assertEqual(d.answers["sheet.frame"], ["Continue", "Yes"])
|
||||||
|
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
|
||||||
|
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
|
||||||
|
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
|
||||||
|
self.assertEqual(d.previews, LIVE)
|
||||||
|
cards = run.evidence["cards"]
|
||||||
|
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
|
||||||
|
for wid in LIVE:
|
||||||
|
self.assertEqual(cards[wid]["emission"]["hv_max"], 610, wid)
|
||||||
|
self.assertEqual(cards[wid]["result"], SCRIPT[wid][1], wid)
|
||||||
|
self.assertNotIn("emission", run.evidence)
|
||||||
|
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
|
||||||
|
self.assertEqual(run.evidence["settings_before"], FOUND)
|
||||||
|
for k, v in FOUND.items():
|
||||||
|
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
|
||||||
|
|
||||||
|
def test_imperial_units_answer_the_thickness_in_inches(self):
|
||||||
|
# The number prompts read ui_units: on an imperial machine the
|
||||||
|
# placement's thickness is 0.125 in and the record carries 3.175 mm.
|
||||||
|
self.fake.state["settings"]["ui_units"] = "imperial"
|
||||||
|
place_prompts, place_result = SCRIPT["sheet.place"]
|
||||||
|
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=3.175))
|
||||||
|
try:
|
||||||
|
t = catalog.load_suite()["commission.sheet"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
run.unattended = True
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
self.assertEqual(self.daemon.answers["sheet.place"][-1], "0.125")
|
||||||
|
self.assertEqual(run.evidence["thickness_answer"], "0.125")
|
||||||
|
finally:
|
||||||
|
SCRIPT["sheet.place"] = (place_prompts, place_result)
|
||||||
|
|
||||||
|
def test_a_metric_answer_on_an_imperial_machine_fails_the_placement(self):
|
||||||
|
# The defect the bench found: 3.2 read as inches is over the 30 mm
|
||||||
|
# cap and the wizard records 0; the test must not read that as a pass.
|
||||||
|
from forgetest.runner import Failed
|
||||||
|
place_prompts, place_result = SCRIPT["sheet.place"]
|
||||||
|
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=0.0))
|
||||||
|
try:
|
||||||
|
t = catalog.load_suite()["commission.sheet"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
run.unattended = True
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
self.assertIn("thickness 0.0 mm", str(cm.exception))
|
||||||
|
self.assertEqual(self.daemon.order, ["sheet.place"]) # nothing burned
|
||||||
|
finally:
|
||||||
|
SCRIPT["sheet.place"] = (place_prompts, place_result)
|
||||||
|
|
||||||
|
def test_a_dark_burn_fails_the_run_and_still_restores(self):
|
||||||
|
from forgetest.runner import Failed
|
||||||
|
SCRIPT_DARK = dict(SCRIPT["laser.floor"][1])
|
||||||
|
SCRIPT_DARK["emission"] = {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0}
|
||||||
|
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], SCRIPT_DARK)
|
||||||
|
try:
|
||||||
|
t = catalog.load_suite()["commission.sheet"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
run.unattended = True
|
||||||
|
with self.assertRaises(Failed):
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus", "laser.floor"])
|
||||||
|
for k, v in FOUND.items():
|
||||||
|
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||||
|
finally:
|
||||||
|
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
"""The commission.* suite on the host: the registration (ids, kinds, the
|
||||||
|
takeover tests, the operator tests' hands), the record builders, the
|
||||||
|
machine's name, the cookie parsing, the LED cue, the settle rule for a
|
||||||
|
gated supervisor, and the cloud-off surface test driven end to end
|
||||||
|
against the fake daemon."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers
|
||||||
|
from forgetest import baseline, catalog
|
||||||
|
from forgetest.runner import Context, Run
|
||||||
|
from forgetest.suite import commission
|
||||||
|
|
||||||
|
IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot",
|
||||||
|
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes",
|
||||||
|
"commission.ssh-until-reboot", "commission.cloud-disabled-surface",
|
||||||
|
"commission.factory-return", "commission.machine-name", "commission.first-run-flow",
|
||||||
|
"commission.first-run-page", "commission.what-changed", "commission.record-export",
|
||||||
|
"commission.mirror")
|
||||||
|
OPERATOR = ("commission.first-run-flow", "commission.first-run-page")
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.reg = catalog.load_suite()
|
||||||
|
|
||||||
|
def test_every_test_is_registered_with_its_kind(self):
|
||||||
|
kinds = {tid: self.reg[tid].kind for tid in IDS if tid in self.reg}
|
||||||
|
self.assertEqual(sorted(kinds), sorted(IDS))
|
||||||
|
for tid in OPERATOR:
|
||||||
|
self.assertEqual(kinds[tid], "operator", tid)
|
||||||
|
for tid in IDS:
|
||||||
|
if tid not in OPERATOR:
|
||||||
|
self.assertEqual(kinds[tid], "auto", tid)
|
||||||
|
self.assertNotIn("commission.root-ssh-refused", self.reg) # folded into ssh-until-reboot
|
||||||
|
self.assertNotIn("commission.wizard-first-run", self.reg) # split into the flow and the page
|
||||||
|
|
||||||
|
def test_the_record_swapping_tests_are_takeovers(self):
|
||||||
|
for tid in ("commission.gate-blocks-controllers", "commission.advisories-rehash",
|
||||||
|
"commission.first-run-flow", "commission.first-run-page",
|
||||||
|
"commission.what-changed", "commission.mirror"):
|
||||||
|
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||||
|
self.assertEqual(self.reg["commission.record-export"].hardware, "api")
|
||||||
|
|
||||||
|
def test_the_lifecycle_tests_cover_their_files(self):
|
||||||
|
changed = set(self.reg["commission.what-changed"].covers)
|
||||||
|
export = set(self.reg["commission.record-export"].covers)
|
||||||
|
mirror = set(self.reg["commission.mirror"].covers)
|
||||||
|
self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
|
||||||
|
self.assertIn(("forgectrl", "src/recordhtml.*"), export)
|
||||||
|
self.assertIn(("forgectrl", "src/logs.*"), export)
|
||||||
|
self.assertIn(("forgectrl", "src/wizdark.*"), mirror)
|
||||||
|
self.assertIn("commission.check-sensors", self.reg["commission.mirror"].requires)
|
||||||
|
|
||||||
|
def test_flag_of_reads_the_status_document(self):
|
||||||
|
w = {"required": ["airflow", {"id": "laser.floor", "reason": "the tube was replaced"}],
|
||||||
|
"recommended": [{"id": "laser.focus", "reason": "the tray was replaced"}]}
|
||||||
|
self.assertEqual(commission.flag_of(w, "required", "laser.floor"), "the tube was replaced")
|
||||||
|
self.assertEqual(commission.flag_of(w, "recommended", "laser.focus"), "the tray was replaced")
|
||||||
|
self.assertIsNone(commission.flag_of(w, "required", "airflow")) # a plain id is the table, not a flag
|
||||||
|
self.assertIsNone(commission.flag_of(w, "required", "laser.focus"))
|
||||||
|
self.assertIsNone(commission.flag_of({}, "recommended", "x"))
|
||||||
|
|
||||||
|
def test_the_first_run_flow_runs_unattended_with_the_fixture_and_the_page_walk_does_not(self):
|
||||||
|
flow = self.reg["commission.first-run-flow"]
|
||||||
|
self.assertEqual(list(flow.actions), ["button"])
|
||||||
|
self.assertFalse(flow.hands)
|
||||||
|
self.assertTrue(flow.fixture_runnable(("button", "lid", "interlock")))
|
||||||
|
page = self.reg["commission.first-run-page"]
|
||||||
|
self.assertTrue(page.hands)
|
||||||
|
self.assertFalse(page.fixture_runnable(("button", "lid", "interlock")))
|
||||||
|
self.assertIn("commission.first-run-flow", page.requires)
|
||||||
|
|
||||||
|
def test_the_first_run_split_keeps_the_backend_and_the_page_apart(self):
|
||||||
|
flow = set(self.reg["commission.first-run-flow"].covers)
|
||||||
|
page = set(self.reg["commission.first-run-page"].covers)
|
||||||
|
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"),
|
||||||
|
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
|
||||||
|
self.assertIn(want, flow, want)
|
||||||
|
self.assertNotIn(want, page, want)
|
||||||
|
for want in (("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/md.js"), ("forgectrl", "src/tls.*")):
|
||||||
|
self.assertIn(want, page, want)
|
||||||
|
|
||||||
|
def test_the_only_attended_tests_need_a_workstation_or_the_sheet(self):
|
||||||
|
# With the bench actuator up, three commissioning tests keep a
|
||||||
|
# person: the page walk, the print from the Glowforge app, and
|
||||||
|
# the sheet. Everything else runs from the queue.
|
||||||
|
attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("commission.")
|
||||||
|
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
|
||||||
|
self.assertEqual(attended, ["commission.cloud-header-capture", "commission.first-run-page",
|
||||||
|
"commission.sheet"])
|
||||||
|
|
||||||
|
def test_the_factory_return_never_runs_the_return(self):
|
||||||
|
import inspect
|
||||||
|
t = self.reg["commission.factory-return"]
|
||||||
|
src = inspect.getsource(t.fn)
|
||||||
|
self.assertNotIn('"confirm": "1"', src) # the only argument that starts the return
|
||||||
|
self.assertIn('"confirm": "0"', src)
|
||||||
|
self.assertEqual(t.kind, "auto") # the return itself is a bench drill, not a test
|
||||||
|
self.assertFalse(t.hands)
|
||||||
|
|
||||||
|
def test_the_machine_name_covers_nothing_by_design(self):
|
||||||
|
self.assertEqual(self.reg["commission.machine-name"].covers, ())
|
||||||
|
|
||||||
|
def test_the_login_test_makes_its_own_account(self):
|
||||||
|
# No bench credentials, no precheck: the test installs a temporary
|
||||||
|
# account under a takeover and restores the real one.
|
||||||
|
t = self.reg["commission.account-login"]
|
||||||
|
self.assertIsNone(getattr(t, "precheck", None))
|
||||||
|
self.assertEqual(t.hardware, "takeover")
|
||||||
|
with open(commission.__file__) as f:
|
||||||
|
src = f.read()
|
||||||
|
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
|
||||||
|
self.assertNotIn(k, src)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordTests(unittest.TestCase):
|
||||||
|
STATUS = {"sheet_id": "ABCDE-FGHIJ",
|
||||||
|
"documents": [{"id": "safety-and-risk", "hash": "a" * 64, "consent": "typed"},
|
||||||
|
{"id": "licenses", "hash": "b" * 64, "consent": "check"}],
|
||||||
|
"wizards": [{"id": "advisories", "version": 1}, {"id": "account", "version": 1},
|
||||||
|
{"id": "machine", "version": 2}]}
|
||||||
|
|
||||||
|
def test_complete_record_counts_as_commissioned(self):
|
||||||
|
rec = commission.complete_record(self.STATUS)
|
||||||
|
self.assertEqual(rec["schema"], 1)
|
||||||
|
self.assertEqual(rec["advisories"]["safety-and-risk"]["hash"], "a" * 64)
|
||||||
|
self.assertEqual(rec["advisories"]["safety-and-risk"]["method"], "typed")
|
||||||
|
self.assertIn("pressed_at", rec["acceptance"])
|
||||||
|
self.assertEqual(rec["account"]["name"], "bench")
|
||||||
|
self.assertEqual(rec["wizards"]["machine"]["version"], 2)
|
||||||
|
self.assertEqual(rec["sheet_id"], "ABCDE-FGHIJ")
|
||||||
|
self.assertTrue(rec["completed"])
|
||||||
|
json.dumps(rec)
|
||||||
|
|
||||||
|
def test_complete_record_keeps_the_base_account_and_machine(self):
|
||||||
|
base = {"account": {"name": "owner", "uid": 1000, "created": "x"}, "machine": {"model": "pro"},
|
||||||
|
"sheet_id": "KKKKK-LLLLL", "created": "then"}
|
||||||
|
rec = commission.complete_record(self.STATUS, base)
|
||||||
|
self.assertEqual(rec["account"]["name"], "owner")
|
||||||
|
self.assertEqual(rec["machine"], {"model": "pro"})
|
||||||
|
self.assertEqual(rec["sheet_id"], "KKKKK-LLLLL")
|
||||||
|
self.assertEqual(rec["created"], "then")
|
||||||
|
|
||||||
|
def test_without_wizards_keeps_consent_and_account(self):
|
||||||
|
rec = commission.complete_record(self.STATUS)
|
||||||
|
rec["flags"] = {"machine": {"level": "required", "reason": "x"}}
|
||||||
|
out = commission.record_without_wizards(rec)
|
||||||
|
self.assertEqual(out["wizards"], {})
|
||||||
|
self.assertEqual(out["flags"], {})
|
||||||
|
self.assertEqual(out["advisories"], rec["advisories"])
|
||||||
|
self.assertEqual(out["account"], rec["account"])
|
||||||
|
self.assertEqual(rec["flags"], {"machine": {"level": "required", "reason": "x"}}) # a copy
|
||||||
|
|
||||||
|
def test_write_file_and_remove(self):
|
||||||
|
tmp = tempfile.mkdtemp(prefix="forgetest-comm-")
|
||||||
|
try:
|
||||||
|
p = os.path.join(tmp, "sub", "commissioning.json")
|
||||||
|
commission.write_file(p, b"{}\n")
|
||||||
|
self.assertEqual(commission.read_file(p), b"{}\n")
|
||||||
|
if os.name == "posix": # a mode means nothing on a Windows host
|
||||||
|
self.assertEqual(oct(os.stat(p).st_mode & 0o777), oct(0o600))
|
||||||
|
commission.write_file(p, None)
|
||||||
|
self.assertIsNone(commission.read_file(p))
|
||||||
|
commission.write_file(p, None) # a second remove is silent
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_paths_follow_the_daemon_environment(self):
|
||||||
|
os.environ["FORGECTRL_DATA_DIR"] = "/tmp/ffdata"
|
||||||
|
os.environ["GF_RUN_DIR"] = "/tmp/ffrun"
|
||||||
|
try:
|
||||||
|
self.assertEqual(commission.record_path(), "/tmp/ffdata/commissioning.json")
|
||||||
|
self.assertEqual(commission.users_path(), "/tmp/ffdata/users")
|
||||||
|
self.assertEqual(commission.override_path(), "/tmp/ffrun/commissioning-override")
|
||||||
|
self.assertEqual(commission.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
|
||||||
|
finally:
|
||||||
|
os.environ.pop("FORGECTRL_DATA_DIR", None)
|
||||||
|
os.environ.pop("GF_RUN_DIR", None)
|
||||||
|
|
||||||
|
|
||||||
|
class MachineNameTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.root = tempfile.mkdtemp()
|
||||||
|
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||||
|
|
||||||
|
def _net(self, **devs):
|
||||||
|
"""A /sys/class/net tree; commission.mac_suffix reads it through
|
||||||
|
commission.read_file, which takes an absolute path."""
|
||||||
|
for dev, mac in devs.items():
|
||||||
|
d = os.path.join(self.root, dev)
|
||||||
|
os.makedirs(d)
|
||||||
|
with open(os.path.join(d, "address"), "w") as f:
|
||||||
|
f.write(mac + "\n")
|
||||||
|
real = commission.read_file
|
||||||
|
|
||||||
|
def read(path):
|
||||||
|
head = "/sys/class/net/"
|
||||||
|
if path.startswith(head):
|
||||||
|
return real(os.path.join(self.root, path[len(head):]))
|
||||||
|
return real(path)
|
||||||
|
|
||||||
|
commission.read_file = read
|
||||||
|
self.addCleanup(setattr, commission, "read_file", real)
|
||||||
|
|
||||||
|
def test_the_wifi_address_names_the_machine(self):
|
||||||
|
self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
|
||||||
|
self.assertEqual(commission.mac_suffix(), "b00a")
|
||||||
|
|
||||||
|
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
|
||||||
|
self._net(eth0="00:11:22:33:44:55")
|
||||||
|
self.assertEqual(commission.mac_suffix(), "4455")
|
||||||
|
|
||||||
|
def test_an_unread_address_is_no_address(self):
|
||||||
|
self._net(wlan0="00:00:00:00:00:00")
|
||||||
|
self.assertEqual(commission.mac_suffix(), "")
|
||||||
|
|
||||||
|
def test_no_interface_is_no_address(self):
|
||||||
|
self._net()
|
||||||
|
self.assertEqual(commission.mac_suffix(), "")
|
||||||
|
|
||||||
|
|
||||||
|
class SmallHelpersTests(unittest.TestCase):
|
||||||
|
def test_cookie_parsing(self):
|
||||||
|
sid = "ab" * 32
|
||||||
|
value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid
|
||||||
|
self.assertEqual(commission.session_from_cookie(value), sid)
|
||||||
|
self.assertEqual(commission.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
|
||||||
|
self.assertIsNone(commission.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
|
||||||
|
self.assertIsNone(commission.session_from_cookie(""))
|
||||||
|
|
||||||
|
def test_the_teal_breathe_cue(self):
|
||||||
|
self.assertTrue(commission.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
|
||||||
|
"pulse_off": [0, 1400, 1400]}))
|
||||||
|
self.assertFalse(commission.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0],
|
||||||
|
"pulse_off": [0, 0, 0]})) # solid green
|
||||||
|
self.assertFalse(commission.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800],
|
||||||
|
"pulse_off": [1800] * 3})) # white
|
||||||
|
self.assertFalse(commission.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
|
||||||
|
self.assertFalse(commission.breathes_teal({}))
|
||||||
|
|
||||||
|
def test_decode(self):
|
||||||
|
self.assertEqual(commission.decode(b'{"a": 1}'), {"a": 1})
|
||||||
|
self.assertEqual(commission.decode(b"cloud mode is not enabled"), "cloud mode is not enabled")
|
||||||
|
|
||||||
|
|
||||||
|
class GatedSettleTests(unittest.TestCase):
|
||||||
|
"""A supervisor that reports the commissioning gate closed is settled:
|
||||||
|
it spawns nothing until the gate opens, so a takeover that installs
|
||||||
|
a gating record must not wait the whole settle timeout for it."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.fake = helpers.FakeForgectrl().start()
|
||||||
|
baseline.Baseline._unreachable_until = 0.0
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.fake.stop()
|
||||||
|
|
||||||
|
def test_gated_returns_at_once(self):
|
||||||
|
self.fake.state["mode"] = {"mode": "grbl", "controller": "gated", "pid": 0, "motion": "unverified",
|
||||||
|
"gated": True, "why": "commissioning required: machine"}
|
||||||
|
lines = []
|
||||||
|
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
|
||||||
|
self.assertEqual(body["controller"], "gated")
|
||||||
|
self.assertTrue(any("controller=gated" in ln for ln in lines))
|
||||||
|
|
||||||
|
|
||||||
|
class FirstRunSeedTests(unittest.TestCase):
|
||||||
|
"""The record the first-run tests install: a first run behind the
|
||||||
|
checks and the sheet."""
|
||||||
|
|
||||||
|
STATUS = {"wizards": [{"id": "advisories", "version": 1, "class": "form"},
|
||||||
|
{"id": "account", "version": 1, "class": "form"},
|
||||||
|
{"id": "cloud", "version": 1, "class": "form"},
|
||||||
|
{"id": "switches", "version": 1, "class": "dark"},
|
||||||
|
{"id": "motion", "version": 2, "class": "dark"},
|
||||||
|
{"id": "sheet.frame", "version": 1, "class": "live"}]}
|
||||||
|
|
||||||
|
def test_no_consent_no_account_not_complete_and_every_check_done(self):
|
||||||
|
base = {"created": "2026-09-01T00:00:00Z", "completed": "2026-09-02T00:00:00Z",
|
||||||
|
"advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"},
|
||||||
|
"account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True},
|
||||||
|
"wizards": {"advisories": {"version": 1}, "switches": {"version": 1, "result": {"lid": True}}}}
|
||||||
|
rec = commission.first_run_record(self.STATUS, base)
|
||||||
|
self.assertNotIn("completed", rec)
|
||||||
|
self.assertNotIn("acceptance", rec)
|
||||||
|
self.assertNotIn("account", rec)
|
||||||
|
self.assertEqual(rec["advisories"], {})
|
||||||
|
self.assertEqual(rec["created"], "2026-09-01T00:00:00Z")
|
||||||
|
self.assertEqual(rec["flags"], {"flow_thin": True})
|
||||||
|
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
|
||||||
|
self.assertEqual(rec["wizards"]["switches"]["result"], {"lid": True}) # kept from the base
|
||||||
|
self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version
|
||||||
|
|
||||||
|
def test_without_a_base_record(self):
|
||||||
|
rec = commission.first_run_record(self.STATUS, None)
|
||||||
|
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
|
||||||
|
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class SshdPolicyTests(unittest.TestCase):
|
||||||
|
def test_the_three_keys_out_of_sshd_t(self):
|
||||||
|
from forgetest import hw
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run(cmd, timeout=60):
|
||||||
|
calls.append(cmd)
|
||||||
|
return 0, "port 22\npermitrootlogin no\npasswordauthentication yes\npermitemptypasswords no\nx11forwarding no\n"
|
||||||
|
real = hw.run
|
||||||
|
hw.run = fake_run
|
||||||
|
try:
|
||||||
|
self.assertEqual(commission.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
|
||||||
|
"permitemptypasswords": "no"})
|
||||||
|
finally:
|
||||||
|
hw.run = real
|
||||||
|
self.assertEqual(calls, [["/usr/sbin/sshd", "-T"]])
|
||||||
|
|
||||||
|
def test_a_failing_sshd_reports_the_error(self):
|
||||||
|
from forgetest import hw
|
||||||
|
real = hw.run
|
||||||
|
hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available")
|
||||||
|
try:
|
||||||
|
self.assertEqual(commission.sshd_policy(), {"error": "sshd: no hostkeys available"})
|
||||||
|
finally:
|
||||||
|
hw.run = real
|
||||||
|
|
||||||
|
|
||||||
|
class CloudDisabledSurfaceTests(unittest.TestCase):
|
||||||
|
"""The cloud-off surface test against the fake daemon: cloud_enabled
|
||||||
|
is turned off with one write that sweeps the cloud choices, the three
|
||||||
|
cloud-pointing writes and the phrase-less cloud_enabled=1 are refused,
|
||||||
|
the settings are left alone, and the prior values come back."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.fake = helpers.FakeForgectrl().start()
|
||||||
|
self.fake.state["settings"].update({"controller_mode": "grbl", "homing_mode": "none",
|
||||||
|
"cloud_enabled": "1", "ui_units": "metric"})
|
||||||
|
|
||||||
|
def on_post(path, form):
|
||||||
|
s = self.fake.state["settings"]
|
||||||
|
if path == "/settings":
|
||||||
|
# as the daemon rules it: on from off takes the typed phrase
|
||||||
|
if form.get("cloud_enabled") == "1" and s.get("cloud_enabled") != "1" \
|
||||||
|
and form.get("phrase") != "I UNDERSTAND":
|
||||||
|
return 400, "type I UNDERSTAND to turn cloud mode on"
|
||||||
|
# the request's own cloud choices, as main.c checks them
|
||||||
|
enabled = form.get("cloud_enabled", s.get("cloud_enabled")) == "1"
|
||||||
|
if not enabled and form.get("controller_mode") == "cloud":
|
||||||
|
return 409, "cloud mode is not enabled on this machine"
|
||||||
|
if not enabled and form.get("homing_mode") == "gfcloud":
|
||||||
|
return 409, "cloud homing needs cloud mode enabled"
|
||||||
|
for k, v in form.items():
|
||||||
|
if k == "phrase":
|
||||||
|
continue
|
||||||
|
if v == "":
|
||||||
|
s.pop(k, None)
|
||||||
|
else:
|
||||||
|
s[k] = v
|
||||||
|
# off sweeps what pointed at the cloud, as the cloud step does
|
||||||
|
if form.get("cloud_enabled") == "0":
|
||||||
|
if "homing_mode" not in form and s.get("homing_mode") == "gfcloud":
|
||||||
|
s["homing_mode"] = "none"
|
||||||
|
if "controller_mode" not in form and s.get("controller_mode") == "cloud":
|
||||||
|
s["controller_mode"] = "grbl"
|
||||||
|
return 200, dict(s)
|
||||||
|
if path == "/mode" and form.get("controller") == "cloud" and s.get("cloud_enabled") != "1":
|
||||||
|
return 409, "cloud mode is not enabled on this machine"
|
||||||
|
return None
|
||||||
|
self.fake.on_post = on_post
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.fake.stop()
|
||||||
|
|
||||||
|
def run_test(self):
|
||||||
|
t = catalog.load_suite()["commission.cloud-disabled-surface"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
return run
|
||||||
|
|
||||||
|
def test_refusals_and_restore(self):
|
||||||
|
run = self.run_test()
|
||||||
|
s = self.fake.state["settings"]
|
||||||
|
self.assertEqual(s["cloud_enabled"], "1") # restored, with the phrase
|
||||||
|
self.assertEqual(s["controller_mode"], "grbl")
|
||||||
|
self.assertEqual(s["homing_mode"], "none")
|
||||||
|
self.assertNotIn("phrase", s)
|
||||||
|
posts = [(p, f) for p, f in self.fake.posts]
|
||||||
|
self.assertIn(("/settings", {"cloud_enabled": "0"}), posts)
|
||||||
|
self.assertIn(("/settings", {"cloud_enabled": "1"}), posts) # refused: no phrase
|
||||||
|
self.assertIn(("/settings", {"cloud_enabled": "1", "phrase": "I UNDERSTAND"}), posts)
|
||||||
|
self.assertIn(("/mode", {"controller": "cloud"}), posts)
|
||||||
|
self.assertEqual(run.evidence["mode_cloud"]["status"], 409)
|
||||||
|
self.assertEqual(run.evidence["settings cloud_enabled=1 no phrase"], 400)
|
||||||
|
self.assertEqual(run.evidence["found"]["cloud_enabled"], "1")
|
||||||
|
|
||||||
|
def test_cloud_homing_and_boot_mode_are_swept_by_the_one_write_and_go_back(self):
|
||||||
|
# As the daemon rules it: cloud_enabled=0 takes the gfcloud homing
|
||||||
|
# and the cloud boot mode down with it, so the test writes the one
|
||||||
|
# key, checks the sweep, and puts the three back, cloud mode first.
|
||||||
|
self.fake.state["settings"].update({"controller_mode": "cloud", "homing_mode": "gfcloud"})
|
||||||
|
run = self.run_test()
|
||||||
|
s = self.fake.state["settings"]
|
||||||
|
self.assertEqual((s["cloud_enabled"], s["homing_mode"], s["controller_mode"]), ("1", "gfcloud", "cloud"))
|
||||||
|
posts = [(p, f) for p, f in self.fake.posts if p == "/settings"]
|
||||||
|
order = [f for _, f in posts]
|
||||||
|
self.assertEqual(order[:1], [{"cloud_enabled": "0"}])
|
||||||
|
self.assertEqual(order[-3:], [{"cloud_enabled": "1", "phrase": "I UNDERSTAND"},
|
||||||
|
{"homing_mode": "gfcloud"}, {"controller_mode": "cloud"}])
|
||||||
|
self.assertEqual(run.evidence["found"], {"cloud_enabled": "1", "homing_mode": "gfcloud",
|
||||||
|
"controller_mode": "cloud"})
|
||||||
|
|
||||||
|
def test_an_unset_value_is_cleared_back(self):
|
||||||
|
self.fake.state["settings"].pop("cloud_enabled")
|
||||||
|
self.run_test()
|
||||||
|
self.assertNotIn("cloud_enabled", self.fake.state["settings"])
|
||||||
|
self.assertIn(("/settings", {"cloud_enabled": ""}), self.fake.posts)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""cooling.aa-offset-calibrate is registered before the heater tools, so
|
||||||
|
a queue runs it first: a heater trial leaves a warm slug circulating past
|
||||||
|
the coolant sensors for minutes, and the tool's edges read it as
|
||||||
|
disagreement."""
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from forgetest import catalog
|
||||||
|
|
||||||
|
|
||||||
|
class CoolingOrderTests(unittest.TestCase):
|
||||||
|
def test_aa_offset_precedes_flow_verify(self):
|
||||||
|
reg = catalog.load_suite()
|
||||||
|
tests = catalog.all_tests(reg)
|
||||||
|
order = catalog.order_by_requires(tests, [t.id for t in tests])
|
||||||
|
self.assertLess(order.index("cooling.aa-offset-calibrate"), order.index("cooling.flow-verify"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
|
||||||
|
under the real runner Context against a scripted machine: a fake
|
||||||
|
forgectrl (/status fans, /cool/status phase and verdict, /settings with
|
||||||
|
the gates table, /logs/tail), a fake kernel sysfs (the fan duties), and
|
||||||
|
a fake Grbl port that answers '?' and every command.
|
||||||
|
|
||||||
|
The bench case that motivated this: the test started one second after
|
||||||
|
the engine of a previous test went idle, took the tachs still coasting
|
||||||
|
at the cooldown level as its idle reference, and then waited for the
|
||||||
|
fans to come back UP to it. The idle reference now needs the engine
|
||||||
|
idle, the idle duty applied, and tachs that have stopped changing; the
|
||||||
|
pass condition is at-or-below that level, and every sample is logged.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers
|
||||||
|
from forgetest.runner import Context, Failed, Run
|
||||||
|
from forgetest.suite import cooling
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGrbl:
|
||||||
|
"""Answers '?' with an Idle report and every line with ok."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.sock = socket.socket()
|
||||||
|
self.sock.bind(("127.0.0.1", 0))
|
||||||
|
self.sock.listen(2)
|
||||||
|
os.environ["GRBL_HOST"] = "127.0.0.1"
|
||||||
|
os.environ["GRBL_PORT"] = str(self.sock.getsockname()[1])
|
||||||
|
self.commands = []
|
||||||
|
self.on_command = None
|
||||||
|
self._stop = False
|
||||||
|
threading.Thread(target=self._serve, daemon=True).start()
|
||||||
|
|
||||||
|
def _serve(self):
|
||||||
|
self.sock.settimeout(0.2)
|
||||||
|
while not self._stop:
|
||||||
|
try:
|
||||||
|
c, _ = self.sock.accept()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
threading.Thread(target=self._client, args=(c,), daemon=True).start()
|
||||||
|
|
||||||
|
def _client(self, c):
|
||||||
|
c.settimeout(0.1)
|
||||||
|
buf = b""
|
||||||
|
c.sendall(b"\r\nGrbl 1.1f ['$' for help]\r\n")
|
||||||
|
while not self._stop:
|
||||||
|
try:
|
||||||
|
d = c.recv(4096)
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
if not d:
|
||||||
|
break
|
||||||
|
for ch in d:
|
||||||
|
if ch == 0x3F: # '?'
|
||||||
|
c.sendall(b"<Idle|MPos:0.000,0.000,0.000|FS:0,0>\r\n")
|
||||||
|
elif ch in (0x18, 0x85):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
buf += bytes([ch])
|
||||||
|
while b"\n" in buf:
|
||||||
|
line, buf = buf.split(b"\n", 1)
|
||||||
|
line = line.decode().strip()
|
||||||
|
if line:
|
||||||
|
self.commands.append(line)
|
||||||
|
if self.on_command:
|
||||||
|
self.on_command(line)
|
||||||
|
c.sendall(b"ok\r\n")
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._stop = True
|
||||||
|
self.sock.close()
|
||||||
|
for k in ("GRBL_HOST", "GRBL_PORT"):
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
|
class FansQuietTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix="forgetest-cool-")
|
||||||
|
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
|
||||||
|
os.makedirs(self.sysfs + "thermal")
|
||||||
|
os.makedirs(self.sysfs + "cnc")
|
||||||
|
with open(self.sysfs + "cnc/state", "w") as f:
|
||||||
|
f.write("idle")
|
||||||
|
os.environ["GF_SYSFS_ROOT"] = self.sysfs
|
||||||
|
self.fc = helpers.FakeForgectrl().start()
|
||||||
|
self.grbl = FakeGrbl()
|
||||||
|
self.saved = (cooling.SAMPLE_S, cooling.IDLE_REF_TIMEOUT_S, cooling.COOLDOWN_TIMEOUT_S,
|
||||||
|
cooling.RESTART_IDLE_TIMEOUT_S, cooling.hw.initd)
|
||||||
|
cooling.SAMPLE_S = 0.1
|
||||||
|
cooling.IDLE_REF_TIMEOUT_S = 3
|
||||||
|
cooling.COOLDOWN_TIMEOUT_S = 4
|
||||||
|
cooling.RESTART_IDLE_TIMEOUT_S = 1
|
||||||
|
# The daemon restart of the last phase, scripted: the init script
|
||||||
|
# is a stub, and the engine that comes up applies `after_start`
|
||||||
|
# (the idle duties by default; a daemon that keeps the busy-start
|
||||||
|
# cooldown airflow applies those instead).
|
||||||
|
self.initd_calls = []
|
||||||
|
self.after_start = (0, 0)
|
||||||
|
|
||||||
|
def initd(service, action, timeout=60):
|
||||||
|
self.initd_calls.append((service, action))
|
||||||
|
if action == "start":
|
||||||
|
self.duty(*self.after_start)
|
||||||
|
return 0, ""
|
||||||
|
cooling.hw.initd = initd
|
||||||
|
self.fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
|
||||||
|
self.duty(0, 0)
|
||||||
|
self.fans(0, 736, 733)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
(cooling.SAMPLE_S, cooling.IDLE_REF_TIMEOUT_S, cooling.COOLDOWN_TIMEOUT_S,
|
||||||
|
cooling.RESTART_IDLE_TIMEOUT_S, cooling.hw.initd) = self.saved
|
||||||
|
self.grbl.close()
|
||||||
|
self.fc.stop()
|
||||||
|
os.environ.pop("GF_SYSFS_ROOT", None)
|
||||||
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
# -- the machine -------------------------------------------------------
|
||||||
|
def duty(self, exhaust, intake):
|
||||||
|
for attr, v in (("thermal/exhaust_pwm", exhaust), ("thermal/intake_pwm", intake)):
|
||||||
|
with open(self.sysfs + attr, "w") as f:
|
||||||
|
f.write(str(v))
|
||||||
|
|
||||||
|
def fans(self, exhaust, i1, i2, air=2080):
|
||||||
|
self.fc.state["status"] = dict(self.fc.state["status"],
|
||||||
|
fans={"air_assist": air, "exhaust": exhaust, "intake_1": i1, "intake_2": i2})
|
||||||
|
|
||||||
|
def run_engine(self):
|
||||||
|
"""M8 -> run profile; M9 -> smoke phase, then idle duty, then the
|
||||||
|
tachs coast down (a few samples of spin-down), like forgectrl's
|
||||||
|
engine on the bench."""
|
||||||
|
def on_command(line):
|
||||||
|
if line == "M8":
|
||||||
|
self.duty(65535, 43278)
|
||||||
|
self.fc.state["cool"]["phase"] = "run"
|
||||||
|
self.fans(6753, 3212, 3328, air=10997)
|
||||||
|
elif line == "M9":
|
||||||
|
def cooldown():
|
||||||
|
self.fc.state["cool"]["phase"] = "smoke"
|
||||||
|
time.sleep(0.3)
|
||||||
|
self.fc.state["cool"]["phase"] = "idle"
|
||||||
|
self.duty(0, 0)
|
||||||
|
for ex, i1 in ((5030, 3319), (3100, 2200), (1200, 1300), (0, 740)):
|
||||||
|
self.fans(ex, i1, i1 + 5)
|
||||||
|
time.sleep(0.25)
|
||||||
|
threading.Thread(target=cooldown, daemon=True).start()
|
||||||
|
self.grbl.on_command = on_command
|
||||||
|
|
||||||
|
def run_test(self):
|
||||||
|
run = Run("test", "cooling.fans-quiet-after-motion", "t")
|
||||||
|
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
|
||||||
|
cooling.fans_quiet(ctx)
|
||||||
|
return run
|
||||||
|
|
||||||
|
# -- cases ------------------------------------------------------------------
|
||||||
|
def test_idle_machine_passes_and_logs_every_sample(self):
|
||||||
|
self.run_engine()
|
||||||
|
run = self.run_test()
|
||||||
|
self.assertEqual(run.evidence["before"]["exhaust"], 0)
|
||||||
|
self.assertIsNotNone(run.evidence["settle_s"])
|
||||||
|
self.assertEqual(run.evidence["duty_after"], cooling.IDLE_DUTY)
|
||||||
|
self.assertIn("M8", self.grbl.commands)
|
||||||
|
self.assertIn("M9", self.grbl.commands)
|
||||||
|
cooldown_lines = [ln for ln in run.lines if "cooldown +" in ln]
|
||||||
|
self.assertGreaterEqual(len(cooldown_lines), 2, run.lines)
|
||||||
|
|
||||||
|
def test_a_spin_down_in_progress_is_not_taken_as_the_idle_reference(self):
|
||||||
|
"""The bench case: the previous test's fans are still coasting when
|
||||||
|
this one starts. The reference waits for them to settle, and the
|
||||||
|
run passes instead of waiting for the fans to come back up."""
|
||||||
|
self.run_engine()
|
||||||
|
self.fans(5030, 3319, 3377) # coasting, engine already idle, duty 0
|
||||||
|
|
||||||
|
def coast():
|
||||||
|
for ex, i1 in ((3900, 2600), (2500, 1700), (1100, 1000), (0, 736), (0, 736)):
|
||||||
|
time.sleep(0.12)
|
||||||
|
self.fans(ex, i1, i1 + 5)
|
||||||
|
threading.Thread(target=coast, daemon=True).start()
|
||||||
|
run = self.run_test()
|
||||||
|
self.assertEqual(run.evidence["before"]["exhaust"], 0, run.evidence["before"])
|
||||||
|
self.assertGreater(run.evidence["idle_ref_s"], 0.3)
|
||||||
|
self.assertIsNotNone(run.evidence["settle_s"])
|
||||||
|
self.assertTrue(any("idle ref:" in ln for ln in run.lines))
|
||||||
|
|
||||||
|
def test_the_restart_phase_stops_disables_starts_and_sees_idle_duty(self):
|
||||||
|
self.run_engine()
|
||||||
|
run = self.run_test()
|
||||||
|
self.assertEqual(run.finished, None)
|
||||||
|
self.assertEqual(self.initd_calls, [("forgectrl", "stop"), ("forgectrl", "start")])
|
||||||
|
with open(self.sysfs + "cnc/disable") as f:
|
||||||
|
self.assertEqual(f.read(), "1")
|
||||||
|
self.assertEqual(run.evidence["duty_after_restart"], cooling.IDLE_DUTY)
|
||||||
|
self.assertIsNotNone(run.evidence["restart_idle_s"])
|
||||||
|
|
||||||
|
def test_a_daemon_that_keeps_the_busy_start_airflow_fails(self):
|
||||||
|
# The bench case: after a takeover's restart the exhaust ran at
|
||||||
|
# cooldown duty on an idle machine for good.
|
||||||
|
self.run_engine()
|
||||||
|
self.after_start = (32768, 21639)
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
self.run_test()
|
||||||
|
self.assertIn("kept the busy-start airflow", str(cm.exception))
|
||||||
|
self.assertIn("32768", str(cm.exception))
|
||||||
|
|
||||||
|
def test_fans_left_running_fail_with_the_reference_in_the_message(self):
|
||||||
|
def on_command(line):
|
||||||
|
if line == "M8":
|
||||||
|
self.duty(65535, 43278)
|
||||||
|
self.fc.state["cool"]["phase"] = "run"
|
||||||
|
self.fans(6753, 3212, 3328)
|
||||||
|
# M9 ignored: the run profile stays on
|
||||||
|
self.grbl.on_command = on_command
|
||||||
|
run = Run("test", "cooling.fans-quiet-after-motion", "t")
|
||||||
|
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
cooling.fans_quiet(ctx)
|
||||||
|
self.assertIn("did not return to the idle profile", str(cm.exception))
|
||||||
|
self.assertIn("idle reference", str(cm.exception))
|
||||||
|
self.assertIn("65535", str(cm.exception))
|
||||||
|
|
||||||
|
def test_a_reference_that_never_settles_fails_early_and_says_so(self):
|
||||||
|
def churn():
|
||||||
|
seq = (1000, 4000, 2500, 700, 3300, 1600, 4200) # aperiodic against the sampler
|
||||||
|
i = 0
|
||||||
|
while not self.grbl._stop:
|
||||||
|
v = seq[i % len(seq)]
|
||||||
|
i += 1
|
||||||
|
self.fans(v, v, v)
|
||||||
|
time.sleep(0.03)
|
||||||
|
threading.Thread(target=churn, daemon=True).start()
|
||||||
|
run = Run("test", "cooling.fans-quiet-after-motion", "t")
|
||||||
|
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
cooling.fans_quiet(ctx)
|
||||||
|
self.assertIn("never settled to an idle reference", str(cm.exception))
|
||||||
|
self.assertEqual(self.grbl.commands, []) # nothing was jogged
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class GateOffTests(unittest.TestCase):
|
||||||
|
"""cooling.gate-off against a scripted engine: the fake forgectrl
|
||||||
|
re-reads the ceiling at every M8 (as the engine reloads its tunables
|
||||||
|
at run start), trips OVERTEMP when the coolant is over it, skips the
|
||||||
|
gate and reports gates_off when the ceiling sits at its top, and
|
||||||
|
writes the run-start line the test looks for."""
|
||||||
|
|
||||||
|
TOP, BOTTOM = 60.0, 5.0
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.fc = helpers.FakeForgectrl().start()
|
||||||
|
self.grbl = FakeGrbl()
|
||||||
|
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S)
|
||||||
|
cooling.VERDICT_WAIT_S = 3
|
||||||
|
cooling.SESSION_END_WAIT_S = 3
|
||||||
|
self.fc.state["status"] = dict(self.fc.state["status"],
|
||||||
|
coolant={"down_c": 22.4, "up_c": 22.3, "pump": True, "tec": False},
|
||||||
|
temps={"chassis_c": 29.0, "supply_raw": 589, "soc_c": 42.8,
|
||||||
|
"soc_throttle": 0},
|
||||||
|
gates_off=[])
|
||||||
|
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
|
||||||
|
"gates_off": []}
|
||||||
|
self.fc.state["settings"].update({"cool_temp_max": "", "cool_temp_resume": ""})
|
||||||
|
self.log_line = True # the engine writes its run-start line
|
||||||
|
self.temps_line = True # the engine writes its run-end temperature line
|
||||||
|
self.report_off = True # the engine reports the off gate
|
||||||
|
self.trips = True # the engine trips a low ceiling
|
||||||
|
self.sessions = 0 # run sessions the engine saw (M8 with the phase not run)
|
||||||
|
self._describe()
|
||||||
|
self.grbl.on_command = self._engine
|
||||||
|
self.fc.on_post = self._on_post
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S = self.saved
|
||||||
|
self.grbl.close()
|
||||||
|
self.fc.stop()
|
||||||
|
|
||||||
|
# -- the scripted machine --------------------------------------------------
|
||||||
|
def ceiling(self):
|
||||||
|
v = self.fc.state["settings"].get("cool_temp_max") or ""
|
||||||
|
return float(v) if v else 33.0
|
||||||
|
|
||||||
|
def _describe(self):
|
||||||
|
"""/settings carries the gates table the way forgectrl's gates.c
|
||||||
|
publishes it, classified from the stored value."""
|
||||||
|
v = self.ceiling()
|
||||||
|
state = "off" if v >= self.TOP else ("ok" if 25 <= v <= 38 else "warn")
|
||||||
|
self.fc.state["settings"]["gates"] = {
|
||||||
|
"cool_temp_max": {"gate": "coolant_max", "def": 33, "lo": self.BOTTOM, "hi": self.TOP,
|
||||||
|
"band": [25, 38], "off": "high", "value": v, "state": state}}
|
||||||
|
|
||||||
|
def _on_post(self, path, form):
|
||||||
|
"""The real settings reply re-classifies the gates from the new values."""
|
||||||
|
if path != "/settings":
|
||||||
|
return None
|
||||||
|
self.fc.state["settings"].update(form)
|
||||||
|
self._describe()
|
||||||
|
return (200, self.fc.state["settings"])
|
||||||
|
|
||||||
|
def _engine(self, line):
|
||||||
|
"""M8 opens a run session: the engine re-reads the ceiling and
|
||||||
|
ticks the gate. M9 ends it a report period later (the phase
|
||||||
|
leaves run); a hold taken against the old ceiling stands until
|
||||||
|
the next session re-reads."""
|
||||||
|
self._describe()
|
||||||
|
cool = self.fc.state["cool"]
|
||||||
|
if line == "M9":
|
||||||
|
def end():
|
||||||
|
time.sleep(0.3)
|
||||||
|
cool["phase"] = "smoke"
|
||||||
|
if self.temps_line:
|
||||||
|
self.fc.state["logs_tail"]["text"] += (
|
||||||
|
"Aug 22 12:00:30 forgectrl: cool: temps this job: chassis 29.0..29.4 C, "
|
||||||
|
"soc 42.8..47.1 C, supply raw 587..592\n")
|
||||||
|
time.sleep(0.2)
|
||||||
|
cool["phase"] = "idle"
|
||||||
|
threading.Thread(target=end, daemon=True).start()
|
||||||
|
return
|
||||||
|
if line != "M8":
|
||||||
|
return
|
||||||
|
self.sessions += 1
|
||||||
|
cool["phase"] = "run"
|
||||||
|
v = self.ceiling()
|
||||||
|
off = v >= self.TOP
|
||||||
|
if off and self.log_line:
|
||||||
|
self.fc.state["logs_tail"]["text"] += (
|
||||||
|
"Aug 21 12:00:00 forgectrl: cool: gate coolant_max OFF: cool_temp_max = 60 "
|
||||||
|
"(the high end of 5 to 60; recommended 25 to 38, default 33)\n")
|
||||||
|
gates_off = ["coolant_max"] if off and self.report_off else []
|
||||||
|
if not off and self.trips and 22.3 > v:
|
||||||
|
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, gates_off=gates_off)
|
||||||
|
else:
|
||||||
|
cool.update(verdict="OK", fire_ok=True, hold=False, gates_off=gates_off)
|
||||||
|
self.fc.state["status"]["gates_off"] = gates_off
|
||||||
|
|
||||||
|
def run_test(self):
|
||||||
|
run = Run("test", "cooling.gate-off", "t")
|
||||||
|
ctx = Context(run, None, helpers.make_test("cooling.gate-off", []))
|
||||||
|
cooling.gate_off(ctx)
|
||||||
|
return run
|
||||||
|
|
||||||
|
def settings_posts(self):
|
||||||
|
return [f for p, f in self.fc.posts if p == "/settings"]
|
||||||
|
|
||||||
|
# -- cases ------------------------------------------------------------------
|
||||||
|
def test_trip_then_off_then_restored_passes(self):
|
||||||
|
run = self.run_test()
|
||||||
|
self.assertEqual(run.evidence["trip"]["verdict"], "OVERTEMP")
|
||||||
|
self.assertEqual(run.evidence["off"]["gates_off"], ["coolant_max"])
|
||||||
|
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
|
||||||
|
posts = self.settings_posts()
|
||||||
|
self.assertEqual(posts[0], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0"})
|
||||||
|
self.assertEqual(posts[1], {"cool_temp_max": "60.0", "cool_temp_resume": ""})
|
||||||
|
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
|
||||||
|
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
||||||
|
self.assertEqual(self.grbl.commands.count("M8"), 3)
|
||||||
|
self.assertEqual(self.grbl.commands.count("M9"), 3)
|
||||||
|
|
||||||
|
def test_an_engine_that_does_not_trip_fails_and_restores(self):
|
||||||
|
self.trips = False
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
self.run_test()
|
||||||
|
self.assertIn("did not trip", str(cm.exception))
|
||||||
|
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
|
||||||
|
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
||||||
|
# The restore cycles a run session so the engine re-reads the
|
||||||
|
# restored values; the bench is not left holding on the test's.
|
||||||
|
self.assertEqual(self.grbl.commands.count("M8"), 2)
|
||||||
|
self.assertEqual(self.grbl.commands.count("M9"), 2)
|
||||||
|
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
||||||
|
|
||||||
|
def test_every_session_waits_for_the_previous_one_to_end(self):
|
||||||
|
"""Each M8 must find the engine out of phase run, or the engine
|
||||||
|
never re-reads: the bench failure behind this case sent M9 and
|
||||||
|
the next M8 300 ms apart and the 1 Hz report pipeline swallowed
|
||||||
|
the session end."""
|
||||||
|
seen = []
|
||||||
|
inner = self._engine
|
||||||
|
|
||||||
|
def engine(line):
|
||||||
|
if line == "M8":
|
||||||
|
seen.append(self.fc.state["cool"]["phase"])
|
||||||
|
inner(line)
|
||||||
|
self.grbl.on_command = engine
|
||||||
|
self.run_test()
|
||||||
|
self.assertEqual(seen, ["idle", "idle", "idle"])
|
||||||
|
self.assertEqual(self.sessions, 3)
|
||||||
|
|
||||||
|
def test_an_engine_that_hides_the_off_gate_fails(self):
|
||||||
|
self.report_off = False
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
self.run_test()
|
||||||
|
self.assertIn("gates_off", str(cm.exception))
|
||||||
|
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
||||||
|
|
||||||
|
def test_a_missing_run_start_log_line_fails(self):
|
||||||
|
self.log_line = False
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
self.run_test()
|
||||||
|
self.assertIn("run-start log line", str(cm.exception))
|
||||||
|
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
|
||||||
|
|
||||||
|
def test_a_missing_run_end_temperature_line_fails(self):
|
||||||
|
self.temps_line = False
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
self.run_test()
|
||||||
|
self.assertIn("board-temperature line", str(cm.exception))
|
||||||
|
|
||||||
|
def test_a_custom_ceiling_is_restored_verbatim(self):
|
||||||
|
self.fc.state["settings"].update({"cool_temp_max": "30", "cool_temp_resume": "28"})
|
||||||
|
self._describe()
|
||||||
|
run = self.run_test()
|
||||||
|
self.assertEqual(run.evidence["orig"], {"cool_temp_max": "30", "cool_temp_resume": "28"})
|
||||||
|
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "30", "cool_temp_resume": "28"})
|
||||||
|
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "30")
|
||||||
|
|
||||||
|
|
||||||
|
class CriticalTierTests(unittest.TestCase):
|
||||||
|
"""cooling.critical-tier against a scripted engine: two tiers on the
|
||||||
|
coolant, the fail tier winning over the pause tier in a run session
|
||||||
|
and ending with it, the critical line off at its top, and the settings
|
||||||
|
cross-check refusing a critical line at or below the ceiling."""
|
||||||
|
|
||||||
|
UP = 22.3
|
||||||
|
ROWS = { # key: (gate, default, lo, hi, band_lo, band_hi, off)
|
||||||
|
"cool_temp_max": ("coolant_max", 33.0, 5.0, 60.0, 25.0, 38.0, "high"),
|
||||||
|
"cool_temp_resume": (None, 31.0, 5.0, 59.0, 20.0, 36.0, "none"),
|
||||||
|
"cool_temp_critical_c": ("coolant_critical", 38.0, 6.0, 70.0, 36.0, 45.0, "high"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.fc = helpers.FakeForgectrl().start()
|
||||||
|
self.grbl = FakeGrbl()
|
||||||
|
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S)
|
||||||
|
cooling.VERDICT_WAIT_S = 3
|
||||||
|
cooling.SESSION_END_WAIT_S = 3
|
||||||
|
self.fc.state["status"] = dict(self.fc.state["status"],
|
||||||
|
coolant={"down_c": 22.4, "up_c": self.UP, "pump": True, "tec": False},
|
||||||
|
gates_off=[])
|
||||||
|
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
|
||||||
|
"gates_off": []}
|
||||||
|
for k in self.ROWS:
|
||||||
|
self.fc.state["settings"].setdefault(k, "")
|
||||||
|
self.fc.state["logs_tail"] = {"text": ""}
|
||||||
|
self.faults = True # the engine has the critical tier
|
||||||
|
self.ends_with_session = True
|
||||||
|
self.cross_checks = True # the settings API refuses crit <= max
|
||||||
|
self._describe()
|
||||||
|
self.grbl.on_command = self._engine
|
||||||
|
self.fc.on_post = self._on_post
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S = self.saved
|
||||||
|
self.grbl.close()
|
||||||
|
self.fc.stop()
|
||||||
|
|
||||||
|
def setting(self, key):
|
||||||
|
v = self.fc.state["settings"].get(key) or ""
|
||||||
|
return float(v) if v else self.ROWS[key][1]
|
||||||
|
|
||||||
|
def _describe(self):
|
||||||
|
gates = {}
|
||||||
|
for k, (gate, d, lo, hi, blo, bhi, off) in self.ROWS.items():
|
||||||
|
v = self.setting(k)
|
||||||
|
state = "off" if (off == "high" and v >= hi) else ("ok" if blo <= v <= bhi else "warn")
|
||||||
|
gates[k] = {"gate": gate, "def": d, "lo": lo, "hi": hi, "band": [blo, bhi], "off": off,
|
||||||
|
"value": v, "state": state}
|
||||||
|
self.fc.state["settings"]["gates"] = gates
|
||||||
|
|
||||||
|
def _on_post(self, path, form):
|
||||||
|
if path != "/settings":
|
||||||
|
return None
|
||||||
|
if self.cross_checks:
|
||||||
|
merged = dict(self.fc.state["settings"])
|
||||||
|
merged.update(form)
|
||||||
|
tmax = float(merged.get("cool_temp_max") or 33.0)
|
||||||
|
tcrit = float(merged.get("cool_temp_critical_c") or 38.0)
|
||||||
|
ceiling_off = tmax >= self.ROWS["cool_temp_max"][3]
|
||||||
|
if not ceiling_off and tcrit <= tmax:
|
||||||
|
return (400, {"error": "cool_temp_critical_c must be above cool_temp_max"})
|
||||||
|
self.fc.state["settings"].update(form)
|
||||||
|
self._describe()
|
||||||
|
return (200, self.fc.state["settings"])
|
||||||
|
|
||||||
|
def _verdict_idle(self):
|
||||||
|
"""Outside a run only the ceiling's pause tier stands."""
|
||||||
|
return "OVERTEMP" if self.UP > self.setting("cool_temp_max") else "OK"
|
||||||
|
|
||||||
|
def _engine(self, line):
|
||||||
|
self._describe()
|
||||||
|
cool = self.fc.state["cool"]
|
||||||
|
if line == "M9":
|
||||||
|
def end():
|
||||||
|
time.sleep(0.3)
|
||||||
|
cool["phase"] = "idle"
|
||||||
|
if self.ends_with_session or cool["verdict"] != "CRITICAL":
|
||||||
|
v = self._verdict_idle()
|
||||||
|
cool.update(verdict=v, fire_ok=v == "OK", hold=v != "OK", reason="")
|
||||||
|
threading.Thread(target=end, daemon=True).start()
|
||||||
|
return
|
||||||
|
if line != "M8":
|
||||||
|
return
|
||||||
|
cool["phase"] = "run"
|
||||||
|
tmax, tcrit = self.setting("cool_temp_max"), self.setting("cool_temp_critical_c")
|
||||||
|
crit_off = tcrit >= self.ROWS["cool_temp_critical_c"][3]
|
||||||
|
off = ["coolant_critical"] if crit_off else []
|
||||||
|
if crit_off:
|
||||||
|
self.fc.state["logs_tail"]["text"] += (
|
||||||
|
"Aug 22 12:00:00 forgectrl: cool: gate coolant_critical OFF: cool_temp_critical_c = 70 "
|
||||||
|
"(the high end of 6 to 70; recommended 36 to 45, default 38)\n")
|
||||||
|
if self.faults and not crit_off and self.UP >= tcrit:
|
||||||
|
cool.update(verdict="CRITICAL", fire_ok=False, hold=True, resume_ok=False,
|
||||||
|
reason="CRITICAL: coolant %.1f C at or over the %.0f C critical line - hold, "
|
||||||
|
"no resume this job" % (self.UP, tcrit), gates_off=off)
|
||||||
|
elif self.UP > tmax:
|
||||||
|
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, resume_ok=False,
|
||||||
|
reason="coolant over the ceiling", gates_off=off)
|
||||||
|
else:
|
||||||
|
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off)
|
||||||
|
self.fc.state["status"]["gates_off"] = off
|
||||||
|
|
||||||
|
def run_test(self):
|
||||||
|
run = Run("test", "cooling.critical-tier", "t")
|
||||||
|
ctx = Context(run, None, helpers.make_test("cooling.critical-tier", []))
|
||||||
|
cooling.critical_tier(ctx)
|
||||||
|
return run
|
||||||
|
|
||||||
|
def settings_posts(self):
|
||||||
|
return [f for p, f in self.fc.posts if p == "/settings"]
|
||||||
|
|
||||||
|
# -- cases ------------------------------------------------------------------
|
||||||
|
def test_fault_then_off_then_restored_passes(self):
|
||||||
|
run = self.run_test()
|
||||||
|
self.assertEqual(run.evidence["cross_check"]["status"], 400)
|
||||||
|
self.assertEqual(run.evidence["critical"]["verdict"], "CRITICAL")
|
||||||
|
self.assertEqual(run.evidence["critical_after"]["verdict"], "OVERTEMP")
|
||||||
|
self.assertEqual(run.evidence["critical_off"]["gates_off"], ["coolant_critical"])
|
||||||
|
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
|
||||||
|
self.assertEqual(run.evidence["ceiling_off_accepted"], 200)
|
||||||
|
posts = self.settings_posts()
|
||||||
|
# refused cross-check, the ceiling at its off end (accepted), its undo, then the low leg
|
||||||
|
self.assertEqual(posts[1], {"cool_temp_max": "60.0"})
|
||||||
|
self.assertEqual(posts[3], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0",
|
||||||
|
"cool_temp_critical_c": "7.0"})
|
||||||
|
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": "", "cool_temp_critical_c": ""})
|
||||||
|
self.assertEqual(self.grbl.commands.count("M8"), 3)
|
||||||
|
self.assertEqual(self.grbl.commands.count("M9"), 3)
|
||||||
|
|
||||||
|
def test_an_engine_without_the_tier_fails_and_restores(self):
|
||||||
|
self.faults = False
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
self.run_test()
|
||||||
|
self.assertIn("did not fault", str(cm.exception))
|
||||||
|
self.assertEqual(self.settings_posts()[-1],
|
||||||
|
{"cool_temp_max": "", "cool_temp_resume": "", "cool_temp_critical_c": ""})
|
||||||
|
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
||||||
|
|
||||||
|
def test_a_fault_that_outlives_its_session_fails(self):
|
||||||
|
self.ends_with_session = False
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
self.run_test()
|
||||||
|
self.assertIn("after the faulted session", str(cm.exception))
|
||||||
|
|
||||||
|
def test_a_missing_cross_check_fails_before_any_session(self):
|
||||||
|
self.cross_checks = False
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
self.run_test()
|
||||||
|
self.assertIn("was accepted", str(cm.exception))
|
||||||
|
self.assertEqual(self.grbl.commands.count("M8"), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class FanGateTests(unittest.TestCase):
|
||||||
|
"""cooling.fan-gate-trips against a scripted engine: the fake reads the
|
||||||
|
floors at every M8 (the engine reloads at run start), holds every fan
|
||||||
|
in grace for the configured seconds, then judges the bench readings
|
||||||
|
(exhaust 6753, intakes 3212/3328, air assist 10997 rpm, purge 628)
|
||||||
|
against the floors: a floor a reading cannot meet trips AIRFLOW three
|
||||||
|
ticks after the grace, a floor of zero reads off."""
|
||||||
|
|
||||||
|
READINGS = {"exhaust": 6753, "intake_1": 3212, "intake_2": 3328, "air_assist": 10997, "purge": 628}
|
||||||
|
FLOORS = {"exhaust": ("cool_tach_exhaust_min_rpm", 3700.0, 20000.0),
|
||||||
|
"intake_1": ("cool_tach_intake_min_rpm", 1800.0, 20000.0),
|
||||||
|
"intake_2": ("cool_tach_intake_min_rpm", 1800.0, 20000.0),
|
||||||
|
"air_assist": ("cool_tach_air_assist_min_rpm", 6000.0, 30000.0),
|
||||||
|
"purge": ("cool_purge_min_current", 300.0, 1023.0)}
|
||||||
|
GATE_OF = {"exhaust": "exhaust", "intake_1": "intake", "intake_2": "intake",
|
||||||
|
"air_assist": "air_assist", "purge": "purge"}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.fc = helpers.FakeForgectrl().start()
|
||||||
|
self.grbl = FakeGrbl()
|
||||||
|
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling.FAN_TRIP_WAIT_S)
|
||||||
|
cooling.VERDICT_WAIT_S = 4
|
||||||
|
cooling.SESSION_END_WAIT_S = 3
|
||||||
|
cooling.FAN_TRIP_WAIT_S = 4
|
||||||
|
self.fc.state["status"] = dict(self.fc.state["status"], gates_off=[])
|
||||||
|
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
|
||||||
|
"gates_off": [], "fan_gates": {}}
|
||||||
|
for key, _d, _h in self.FLOORS.values():
|
||||||
|
self.fc.state["settings"].setdefault(key, "")
|
||||||
|
self.fc.state["settings"].setdefault("cool_fan_grace_s", "")
|
||||||
|
self.trips = True # the engine trips an unmeetable floor
|
||||||
|
self.reports_off = True # the engine reports a zero floor as off
|
||||||
|
self.grace_scale = 0.1 # seconds of fake grace per configured second
|
||||||
|
self._describe()
|
||||||
|
self.grbl.on_command = self._engine
|
||||||
|
self.fc.on_post = self._on_post
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling.FAN_TRIP_WAIT_S = self.saved
|
||||||
|
self.grbl.close()
|
||||||
|
self.fc.stop()
|
||||||
|
|
||||||
|
def setting(self, key, default):
|
||||||
|
v = self.fc.state["settings"].get(key) or ""
|
||||||
|
return float(v) if v else default
|
||||||
|
|
||||||
|
def _describe(self):
|
||||||
|
gates = {}
|
||||||
|
for fan, (key, default, hi) in self.FLOORS.items():
|
||||||
|
v = self.setting(key, default)
|
||||||
|
gates[key] = {"gate": self.GATE_OF[fan], "def": default, "lo": 0.0, "hi": hi,
|
||||||
|
"band": [default * 0.7, default * 1.4], "off": "low", "value": v,
|
||||||
|
"state": "off" if v <= 0 else "ok"}
|
||||||
|
g = self.setting("cool_fan_grace_s", 15.0)
|
||||||
|
gates["cool_fan_grace_s"] = {"gate": None, "def": 15.0, "lo": 0.0, "hi": 120.0, "band": [5, 30],
|
||||||
|
"off": "none", "value": g, "state": "ok"}
|
||||||
|
self.fc.state["settings"]["gates"] = gates
|
||||||
|
|
||||||
|
def _on_post(self, path, form):
|
||||||
|
if path != "/settings":
|
||||||
|
return None
|
||||||
|
self.fc.state["settings"].update(form)
|
||||||
|
self._describe()
|
||||||
|
return (200, self.fc.state["settings"])
|
||||||
|
|
||||||
|
def _engine(self, line):
|
||||||
|
self._describe()
|
||||||
|
cool = self.fc.state["cool"]
|
||||||
|
if line == "M9":
|
||||||
|
def end():
|
||||||
|
time.sleep(0.3)
|
||||||
|
cool["phase"] = "idle"
|
||||||
|
# the fan fault is the session's: it ends with it
|
||||||
|
cool.update(verdict="OK", fire_ok=True, hold=False, reason="")
|
||||||
|
for g in cool.get("fan_gates", {}).values():
|
||||||
|
g["state"] = "idle"
|
||||||
|
threading.Thread(target=end, daemon=True).start()
|
||||||
|
return
|
||||||
|
if line != "M8":
|
||||||
|
return
|
||||||
|
cool["phase"] = "run"
|
||||||
|
floors = {fan: self.setting(key, default) for fan, (key, default, _h) in self.FLOORS.items()}
|
||||||
|
off = sorted({self.GATE_OF[f] for f, v in floors.items() if v <= 0}) if self.reports_off else []
|
||||||
|
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off,
|
||||||
|
fan_gates={f: {"reading": self.READINGS[f], "floor": floors[f],
|
||||||
|
"state": "off" if floors[f] <= 0 else "grace"} for f in floors})
|
||||||
|
self.fc.state["status"]["gates_off"] = off
|
||||||
|
grace = self.setting("cool_fan_grace_s", 15.0) * self.grace_scale
|
||||||
|
|
||||||
|
def judge():
|
||||||
|
time.sleep(grace)
|
||||||
|
if cool["phase"] != "run":
|
||||||
|
return
|
||||||
|
for f in floors:
|
||||||
|
if floors[f] > 0:
|
||||||
|
cool["fan_gates"][f]["state"] = "ok" if self.READINGS[f] >= floors[f] else "under"
|
||||||
|
time.sleep(0.3)
|
||||||
|
if cool["phase"] != "run" or not self.trips:
|
||||||
|
return
|
||||||
|
for f in floors:
|
||||||
|
if 0 < floors[f] > self.READINGS[f]:
|
||||||
|
cool["fan_gates"][f]["state"] = "TRIPPED"
|
||||||
|
cool.update(verdict="AIRFLOW", fire_ok=False, hold=True, resume_ok=False,
|
||||||
|
reason="AIRFLOW: %s %d under the %d floor for 3 s - hold, no resume this job"
|
||||||
|
% (f, self.READINGS[f], floors[f]))
|
||||||
|
break
|
||||||
|
threading.Thread(target=judge, daemon=True).start()
|
||||||
|
|
||||||
|
def run_test(self):
|
||||||
|
run = Run("test", "cooling.fan-gate-trips", "t")
|
||||||
|
ctx = Context(run, None, helpers.make_test("cooling.fan-gate-trips", []))
|
||||||
|
cooling.fan_gate_trips(ctx)
|
||||||
|
return run
|
||||||
|
|
||||||
|
def settings_posts(self):
|
||||||
|
return [f for p, f in self.fc.posts if p == "/settings"]
|
||||||
|
|
||||||
|
def test_trip_purge_off_and_restore_pass(self):
|
||||||
|
run = self.run_test()
|
||||||
|
ev = run.evidence
|
||||||
|
self.assertEqual(ev["exhaust_trip"]["cool"]["verdict"], "AIRFLOW")
|
||||||
|
self.assertEqual(ev["exhaust_trip"]["gate"]["state"], "TRIPPED")
|
||||||
|
self.assertEqual(ev["purge_trip"]["gate"]["state"], "TRIPPED")
|
||||||
|
self.assertEqual(ev["exhaust_off"]["cool"]["gates_off"], ["exhaust"])
|
||||||
|
self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values()))
|
||||||
|
posts = self.settings_posts()
|
||||||
|
self.assertEqual(posts[0], {"cool_tach_exhaust_min_rpm": "20000.0", "cool_fan_grace_s": "8"})
|
||||||
|
self.assertEqual(posts[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
|
||||||
|
"cool_fan_grace_s": ""})
|
||||||
|
self.assertEqual(self.grbl.commands.count("M8"), 4)
|
||||||
|
self.assertEqual(self.grbl.commands.count("M9"), 4)
|
||||||
|
|
||||||
|
def test_an_engine_that_does_not_trip_fails_and_restores(self):
|
||||||
|
self.trips = False
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
self.run_test()
|
||||||
|
self.assertIn("did not trip", str(cm.exception))
|
||||||
|
self.assertEqual(self.settings_posts()[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
|
||||||
|
"cool_fan_grace_s": ""})
|
||||||
|
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
||||||
|
|
||||||
|
def test_an_engine_that_hides_an_off_floor_fails(self):
|
||||||
|
self.reports_off = False
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
self.run_test()
|
||||||
|
self.assertIn("lacks exhaust", str(cm.exception))
|
||||||
|
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
|
||||||
@@ -0,0 +1,712 @@
|
|||||||
|
"""The bench actuator as the tool sees it.
|
||||||
|
|
||||||
|
The fixture is a box on the network that opens the lid loop, pulls the
|
||||||
|
interlock loop and presses the button on request. What has to hold: the
|
||||||
|
tool finds it by name without a resolver on the image, speaks its API
|
||||||
|
under the key, falls back to the operator when it cannot act, moves an
|
||||||
|
operator test it can perform alone into the unattended queue (never a
|
||||||
|
live one, never one that needs a person for anything else), refuses a
|
||||||
|
prompt during such a run rather than hanging on it, releases what the
|
||||||
|
box still holds after every run, and presses the arm button only where
|
||||||
|
the bench opted in.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
import helpers
|
||||||
|
from forgetest import fixture as fx
|
||||||
|
from forgetest import runner as runner_mod
|
||||||
|
from forgetest.log import Log
|
||||||
|
from forgetest.runner import Context, Failed, Run, Runner
|
||||||
|
|
||||||
|
KEY = "0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFixture:
|
||||||
|
"""The device's API, as fixture/README.md describes it."""
|
||||||
|
|
||||||
|
def __init__(self, button_enabled=True):
|
||||||
|
self.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
|
||||||
|
self.button_enabled = button_enabled
|
||||||
|
self.calls = []
|
||||||
|
self.presses = 0 # presses the device performed
|
||||||
|
srv = self
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _json(self, code, obj):
|
||||||
|
body = json.dumps(obj).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _state(self):
|
||||||
|
return {"device": "forgefixture", "hostname": "forgefixture", "version": "1.0.0",
|
||||||
|
"idf": "v5.5.5", "uptime_s": 12, "channels": dict(srv.state),
|
||||||
|
"button_enabled": srv.button_enabled,
|
||||||
|
"button_pulsing": srv.state["button"] == "pressed",
|
||||||
|
"wifi": {"connected": True, "ip": "127.0.0.1", "rssi": -50}}
|
||||||
|
|
||||||
|
def _auth(self):
|
||||||
|
if self.headers.get("X-Fixture-Key") != KEY:
|
||||||
|
self._json(401, {"error": "X-Fixture-Key missing or wrong"})
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
srv.calls.append(("GET", self.path))
|
||||||
|
if not self._auth():
|
||||||
|
return
|
||||||
|
self._json(200, self._state())
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
body = json.loads(self.rfile.read(n).decode() or "{}")
|
||||||
|
srv.calls.append(("POST", self.path, body))
|
||||||
|
if not self._auth():
|
||||||
|
return
|
||||||
|
if self.path in ("/lid", "/interlock"):
|
||||||
|
st = body.get("state")
|
||||||
|
if st not in ("open", "close", "closed"):
|
||||||
|
return self._json(400, {"error": "state must be \"open\" or \"close\""})
|
||||||
|
srv.state[self.path[1:]] = "open" if st == "open" else "closed"
|
||||||
|
return self._json(200, self._state())
|
||||||
|
if self.path == "/button":
|
||||||
|
if not srv.button_enabled:
|
||||||
|
return self._json(409, {"error": "button disabled: the enable jumper is out"})
|
||||||
|
if srv.state["button"] == "pressed":
|
||||||
|
return self._json(409, {"error": "a button pulse is in progress"})
|
||||||
|
srv.state["button"] = "pressed"
|
||||||
|
srv.presses += 1
|
||||||
|
threading.Timer(0.05, lambda: srv.state.__setitem__("button", "idle")).start()
|
||||||
|
s = self._state()
|
||||||
|
s["pulse_ms"] = 200
|
||||||
|
return self._json(200, s)
|
||||||
|
if self.path == "/release":
|
||||||
|
srv.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
|
||||||
|
return self._json(200, self._state())
|
||||||
|
self._json(404, {"error": "no such path"})
|
||||||
|
|
||||||
|
self.httpd = HTTPServer(("127.0.0.1", 0), H)
|
||||||
|
self.port = self.httpd.server_address[1]
|
||||||
|
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.httpd.shutdown()
|
||||||
|
self.httpd.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
class MdnsTests(unittest.TestCase):
|
||||||
|
def test_query_packet(self):
|
||||||
|
q = fx.mdns_query("forgefixture.local")
|
||||||
|
self.assertEqual(q[:12], struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0))
|
||||||
|
self.assertEqual(q[12:], b"\x0cforgefixture\x05local\x00" + struct.pack("!HH", 1, 0x8001))
|
||||||
|
|
||||||
|
def _response(self, name, ip, extra_name=None):
|
||||||
|
labels = b"".join(struct.pack("B", len(p)) + p.encode() for p in name.split(".")) + b"\x00"
|
||||||
|
hdr = struct.pack("!HHHHHH", 0, 0x8400, 0, 2 if extra_name else 1, 0, 0)
|
||||||
|
rr = labels + struct.pack("!HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(ip)
|
||||||
|
if extra_name:
|
||||||
|
# a second answer naming the first through a compression pointer
|
||||||
|
# to offset 12 (the first name), plus its own first label
|
||||||
|
other = struct.pack("B", len(extra_name)) + extra_name.encode() + struct.pack("!H", 0xC000 | 12)
|
||||||
|
rr += other + struct.pack("!HHIH", 1, 0x8001, 120, 4) + socket.inet_aton("10.0.0.9")
|
||||||
|
return hdr + rr
|
||||||
|
|
||||||
|
def test_answers_are_the_named_a_records(self):
|
||||||
|
data = self._response("forgefixture.local", "192.0.2.50")
|
||||||
|
self.assertEqual(fx.mdns_answers(data, "forgefixture.local"), ["192.0.2.50"])
|
||||||
|
self.assertEqual(fx.mdns_answers(data, "FORGEFIXTURE.local."), ["192.0.2.50"])
|
||||||
|
self.assertEqual(fx.mdns_answers(data, "other.local"), [])
|
||||||
|
# a query (QR clear) is never an answer
|
||||||
|
self.assertEqual(fx.mdns_answers(fx.mdns_query("forgefixture.local"), "forgefixture.local"), [])
|
||||||
|
# compression pointers are followed; the other record is not ours
|
||||||
|
data2 = self._response("forgefixture.local", "192.0.2.50", extra_name="printer")
|
||||||
|
self.assertEqual(fx.mdns_answers(data2, "forgefixture.local"), ["192.0.2.50"])
|
||||||
|
self.assertEqual(fx.mdns_answers(data2, "printer.forgefixture.local"), ["10.0.0.9"])
|
||||||
|
# garbage does not raise
|
||||||
|
self.assertEqual(fx.mdns_answers(b"\x00\x01", "forgefixture.local"), [])
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def write(self, obj):
|
||||||
|
p = os.path.join(self.tmp, "fixture.json")
|
||||||
|
with open(p, "w") as f:
|
||||||
|
json.dump(obj, f)
|
||||||
|
return p
|
||||||
|
|
||||||
|
def test_no_file_is_no_fixture(self):
|
||||||
|
self.assertIsNone(fx.load_config(os.path.join(self.tmp, "none.json")))
|
||||||
|
|
||||||
|
def test_defaults_and_checks(self):
|
||||||
|
cfg = fx.load_config(self.write({"key": KEY}))
|
||||||
|
self.assertEqual(cfg["hostname"], "forgefixture")
|
||||||
|
self.assertEqual(cfg["channels"], ["lid", "interlock", "button"])
|
||||||
|
self.assertFalse(cfg["arm_press"])
|
||||||
|
self.assertIsNone(cfg["ip"])
|
||||||
|
with self.assertRaises(fx.FixtureError):
|
||||||
|
fx.load_config(self.write({"hostname": "x"})) # no key
|
||||||
|
with self.assertRaises(fx.FixtureError):
|
||||||
|
fx.load_config(self.write({"key": KEY, "channels": ["lid", "laser"]}))
|
||||||
|
p = self.write({"key": KEY})
|
||||||
|
with open(p, "w") as f:
|
||||||
|
f.write("{not json")
|
||||||
|
with self.assertRaises(fx.FixtureError):
|
||||||
|
fx.load_config(p)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dev = FakeFixture()
|
||||||
|
self.resolved = []
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.dev.stop()
|
||||||
|
|
||||||
|
def resolver(self, hostname):
|
||||||
|
self.resolved.append(hostname)
|
||||||
|
return "127.0.0.1"
|
||||||
|
|
||||||
|
def client(self, **over):
|
||||||
|
cfg = {"hostname": "forgefixture", "key": KEY, "channels": ["lid", "interlock", "button"],
|
||||||
|
"port": self.dev.port}
|
||||||
|
cfg.update(over)
|
||||||
|
return fx.Fixture(cfg, resolver=self.resolver)
|
||||||
|
|
||||||
|
def test_status_covers_act_release(self):
|
||||||
|
f = self.client()
|
||||||
|
st = f.status()
|
||||||
|
self.assertEqual(st["device"], "forgefixture")
|
||||||
|
self.assertEqual(self.resolved, ["forgefixture"]) # resolved once
|
||||||
|
self.assertTrue(f.covers("lid") and f.covers("interlock") and f.covers("button"))
|
||||||
|
f.act("lid", "open")
|
||||||
|
self.assertEqual(self.dev.state["lid"], "open")
|
||||||
|
f.act("interlock", "open")
|
||||||
|
f.act("button", "press")
|
||||||
|
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {}))
|
||||||
|
time.sleep(0.15) # the fake's pulse ends
|
||||||
|
self.assertEqual(sorted(fx.Fixture.energized(f.status())), ["interlock", "lid"])
|
||||||
|
f.release()
|
||||||
|
self.assertEqual(fx.Fixture.energized(f.status()), [])
|
||||||
|
self.assertEqual(self.resolved, ["forgefixture"]) # the address is cached
|
||||||
|
with self.assertRaises(fx.FixtureError):
|
||||||
|
f.act("button", "hold")
|
||||||
|
with self.assertRaises(fx.FixtureError):
|
||||||
|
f.act("lid", "ajar")
|
||||||
|
|
||||||
|
def test_the_button_is_covered_only_with_the_jumper_in(self):
|
||||||
|
self.dev.button_enabled = False
|
||||||
|
f = self.client()
|
||||||
|
f.status()
|
||||||
|
self.assertTrue(f.covers("lid"))
|
||||||
|
self.assertFalse(f.covers("button"))
|
||||||
|
with self.assertRaises(fx.FixtureError) as cm:
|
||||||
|
f.act("button", "press")
|
||||||
|
self.assertIn("jumper", str(cm.exception))
|
||||||
|
|
||||||
|
def test_two_presses_are_spaced_so_the_controller_sees_the_release(self):
|
||||||
|
f = self.client()
|
||||||
|
f.status()
|
||||||
|
t0 = time.time()
|
||||||
|
f.act("button", "press")
|
||||||
|
f.act("button", "press")
|
||||||
|
took = time.time() - t0
|
||||||
|
posts = [c for c in self.dev.calls if c[:2] == ("POST", "/button")]
|
||||||
|
self.assertEqual(len(posts), 2) # no 409 round trip was needed
|
||||||
|
self.assertEqual(self.dev.presses, 2)
|
||||||
|
# the second waited for the first pulse (200 ms as reported) and the gap
|
||||||
|
self.assertGreaterEqual(took, 0.2 + fx.BUTTON_GAP_S - 0.05)
|
||||||
|
|
||||||
|
def test_a_pulse_in_progress_is_waited_out_then_retried(self):
|
||||||
|
f = self.client()
|
||||||
|
f.status()
|
||||||
|
self.dev.state["button"] = "pressed" # a press this client did not time
|
||||||
|
threading.Timer(0.3, lambda: self.dev.state.__setitem__("button", "idle")).start()
|
||||||
|
f.act("button", "press")
|
||||||
|
posts = [c for c in self.dev.calls if c[:2] == ("POST", "/button")]
|
||||||
|
self.assertEqual(len(posts), 2) # the 409, then the press
|
||||||
|
self.assertEqual(self.dev.presses, 1)
|
||||||
|
|
||||||
|
def test_a_pulse_that_never_ends_is_the_fixtures_error(self):
|
||||||
|
f = self.client()
|
||||||
|
f.status()
|
||||||
|
self.dev.state["button"] = "pressed"
|
||||||
|
with self.assertRaises(fx.FixtureError) as cm:
|
||||||
|
f.act("button", "press")
|
||||||
|
self.assertIn("in progress", str(cm.exception))
|
||||||
|
|
||||||
|
def test_a_wrong_key_is_refused(self):
|
||||||
|
f = self.client(key="wrong")
|
||||||
|
with self.assertRaises(fx.FixtureError) as cm:
|
||||||
|
f.status()
|
||||||
|
self.assertIn("refused the key", str(cm.exception))
|
||||||
|
|
||||||
|
def test_an_ip_override_skips_the_lookup(self):
|
||||||
|
f = self.client(ip="127.0.0.1")
|
||||||
|
f.status()
|
||||||
|
self.assertEqual(self.resolved, [])
|
||||||
|
|
||||||
|
def test_channels_not_wired_are_not_covered(self):
|
||||||
|
f = self.client(channels=["lid"])
|
||||||
|
f.status()
|
||||||
|
self.assertTrue(f.covers("lid"))
|
||||||
|
self.assertFalse(f.covers("interlock"))
|
||||||
|
|
||||||
|
def test_probe_without_a_config_is_none_and_a_silent_box_is_logged(self):
|
||||||
|
lines = []
|
||||||
|
self.assertIsNone(fx.probe(lines.append, path=os.path.join(tempfile.gettempdir(), "no-such-fixture.json")))
|
||||||
|
self.assertEqual(lines, [])
|
||||||
|
tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
|
||||||
|
try:
|
||||||
|
p = os.path.join(tmp, "fixture.json")
|
||||||
|
with open(p, "w") as f:
|
||||||
|
json.dump({"key": KEY, "ip": "127.0.0.1", "port": 1}, f)
|
||||||
|
# port 1 on localhost: nothing listens there
|
||||||
|
f2 = fx.probe(lines.append, path=p, resolver=lambda h: None)
|
||||||
|
self.assertIsNone(f2)
|
||||||
|
self.assertTrue(lines and "running without it" in lines[-1], lines)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogTests(unittest.TestCase):
|
||||||
|
def test_fixture_runnable(self):
|
||||||
|
op = helpers.make_test("m.op", [], kind="operator", actions=("lid", "button"))
|
||||||
|
self.assertTrue(op.fixture_runnable(("lid", "interlock", "button")))
|
||||||
|
self.assertFalse(op.fixture_runnable(("lid",))) # the button is not covered
|
||||||
|
self.assertFalse(helpers.make_test("m.live", [], kind="live", actions=("lid",))
|
||||||
|
.fixture_runnable(("lid",))) # live never downgrades
|
||||||
|
self.assertFalse(helpers.make_test("m.app", [], kind="operator", hands=("app",))
|
||||||
|
.fixture_runnable(("lid", "interlock", "button")))
|
||||||
|
self.assertFalse(helpers.make_test("m.none", [], kind="operator")
|
||||||
|
.fixture_runnable(("lid",))) # no actions: a person does something
|
||||||
|
self.assertFalse(helpers.make_test("m.auto", []).fixture_runnable(("lid",)))
|
||||||
|
|
||||||
|
def test_an_auto_test_asks_nothing_of_a_person(self):
|
||||||
|
from forgetest import catalog
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
catalog.test("z.auto", title="t", subsystem="z", hands=("app",))(lambda ctx: None)
|
||||||
|
|
||||||
|
|
||||||
|
class StubFixture:
|
||||||
|
"""What the runner needs of a fixture, scripted."""
|
||||||
|
|
||||||
|
def __init__(self, channels=("lid", "interlock", "button"), button_enabled=True, arm_press=False,
|
||||||
|
fail=False, fc=None):
|
||||||
|
self.fc = fc # the fake forgectrl whose switches follow the actions
|
||||||
|
self.hostname = "forgefixture"
|
||||||
|
self._ip = "127.0.0.1"
|
||||||
|
self.channels = tuple(channels)
|
||||||
|
self.button_enabled = button_enabled
|
||||||
|
self.arm_press = arm_press
|
||||||
|
self.fail = fail
|
||||||
|
self.acts = []
|
||||||
|
self.held = []
|
||||||
|
self.released = 0
|
||||||
|
|
||||||
|
def covers(self, channel):
|
||||||
|
return channel in self.channels and (channel != "button" or self.button_enabled)
|
||||||
|
|
||||||
|
def act(self, channel, state):
|
||||||
|
if self.fail:
|
||||||
|
raise fx.FixtureError("the box is off")
|
||||||
|
self.acts.append((channel, state))
|
||||||
|
if state == "open":
|
||||||
|
self.held.append(channel)
|
||||||
|
if self.fc is not None and channel in ("lid", "interlock"):
|
||||||
|
sw = self.fc.state["status"]["switches"]
|
||||||
|
sw["lid" if channel == "lid" else "interlock_ok"] = state != "open"
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return {"channels": {c: ("open" if c in self.held else "closed") for c in ("lid", "interlock")}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def energized(state):
|
||||||
|
return fx.Fixture.energized(state)
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
self.released += 1
|
||||||
|
self.held = []
|
||||||
|
|
||||||
|
def summary(self):
|
||||||
|
return {"hostname": self.hostname, "ip": self._ip, "channels": list(self.channels),
|
||||||
|
"button_enabled": self.button_enabled, "arm_press": self.arm_press}
|
||||||
|
|
||||||
|
|
||||||
|
def t_lid(ctx):
|
||||||
|
ctx.ready("On Ready the lid opens")
|
||||||
|
ctx.act("lid", "open")
|
||||||
|
ctx.act("lid", "close")
|
||||||
|
|
||||||
|
|
||||||
|
def t_asks(ctx):
|
||||||
|
ctx.act("lid", "open")
|
||||||
|
ctx.confirm("Did it?")
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingTests(unittest.TestCase):
|
||||||
|
"""The queues with a fixture up: the runner's probe is scripted."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix="forgetest-fixture-run-")
|
||||||
|
os.environ["FORGETEST_DATA"] = self.tmp
|
||||||
|
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
|
||||||
|
self.fc = helpers.FakeForgectrl().start()
|
||||||
|
self.man = helpers.make_manifest()
|
||||||
|
self.reg = helpers.registry(
|
||||||
|
helpers.make_test("r.auto", [("forgectrl", "src/ui.c")]),
|
||||||
|
helpers.make_test("r.lid", [("forgectrl", "src/auth.c")], kind="operator", actions=("lid",), fn=t_lid),
|
||||||
|
helpers.make_test("r.btn", [("forgectrl", "src/cool.c")], kind="operator", actions=("button",), fn=t_lid),
|
||||||
|
helpers.make_test("r.app", [("forgectrl", "src/main.c")], kind="operator", actions=("lid",),
|
||||||
|
hands=("app",), fn=t_lid),
|
||||||
|
helpers.make_test("r.live", [("grblhal-glowforge", "src/**")], kind="live", actions=("lid",)),
|
||||||
|
helpers.make_test("r.asks", [("linux-fslc", "**")], kind="operator", actions=("lid",), fn=t_asks),
|
||||||
|
)
|
||||||
|
self.log = Log(os.path.join(self.tmp, "results.jsonl"))
|
||||||
|
self.runner = Runner(self.log, self.man, self.reg)
|
||||||
|
self.saved_probe = fx.probe
|
||||||
|
self.stub = StubFixture(fc=self.fc)
|
||||||
|
fx.probe = lambda log, path=None, resolver=None: self.stub
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
fx.probe = self.saved_probe
|
||||||
|
self.fc.stop()
|
||||||
|
for k in ("FORGETEST_DATA", "FORGETEST_MARKER"):
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def selection(self):
|
||||||
|
st, _ = self.runner.state()
|
||||||
|
return st["batch_available"], st["fixture"]
|
||||||
|
|
||||||
|
def test_operator_tests_the_fixture_covers_move_to_the_unattended_queue(self):
|
||||||
|
av, summary = self.selection()
|
||||||
|
self.assertEqual(av["unattended"], ["r.auto", "r.lid", "r.btn", "r.asks"])
|
||||||
|
self.assertEqual(av["attended"], ["r.app", "r.live"]) # needs the app; live never moves
|
||||||
|
self.assertEqual(summary["hostname"], "forgefixture")
|
||||||
|
|
||||||
|
def test_without_the_jumper_the_button_tests_stay_attended(self):
|
||||||
|
self.stub.button_enabled = False
|
||||||
|
av, _ = self.selection()
|
||||||
|
self.assertIn("r.btn", av["attended"])
|
||||||
|
self.assertIn("r.lid", av["unattended"])
|
||||||
|
|
||||||
|
def test_no_fixture_is_the_old_routing(self):
|
||||||
|
fx.probe = lambda log, path=None, resolver=None: None
|
||||||
|
self.runner._fixture_probed = 0.0
|
||||||
|
av, summary = self.selection()
|
||||||
|
self.assertEqual(av["unattended"], ["r.auto"])
|
||||||
|
self.assertIsNone(summary)
|
||||||
|
|
||||||
|
def test_a_queue_started_during_a_probe_waits_for_the_fixture(self):
|
||||||
|
# The daemon just came up: nothing probed yet, a page poll starts
|
||||||
|
# the first probe (slow: an mDNS answer), and the queue start
|
||||||
|
# lands while it is in flight. The start must see the fixture.
|
||||||
|
stub = self.stub
|
||||||
|
|
||||||
|
def slow_probe(log, path=None, resolver=None):
|
||||||
|
time.sleep(0.5)
|
||||||
|
return stub
|
||||||
|
fx.probe = slow_probe
|
||||||
|
self.runner._fixture_probed = 0.0
|
||||||
|
self.runner.fixture = None
|
||||||
|
poll = threading.Thread(target=self.runner.probe_fixture)
|
||||||
|
poll.start()
|
||||||
|
time.sleep(0.1)
|
||||||
|
order = self.runner.batch_selection("unattended")
|
||||||
|
poll.join()
|
||||||
|
self.assertIn("r.lid", order)
|
||||||
|
self.assertIn("r.btn", order)
|
||||||
|
|
||||||
|
def run_queue(self, group, ack_live=False):
|
||||||
|
ok, msg, order = self.runner.start_batch(group, ack_live=ack_live)
|
||||||
|
self.assertTrue(ok, msg)
|
||||||
|
deadline = time.time() + 30
|
||||||
|
while not self.runner.batch["finished"] and time.time() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
return self.runner.batch_snapshot(), order
|
||||||
|
|
||||||
|
def last_result(self, tid):
|
||||||
|
for rec in reversed(self.log.read()):
|
||||||
|
if rec.get("t") == "result" and rec["test"] == tid:
|
||||||
|
return rec
|
||||||
|
return None
|
||||||
|
|
||||||
|
def test_the_fixture_performs_the_actions_and_the_ready_gate_passes(self):
|
||||||
|
# r.asks confirms after its action: a person is asked -> a FAIL that
|
||||||
|
# says so, and the queue stops there, after r.lid and r.btn passed
|
||||||
|
b, order = self.run_queue("unattended")
|
||||||
|
self.assertEqual(order, ["r.auto", "r.lid", "r.btn", "r.asks"])
|
||||||
|
results = {x["test"]: x["result"] for x in b["done"]}
|
||||||
|
self.assertEqual(results["r.lid"], "PASS")
|
||||||
|
self.assertEqual(results["r.btn"], "PASS")
|
||||||
|
self.assertEqual(results["r.asks"], "FAIL")
|
||||||
|
rec = self.last_result("r.lid")
|
||||||
|
acts = rec["evidence"]["actions"]
|
||||||
|
self.assertEqual([(a["channel"], a["state"], a["by"]) for a in acts],
|
||||||
|
[("lid", "open", "fixture"), ("lid", "close", "fixture")])
|
||||||
|
self.assertTrue(any("READY (fixture performs the step)" in l for l in rec["log"]), rec["log"])
|
||||||
|
self.assertEqual(rec["evidence"]["fixture"]["channels"], ["lid", "interlock", "button"])
|
||||||
|
asks = self.last_result("r.asks")
|
||||||
|
self.assertIn("declare the step in hands=", asks["message"])
|
||||||
|
|
||||||
|
def test_a_failing_box_hands_the_action_to_the_operator(self):
|
||||||
|
self.stub.fail = True
|
||||||
|
run = Run("test", "r.lid", "r.lid")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.lid"])
|
||||||
|
self.runner.probe_fixture(force=True)
|
||||||
|
seen = []
|
||||||
|
ctx.wait_for = lambda cond, timeout, poll=0.25: 1.0 # the machine shows the state
|
||||||
|
run.set_notice = lambda text: seen.append(text)
|
||||||
|
ctx.act("lid", "open")
|
||||||
|
rec = run.evidence["actions"][0]
|
||||||
|
self.assertEqual(rec["by"], "operator")
|
||||||
|
self.assertIn("the box is off", rec["fixture_error"])
|
||||||
|
self.assertTrue(seen and "lid" in seen[0].lower())
|
||||||
|
|
||||||
|
def test_a_failing_box_in_an_unattended_run_ends_the_test_as_an_error(self):
|
||||||
|
# nobody is in the room: the run must not wait ACT_TIMEOUT_S for a
|
||||||
|
# hand that is not there; it ends at once, as the harness's error
|
||||||
|
self.stub.fail = True
|
||||||
|
t0 = time.time()
|
||||||
|
b, order = self.run_queue("unattended")
|
||||||
|
results = {x["test"]: x["result"] for x in b["done"]}
|
||||||
|
self.assertEqual(results["r.lid"], "ERROR")
|
||||||
|
self.assertLess(time.time() - t0, 20)
|
||||||
|
rec = self.last_result("r.lid")
|
||||||
|
self.assertIn("the fixture could not perform a step", rec["message"])
|
||||||
|
self.assertIn("the box is off", rec["message"])
|
||||||
|
act = rec["evidence"]["actions"][0]
|
||||||
|
self.assertEqual((act["by"], act["fixture_error"]), ("fixture", "the box is off"))
|
||||||
|
self.assertFalse(any("asking the operator" in l for l in rec["log"]), rec["log"])
|
||||||
|
|
||||||
|
def test_what_the_box_still_holds_is_released_after_a_run(self):
|
||||||
|
def holds(ctx):
|
||||||
|
ctx.act("lid", "open") # and never closes it
|
||||||
|
self.reg["r.hold"] = helpers.make_test("r.hold", [("forgectrl", "src/ui.c")], kind="operator",
|
||||||
|
actions=("lid",), fn=holds)
|
||||||
|
self.runner.registry = self.reg
|
||||||
|
ok, msg, run = self.runner._start_test("r.hold")
|
||||||
|
self.assertTrue(ok, msg)
|
||||||
|
while run.finished is None:
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.assertEqual(self.stub.released, 1)
|
||||||
|
rec = self.last_result("r.hold")
|
||||||
|
self.assertEqual(rec["evidence"]["fixture"]["released"], ["lid"])
|
||||||
|
|
||||||
|
def press_button(self, down):
|
||||||
|
self.fc.state["status"]["switches"]["button"] = bool(down)
|
||||||
|
|
||||||
|
def test_ready_takes_a_press_on_the_machine_as_the_presence_check(self):
|
||||||
|
"""With an actuator wired to the button, the operator proves they
|
||||||
|
are at the machine by pressing it, not by clicking the page. The
|
||||||
|
actuator then owns every press in the test, so no press in a live
|
||||||
|
cut is a person's and none can go uncounted."""
|
||||||
|
run = Run("test", "r.live", "r.live")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||||
|
self.runner.probe_fixture(force=True)
|
||||||
|
asked = []
|
||||||
|
run.ask = lambda q, o: asked.append(q)
|
||||||
|
self.press_button(False)
|
||||||
|
threading.Timer(0.3, self.press_button, args=(True,)).start()
|
||||||
|
threading.Timer(0.8, self.press_button, args=(False,)).start()
|
||||||
|
ctx.ready("LIVE FIRE. Scrap under the head.")
|
||||||
|
self.assertEqual(asked, []) # no page click asked for
|
||||||
|
self.assertTrue(run.fixture_takeover)
|
||||||
|
rec = [r for r in run.evidence["actions"] if r["state"] == "presence"]
|
||||||
|
self.assertEqual(len(rec), 1)
|
||||||
|
self.assertEqual(rec[0]["by"], "operator")
|
||||||
|
self.assertTrue(any("press the button on the machine" in ln.lower() for ln in run.lines))
|
||||||
|
|
||||||
|
def test_presence_is_proved_once_per_test_not_once_per_gate(self):
|
||||||
|
"""A test with two armed halves reaches the ready gate twice. The
|
||||||
|
second one must not ask for another press: the operator proved
|
||||||
|
presence a moment earlier and the actuator has the presses. The
|
||||||
|
setup line still goes up, because the second half may want the
|
||||||
|
scrap moved."""
|
||||||
|
run = Run("test", "r.live", "r.live")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||||
|
self.runner.probe_fixture(force=True)
|
||||||
|
asked = []
|
||||||
|
run.ask = lambda q, o: asked.append(q)
|
||||||
|
self.press_button(False)
|
||||||
|
threading.Timer(0.3, self.press_button, args=(True,)).start()
|
||||||
|
threading.Timer(0.8, self.press_button, args=(False,)).start()
|
||||||
|
ctx.ready("First half.")
|
||||||
|
self.assertTrue(run.fixture_takeover)
|
||||||
|
presses = [r for r in run.evidence["actions"] if r["state"] == "presence"]
|
||||||
|
self.assertEqual(len(presses), 1)
|
||||||
|
|
||||||
|
# the second gate: returns at once, no new press, notice still shown
|
||||||
|
seen = []
|
||||||
|
run.set_notice = lambda text: seen.append(text)
|
||||||
|
t0 = time.time()
|
||||||
|
ctx.ready("Second half. Move the scrap.")
|
||||||
|
self.assertLess(time.time() - t0, 1.0)
|
||||||
|
self.assertEqual(asked, [])
|
||||||
|
self.assertEqual(len([r for r in run.evidence["actions"] if r["state"] == "presence"]), 1)
|
||||||
|
self.assertIn("Second half. Move the scrap.", seen)
|
||||||
|
self.assertTrue(any("presence already proved" in ln for ln in run.lines))
|
||||||
|
|
||||||
|
def test_the_presence_press_hands_the_arm_press_to_the_actuator(self):
|
||||||
|
"""The bench's standing opt-in is not needed once the operator has
|
||||||
|
proved presence: that press is what the opt-in existed to
|
||||||
|
establish."""
|
||||||
|
run = Run("test", "r.live", "r.live")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||||
|
self.runner.probe_fixture(force=True)
|
||||||
|
self.stub.arm_press = False
|
||||||
|
run.fixture_takeover = True
|
||||||
|
saved = runner_mod.hw.button_lit
|
||||||
|
runner_mod.hw.button_lit = lambda: True
|
||||||
|
try:
|
||||||
|
self.assertTrue(ctx.arm_press())
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while ("button", "press") not in self.stub.acts and time.time() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.assertIn(("button", "press"), self.stub.acts)
|
||||||
|
finally:
|
||||||
|
runner_mod.hw.button_lit = saved
|
||||||
|
|
||||||
|
def test_the_press_prompt_is_what_the_actuator_presses_on(self):
|
||||||
|
"""The machine's own press prompt, not a button LED read from
|
||||||
|
here: the button is lit through parts of a live check that are
|
||||||
|
not the arm, and a press then lands before the job waits for it
|
||||||
|
and is lost. press_now presses at the moment the check asks, with
|
||||||
|
no LED reading and no waiting thread."""
|
||||||
|
run = Run("test", "r.live", "r.live")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||||
|
self.runner.probe_fixture(force=True)
|
||||||
|
self.stub.arm_press = False
|
||||||
|
run.fixture_takeover = True
|
||||||
|
saved = runner_mod.hw.button_lit
|
||||||
|
runner_mod.hw.button_lit = lambda: False # never consulted
|
||||||
|
try:
|
||||||
|
self.assertTrue(ctx.press_now())
|
||||||
|
self.assertIn(("button", "press"), self.stub.acts)
|
||||||
|
finally:
|
||||||
|
runner_mod.hw.button_lit = saved
|
||||||
|
rec = [r for r in run.evidence["actions"] if r["state"] == "arm"][-1]
|
||||||
|
self.assertEqual(rec["by"], "fixture")
|
||||||
|
|
||||||
|
def test_the_press_prompt_goes_to_the_operator_without_an_actuator(self):
|
||||||
|
"""No actuator, or one that never took this test's presses: the
|
||||||
|
notice goes up for a person, who is looking at the same prompt."""
|
||||||
|
run = Run("test", "r.live", "r.live")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||||
|
self.runner.probe_fixture(force=True)
|
||||||
|
self.stub.arm_press = False
|
||||||
|
run.fixture_takeover = False
|
||||||
|
seen = []
|
||||||
|
run.set_notice = lambda text: seen.append(text)
|
||||||
|
self.assertFalse(ctx.press_now("Press it now."))
|
||||||
|
self.assertNotIn(("button", "press"), self.stub.acts)
|
||||||
|
self.assertIn("Press it now.", seen)
|
||||||
|
|
||||||
|
def test_the_arm_press_waits_as_long_as_the_card_says_for_the_light(self):
|
||||||
|
"""A card that settles the coolant before it lights the button
|
||||||
|
takes minutes; the caller names the wait, and the actuator presses
|
||||||
|
when the light comes instead of handing the press to a person at
|
||||||
|
the default 60 s (found on the bench by the flow-load card)."""
|
||||||
|
run = Run("test", "r.live", "r.live")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||||
|
self.runner.probe_fixture(force=True)
|
||||||
|
run.fixture_takeover = True
|
||||||
|
lit_at = time.time() + 0.6
|
||||||
|
saved = runner_mod.hw.button_lit
|
||||||
|
runner_mod.hw.button_lit = lambda: time.time() >= lit_at
|
||||||
|
seen = []
|
||||||
|
run.set_notice = lambda text: seen.append(text)
|
||||||
|
try:
|
||||||
|
self.assertTrue(ctx.arm_press(lit_timeout=5))
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while ("button", "press") not in self.stub.acts and time.time() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.assertIn(("button", "press"), self.stub.acts)
|
||||||
|
self.assertEqual([n for n in seen if n], []) # nobody was asked
|
||||||
|
rec = run.evidence["actions"][-1]
|
||||||
|
self.assertEqual(rec["by"], "fixture")
|
||||||
|
self.assertGreaterEqual(rec["took_s"], 0.5)
|
||||||
|
finally:
|
||||||
|
runner_mod.hw.button_lit = saved
|
||||||
|
|
||||||
|
def test_an_actuator_lost_after_the_takeover_is_said_out_loud(self):
|
||||||
|
"""Falling back to the operator without a word is how one dropped
|
||||||
|
actuator becomes a press nobody can account for afterwards."""
|
||||||
|
run = Run("test", "r.live", "r.live")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||||
|
self.runner.probe_fixture(force=True)
|
||||||
|
run.fixture_takeover = True
|
||||||
|
self.runner.fixture = None # the box drops off mid-test
|
||||||
|
self.fc.state["status"]["switches"]["lid"] = True
|
||||||
|
threading.Timer(0.3, lambda: self.fc.state["status"]["switches"].__setitem__("lid", False)).start()
|
||||||
|
ctx.act("lid", "open", timeout=5)
|
||||||
|
rec = run.evidence["actions"][-1]
|
||||||
|
self.assertTrue(rec.get("fixture_lost"))
|
||||||
|
self.assertEqual(rec["by"], "operator")
|
||||||
|
self.assertTrue(any("WARNING" in ln and "now gone" in ln for ln in run.lines))
|
||||||
|
|
||||||
|
def test_ready_still_asks_the_page_with_no_actuator(self):
|
||||||
|
run = Run("test", "r.live", "r.live")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||||
|
self.runner.fixture = None
|
||||||
|
asked = []
|
||||||
|
|
||||||
|
def ask(q, o):
|
||||||
|
asked.append((q, list(o)))
|
||||||
|
return "Ready"
|
||||||
|
run.ask = ask
|
||||||
|
ctx.ready("LIVE FIRE. Scrap under the head.")
|
||||||
|
self.assertEqual(asked, [("LIVE FIRE. Scrap under the head.", ["Ready", "Cannot"])])
|
||||||
|
self.assertFalse(run.fixture_takeover)
|
||||||
|
|
||||||
|
def test_the_arm_press_is_the_operators_unless_the_bench_opted_in(self):
|
||||||
|
run = Run("test", "r.live", "r.live")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||||
|
self.runner.probe_fixture(force=True)
|
||||||
|
seen = []
|
||||||
|
run.set_notice = lambda text: seen.append(text)
|
||||||
|
self.assertFalse(ctx.arm_press())
|
||||||
|
self.assertEqual(run.evidence["actions"][0]["by"], "operator")
|
||||||
|
self.assertTrue(seen)
|
||||||
|
# opted in: the press waits for the button to light
|
||||||
|
self.stub.arm_press = True
|
||||||
|
lit = {"v": False}
|
||||||
|
saved = runner_mod.hw.button_lit
|
||||||
|
runner_mod.hw.button_lit = lambda: lit["v"]
|
||||||
|
try:
|
||||||
|
seen[:] = []
|
||||||
|
self.assertTrue(ctx.arm_press())
|
||||||
|
time.sleep(0.3)
|
||||||
|
self.assertEqual(self.stub.acts, []) # not yet: dark button
|
||||||
|
lit["v"] = True
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while not self.stub.acts and time.time() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.assertEqual(self.stub.acts, [("button", "press")])
|
||||||
|
self.assertEqual(run.evidence["actions"][1]["by"], "fixture")
|
||||||
|
self.assertEqual(seen, []) # no notice went up
|
||||||
|
finally:
|
||||||
|
runner_mod.hw.button_lit = saved
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from forgetest.suite import image
|
||||||
|
|
||||||
|
|
||||||
|
class KernelIdentTests(unittest.TestCase):
|
||||||
|
def test_localversion_hash_is_stripped(self):
|
||||||
|
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc-g72a0b1431a9d"), "6.12.20-fslc-fslc")
|
||||||
|
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc+g72a0b1431a9d"), "6.12.20-fslc-fslc")
|
||||||
|
|
||||||
|
def test_plain_name_is_unchanged(self):
|
||||||
|
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc"), "6.12.20-fslc-fslc")
|
||||||
|
self.assertEqual(image.kernel_ident("6.12.20-fslc-g12"), "6.12.20-fslc-g12")
|
||||||
|
|
||||||
|
def test_release_matches_manifest_with_or_without_hash(self):
|
||||||
|
rel = "6.12.20-fslc-fslc-g72a0b1431a9d"
|
||||||
|
self.assertTrue(image.kernel_matches(rel, ["6.12.20-fslc-fslc"]))
|
||||||
|
self.assertTrue(image.kernel_matches(rel, ["6.12.20-fslc-fslc-gf52af5b522f4"]))
|
||||||
|
self.assertTrue(image.kernel_matches(rel, []))
|
||||||
|
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""wait_hv_off against a scripted charge-pump readback: a chain that
|
||||||
|
releases inside the window passes, a chain held past it is the refusal,
|
||||||
|
and a chain already off costs no wait."""
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from forgetest.suite import kernel
|
||||||
|
|
||||||
|
|
||||||
|
class Ctx:
|
||||||
|
def __init__(self):
|
||||||
|
self.evidence = {}
|
||||||
|
self.lines = []
|
||||||
|
|
||||||
|
def sleep(self, s):
|
||||||
|
time.sleep(s)
|
||||||
|
|
||||||
|
def log(self, fmt, *a):
|
||||||
|
self.lines.append(fmt % a)
|
||||||
|
|
||||||
|
|
||||||
|
def scripted(alive_for_s):
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
def rd(path):
|
||||||
|
if path == "cnc/charge_pump_alive":
|
||||||
|
return "1" if time.time() - t0 < alive_for_s else "0"
|
||||||
|
if path == "cnc/state":
|
||||||
|
return "idle"
|
||||||
|
return None
|
||||||
|
return rd
|
||||||
|
|
||||||
|
|
||||||
|
class WaitHvOffTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._rd = kernel.rd
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
kernel.rd = self._rd
|
||||||
|
|
||||||
|
def test_release_inside_the_window_passes(self):
|
||||||
|
kernel.rd = scripted(0.3)
|
||||||
|
ctx = Ctx()
|
||||||
|
t0 = time.time()
|
||||||
|
self.assertIsNone(kernel.wait_hv_off(ctx, timeout_s=2.0))
|
||||||
|
self.assertGreaterEqual(time.time() - t0, 0.25)
|
||||||
|
self.assertTrue(any("released" in l for l in ctx.lines))
|
||||||
|
self.assertGreaterEqual(ctx.evidence["hv_release_s"][0], 0.25)
|
||||||
|
|
||||||
|
def test_chain_held_past_the_window_is_the_refusal(self):
|
||||||
|
kernel.rd = scripted(99)
|
||||||
|
ctx = Ctx()
|
||||||
|
why = kernel.wait_hv_off(ctx, timeout_s=0.3)
|
||||||
|
self.assertIn("charge_pump_alive=1", why)
|
||||||
|
self.assertTrue(any("still held" in l for l in ctx.lines))
|
||||||
|
|
||||||
|
def test_chain_already_off_costs_no_wait(self):
|
||||||
|
kernel.rd = scripted(0)
|
||||||
|
ctx = Ctx()
|
||||||
|
t0 = time.time()
|
||||||
|
self.assertIsNone(kernel.wait_hv_off(ctx, timeout_s=2.0))
|
||||||
|
self.assertLess(time.time() - t0, 0.1)
|
||||||
|
self.assertEqual(ctx.lines, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""The dwell-gap verdict of laser.emission-witness over synthetic trails:
|
||||||
|
the button latch judged from the first emission through every sample
|
||||||
|
whose readback word shows the laser latch unlocked, both bits from that
|
||||||
|
word, so the relocked tail (where the relock itself sets the latch and
|
||||||
|
the emission counter still reads nonzero) is never judged."""
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from forgetest.suite import laser
|
||||||
|
|
||||||
|
|
||||||
|
def smp(emission, latch, locked=False, hv_enable=True, armed=True):
|
||||||
|
il = (laser.IL_BUTTON_LATCH if latch else 0) | (laser.IL_LASER_LATCH if locked else 0)
|
||||||
|
return {"emission": emission, "armed": armed, "il": il, "hv_enable": hv_enable}
|
||||||
|
|
||||||
|
|
||||||
|
class DwellGapTests(unittest.TestCase):
|
||||||
|
def test_the_relocked_tail_is_not_judged(self):
|
||||||
|
# the arm wait (unlocked, latch set until the press), fire, the
|
||||||
|
# gap, fire, then the relock sets the latch while the counter
|
||||||
|
# still reads nonzero
|
||||||
|
trail = [smp(0, 1), smp(0, 0), smp(10, 0), smp(20, 0, hv_enable=False),
|
||||||
|
smp(20, 0, hv_enable=False), smp(30, 0), smp(40, 0),
|
||||||
|
smp(40, 1, locked=True, armed=False), smp(40, 1, locked=True, armed=False),
|
||||||
|
smp(0, 1, locked=True, armed=False)]
|
||||||
|
g = laser.dwell_gap(trail)
|
||||||
|
self.assertEqual(g["button_latch_unlocked_max"], 0)
|
||||||
|
self.assertEqual(g["button_latch_unlocked_samples"], 5)
|
||||||
|
self.assertEqual(g["button_latch_set_at"], [])
|
||||||
|
self.assertTrue(g["hv_enable_dipped"])
|
||||||
|
self.assertTrue(g["hv_enable_back_lit"])
|
||||||
|
|
||||||
|
def test_latch_set_while_unlocked_is_the_defect(self):
|
||||||
|
trail = [smp(0, 0), smp(10, 0), smp(20, 1, hv_enable=False), smp(30, 0), smp(0, 1, locked=True)]
|
||||||
|
g = laser.dwell_gap(trail)
|
||||||
|
self.assertEqual(g["button_latch_unlocked_max"], 1)
|
||||||
|
self.assertEqual(g["button_latch_set_at"], [1])
|
||||||
|
|
||||||
|
def test_no_emission_judges_nothing(self):
|
||||||
|
g = laser.dwell_gap([smp(0, 0), smp(0, 1, locked=True)])
|
||||||
|
self.assertIsNone(g["button_latch_unlocked_max"])
|
||||||
|
self.assertEqual(g["button_latch_unlocked_samples"], 0)
|
||||||
|
self.assertFalse(g["hv_enable_dipped"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers
|
||||||
|
from forgetest import manifest as m
|
||||||
|
from forgetest import catalog
|
||||||
|
|
||||||
|
|
||||||
|
class GlobTests(unittest.TestCase):
|
||||||
|
def test_star_and_doublestar(self):
|
||||||
|
rx = m.glob_to_regex("src/*.c")
|
||||||
|
self.assertTrue(rx.match("src/main.c"))
|
||||||
|
self.assertFalse(rx.match("src/sub/main.c"))
|
||||||
|
rx = m.glob_to_regex("src/**")
|
||||||
|
self.assertTrue(rx.match("src/main.c"))
|
||||||
|
self.assertTrue(rx.match("src/sub/deep/x.h"))
|
||||||
|
self.assertFalse(rx.match("docs/x"))
|
||||||
|
rx = m.glob_to_regex("**/*.md")
|
||||||
|
self.assertTrue(rx.match("README.md"))
|
||||||
|
self.assertTrue(rx.match("docs/a/b.md"))
|
||||||
|
self.assertFalse(rx.match("docs/a/b.txt"))
|
||||||
|
rx = m.glob_to_regex("**")
|
||||||
|
self.assertTrue(rx.match("anything/at/all"))
|
||||||
|
rx = m.glob_to_regex("src/gfcool*")
|
||||||
|
self.assertTrue(rx.match("src/gfcool_client.c"))
|
||||||
|
self.assertFalse(rx.match("src/x/gfcool.c"))
|
||||||
|
|
||||||
|
def test_question_mark_and_escaping(self):
|
||||||
|
rx = m.glob_to_regex("a?c.d")
|
||||||
|
self.assertTrue(rx.match("abc.d"))
|
||||||
|
self.assertFalse(rx.match("abcxd"))
|
||||||
|
self.assertFalse(rx.match("a/c.d"))
|
||||||
|
|
||||||
|
|
||||||
|
class FingerprintTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.man = helpers.make_manifest()
|
||||||
|
|
||||||
|
def test_stable_and_order_independent(self):
|
||||||
|
c1 = [("forgectrl", "src/ui.c"), ("forgectrl", "src/auth.c")]
|
||||||
|
c2 = list(reversed(c1))
|
||||||
|
self.assertEqual(m.fingerprint(self.man, c1), m.fingerprint(self.man, c2))
|
||||||
|
self.assertEqual(m.fingerprint(self.man, c1), m.fingerprint(self.man, c1))
|
||||||
|
|
||||||
|
def test_changes_only_when_covered_file_changes(self):
|
||||||
|
covers = [("forgectrl", "src/ui.c")]
|
||||||
|
base = m.fingerprint(self.man, covers)
|
||||||
|
other = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
|
||||||
|
self.assertEqual(base, m.fingerprint(other, covers), "an uncovered change must not move the fingerprint")
|
||||||
|
changed = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
|
||||||
|
self.assertNotEqual(base, m.fingerprint(changed, covers))
|
||||||
|
added = helpers.with_file(self.man, "forgectrl", "src/ui_extra.c", "new")
|
||||||
|
self.assertEqual(base, m.fingerprint(added, covers), "an unmatched new file does not move it")
|
||||||
|
added2 = helpers.with_file(self.man, "forgectrl", "src/x.c", "new")
|
||||||
|
self.assertNotEqual(base, m.fingerprint(added2, [("forgectrl", "src/*.c")]))
|
||||||
|
|
||||||
|
def test_platform_is_always_in(self):
|
||||||
|
covers = [("forgectrl", "src/ui.c")]
|
||||||
|
base = m.fingerprint(self.man, covers)
|
||||||
|
p2 = helpers.with_platform(self.man, dtb={"glowforge.dtb": "e" * 64})
|
||||||
|
self.assertNotEqual(base, m.fingerprint(p2, covers))
|
||||||
|
p3 = helpers.with_platform(self.man, layers={"meta-forgefirm": {"content_sha256": "2" * 64},
|
||||||
|
"poky": {"rev": "p" * 40}})
|
||||||
|
self.assertNotEqual(base, m.fingerprint(p3, covers))
|
||||||
|
|
||||||
|
def test_kernel_pseudo_files(self):
|
||||||
|
covers = [("linux-fslc", "**")]
|
||||||
|
base = m.fingerprint(self.man, covers)
|
||||||
|
k2 = helpers.with_file(self.man, "linux-fslc", "@config", "cfg2")
|
||||||
|
self.assertNotEqual(base, m.fingerprint(k2, covers))
|
||||||
|
|
||||||
|
def test_missing_component_marker(self):
|
||||||
|
f1 = m.fingerprint(self.man, [("nonexistent", "**")])
|
||||||
|
f2 = m.fingerprint(self.man, [("forgectrl", "nothing-matches-*")])
|
||||||
|
self.assertNotEqual(f1, f2)
|
||||||
|
|
||||||
|
def test_dev_only_refused(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
m.fingerprint(self.man, [("forgetest", "**")])
|
||||||
|
|
||||||
|
def test_extra_folds_in(self):
|
||||||
|
covers = [("forgectrl", "src/ui.c")]
|
||||||
|
self.assertNotEqual(m.fingerprint(self.man, covers, extra=["a"]),
|
||||||
|
m.fingerprint(self.man, covers, extra=["b"]))
|
||||||
|
|
||||||
|
def test_submodule_gitlink_and_files(self):
|
||||||
|
covers = [("grblhal-glowforge", "src/grbl/**")]
|
||||||
|
base = m.fingerprint(self.man, covers)
|
||||||
|
core = helpers.with_file(self.man, "grblhal-glowforge", "src/grbl/core.c", "core v2")
|
||||||
|
self.assertNotEqual(base, m.fingerprint(core, covers))
|
||||||
|
link = [("grblhal-glowforge", "src/grbl")]
|
||||||
|
self.assertEqual(m.fingerprint(self.man, link), m.fingerprint(core, link),
|
||||||
|
"the gitlink alone does not see a file-level change (fixture keeps the link id)")
|
||||||
|
|
||||||
|
def test_identity_sha_ignores_dev_only(self):
|
||||||
|
a = self.man.identity_sha()
|
||||||
|
b = helpers.with_file(self.man, "forgetest", "forgetest/x.py", "changed").identity_sha()
|
||||||
|
self.assertEqual(a, b)
|
||||||
|
c = helpers.with_file(self.man, "forgectrl", "src/ui.c", "changed").identity_sha()
|
||||||
|
self.assertNotEqual(a, c)
|
||||||
|
|
||||||
|
|
||||||
|
class CoverageReportTests(unittest.TestCase):
|
||||||
|
def test_report(self):
|
||||||
|
man = helpers.make_manifest()
|
||||||
|
t1 = helpers.make_test("a.one", [("forgectrl", "src/ui.c"), ("forgectrl", "src/auth.c")])
|
||||||
|
t2 = helpers.make_test("a.two", [("grblhal-glowforge", "**"), ("kernel-module-glowforge", "**"),
|
||||||
|
("linux-fslc", "**")])
|
||||||
|
rep = m.coverage_report(man, [t1, t2], allow=[("*", "**/*.md")])
|
||||||
|
self.assertEqual(set(rep), {"forgectrl"})
|
||||||
|
self.assertEqual(rep["forgectrl"], ["src/cool.c", "src/main.c"])
|
||||||
|
rep2 = m.coverage_report(man, [t1, t2, helpers.make_test("a.three", [("forgectrl", "src/**")])],
|
||||||
|
allow=[("*", "**/*.md")])
|
||||||
|
self.assertEqual(rep2, {})
|
||||||
|
self.assertNotIn("forgetest", m.coverage_report(man, [], allow=[]),
|
||||||
|
"dev-only components are outside the report")
|
||||||
|
|
||||||
|
def test_non_behavioral_paths_are_outside_every_fingerprint(self):
|
||||||
|
man = helpers.make_manifest()
|
||||||
|
t = helpers.make_test("a.one", [("forgectrl", "**")])
|
||||||
|
a = m.fingerprint(man, t.covers)
|
||||||
|
for comp, path in (("forgectrl", "README.md"), ("forgectrl", "docs/SERVICES.md"),
|
||||||
|
("forgectrl", "tests/test_x.py"), ("forgectrl", ".github/workflows/ci.yml")):
|
||||||
|
self.assertEqual(a, m.fingerprint(helpers.with_file(man, comp, path, "changed"), t.covers),
|
||||||
|
path)
|
||||||
|
self.assertNotEqual(a, m.fingerprint(helpers.with_file(man, "forgectrl", "src/ui.c", "changed"),
|
||||||
|
t.covers))
|
||||||
|
self.assertTrue(m.non_behavioral("forgectrl", "tools/devserver.py"))
|
||||||
|
self.assertFalse(m.non_behavioral("grblhal-glowforge", "tools/devserver.py"))
|
||||||
|
|
||||||
|
def test_an_entry_that_selects_nothing_is_reported(self):
|
||||||
|
man = helpers.make_manifest()
|
||||||
|
good = helpers.make_test("a.one", [("forgectrl", "src/ui.c")])
|
||||||
|
# the recipe's subdirectory left out of the glob, and an unknown component
|
||||||
|
bad = helpers.make_test("a.two", [("forgectrl", "ui.c"), ("no-such", "**")])
|
||||||
|
self.assertEqual(m.empty_covers(man, [good]), [])
|
||||||
|
self.assertEqual(m.empty_covers(man, [good, bad]),
|
||||||
|
[("a.two", "forgectrl", "ui.c"), ("a.two", "no-such", "**")])
|
||||||
|
# a glob that selects docs only names nothing any fingerprint carries
|
||||||
|
docs = helpers.make_test("a.three", [("forgectrl", "**/*.md")])
|
||||||
|
self.assertEqual(m.empty_covers(man, [docs]), [("a.three", "forgectrl", "**/*.md")])
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogTests(unittest.TestCase):
|
||||||
|
def test_catalog_hash_is_definition_only(self):
|
||||||
|
r1 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")]))
|
||||||
|
r2 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")], fn=lambda ctx: 1))
|
||||||
|
self.assertEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r2))
|
||||||
|
r3 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/**")]))
|
||||||
|
self.assertNotEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r3))
|
||||||
|
r4 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")], always=True))
|
||||||
|
self.assertNotEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r4))
|
||||||
|
|
||||||
|
def test_validate(self):
|
||||||
|
r = helpers.registry(helpers.make_test("a.one", [], requires=("a.two",)))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
catalog.validate(r)
|
||||||
|
r = helpers.registry(helpers.make_test("a.one", [], requires=("a.two",)),
|
||||||
|
helpers.make_test("a.two", [], requires=("a.one",)))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
catalog.validate(r)
|
||||||
|
|
||||||
|
def test_decorator_rules(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
catalog.test("bad id", title="x", subsystem="s")(lambda c: None)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
catalog.test("s.x", title="x", subsystem="s", covers=[("forgetest", "**")])(lambda c: None)
|
||||||
|
|
||||||
|
def test_real_suite_loads_and_validates(self):
|
||||||
|
reg = catalog.load_suite()
|
||||||
|
self.assertIn("image.health", reg)
|
||||||
|
self.assertTrue(reg["image.health"].always)
|
||||||
|
catalog.validate(reg)
|
||||||
|
for t in catalog.all_tests(reg):
|
||||||
|
self.assertEqual(len(t.source_sha), 64)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class ImplementationHashTests(unittest.TestCase):
|
||||||
|
"""A test's implementation hash is its own function plus the code its
|
||||||
|
module shares: a body edit moves one test, a helper edit moves them
|
||||||
|
all, and a file without @test functions hashes whole."""
|
||||||
|
|
||||||
|
MODULE = '''"""a suite module"""
|
||||||
|
from forgetest.catalog import test
|
||||||
|
|
||||||
|
|
||||||
|
def helper(x):
|
||||||
|
return x + 1
|
||||||
|
|
||||||
|
|
||||||
|
@test("m.one", title="one", subsystem="m")
|
||||||
|
def one(ctx):
|
||||||
|
ctx.log(helper(1))
|
||||||
|
|
||||||
|
|
||||||
|
@test("m.two", title="two", subsystem="m",
|
||||||
|
steps=["a step"])
|
||||||
|
def two(ctx):
|
||||||
|
ctx.log(helper(2))
|
||||||
|
'''
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import tempfile
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix="forgetest-impl-")
|
||||||
|
self.path = os.path.join(self.tmp, "mod.py")
|
||||||
|
self.write(self.MODULE)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def write(self, text):
|
||||||
|
catalog._PARTS.pop(self.path, None)
|
||||||
|
with open(self.path, "w", newline="\n") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
def shas(self):
|
||||||
|
return catalog.implementation_sha(self.path, "m.one"), catalog.implementation_sha(self.path, "m.two")
|
||||||
|
|
||||||
|
def test_a_body_edit_moves_that_test_only(self):
|
||||||
|
a1, b1 = self.shas()
|
||||||
|
self.write(self.MODULE.replace("ctx.log(helper(2))", "ctx.log(helper(3))"))
|
||||||
|
a2, b2 = self.shas()
|
||||||
|
self.assertEqual(a1, a2)
|
||||||
|
self.assertNotEqual(b1, b2)
|
||||||
|
|
||||||
|
def test_a_decorator_edit_moves_that_test_only(self):
|
||||||
|
a1, b1 = self.shas()
|
||||||
|
self.write(self.MODULE.replace('steps=["a step"]', 'steps=["another step"]'))
|
||||||
|
a2, b2 = self.shas()
|
||||||
|
self.assertEqual(a1, a2)
|
||||||
|
self.assertNotEqual(b1, b2)
|
||||||
|
|
||||||
|
def test_a_helper_edit_moves_every_test_of_the_module(self):
|
||||||
|
a1, b1 = self.shas()
|
||||||
|
self.write(self.MODULE.replace("return x + 1", "return x + 2"))
|
||||||
|
a2, b2 = self.shas()
|
||||||
|
self.assertNotEqual(a1, a2)
|
||||||
|
self.assertNotEqual(b1, b2)
|
||||||
|
|
||||||
|
def test_a_helper_edit_in_an_imported_sibling_moves_the_tests(self):
|
||||||
|
# laser.py imports its judges from motion.py: an edit to such a
|
||||||
|
# helper changes the judge, so it has to move the fingerprints of
|
||||||
|
# the tests that import it, transitively.
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
a2, b2 = self.shas()
|
||||||
|
self.assertNotEqual(a1, a2)
|
||||||
|
self.assertNotEqual(b1, b2)
|
||||||
|
# a module that is not imported changes nothing
|
||||||
|
other = os.path.join(self.tmp, "other.py")
|
||||||
|
with open(other, "w", newline="\n") as f:
|
||||||
|
f.write("def o():\n return 1\n")
|
||||||
|
self.assertEqual((a2, b2), self.shas())
|
||||||
|
|
||||||
|
def test_line_endings_do_not_count(self):
|
||||||
|
a1, b1 = self.shas()
|
||||||
|
catalog._PARTS.pop(self.path, None)
|
||||||
|
with open(self.path, "wb") as f:
|
||||||
|
f.write(self.MODULE.replace("\n", "\r\n").encode())
|
||||||
|
self.assertEqual((a1, b1), self.shas())
|
||||||
|
|
||||||
|
def test_an_unknown_id_hashes_the_whole_file(self):
|
||||||
|
self.assertEqual(catalog.implementation_sha(self.path, "m.none"), catalog.source_file_sha(self.path))
|
||||||
|
|
||||||
|
def test_every_suite_test_is_spanned(self):
|
||||||
|
import inspect
|
||||||
|
reg = catalog.load_suite()
|
||||||
|
for t in catalog.all_tests(reg):
|
||||||
|
path = inspect.getsourcefile(t.fn)
|
||||||
|
self.assertIn(t.id, catalog.module_parts(path)[1], t.id)
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""The controller mode a test declares: the runner puts the machine there
|
||||||
|
before the test and the baseline keeps it there afterward.
|
||||||
|
|
||||||
|
The cloud tests enter cloud mode and stay (the operator rule: no switch
|
||||||
|
back after every test), so a queue that runs on past them reaches the
|
||||||
|
motion tests with gfcloud as the controller and no grblHAL process to
|
||||||
|
find. A test that names its mode gets the switch made for it, once, in the
|
||||||
|
pre pass - after the leftovers are handled and before the preserved state
|
||||||
|
is captured, so the post pass hands back the mode the test asked for
|
||||||
|
rather than the one the run found.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers
|
||||||
|
from forgetest import baseline
|
||||||
|
from forgetest.log import Log
|
||||||
|
from forgetest.runner import Runner
|
||||||
|
|
||||||
|
|
||||||
|
class GrblPort:
|
||||||
|
"""A listening socket standing in for the Grbl port of grblHAL."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.sock = socket.socket()
|
||||||
|
self.sock.bind(("127.0.0.1", 0))
|
||||||
|
self.sock.listen(5)
|
||||||
|
os.environ["GRBL_HOST"] = "127.0.0.1"
|
||||||
|
os.environ["GRBL_PORT"] = str(self.sock.getsockname()[1])
|
||||||
|
self._stop = False
|
||||||
|
self._th = threading.Thread(target=self._accept, daemon=True)
|
||||||
|
self._th.start()
|
||||||
|
|
||||||
|
def _accept(self):
|
||||||
|
self.sock.settimeout(0.2)
|
||||||
|
while not self._stop:
|
||||||
|
try:
|
||||||
|
c, _ = self.sock.accept()
|
||||||
|
c.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._stop = True
|
||||||
|
self.sock.close()
|
||||||
|
for k in ("GRBL_HOST", "GRBL_PORT"):
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
|
def cloud(fc):
|
||||||
|
fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
|
||||||
|
fc.state["settings"]["controller_mode"] = "cloud"
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchModeTests(unittest.TestCase):
|
||||||
|
"""Baseline.switch_mode against the fake forgectrl."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.fc = helpers.FakeForgectrl().start()
|
||||||
|
self.port = GrblPort()
|
||||||
|
baseline.Baseline._unreachable_until = 0.0
|
||||||
|
self.lines = []
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.port.close()
|
||||||
|
self.fc.stop()
|
||||||
|
|
||||||
|
def bl(self):
|
||||||
|
return baseline.Baseline(self.lines.append)
|
||||||
|
|
||||||
|
def test_already_in_the_mode_posts_nothing(self):
|
||||||
|
ok, detail = self.bl().switch_mode("grbl")
|
||||||
|
self.assertTrue(ok, detail)
|
||||||
|
self.assertEqual(self.fc.posts, [])
|
||||||
|
|
||||||
|
def test_the_supervisors_levers_get_the_slow_timeout(self):
|
||||||
|
from forgetest import hw
|
||||||
|
fc = hw.Forgectrl(timeout=1.0, slow_timeout=2.0)
|
||||||
|
self.assertEqual(fc.timeout_for("POST", "/mode"), 2.0)
|
||||||
|
self.assertEqual(fc.timeout_for("POST", "/controller/start"), 2.0)
|
||||||
|
self.assertEqual(fc.timeout_for("POST", "/controller/stop"), 2.0)
|
||||||
|
self.assertEqual(fc.timeout_for("GET", "/mode"), 1.0)
|
||||||
|
self.assertEqual(fc.timeout_for("POST", "/settings"), 1.0)
|
||||||
|
self.assertGreater(hw.SLOW_TIMEOUT_S, 15.0) # forgectrl's first-report wait
|
||||||
|
# a switch that answers after the ordinary timeout still lands
|
||||||
|
import time
|
||||||
|
|
||||||
|
def on_post(path, form):
|
||||||
|
time.sleep(1.5)
|
||||||
|
self.fc.state["mode"] = dict(self.fc.state["mode"], mode="cloud")
|
||||||
|
return None
|
||||||
|
self.fc.on_post = on_post
|
||||||
|
st, body = fc.post("/mode", data={"controller": "cloud"})
|
||||||
|
self.assertEqual(st, 200) # 1.5 s > timeout, < slow_timeout
|
||||||
|
self.assertEqual(self.fc.state["mode"]["mode"], "cloud")
|
||||||
|
|
||||||
|
def test_cloud_to_grbl_switches_and_waits_for_the_port(self):
|
||||||
|
cloud(self.fc)
|
||||||
|
b = self.bl()
|
||||||
|
ok, detail = b.switch_mode("grbl")
|
||||||
|
self.assertTrue(ok, detail)
|
||||||
|
self.assertEqual(self.fc.posts, [("/mode", {"controller": "grbl"})])
|
||||||
|
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
|
||||||
|
self.assertEqual(b.mode, "grbl")
|
||||||
|
|
||||||
|
def test_a_controller_that_never_comes_up_is_reported(self):
|
||||||
|
cloud(self.fc)
|
||||||
|
self.fc.on_post = lambda path, form: (200, {"ok": True}) # accepted, nothing happens
|
||||||
|
ok, detail = self.bl().switch_mode("grbl", timeout=2)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIn("did not come up", detail)
|
||||||
|
|
||||||
|
def test_a_refused_switch_is_reported(self):
|
||||||
|
cloud(self.fc)
|
||||||
|
self.fc.on_post = lambda path, form: (409, {"error": "busy"})
|
||||||
|
ok, detail = self.bl().switch_mode("grbl")
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIn("409", detail)
|
||||||
|
|
||||||
|
def test_grbl_needs_the_port_open(self):
|
||||||
|
cloud(self.fc)
|
||||||
|
self.port.close() # grblHAL running, port never listening
|
||||||
|
os.environ["GRBL_HOST"], os.environ["GRBL_PORT"] = "127.0.0.1", "1"
|
||||||
|
old = baseline.GRBL_PORT_S
|
||||||
|
baseline.GRBL_PORT_S = 2
|
||||||
|
try:
|
||||||
|
ok, detail = self.bl().switch_mode("grbl")
|
||||||
|
finally:
|
||||||
|
baseline.GRBL_PORT_S = old
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIn("Grbl port", detail)
|
||||||
|
|
||||||
|
|
||||||
|
class RunnerModeTests(unittest.TestCase):
|
||||||
|
"""The runner end to end: a declared mode is established before the
|
||||||
|
test function runs and kept by the post pass."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix="forgetest-mode-")
|
||||||
|
os.environ["FORGETEST_DATA"] = self.tmp
|
||||||
|
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
|
||||||
|
self.fc = helpers.FakeForgectrl().start()
|
||||||
|
self.port = GrblPort()
|
||||||
|
baseline.Baseline._unreachable_until = 0.0
|
||||||
|
self.seen = {}
|
||||||
|
fc, seen = self.fc, self.seen
|
||||||
|
|
||||||
|
def t_sees_mode(ctx):
|
||||||
|
seen["mode"] = fc.state["mode"]["mode"]
|
||||||
|
|
||||||
|
reg = helpers.registry(
|
||||||
|
helpers.make_test("m.grbl", [("forgectrl", "src/ui.c")], fn=t_sees_mode, mode="grbl"),
|
||||||
|
helpers.make_test("m.any", [("forgectrl", "src/ui.c")], fn=t_sees_mode),
|
||||||
|
)
|
||||||
|
self.runner = Runner(Log(os.path.join(self.tmp, "results.jsonl")), helpers.make_manifest(), reg)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.port.close()
|
||||||
|
self.fc.stop()
|
||||||
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||||
|
for k in ("FORGETEST_DATA", "FORGETEST_MARKER"):
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
|
||||||
|
def run_test(self, tid):
|
||||||
|
ok, msg = self.runner.start_test(tid)
|
||||||
|
self.assertTrue(ok, msg)
|
||||||
|
run = self.runner.current
|
||||||
|
deadline = time.time() + 30
|
||||||
|
while not run.finished and time.time() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.assertTrue(run.finished, "run did not finish")
|
||||||
|
return run
|
||||||
|
|
||||||
|
def test_grbl_test_found_in_cloud_mode_gets_the_switch_and_keeps_it(self):
|
||||||
|
cloud(self.fc)
|
||||||
|
run = self.run_test("m.grbl")
|
||||||
|
self.assertEqual(run.finished["result"], "PASS", run.finished)
|
||||||
|
self.assertEqual(self.seen["mode"], "grbl")
|
||||||
|
# one switch, made before the test; the post pass keeps grbl
|
||||||
|
self.assertEqual(self.fc.posts, [("/mode", {"controller": "grbl"})])
|
||||||
|
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
|
||||||
|
self.assertEqual(run.baseline_captured["mode"], "grbl")
|
||||||
|
self.assertEqual(run.evidence["mode"]["found"], "cloud")
|
||||||
|
self.assertTrue(run.evidence["mode"]["switched"])
|
||||||
|
self.assertTrue(any("mode: test needs grbl" in ln for ln in run.lines), run.lines)
|
||||||
|
|
||||||
|
def test_grbl_test_in_grbl_mode_switches_nothing(self):
|
||||||
|
run = self.run_test("m.grbl")
|
||||||
|
self.assertEqual(run.finished["result"], "PASS", run.finished)
|
||||||
|
self.assertEqual(self.fc.posts, [])
|
||||||
|
self.assertFalse(run.evidence["mode"]["switched"])
|
||||||
|
|
||||||
|
def test_undeclared_test_runs_in_the_mode_it_finds(self):
|
||||||
|
cloud(self.fc)
|
||||||
|
run = self.run_test("m.any")
|
||||||
|
self.assertEqual(run.finished["result"], "PASS", run.finished)
|
||||||
|
self.assertEqual(self.seen["mode"], "cloud")
|
||||||
|
self.assertEqual(self.fc.posts, [])
|
||||||
|
self.assertNotIn("mode", run.evidence)
|
||||||
|
|
||||||
|
def test_a_switch_that_fails_fails_the_test_before_it_runs(self):
|
||||||
|
cloud(self.fc)
|
||||||
|
self.fc.on_post = lambda path, form: (503, {"error": "supervisor busy"})
|
||||||
|
run = self.run_test("m.grbl")
|
||||||
|
self.assertEqual(run.finished["result"], "FAIL")
|
||||||
|
self.assertIn("needs grbl mode", run.finished["message"])
|
||||||
|
self.assertNotIn("mode", self.seen)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
"""The operator channel: notices, `ready`, `act`, and the precheck.
|
||||||
|
|
||||||
|
A test asks for the operator's part in a way the page can show before
|
||||||
|
it is needed and a bench actuator can later perform: `act` names a
|
||||||
|
machine action and watches the machine for it (a standing notice for
|
||||||
|
the human, no button to race), `ready` pre-announces a timed step, and
|
||||||
|
a `precheck` refuses a start the machine cannot honor instead of
|
||||||
|
recording a FAIL. Runner-level events go to the journal, never to the
|
||||||
|
page state.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers
|
||||||
|
from forgetest import catalog
|
||||||
|
from forgetest.log import Log
|
||||||
|
from forgetest.runner import Context, Failed, Run, Runner, journal
|
||||||
|
|
||||||
|
|
||||||
|
class Catch(logging.Handler):
|
||||||
|
def __init__(self):
|
||||||
|
logging.Handler.__init__(self)
|
||||||
|
self.lines = []
|
||||||
|
|
||||||
|
def emit(self, rec):
|
||||||
|
self.lines.append(rec.getMessage())
|
||||||
|
|
||||||
|
|
||||||
|
class Answerer:
|
||||||
|
"""Answers every prompt with its first option and records it, and
|
||||||
|
records every notice it sees."""
|
||||||
|
|
||||||
|
def __init__(self, run, on_prompt=None):
|
||||||
|
self.run = run
|
||||||
|
self.on_prompt = on_prompt
|
||||||
|
self.asked = []
|
||||||
|
self.notices = []
|
||||||
|
self.stop = False
|
||||||
|
threading.Thread(target=self._loop, daemon=True).start()
|
||||||
|
|
||||||
|
def _loop(self):
|
||||||
|
seen = None
|
||||||
|
seen_n = None
|
||||||
|
while not self.stop:
|
||||||
|
n = self.run.notice
|
||||||
|
if n and n["id"] != seen_n:
|
||||||
|
seen_n = n["id"]
|
||||||
|
self.notices.append(n["text"])
|
||||||
|
p = self.run.prompt
|
||||||
|
if p and p["id"] != seen:
|
||||||
|
seen = p["id"]
|
||||||
|
self.asked.append((p["question"], list(p["options"])))
|
||||||
|
if self.on_prompt:
|
||||||
|
self.on_prompt(p)
|
||||||
|
self.run.answer(p["id"], p["options"][0])
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
class ActTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.fc = helpers.FakeForgectrl().start()
|
||||||
|
self.run = Run("test", "t.x", "t.x")
|
||||||
|
self.ctx = Context(self.run, None, helpers.make_test("t.x", [], kind="operator"))
|
||||||
|
self.ans = Answerer(self.run)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.ans.stop = True
|
||||||
|
self.fc.stop()
|
||||||
|
|
||||||
|
def lid(self, closed):
|
||||||
|
self.fc.state["status"]["switches"]["lid"] = bool(closed)
|
||||||
|
|
||||||
|
def interlock(self, ok):
|
||||||
|
self.fc.state["status"]["switches"]["interlock_ok"] = bool(ok)
|
||||||
|
|
||||||
|
def test_lid_open_is_a_notice_until_the_switch_shows_it(self):
|
||||||
|
threading.Timer(0.3, self.lid, args=(False,)).start()
|
||||||
|
dt = self.ctx.act("lid", "open", timeout=5)
|
||||||
|
self.assertGreater(dt, 0.2)
|
||||||
|
self.assertEqual(self.ans.asked, []) # no button to click
|
||||||
|
self.assertEqual(self.ans.notices, ["Open the lid."])
|
||||||
|
self.assertIsNone(self.run.notice) # cleared when seen
|
||||||
|
rec = self.run.evidence["actions"][0]
|
||||||
|
self.assertEqual((rec["channel"], rec["state"], rec["by"]), ("lid", "open", "operator"))
|
||||||
|
self.assertIsNotNone(rec["took_s"])
|
||||||
|
self.assertTrue(any("ACT lid open: done" in ln for ln in self.run.lines))
|
||||||
|
|
||||||
|
def test_the_tests_context_follows_the_wording(self):
|
||||||
|
self.lid(False)
|
||||||
|
self.ctx.act("lid", "close", text="Leave it closed.", until=lambda: True, timeout=1)
|
||||||
|
self.assertTrue(any("NOTICE: Close the lid. Leave it closed." in ln for ln in self.run.lines))
|
||||||
|
|
||||||
|
def test_already_done_returns_at_once(self):
|
||||||
|
self.lid(False)
|
||||||
|
dt = self.ctx.act("lid", "open", timeout=2)
|
||||||
|
self.assertLess(dt, 1.0)
|
||||||
|
|
||||||
|
def test_interlock_watches_interlock_ok(self):
|
||||||
|
threading.Timer(0.2, self.interlock, args=(False,)).start()
|
||||||
|
self.ctx.act("interlock", "open", timeout=5)
|
||||||
|
threading.Timer(0.2, self.interlock, args=(True,)).start()
|
||||||
|
self.ctx.act("interlock", "close", timeout=5)
|
||||||
|
self.assertEqual([r["state"] for r in self.run.evidence["actions"]], ["open", "close"])
|
||||||
|
|
||||||
|
def test_a_timeout_fails_the_test_and_clears_the_notice(self):
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
self.ctx.act("lid", "open", timeout=0.6)
|
||||||
|
self.assertIn("lid open was not seen", str(cm.exception))
|
||||||
|
self.assertIsNone(self.run.notice)
|
||||||
|
self.assertIsNone(self.run.evidence["actions"][0]["took_s"])
|
||||||
|
|
||||||
|
def test_a_timeout_can_be_handed_back_instead(self):
|
||||||
|
self.assertIsNone(self.ctx.act("lid", "open", timeout=0.4, fail=False))
|
||||||
|
|
||||||
|
def test_a_button_press_needs_what_it_is_expected_to_do(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.ctx.act("button", "press")
|
||||||
|
held = []
|
||||||
|
threading.Timer(0.2, held.append, args=(1,)).start()
|
||||||
|
self.ctx.act("button", "press", until=lambda: bool(held), timeout=5)
|
||||||
|
self.assertEqual(self.ans.notices, ["Press the button once."])
|
||||||
|
|
||||||
|
def test_unknown_actions_are_refused(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.ctx.act("lid", "press")
|
||||||
|
|
||||||
|
def test_a_fixture_covering_the_channel_performs_it(self):
|
||||||
|
class Fixture:
|
||||||
|
done = []
|
||||||
|
|
||||||
|
def covers(self, channel):
|
||||||
|
return channel == "lid"
|
||||||
|
|
||||||
|
def act(self, channel, state):
|
||||||
|
self.done.append((channel, state))
|
||||||
|
self_outer.lid(state != "open")
|
||||||
|
self_outer = self
|
||||||
|
|
||||||
|
class R:
|
||||||
|
fixture = Fixture()
|
||||||
|
ctx = Context(self.run, R(), helpers.make_test("t.f", [], kind="operator"))
|
||||||
|
ctx.act("lid", "open", timeout=3)
|
||||||
|
self.assertEqual(Fixture.done, [("lid", "open")])
|
||||||
|
self.assertEqual(self.ans.notices, [])
|
||||||
|
self.assertEqual(self.run.evidence["actions"][0]["by"], "fixture")
|
||||||
|
# a channel the fixture does not cover falls back to the operator
|
||||||
|
threading.Timer(0.2, self.interlock, args=(False,)).start()
|
||||||
|
ctx.act("interlock", "open", timeout=3)
|
||||||
|
self.assertEqual(self.ans.notices, [ctx.runner and "Open the remote-interlock loop: unplug the "
|
||||||
|
"Pro's interlock plug, or pull the jumper at J8 on a Basic/Plus."])
|
||||||
|
|
||||||
|
def test_ready_is_a_single_button_prompt(self):
|
||||||
|
self.ctx.ready("On Ready the head moves.")
|
||||||
|
self.assertEqual(self.ans.asked, [("On Ready the head moves.", ["Ready", "Cannot"])])
|
||||||
|
|
||||||
|
def test_cannot_on_ready_fails(self):
|
||||||
|
self.ans.stop = True
|
||||||
|
ans = Answerer(self.run, on_prompt=lambda p: None)
|
||||||
|
ans.stop = True
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
def say_cannot():
|
||||||
|
while self.run.prompt is None:
|
||||||
|
time.sleep(0.01)
|
||||||
|
self.run.answer(self.run.prompt["id"], "Cannot")
|
||||||
|
threading.Thread(target=say_cannot, daemon=True).start()
|
||||||
|
with self.assertRaises(Failed):
|
||||||
|
self.ctx.ready("x")
|
||||||
|
|
||||||
|
def test_notice_is_logged_and_in_the_snapshot(self):
|
||||||
|
self.ctx.notice("Hold still.")
|
||||||
|
self.assertEqual(self.run.snapshot()["notice"]["text"], "Hold still.")
|
||||||
|
self.assertTrue(any("NOTICE: Hold still." in ln for ln in self.run.lines))
|
||||||
|
self.ctx.clear_notice()
|
||||||
|
self.assertIsNone(self.run.snapshot()["notice"])
|
||||||
|
|
||||||
|
|
||||||
|
class PrecheckTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix="forgetest-pre-")
|
||||||
|
os.environ["FORGETEST_DATA"] = self.tmp
|
||||||
|
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
|
||||||
|
self.man = helpers.make_manifest()
|
||||||
|
self.blocked = {"why": "HV reports good: open the lid"}
|
||||||
|
self.reg = helpers.registry(
|
||||||
|
helpers.make_test("p.gated", [("forgectrl", "src/main.c")],
|
||||||
|
precheck=lambda: self.blocked["why"]),
|
||||||
|
helpers.make_test("p.free", [("forgectrl", "src/ui.c")]),
|
||||||
|
)
|
||||||
|
self.log = Log(os.path.join(self.tmp, "r.jsonl"))
|
||||||
|
self.runner = Runner(self.log, self.man, self.reg)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_a_precheck_reason_refuses_the_start_without_a_result(self):
|
||||||
|
ok, msg = self.runner.start_test("p.gated")
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(msg, "cannot start: HV reports good: open the lid")
|
||||||
|
self.assertEqual([r for r in self.log.read() if r["t"] == "result"], [])
|
||||||
|
# the campaign is not even opened by a refused start
|
||||||
|
self.assertEqual([r for r in self.log.read() if r["t"] == "campaign"], [])
|
||||||
|
|
||||||
|
def test_a_clear_precheck_starts(self):
|
||||||
|
self.blocked["why"] = None
|
||||||
|
ok, msg = self.runner.start_test("p.gated")
|
||||||
|
self.assertTrue(ok, msg)
|
||||||
|
while self.runner.busy():
|
||||||
|
time.sleep(0.02)
|
||||||
|
self.assertEqual(self.runner.state()[0]["tests"]["p.gated"]["status"], "pass")
|
||||||
|
|
||||||
|
def test_the_queue_skips_it_with_the_reason_and_carries_on(self):
|
||||||
|
ok, msg, order = self.runner.start_batch("unattended")
|
||||||
|
self.assertTrue(ok, msg)
|
||||||
|
deadline = time.time() + 20
|
||||||
|
while not self.runner.batch["finished"] and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
b = self.runner.batch_snapshot()
|
||||||
|
self.assertEqual([x["test"] for x in b["skipped"]], ["p.gated"])
|
||||||
|
self.assertIn("cannot start: HV reports good", b["skipped"][0]["reason"])
|
||||||
|
self.assertEqual([x["test"] for x in b["done"]], ["p.free"])
|
||||||
|
|
||||||
|
def test_an_erroring_precheck_refuses_rather_than_crashes(self):
|
||||||
|
def boom():
|
||||||
|
raise RuntimeError("sysfs gone")
|
||||||
|
t = helpers.make_test("p.boom", [], precheck=boom)
|
||||||
|
self.assertIn("precheck errored: RuntimeError: sysfs gone", t.cannot_start())
|
||||||
|
|
||||||
|
def test_the_catalog_describes_actions_and_the_precheck(self):
|
||||||
|
t = helpers.make_test("p.x", [], kind="operator", actions=("lid", "button"),
|
||||||
|
precheck=lambda: None)
|
||||||
|
d = t.describe()
|
||||||
|
self.assertEqual(d["actions"], ["lid", "button"])
|
||||||
|
self.assertTrue(d["precheck"])
|
||||||
|
# neither is part of the gate-visible definition
|
||||||
|
self.assertNotIn("actions", t.definition())
|
||||||
|
|
||||||
|
def test_the_decorator_checks_actions(self):
|
||||||
|
saved = dict(catalog.REGISTRY)
|
||||||
|
try:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
catalog.test("x.a", title="t", subsystem="x", kind="operator", actions=("foot",))(lambda c: None)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
catalog.test("x.b", title="t", subsystem="x", kind="auto", actions=("lid",))(lambda c: None)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
catalog.test("x.c", title="t", subsystem="x", precheck="no")(lambda c: None)
|
||||||
|
catalog.test("x.d", title="t", subsystem="x", kind="operator", actions=("lid",),
|
||||||
|
precheck=lambda: None)(lambda c: None)
|
||||||
|
self.assertIn("x.d", catalog.REGISTRY)
|
||||||
|
finally:
|
||||||
|
catalog.REGISTRY.clear()
|
||||||
|
catalog.REGISTRY.update(saved)
|
||||||
|
|
||||||
|
|
||||||
|
class JournalTests(unittest.TestCase):
|
||||||
|
def test_runner_notes_go_to_the_journal_and_the_run_in_progress(self):
|
||||||
|
tmp = tempfile.mkdtemp(prefix="forgetest-j-")
|
||||||
|
os.environ["FORGETEST_DATA"] = tmp
|
||||||
|
os.environ["FORGETEST_MARKER"] = os.path.join(tmp, "marker")
|
||||||
|
h = Catch()
|
||||||
|
journal.addHandler(h)
|
||||||
|
try:
|
||||||
|
r = Runner(Log(os.path.join(tmp, "r.jsonl")), helpers.make_manifest(),
|
||||||
|
helpers.registry(helpers.make_test("j.a", [])))
|
||||||
|
r._note("queue unattended: 1 test(s) to run")
|
||||||
|
self.assertIn("queue unattended: 1 test(s) to run", h.lines)
|
||||||
|
self.assertNotIn("messages", r.state()[0])
|
||||||
|
run = Run("test", "j.a", "j.a")
|
||||||
|
r.current = run
|
||||||
|
r._note("queue unattended: stop requested")
|
||||||
|
self.assertTrue(any("stop requested" in ln for ln in run.lines))
|
||||||
|
finally:
|
||||||
|
journal.removeHandler(h)
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_journal_tail_serves_the_daemon_log(self):
|
||||||
|
from forgetest.server import journal_tail
|
||||||
|
tmp = tempfile.mkdtemp(prefix="forgetest-jt-")
|
||||||
|
try:
|
||||||
|
p = os.path.join(tmp, "daemon.log")
|
||||||
|
self.assertIn(b"no journal yet", journal_tail(p))
|
||||||
|
with open(p, "wb") as f:
|
||||||
|
for i in range(2000):
|
||||||
|
f.write(b"line %d\n" % i)
|
||||||
|
tail = journal_tail(p, max_bytes=200)
|
||||||
|
self.assertTrue(tail.endswith(b"line 1999\n"))
|
||||||
|
self.assertLess(len(tail), 200)
|
||||||
|
self.assertTrue(tail.startswith(b"line ")) # a whole line, not a fragment
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class AccelSamplerTests(unittest.TestCase):
|
||||||
|
"""The head accelerometer sampler over a fake iio tree."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from forgetest import hw
|
||||||
|
self.hw = hw
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix="forgetest-iio-")
|
||||||
|
d = os.path.join(self.tmp, "iio_device1") # no colon: the host may be Windows
|
||||||
|
os.makedirs(d)
|
||||||
|
with open(os.path.join(d, "name"), "w") as f:
|
||||||
|
f.write("lis2hh12 3-001e\n")
|
||||||
|
self.dir = d
|
||||||
|
self.write(100, 200)
|
||||||
|
os.environ["GF_IIO_ROOT"] = self.tmp
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
os.environ.pop("GF_IIO_ROOT", None)
|
||||||
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def write(self, x, y):
|
||||||
|
for axis, v in (("x", x), ("y", y)):
|
||||||
|
with open(os.path.join(self.dir, "in_accel_%s_raw" % axis), "w") as f:
|
||||||
|
f.write("%d\n" % v)
|
||||||
|
|
||||||
|
def test_found_by_bus_address_and_sampled(self):
|
||||||
|
self.assertEqual(self.hw.head_accel_dir(), self.dir)
|
||||||
|
s = self.hw.AccelSampler(period=0.02)
|
||||||
|
self.assertTrue(s.available)
|
||||||
|
with s:
|
||||||
|
t0 = time.time()
|
||||||
|
time.sleep(0.1)
|
||||||
|
self.write(1100, 200)
|
||||||
|
time.sleep(0.1)
|
||||||
|
self.write(100, 200)
|
||||||
|
time.sleep(0.1)
|
||||||
|
t1 = time.time()
|
||||||
|
p2px, p2py, n = s.p2p(t0, t1)
|
||||||
|
self.assertGreaterEqual(n, 5)
|
||||||
|
self.assertEqual((p2px, p2py), (1000, 0))
|
||||||
|
# a window with no samples reads as nothing, not as an error
|
||||||
|
self.assertEqual(s.p2p(t1 + 10, t1 + 20), (0, 0, 0))
|
||||||
|
|
||||||
|
def test_absent_device_is_unavailable(self):
|
||||||
|
os.environ["GF_IIO_ROOT"] = os.path.join(self.tmp, "nowhere")
|
||||||
|
s = self.hw.AccelSampler()
|
||||||
|
self.assertFalse(s.available)
|
||||||
|
with s:
|
||||||
|
pass
|
||||||
|
self.assertEqual(s.p2p(0), (0, 0, 0))
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""The page assembly: the ui/ files inlined in order, the gzipped install
|
||||||
|
(what the dev image carries) assembling to the same bytes as a checkout,
|
||||||
|
the token placeholder carried exactly once, and a missing marker refused
|
||||||
|
rather than served."""
|
||||||
|
import gzip
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from forgetest import page
|
||||||
|
|
||||||
|
|
||||||
|
def gzipped_copy(src_dir):
|
||||||
|
"""A copy of src_dir with every file gzipped in place, the way the
|
||||||
|
recipe installs ui/."""
|
||||||
|
tmp = tempfile.mkdtemp(prefix="forgetest-ui-")
|
||||||
|
for root, _, files in os.walk(src_dir):
|
||||||
|
rel = os.path.relpath(root, src_dir)
|
||||||
|
dst_dir = os.path.normpath(os.path.join(tmp, rel))
|
||||||
|
os.makedirs(dst_dir, exist_ok=True)
|
||||||
|
for f in files:
|
||||||
|
with open(os.path.join(root, f), "rb") as i, \
|
||||||
|
gzip.open(os.path.join(dst_dir, f + ".gz"), "wb") as o:
|
||||||
|
o.write(i.read())
|
||||||
|
return tmp
|
||||||
|
|
||||||
|
|
||||||
|
class PageTests(unittest.TestCase):
|
||||||
|
def test_checkout_and_gzipped_install_agree(self):
|
||||||
|
plain = page.assemble()
|
||||||
|
tmp = gzipped_copy(page.UI_DIR)
|
||||||
|
try:
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(tmp, "index.html")))
|
||||||
|
self.assertEqual(page.assemble(tmp), plain)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_page_is_self_contained(self):
|
||||||
|
html = page.assemble()
|
||||||
|
self.assertNotIn("<link ", html)
|
||||||
|
self.assertNotIn("<script src=", html)
|
||||||
|
self.assertEqual(html.count(page.TOKEN_MARK), 1)
|
||||||
|
self.assertIn("data-bs-theme", html)
|
||||||
|
for name in page.CSS_FILES + page.JS_FILES:
|
||||||
|
self.assertNotIn('href="%s"' % name, html)
|
||||||
|
self.assertNotIn('src="%s"' % name, html)
|
||||||
|
|
||||||
|
def test_render_substitutes_the_token(self):
|
||||||
|
out = page.render("deadbeef0123")
|
||||||
|
self.assertEqual(out.count("deadbeef0123"), 1)
|
||||||
|
self.assertNotIn(page.TOKEN_MARK, out)
|
||||||
|
|
||||||
|
def test_missing_marker_is_refused(self):
|
||||||
|
tmp = tempfile.mkdtemp(prefix="forgetest-ui-")
|
||||||
|
try:
|
||||||
|
with open(os.path.join(tmp, "index.html"), "w", encoding="utf-8") as f:
|
||||||
|
f.write("<html><head></head><body>no markers</body></html>")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
page.assemble(tmp)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"""The two queues: what they select, the order they run in, and where
|
||||||
|
they stop.
|
||||||
|
|
||||||
|
A campaign is mostly waiting, so the page offers to run what is still
|
||||||
|
owed in one go: the unattended tests for an empty room, the attended ones
|
||||||
|
for an operator at the machine. The rules that matter here are that a
|
||||||
|
queue takes exactly the unsatisfied tests of its kinds, runs a
|
||||||
|
prerequisite before the test that names it, stops on the first result
|
||||||
|
that is not a PASS (a FAIL closes the campaign, so going on would open a
|
||||||
|
second one behind the operator), and never fires the laser without the
|
||||||
|
acknowledgment.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import helpers
|
||||||
|
from forgetest import catalog, server
|
||||||
|
from forgetest.log import Log
|
||||||
|
from forgetest.runner import BATCH_GROUPS, Failed, Runner
|
||||||
|
|
||||||
|
|
||||||
|
def t_pass(ctx):
|
||||||
|
ctx.log("ok")
|
||||||
|
|
||||||
|
|
||||||
|
def t_fail(ctx):
|
||||||
|
ctx.check(False, "deliberate")
|
||||||
|
|
||||||
|
|
||||||
|
def t_prompt(ctx):
|
||||||
|
ctx.prompt("Ready?", ("Continue",))
|
||||||
|
|
||||||
|
|
||||||
|
def t_slow(ctx):
|
||||||
|
ctx.sleep(30)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderTests(unittest.TestCase):
|
||||||
|
"""order_by_requires is a pure function over the requires graph."""
|
||||||
|
|
||||||
|
def order(self, tests, selected):
|
||||||
|
return catalog.order_by_requires(tests, selected)
|
||||||
|
|
||||||
|
def test_prerequisite_first(self):
|
||||||
|
a = helpers.make_test("s.a", [], fn=t_pass)
|
||||||
|
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
|
||||||
|
# selection order must not matter; registration order breaks ties
|
||||||
|
self.assertEqual(self.order([b, a], ["s.b", "s.a"]), ["s.a", "s.b"])
|
||||||
|
self.assertEqual(self.order([a, b], ["s.b", "s.a"]), ["s.a", "s.b"])
|
||||||
|
|
||||||
|
def test_chain(self):
|
||||||
|
c = helpers.make_test("s.c", [], requires=("s.b",), fn=t_pass)
|
||||||
|
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
|
||||||
|
a = helpers.make_test("s.a", [], fn=t_pass)
|
||||||
|
self.assertEqual(self.order([c, b, a], ["s.c", "s.b", "s.a"]), ["s.a", "s.b", "s.c"])
|
||||||
|
|
||||||
|
def test_registration_order_otherwise(self):
|
||||||
|
ts = [helpers.make_test("s.%d" % i, [], fn=t_pass) for i in range(4)]
|
||||||
|
self.assertEqual(self.order(ts, [t.id for t in ts]), ["s.0", "s.1", "s.2", "s.3"])
|
||||||
|
|
||||||
|
def test_prerequisite_outside_the_selection_places_nothing(self):
|
||||||
|
a = helpers.make_test("s.a", [], fn=t_pass)
|
||||||
|
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
|
||||||
|
self.assertEqual(self.order([a, b], ["s.b"]), ["s.b"])
|
||||||
|
|
||||||
|
def test_unknown_and_empty(self):
|
||||||
|
a = helpers.make_test("s.a", [], fn=t_pass)
|
||||||
|
self.assertEqual(self.order([a], []), [])
|
||||||
|
self.assertEqual(self.order([a], ["s.nope"]), [])
|
||||||
|
|
||||||
|
def test_a_cycle_does_not_recurse_forever(self):
|
||||||
|
"""validate() rejects cycles; a malformed registry must still not
|
||||||
|
hang the runner."""
|
||||||
|
a = helpers.make_test("s.a", [], requires=("s.b",), fn=t_pass)
|
||||||
|
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
|
||||||
|
out = self.order([a, b], ["s.a", "s.b"])
|
||||||
|
self.assertEqual(sorted(out), ["s.a", "s.b"])
|
||||||
|
|
||||||
|
|
||||||
|
class RunClockTests(unittest.TestCase):
|
||||||
|
"""The page shows the last run until the next one starts, so a
|
||||||
|
finished run's elapsed figure must be the time it took, not the time
|
||||||
|
since. Found on the bench reference, where a failed test went on
|
||||||
|
counting past 5000 s and read as still running."""
|
||||||
|
|
||||||
|
def test_a_finished_runs_clock_stops(self):
|
||||||
|
from forgetest.runner import Run
|
||||||
|
run = Run("test", "t.x", "t.x")
|
||||||
|
run.started = time.time() - 30
|
||||||
|
self.assertGreaterEqual(run.snapshot()["elapsed_s"], 30)
|
||||||
|
run.finished = {"result": "FAIL", "message": "", "duration_s": 31}
|
||||||
|
first = run.snapshot()["elapsed_s"]
|
||||||
|
time.sleep(1.1)
|
||||||
|
self.assertEqual(first, 31)
|
||||||
|
self.assertEqual(run.snapshot()["elapsed_s"], 31)
|
||||||
|
|
||||||
|
def test_a_running_runs_clock_ticks(self):
|
||||||
|
from forgetest.runner import Run
|
||||||
|
run = Run("test", "t.x", "t.x")
|
||||||
|
run.started = time.time() - 5
|
||||||
|
first = run.snapshot()["elapsed_s"]
|
||||||
|
time.sleep(1.1)
|
||||||
|
self.assertGreater(run.snapshot()["elapsed_s"], first)
|
||||||
|
|
||||||
|
|
||||||
|
class QueueTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.tmp = tempfile.mkdtemp(prefix="forgetest-queue-")
|
||||||
|
os.environ["FORGETEST_DATA"] = cls.tmp
|
||||||
|
os.environ["FORGETEST_MARKER"] = os.path.join(cls.tmp, "marker")
|
||||||
|
cls.man = helpers.make_manifest()
|
||||||
|
cls.reg = helpers.registry(
|
||||||
|
# auto: b requires a, so a queue must run a first even though
|
||||||
|
# b registers earlier
|
||||||
|
helpers.make_test("q.b", [("forgectrl", "src/ui.c")], requires=("q.a",), fn=t_pass),
|
||||||
|
helpers.make_test("q.a", [("forgectrl", "src/auth.c")], fn=t_pass),
|
||||||
|
helpers.make_test("q.c", [("forgectrl", "src/cool.c")], fn=t_pass),
|
||||||
|
# attended
|
||||||
|
helpers.make_test("q.op", [("forgectrl", "src/main.c")], kind="operator", fn=t_prompt),
|
||||||
|
helpers.make_test("q.live", [("grblhal-glowforge", "src/**")], kind="live", fn=t_pass),
|
||||||
|
)
|
||||||
|
cls.log = Log(os.path.join(cls.tmp, "results.jsonl"))
|
||||||
|
cls.runner = Runner(cls.log, cls.man, cls.reg)
|
||||||
|
cls.token = server.load_token(os.path.join(cls.tmp, "token"))
|
||||||
|
cls.app = server.App(cls.runner, cls.token, export_dir=os.path.join(cls.tmp, "export"))
|
||||||
|
cls.srv = server.make_server(cls.app, "127.0.0.1", 0)
|
||||||
|
cls.port = cls.srv.server_address[1]
|
||||||
|
threading.Thread(target=cls.srv.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.srv.shutdown()
|
||||||
|
cls.srv.server_close()
|
||||||
|
shutil.rmtree(cls.tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# A clean history per test: a PASS left by an earlier one would be
|
||||||
|
# inherited and drop that test out of the selection. Truncating is
|
||||||
|
# also the shrunk-file path Log.read has to notice.
|
||||||
|
open(self.log.path, "w").close()
|
||||||
|
self.assertEqual(self.log.read(), [])
|
||||||
|
self.runner.batch = None
|
||||||
|
|
||||||
|
def call(self, method, path, body=None):
|
||||||
|
url = "http://127.0.0.1:%d%s" % (self.port, path)
|
||||||
|
hdrs = {"Host": "127.0.0.1:%d" % self.port, "X-ForgeFIRM-Token": self.token}
|
||||||
|
data = None
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
hdrs["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
return r.status, json.loads(r.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, json.loads(e.read().decode())
|
||||||
|
|
||||||
|
def wait_queue(self, timeout=30):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
st, d = self.call("GET", "/state")
|
||||||
|
if d["batch"] and d["batch"]["finished"]:
|
||||||
|
return d
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.fail("the queue did not finish")
|
||||||
|
|
||||||
|
# -- selection ---------------------------------------------------------
|
||||||
|
def test_groups_split_the_catalog_by_who_has_to_be_there(self):
|
||||||
|
self.assertEqual(BATCH_GROUPS["unattended"], ("auto",))
|
||||||
|
self.assertEqual(sorted(BATCH_GROUPS["attended"]), ["live", "operator"])
|
||||||
|
st, d = self.call("GET", "/state")
|
||||||
|
self.assertEqual(d["batch_available"]["unattended"], ["q.a", "q.b", "q.c"])
|
||||||
|
self.assertEqual(d["batch_available"]["attended"], ["q.op", "q.live"])
|
||||||
|
|
||||||
|
def test_selection_runs_prerequisites_first(self):
|
||||||
|
"""q.b registers before q.a but requires it."""
|
||||||
|
st, d = self.call("GET", "/state")
|
||||||
|
order = d["batch_available"]["unattended"]
|
||||||
|
self.assertLess(order.index("q.a"), order.index("q.b"))
|
||||||
|
|
||||||
|
def test_satisfied_tests_drop_out(self):
|
||||||
|
st, d = self.call("POST", "/start", {"test": "q.c"})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
self.wait_idle()
|
||||||
|
st, d = self.call("GET", "/state")
|
||||||
|
self.assertEqual(d["tests"]["q.c"]["status"], "pass")
|
||||||
|
self.assertNotIn("q.c", d["batch_available"]["unattended"])
|
||||||
|
self.assertEqual(d["batch_available"]["unattended"], ["q.a", "q.b"])
|
||||||
|
|
||||||
|
def wait_idle(self, timeout=20):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
st, d = self.call("GET", "/state")
|
||||||
|
if not d["running"]:
|
||||||
|
return d
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.fail("run did not finish")
|
||||||
|
|
||||||
|
# -- running -----------------------------------------------------------
|
||||||
|
def test_unattended_queue_runs_everything_in_order(self):
|
||||||
|
st, d = self.call("POST", "/batch", {"group": "unattended"})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
self.assertEqual(d["order"], ["q.a", "q.b", "q.c"])
|
||||||
|
d = self.wait_queue()
|
||||||
|
b = d["batch"]
|
||||||
|
self.assertEqual([x["test"] for x in b["done"]], ["q.a", "q.b", "q.c"])
|
||||||
|
self.assertEqual(set(x["result"] for x in b["done"]), {"PASS"})
|
||||||
|
self.assertEqual(b["skipped"], [])
|
||||||
|
self.assertIsNone(b["stopped"])
|
||||||
|
for tid in ("q.a", "q.b", "q.c"):
|
||||||
|
self.assertEqual(d["tests"][tid]["status"], "pass", tid)
|
||||||
|
# and the run records say which queue put them there
|
||||||
|
st, rec = self.call("GET", "/result?test=q.b")
|
||||||
|
self.assertEqual(rec["evidence"]["batch"]["group"], "unattended")
|
||||||
|
|
||||||
|
def test_an_empty_queue_is_refused_not_started(self):
|
||||||
|
self.call("POST", "/batch", {"group": "unattended"})
|
||||||
|
self.wait_queue()
|
||||||
|
st, d = self.call("POST", "/batch", {"group": "unattended"})
|
||||||
|
self.assertEqual(st, 409)
|
||||||
|
self.assertIn("already satisfied", d["message"])
|
||||||
|
|
||||||
|
def test_a_failure_stops_the_queue(self):
|
||||||
|
reg = helpers.registry(
|
||||||
|
helpers.make_test("f.a", [("forgectrl", "src/ui.c")], fn=t_pass),
|
||||||
|
helpers.make_test("f.b", [("forgectrl", "src/auth.c")], fn=t_fail),
|
||||||
|
helpers.make_test("f.c", [("forgectrl", "src/cool.c")], fn=t_pass),
|
||||||
|
)
|
||||||
|
tmp = tempfile.mkdtemp(prefix="forgetest-fail-")
|
||||||
|
try:
|
||||||
|
r = Runner(Log(os.path.join(tmp, "r.jsonl")), self.man, reg)
|
||||||
|
ok, msg, order = r.start_batch("unattended")
|
||||||
|
self.assertTrue(ok, msg)
|
||||||
|
self.assertEqual(order, ["f.a", "f.b", "f.c"])
|
||||||
|
deadline = time.time() + 20
|
||||||
|
while not r.batch["finished"] and time.time() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
b = r.batch_snapshot()
|
||||||
|
self.assertEqual([x["test"] for x in b["done"]], ["f.a", "f.b"])
|
||||||
|
self.assertEqual(b["done"][1]["result"], "FAIL")
|
||||||
|
self.assertEqual(b["stopped"], "FAIL on f.b")
|
||||||
|
self.assertEqual(b["pending"], ["f.c"], "f.c ran after a FAIL closed the campaign")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_a_test_that_cannot_start_is_skipped_not_silently_dropped(self):
|
||||||
|
"""An unmet prerequisite outside the queue refuses the start; the
|
||||||
|
queue records why and carries on."""
|
||||||
|
reg = helpers.registry(
|
||||||
|
helpers.make_test("k.op", [("forgectrl", "src/main.c")], kind="operator", fn=t_pass),
|
||||||
|
helpers.make_test("k.a", [("forgectrl", "src/ui.c")], requires=("k.op",), fn=t_pass),
|
||||||
|
helpers.make_test("k.b", [("forgectrl", "src/auth.c")], fn=t_pass),
|
||||||
|
)
|
||||||
|
tmp = tempfile.mkdtemp(prefix="forgetest-skip-")
|
||||||
|
try:
|
||||||
|
r = Runner(Log(os.path.join(tmp, "r.jsonl")), self.man, reg)
|
||||||
|
ok, msg, order = r.start_batch("unattended")
|
||||||
|
self.assertEqual(order, ["k.a", "k.b"])
|
||||||
|
deadline = time.time() + 20
|
||||||
|
while not r.batch["finished"] and time.time() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
b = r.batch_snapshot()
|
||||||
|
self.assertEqual([x["test"] for x in b["skipped"]], ["k.a"])
|
||||||
|
self.assertIn("prerequisites not satisfied", b["skipped"][0]["reason"])
|
||||||
|
self.assertEqual([x["test"] for x in b["done"]], ["k.b"])
|
||||||
|
self.assertIsNone(b["stopped"])
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
# -- safety and exclusion ----------------------------------------------
|
||||||
|
def test_an_attended_queue_with_a_live_test_needs_the_acknowledgment(self):
|
||||||
|
st, d = self.call("POST", "/batch", {"group": "attended"})
|
||||||
|
self.assertEqual(st, 409)
|
||||||
|
self.assertIn("fires the laser", d["message"])
|
||||||
|
self.assertIn("q.live", d["message"])
|
||||||
|
st, d = self.call("GET", "/state")
|
||||||
|
self.assertIsNone(d["batch"], "a refused queue must not exist")
|
||||||
|
|
||||||
|
def test_the_acknowledgment_reaches_every_live_run(self):
|
||||||
|
st, d = self.call("POST", "/batch", {"group": "attended", "ack_live": True})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
# the operator test prompts; answer it so the queue can reach the live one
|
||||||
|
deadline = time.time() + 20
|
||||||
|
while time.time() < deadline:
|
||||||
|
st, s = self.call("GET", "/state")
|
||||||
|
if s["running"] and s["running"]["prompt"]:
|
||||||
|
self.call("POST", "/answer", {"prompt_id": s["running"]["prompt"]["id"],
|
||||||
|
"value": "Continue"})
|
||||||
|
break
|
||||||
|
if s["batch"] and s["batch"]["finished"]:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
d = self.wait_queue()
|
||||||
|
self.assertEqual([x["test"] for x in d["batch"]["done"]], ["q.op", "q.live"])
|
||||||
|
st, rec = self.call("GET", "/result?test=q.live")
|
||||||
|
self.assertTrue(rec["evidence"]["operator"]["ack_live"])
|
||||||
|
|
||||||
|
def test_a_single_start_is_refused_while_a_queue_runs(self):
|
||||||
|
reg = helpers.registry(
|
||||||
|
helpers.make_test("s.slow", [("forgectrl", "src/ui.c")], fn=t_slow),
|
||||||
|
helpers.make_test("s.other", [("forgectrl", "src/auth.c")], fn=t_pass),
|
||||||
|
)
|
||||||
|
tmp = tempfile.mkdtemp(prefix="forgetest-excl-")
|
||||||
|
try:
|
||||||
|
r = Runner(Log(os.path.join(tmp, "r.jsonl")), self.man, reg)
|
||||||
|
r.start_batch("unattended")
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while r.current is None and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
ok, msg = r.start_test("s.other")
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(msg, "a queue is running")
|
||||||
|
ok, msg = r.start_bench("anything")
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(msg, "a queue is running")
|
||||||
|
ok, msg, _ = r.start_batch("unattended")
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIn("already running", msg)
|
||||||
|
r.stop_batch()
|
||||||
|
r.abort()
|
||||||
|
deadline = time.time() + 20
|
||||||
|
while not r.batch["finished"] and time.time() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_stop_cancels_what_is_waiting(self):
|
||||||
|
reg = helpers.registry(
|
||||||
|
helpers.make_test("p.slow", [("forgectrl", "src/ui.c")], fn=t_slow),
|
||||||
|
helpers.make_test("p.next", [("forgectrl", "src/auth.c")], fn=t_pass),
|
||||||
|
)
|
||||||
|
tmp = tempfile.mkdtemp(prefix="forgetest-stop-")
|
||||||
|
try:
|
||||||
|
r = Runner(Log(os.path.join(tmp, "r.jsonl")), self.man, reg)
|
||||||
|
r.start_batch("unattended")
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while r.current is None and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
ok, msg = r.stop_batch()
|
||||||
|
self.assertTrue(ok, msg)
|
||||||
|
self.assertTrue(r.batch_snapshot()["stopping"])
|
||||||
|
r.abort() # the run in progress needs its own lever
|
||||||
|
deadline = time.time() + 20
|
||||||
|
while not r.batch["finished"] and time.time() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
b = r.batch_snapshot()
|
||||||
|
self.assertNotIn("p.next", [x["test"] for x in b["done"]])
|
||||||
|
ok, msg = r.stop_batch()
|
||||||
|
self.assertFalse(ok)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_unknown_group(self):
|
||||||
|
st, d = self.call("POST", "/batch", {"group": "everything"})
|
||||||
|
self.assertEqual(st, 409)
|
||||||
|
self.assertEqual(d["message"], "unknown queue")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""What keeps the page answering the operator instead of the timer.
|
||||||
|
|
||||||
|
Two things went wrong on the bench and are pinned here:
|
||||||
|
|
||||||
|
- the state cost. Every poll re-read and re-parsed the whole result log,
|
||||||
|
and recomputed every test's domain fingerprint. A result record carries
|
||||||
|
its run log, so the file reaches megabytes over a campaign and the poll
|
||||||
|
grew with it. Both are now parsed and computed once.
|
||||||
|
- the wasted payload. An idle page polls an unchanged state; it now gets
|
||||||
|
a 304 instead of the whole thing.
|
||||||
|
|
||||||
|
The click-swallowing defect these were found with lives in the page's
|
||||||
|
JavaScript and is not reachable from here: rows, prompt buttons and tool
|
||||||
|
entries are built once and afterwards only updated in place, because a
|
||||||
|
poll that rebuilt them removed the button between the operator's mousedown
|
||||||
|
and mouseup, and no click event was ever raised. `test_page_never_rebuilds`
|
||||||
|
holds the shape of that rule.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers
|
||||||
|
from forgetest import catalog, manifest as manifest_mod, page
|
||||||
|
from forgetest.log import Log
|
||||||
|
|
||||||
|
|
||||||
|
def t_noop(ctx):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LogCacheTests(unittest.TestCase):
|
||||||
|
"""The log is append-only, so a read parses each line exactly once."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix="forgetest-log-")
|
||||||
|
self.path = os.path.join(self.tmp, "results.jsonl")
|
||||||
|
self.log = Log(self.path)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def write(self, *lines):
|
||||||
|
with open(self.path, "a", encoding="utf-8") as f:
|
||||||
|
for line in lines:
|
||||||
|
f.write(line + "\n")
|
||||||
|
|
||||||
|
def test_missing_file_reads_empty(self):
|
||||||
|
self.assertEqual(self.log.read(), [])
|
||||||
|
|
||||||
|
def test_appends_are_picked_up(self):
|
||||||
|
self.log.append({"t": "campaign", "id": "c1"})
|
||||||
|
self.assertEqual([r["id"] for r in self.log.read()], ["c1"])
|
||||||
|
self.log.append({"t": "result", "test": "a.b"})
|
||||||
|
self.log.append({"t": "result", "test": "c.d"})
|
||||||
|
recs = self.log.read()
|
||||||
|
self.assertEqual([r["t"] for r in recs], ["campaign", "result", "result"])
|
||||||
|
self.assertEqual(recs[2]["test"], "c.d")
|
||||||
|
|
||||||
|
def test_only_new_bytes_are_parsed(self):
|
||||||
|
for i in range(5):
|
||||||
|
self.log.append({"t": "result", "test": "t%d" % i})
|
||||||
|
self.log.read()
|
||||||
|
# a second read must not touch the file at all
|
||||||
|
real_open = open
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def counting_open(*a, **kw):
|
||||||
|
opened.append(a[0])
|
||||||
|
return real_open(*a, **kw)
|
||||||
|
|
||||||
|
import builtins
|
||||||
|
builtins.open = counting_open
|
||||||
|
try:
|
||||||
|
recs = self.log.read()
|
||||||
|
finally:
|
||||||
|
builtins.open = real_open
|
||||||
|
self.assertEqual(opened, [], "an unchanged log was re-opened")
|
||||||
|
self.assertEqual(len(recs), 5)
|
||||||
|
|
||||||
|
def test_read_returns_a_fresh_list(self):
|
||||||
|
"""Callers filter and sort the result; the cache must not be theirs."""
|
||||||
|
self.log.append({"t": "result", "test": "a.b"})
|
||||||
|
first = self.log.read()
|
||||||
|
first.append({"t": "bogus"})
|
||||||
|
self.assertEqual(len(self.log.read()), 1)
|
||||||
|
|
||||||
|
def test_partial_trailing_line_is_held_not_counted_corrupt(self):
|
||||||
|
self.write('{"t":"result","test":"a.b"}')
|
||||||
|
with open(self.path, "a", encoding="utf-8") as f:
|
||||||
|
f.write('{"t":"result","te') # a line still being written
|
||||||
|
recs = self.log.read()
|
||||||
|
self.assertEqual(len(recs), 1)
|
||||||
|
self.assertEqual(self.log.corrupt, 0)
|
||||||
|
with open(self.path, "a", encoding="utf-8") as f:
|
||||||
|
f.write('st":"c.d"}\n') # ... now finished
|
||||||
|
recs = self.log.read()
|
||||||
|
self.assertEqual([r["test"] for r in recs], ["a.b", "c.d"])
|
||||||
|
self.assertEqual(self.log.corrupt, 0)
|
||||||
|
|
||||||
|
def test_corrupt_lines_counted_once_not_per_read(self):
|
||||||
|
self.write('{"t":"result","test":"a.b"}', "not json at all", '["not","a","record"]',
|
||||||
|
'{"t":"result","test":"c.d"}')
|
||||||
|
recs = self.log.read()
|
||||||
|
self.assertEqual([r["test"] for r in recs], ["a.b", "c.d"])
|
||||||
|
self.assertEqual(self.log.corrupt, 2)
|
||||||
|
self.log.read()
|
||||||
|
self.log.read()
|
||||||
|
self.assertEqual(self.log.corrupt, 2, "corrupt lines re-counted on every read")
|
||||||
|
|
||||||
|
def test_replaced_file_is_read_again(self):
|
||||||
|
self.write('{"t":"result","test":"a.b"}', '{"t":"result","test":"c.d"}')
|
||||||
|
self.assertEqual(len(self.log.read()), 2)
|
||||||
|
with open(self.path, "w", encoding="utf-8") as f:
|
||||||
|
f.write('{"t":"campaign","id":"c9"}\n')
|
||||||
|
recs = self.log.read()
|
||||||
|
self.assertEqual([r.get("id") for r in recs], ["c9"])
|
||||||
|
|
||||||
|
def test_blank_lines_are_not_corrupt(self):
|
||||||
|
self.write('{"t":"result","test":"a.b"}', "", " ", '{"t":"result","test":"c.d"}')
|
||||||
|
self.assertEqual(len(self.log.read()), 2)
|
||||||
|
self.assertEqual(self.log.corrupt, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class FingerprintCacheTests(unittest.TestCase):
|
||||||
|
"""Memoized per manifest: the page recomputes every test's fingerprint
|
||||||
|
on every poll, and a manifest never changes under a running tool."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.man = helpers.make_manifest()
|
||||||
|
self.t = helpers.make_test("fake.fp", [("forgectrl", "src/ui.c")], fn=t_noop)
|
||||||
|
|
||||||
|
def test_repeat_calls_agree(self):
|
||||||
|
first = self.t.fingerprint(self.man)
|
||||||
|
self.assertEqual(self.t.fingerprint(self.man), first)
|
||||||
|
self.assertEqual(self.t.fingerprint(self.man), first)
|
||||||
|
|
||||||
|
def test_a_changed_manifest_is_not_served_from_the_cache(self):
|
||||||
|
first = self.t.fingerprint(self.man)
|
||||||
|
man2 = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
|
||||||
|
self.assertNotEqual(self.t.fingerprint(man2), first)
|
||||||
|
# and back again: the cache must not have latched the new one either
|
||||||
|
self.assertEqual(self.t.fingerprint(self.man), first)
|
||||||
|
|
||||||
|
def test_a_file_outside_the_coverage_does_not_move_it(self):
|
||||||
|
first = self.t.fingerprint(self.man)
|
||||||
|
man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
|
||||||
|
self.assertEqual(self.t.fingerprint(man2), first)
|
||||||
|
|
||||||
|
def test_manifest_without_a_content_hash_is_not_cached(self):
|
||||||
|
"""The cache is keyed by the manifest's content hash. A manifest
|
||||||
|
that has none must still fingerprint correctly, not collide."""
|
||||||
|
a = manifest_mod.Manifest({"components": {"forgectrl": {"files": [["src/ui.c", "aaa"]]}},
|
||||||
|
"platform": {}})
|
||||||
|
b = manifest_mod.Manifest({"components": {"forgectrl": {"files": [["src/ui.c", "bbb"]]}},
|
||||||
|
"platform": {}})
|
||||||
|
self.assertIsNone(a.content_sha)
|
||||||
|
self.assertNotEqual(self.t.fingerprint(a), self.t.fingerprint(b))
|
||||||
|
|
||||||
|
def test_component_file_list_is_stable_and_read_only(self):
|
||||||
|
files = self.man.files("forgectrl")
|
||||||
|
self.assertEqual(files, self.man.files("forgectrl"))
|
||||||
|
self.assertIsNone(self.man.files("no-such-component"))
|
||||||
|
|
||||||
|
|
||||||
|
class PageTests(unittest.TestCase):
|
||||||
|
def test_page_never_rebuilds_what_the_operator_may_be_pressing(self):
|
||||||
|
"""A poll must update rows, prompt buttons and tool entries in
|
||||||
|
place. Assigning innerHTML to their containers on every poll is
|
||||||
|
what swallowed the clicks; only the one-time build may do it."""
|
||||||
|
html = page.render("0" * 32)
|
||||||
|
for container, builder in (("groups", "buildGroups"), ("tools", "buildBench")):
|
||||||
|
self.assertIn("function %s(" % builder, html,
|
||||||
|
"no one-time builder for #%s" % container)
|
||||||
|
assigns = html.count("$('%s').innerHTML=" % container)
|
||||||
|
self.assertEqual(assigns, 1,
|
||||||
|
"#%s is assigned innerHTML %d times; it belongs to the "
|
||||||
|
"builder alone" % (container, assigns))
|
||||||
|
# the per-poll path writes through the guarded setters only
|
||||||
|
self.assertIn("function setHtml(e,h){if(e&&e.__h!==h)", html)
|
||||||
|
self.assertIn("function updateGroups()", html)
|
||||||
|
self.assertIn("function updateBench()", html)
|
||||||
|
# the prompt buttons are rebuilt only when the prompt changes
|
||||||
|
self.assertIn("if(pk!==promptKey)", html)
|
||||||
|
# the queue controls are static markup: a poll relabels them and
|
||||||
|
# flips disabled, it never replaces the node
|
||||||
|
for bid in ("q-unattended", "q-attended", "q-stop"):
|
||||||
|
self.assertIn('id="%s"' % bid, html)
|
||||||
|
self.assertNotIn("id='" + bid + "-", html)
|
||||||
|
self.assertNotIn('id="' + bid + "-", html)
|
||||||
|
self.assertIn("function renderQueue()", html)
|
||||||
|
# the help popovers sit on static markup only: a rebuilt row or
|
||||||
|
# tool entry would orphan one
|
||||||
|
self.assertNotIn("data-help", html[html.index("function buildGroups("):])
|
||||||
|
|
||||||
|
def test_every_group_is_built_on_the_same_grid(self):
|
||||||
|
"""The subsystems are separate tables. Left to size themselves
|
||||||
|
from their own content no two line up, which is what made the
|
||||||
|
page look busy, so they share one colgroup and a fixed layout."""
|
||||||
|
html = page.render("0" * 32)
|
||||||
|
self.assertRegex(html, r"#groups table\s*\{\s*table-layout:\s*fixed")
|
||||||
|
# one colgroup definition, emitted into every group's table
|
||||||
|
self.assertEqual(html.count('var COLS="<colgroup>'), 1)
|
||||||
|
self.assertEqual(html.count('<table>"+COLS+"'), 1,
|
||||||
|
"a group table built without the shared colgroup")
|
||||||
|
colgroup = re.search(r'var COLS="(.*?)";', html).group(1)
|
||||||
|
heads = ["Test", "Kind", "Status", "Last result"]
|
||||||
|
for h in heads:
|
||||||
|
self.assertIn("<th>%s</th>" % h, html)
|
||||||
|
self.assertEqual(len(re.findall(r"<col(?:>| )", colgroup)), len(heads) + 1,
|
||||||
|
"a column per header, the action column included")
|
||||||
|
# every fixed column carries a width, or the grid is only a wish
|
||||||
|
for cls in re.findall(r"<col class='(\w)'", colgroup):
|
||||||
|
self.assertRegex(html, r"#groups col\.%s\s*\{\s*width:\s*\d+px" % cls)
|
||||||
|
|
||||||
|
def test_details_and_the_requires_note_stay_out_of_the_columns(self):
|
||||||
|
"""Both are long enough to stretch a cell. In a column they would
|
||||||
|
pull one group's grid out of step with the rest: the details get a
|
||||||
|
full-width row, and the note sits under Status, which is sized for
|
||||||
|
it."""
|
||||||
|
html = page.render("0" * 32)
|
||||||
|
self.assertIn("<tr class='detrow'><td colspan='5'>", html)
|
||||||
|
# the status cell carries the status, the unmet note and the row
|
||||||
|
# message; the action cell carries the button and nothing else
|
||||||
|
self.assertIn("<td><div id='st-\"+d+\"'></div><div id='unmet-\"+d+"
|
||||||
|
"\"'></div><div id='note-\"+d+\"'></div></td>", html)
|
||||||
|
self.assertIn("<td><button class='btn btn-sm btn-primary' id='btn-\"+d+\"' "
|
||||||
|
"onclick='startTest(\\\"\"+d+\"\\\")'>Start</button></td>", html)
|
||||||
|
|
||||||
|
def test_page_is_self_contained_ascii(self):
|
||||||
|
html = page.render("0" * 32)
|
||||||
|
self.assertNotIn("__TOKEN__", html)
|
||||||
|
# our own sources stay ASCII (no stray typography in an embedded
|
||||||
|
# page); the vendored Bootstrap carries its own glyphs
|
||||||
|
for name in ("index.html",) + page.CSS_FILES + page.JS_FILES:
|
||||||
|
if name.startswith("vendor/"):
|
||||||
|
continue
|
||||||
|
page.read_ui(name).encode("ascii")
|
||||||
|
stray = sorted(set(hex(ord(c)) for c in html if ord(c) < 32 and c != "\n"))
|
||||||
|
self.assertEqual(stray, [], "control characters in the page source")
|
||||||
|
# nothing fetched from anywhere: the documentation links open a
|
||||||
|
# site, they are not assets
|
||||||
|
for remote in ("<link ", "<script src=", "@import", "url(http", "url(//",
|
||||||
|
'src="http', "src='http", "//cdn"):
|
||||||
|
self.assertNotIn(remote, html)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
"""Runner + HTTP API end to end on localhost with a fake catalog and a
|
||||||
|
fake bench tool. No hardware, no forgectrl."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import helpers
|
||||||
|
from forgetest import bench as bench_mod
|
||||||
|
from forgetest import catalog, server
|
||||||
|
from forgetest.log import Log
|
||||||
|
from forgetest.runner import Failed, Runner
|
||||||
|
|
||||||
|
|
||||||
|
def t_pass(ctx):
|
||||||
|
ctx.log("hello")
|
||||||
|
ctx.evidence["k"] = 1
|
||||||
|
|
||||||
|
|
||||||
|
def t_prompt(ctx):
|
||||||
|
ans = ctx.prompt("Did the light blink?", ("Yes", "No"))
|
||||||
|
if ans != "Yes":
|
||||||
|
raise Failed("operator said no")
|
||||||
|
|
||||||
|
|
||||||
|
def t_fail(ctx):
|
||||||
|
ctx.check(False, "deliberate")
|
||||||
|
|
||||||
|
|
||||||
|
def t_slow(ctx):
|
||||||
|
ctx.sleep(30)
|
||||||
|
|
||||||
|
|
||||||
|
def t_error(ctx):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
|
||||||
|
class ServerTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.tmp = tempfile.mkdtemp(prefix="forgetest-")
|
||||||
|
os.environ["FORGETEST_DATA"] = cls.tmp
|
||||||
|
os.environ["FORGETEST_MARKER"] = os.path.join(cls.tmp, "marker")
|
||||||
|
cls.man = helpers.make_manifest()
|
||||||
|
cls.reg = helpers.registry(
|
||||||
|
helpers.make_test("fake.pass", [("forgectrl", "src/ui.c")], always=True, fn=t_pass),
|
||||||
|
helpers.make_test("fake.prompt", [("forgectrl", "src/auth.c")], fn=t_prompt, kind="operator"),
|
||||||
|
helpers.make_test("fake.fail", [("forgectrl", "src/cool.c")], fn=t_fail),
|
||||||
|
helpers.make_test("fake.slow", [("forgectrl", "src/main.c")], fn=t_slow),
|
||||||
|
helpers.make_test("fake.error", [("forgectrl", "src/main.c")], fn=t_error),
|
||||||
|
helpers.make_test("fake.live", [("grblhal-glowforge", "src/**")], fn=t_pass, kind="live"),
|
||||||
|
helpers.make_test("fake.needs", [("kernel-module-glowforge", "**")], fn=t_pass,
|
||||||
|
requires=("fake.prompt",)),
|
||||||
|
)
|
||||||
|
# a fake bench tool
|
||||||
|
cls.tooldir = os.path.join(cls.tmp, "bench")
|
||||||
|
os.makedirs(cls.tooldir)
|
||||||
|
with open(os.path.join(cls.tooldir, "echo_tool.py"), "w") as f:
|
||||||
|
f.write("import os, sys, time\nprint('args', sys.argv[1:])\n"
|
||||||
|
"print('env', os.environ.get('GF_HOST'), os.environ.get('FORGETEST_BENCH_DATA'))\n"
|
||||||
|
"sys.stdout.flush()\n"
|
||||||
|
"if 'slow' in sys.argv: time.sleep(30)\nsys.exit(0 if 'fail' not in sys.argv else 3)\n")
|
||||||
|
tools = [{"id": "echo", "title": "Echo", "script": "echo_tool.py", "safety": "dry", "where": "board",
|
||||||
|
"ported": True, "desc": "echo",
|
||||||
|
"args": [{"name": "word", "type": "str", "default": "hi", "help": ""},
|
||||||
|
{"name": "n", "type": "int", "default": 2, "help": ""}]},
|
||||||
|
{"id": "unported", "title": "U", "script": "nope.py", "safety": "dry", "where": "host",
|
||||||
|
"ported": False, "desc": "", "args": []},
|
||||||
|
{"id": "hot", "title": "H", "script": "echo_tool.py", "safety": "live", "where": "board",
|
||||||
|
"ported": True, "desc": "", "args": []},
|
||||||
|
{"id": "tk", "title": "T", "script": "echo_tool.py", "safety": "takeover", "where": "board",
|
||||||
|
"ported": True, "desc": "", "args": []},
|
||||||
|
{"id": "sc", "title": "S", "script": "echo_tool.py", "safety": "scope", "where": "board",
|
||||||
|
"ported": True, "desc": "", "args": []}]
|
||||||
|
cls.bench = bench_mod.Bench(tools, tool_dir=cls.tooldir,
|
||||||
|
index_path=os.path.join(cls.tmp, "bench.jsonl"))
|
||||||
|
cls.log = Log(os.path.join(cls.tmp, "results.jsonl"))
|
||||||
|
cls.runner = Runner(cls.log, cls.man, cls.reg, cls.bench)
|
||||||
|
cls.token = server.load_token(os.path.join(cls.tmp, "token"))
|
||||||
|
cls.app = server.App(cls.runner, cls.token, export_dir=os.path.join(cls.tmp, "export"))
|
||||||
|
cls.srv = server.make_server(cls.app, "127.0.0.1", 0)
|
||||||
|
cls.port = cls.srv.server_address[1]
|
||||||
|
cls.th = threading.Thread(target=cls.srv.serve_forever, daemon=True)
|
||||||
|
cls.th.start()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.srv.shutdown()
|
||||||
|
cls.srv.server_close()
|
||||||
|
shutil.rmtree(cls.tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
# -- helpers ----------------------------------------------------------
|
||||||
|
def call(self, method, path, body=None, token=True, headers=None):
|
||||||
|
st, payload, _ = self.call_h(method, path, body, token, headers)
|
||||||
|
return st, payload
|
||||||
|
|
||||||
|
def call_h(self, method, path, body=None, token=True, headers=None):
|
||||||
|
"""As call, plus the response headers."""
|
||||||
|
url = "http://127.0.0.1:%d%s" % (self.port, path)
|
||||||
|
hdrs = {"Host": "127.0.0.1:%d" % self.port}
|
||||||
|
if token:
|
||||||
|
hdrs["X-ForgeFIRM-Token"] = self.token
|
||||||
|
data = None
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
hdrs["Content-Type"] = "application/json"
|
||||||
|
if headers:
|
||||||
|
hdrs.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
raw = r.read()
|
||||||
|
st = r.status
|
||||||
|
rh = r.headers
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read()
|
||||||
|
st = e.code
|
||||||
|
rh = e.headers
|
||||||
|
ct = rh.get("Content-Type", "")
|
||||||
|
if "json" in ct:
|
||||||
|
return st, json.loads(raw.decode()), rh
|
||||||
|
return st, raw, rh
|
||||||
|
|
||||||
|
def wait_idle(self, timeout=10):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
st, d = self.call("GET", "/state")
|
||||||
|
if not d["running"]:
|
||||||
|
return d
|
||||||
|
time.sleep(0.1)
|
||||||
|
self.fail("run did not finish")
|
||||||
|
|
||||||
|
def wait_prompt(self, timeout=10):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
st, d = self.call("GET", "/state")
|
||||||
|
if d["running"] and d["running"]["prompt"]:
|
||||||
|
return d["running"]["prompt"]
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.fail("no prompt appeared")
|
||||||
|
|
||||||
|
# -- tests -----------------------------------------------------------------
|
||||||
|
def test_01_auth_and_page(self):
|
||||||
|
st, d = self.call("GET", "/state", token=False)
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
st, d = self.call("GET", "/", token=False)
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
self.assertIn(self.token.encode(), d)
|
||||||
|
page = d.decode("utf-8")
|
||||||
|
# one self-contained response: nothing linked, the token once,
|
||||||
|
# the theme attribute the head script sets
|
||||||
|
self.assertEqual(page.count(self.token), 1)
|
||||||
|
self.assertNotIn("<link ", page)
|
||||||
|
self.assertNotIn("<script src=", page)
|
||||||
|
self.assertIn("data-bs-theme", page)
|
||||||
|
st, d = self.call("GET", "/state", headers={"Host": "evil.example.net"})
|
||||||
|
self.assertEqual(st, 403)
|
||||||
|
st, d = self.call("GET", "/state", headers={"Origin": "http://evil.example.net"})
|
||||||
|
self.assertEqual(st, 403)
|
||||||
|
st, d = self.call("GET", "/state", headers={"Sec-Fetch-Site": "cross-site"})
|
||||||
|
self.assertEqual(st, 403)
|
||||||
|
st, d = self.call("POST", "/start", {"test": "fake.pass"}, token=False)
|
||||||
|
self.assertEqual(st, 403)
|
||||||
|
self.assertEqual(d["error"], "authentication required")
|
||||||
|
st, d = self.call("GET", "/catalog")
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
self.assertEqual(len(d["tests"]), 7)
|
||||||
|
st, d = self.call("GET", "/nope")
|
||||||
|
self.assertEqual(st, 404)
|
||||||
|
|
||||||
|
def test_01c_connection_is_kept_alive(self):
|
||||||
|
"""A poll per second over a fresh TCP connection each time is
|
||||||
|
waste the board does not need to pay."""
|
||||||
|
import http.client
|
||||||
|
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||||
|
try:
|
||||||
|
for _ in range(3):
|
||||||
|
c.request("GET", "/state", headers={"Host": "127.0.0.1:%d" % self.port})
|
||||||
|
r = c.getresponse()
|
||||||
|
r.read()
|
||||||
|
self.assertEqual(r.status, 200)
|
||||||
|
self.assertEqual(r.version, 11)
|
||||||
|
self.assertNotEqual((r.getheader("Connection") or "").lower(), "close")
|
||||||
|
self.assertIsNotNone(r.getheader("Content-Length"))
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
def test_01d_refused_post_does_not_poison_the_connection(self):
|
||||||
|
"""A POST refused before its body is read leaves that body in the
|
||||||
|
socket. On a kept-alive connection the next read would take it for
|
||||||
|
a request line, so a refusal must end the connection instead."""
|
||||||
|
import http.client
|
||||||
|
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||||
|
try:
|
||||||
|
body = json.dumps({"test": "fake.pass"}).encode()
|
||||||
|
c.request("POST", "/start", body=body,
|
||||||
|
headers={"Host": "127.0.0.1:%d" % self.port,
|
||||||
|
"Content-Type": "application/json"}) # no token
|
||||||
|
r = c.getresponse()
|
||||||
|
payload = json.loads(r.read().decode())
|
||||||
|
self.assertEqual(r.status, 403)
|
||||||
|
self.assertEqual(payload["error"], "authentication required")
|
||||||
|
self.assertEqual((r.getheader("Connection") or "").lower(), "close",
|
||||||
|
"an unread body was left on a connection kept alive")
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
# the server is still healthy, and the body was never taken for a request
|
||||||
|
st, d = self.call("GET", "/state")
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
self.assertIsNone(d["running"])
|
||||||
|
|
||||||
|
def test_02_run_pass_opens_campaign(self):
|
||||||
|
st, d = self.call("GET", "/state")
|
||||||
|
self.assertIsNone(d["campaign"])
|
||||||
|
st, d = self.call("POST", "/start", {"test": "fake.pass"})
|
||||||
|
self.assertEqual(st, 200, d)
|
||||||
|
state = self.wait_idle()
|
||||||
|
self.assertIsNotNone(state["campaign"])
|
||||||
|
self.assertEqual(state["tests"]["fake.pass"]["status"], "pass")
|
||||||
|
self.assertEqual(state["last_run"]["finished"]["result"], "PASS")
|
||||||
|
st, rec = self.call("GET", "/result?test=fake.pass")
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
self.assertEqual(rec["evidence"]["k"], 1)
|
||||||
|
# the baseline passes ran (no machine on the host: nothing to restore)
|
||||||
|
self.assertEqual(rec["evidence"]["baseline"], {"pre": [], "post": []})
|
||||||
|
self.assertTrue(any("hello" in l for l in rec["log"]))
|
||||||
|
|
||||||
|
def test_03_requires_and_live_gate(self):
|
||||||
|
st, d = self.call("POST", "/start", {"test": "fake.needs"})
|
||||||
|
self.assertEqual(st, 409)
|
||||||
|
self.assertIn("prerequisites", d["message"])
|
||||||
|
# the operator's override: the test runs alone and the record says so
|
||||||
|
st, d = self.call("POST", "/start", {"test": "fake.needs", "ignore_requires": True})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
state = self.wait_idle()
|
||||||
|
self.assertEqual(state["tests"]["fake.needs"]["status"], "pass")
|
||||||
|
st, rec = self.call("GET", "/result?test=fake.needs")
|
||||||
|
self.assertEqual(rec["evidence"]["prerequisites"]["missing"], ["fake.prompt"])
|
||||||
|
self.assertTrue(rec["evidence"]["prerequisites"]["overridden"])
|
||||||
|
self.assertTrue(any("prerequisites overridden" in l for l in rec["log"]))
|
||||||
|
# its prerequisite is still required for the release
|
||||||
|
self.assertFalse(state["tests"]["fake.prompt"]["satisfied"])
|
||||||
|
self.assertFalse(state["authorized"])
|
||||||
|
st, d = self.call("POST", "/start", {"test": "fake.live"})
|
||||||
|
self.assertEqual(st, 409)
|
||||||
|
self.assertIn("live", d["message"])
|
||||||
|
st, d = self.call("POST", "/start", {"test": "fake.live", "ack_live": True})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
state = self.wait_idle()
|
||||||
|
self.assertEqual(state["tests"]["fake.live"]["status"], "pass")
|
||||||
|
st, rec = self.call("GET", "/result?test=fake.live")
|
||||||
|
self.assertTrue(rec["evidence"]["operator"]["ack_live"])
|
||||||
|
|
||||||
|
def test_04_prompt_flow(self):
|
||||||
|
st, d = self.call("POST", "/start", {"test": "fake.prompt"})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
p = self.wait_prompt()
|
||||||
|
self.assertEqual(p["options"], ["Yes", "No"])
|
||||||
|
st, d = self.call("POST", "/answer", {"prompt_id": p["id"], "value": "Maybe"})
|
||||||
|
self.assertEqual(st, 409)
|
||||||
|
st, d = self.call("POST", "/answer", {"prompt_id": p["id"], "value": "Yes"})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
state = self.wait_idle()
|
||||||
|
self.assertEqual(state["tests"]["fake.prompt"]["status"], "pass")
|
||||||
|
st, rec = self.call("GET", "/result?test=fake.prompt")
|
||||||
|
self.assertEqual(rec["answers"][0]["answer"], "Yes")
|
||||||
|
# now the dependent test may start
|
||||||
|
st, d = self.call("POST", "/start", {"test": "fake.needs"})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
self.wait_idle()
|
||||||
|
|
||||||
|
def test_05_busy_abort(self):
|
||||||
|
st, d = self.call("POST", "/start", {"test": "fake.slow"})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
st, d = self.call("POST", "/start", {"test": "fake.pass"})
|
||||||
|
self.assertEqual(st, 409)
|
||||||
|
st, d = self.call("POST", "/abort")
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
state = self.wait_idle()
|
||||||
|
self.assertEqual(state["tests"]["fake.slow"]["status"], "aborted")
|
||||||
|
self.assertIsNotNone(state["campaign"], "an abort does not close the campaign")
|
||||||
|
|
||||||
|
def test_06_error_and_fail_close_campaign(self):
|
||||||
|
st, d = self.call("GET", "/state")
|
||||||
|
cid = d["campaign"]["id"]
|
||||||
|
st, d = self.call("POST", "/start", {"test": "fake.error"})
|
||||||
|
state = self.wait_idle()
|
||||||
|
self.assertEqual(state["tests"]["fake.error"]["status"], "error")
|
||||||
|
self.assertIsNone(state["campaign"])
|
||||||
|
self.assertEqual(state["closed_by"], "fail")
|
||||||
|
# inherited passes survive; the core is required again
|
||||||
|
self.assertEqual(state["tests"]["fake.prompt"]["status"], "inherited")
|
||||||
|
self.assertTrue(state["tests"]["fake.pass"]["required"])
|
||||||
|
# a new start opens a new campaign
|
||||||
|
st, d = self.call("POST", "/start", {"test": "fake.pass"})
|
||||||
|
state = self.wait_idle()
|
||||||
|
self.assertIsNotNone(state["campaign"])
|
||||||
|
self.assertNotEqual(state["campaign"]["id"], cid)
|
||||||
|
|
||||||
|
def test_07_export_and_invalidate(self):
|
||||||
|
st, d = self.call("POST", "/export")
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
self.assertFalse(d["authorized"])
|
||||||
|
st, raw = self.call("GET", "/export/acceptance.json")
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
art = raw if isinstance(raw, dict) else json.loads(raw)
|
||||||
|
self.assertEqual(art["manifest_sha"], self.man.content_sha)
|
||||||
|
st, raw = self.call("GET", "/export/acceptance.md")
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
self.assertIn(b"Release authorized: NO", raw)
|
||||||
|
st, d = self.call("POST", "/invalidate", {"reason": ""})
|
||||||
|
self.assertEqual(st, 400)
|
||||||
|
st, d = self.call("POST", "/invalidate", {"reason": "new tube"})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
st, d = self.call("GET", "/state")
|
||||||
|
self.assertEqual(d["invalidate"]["reason"], "new tube")
|
||||||
|
self.assertIsNone(d["campaign"])
|
||||||
|
for tid, ts in d["tests"].items():
|
||||||
|
self.assertNotEqual(ts["status"], "inherited", tid)
|
||||||
|
st, raw = self.call("GET", "/log", token=False)
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
self.assertIn(b'"t": "invalidate"'.replace(b" ", b""), raw.replace(b" ", b""))
|
||||||
|
|
||||||
|
def test_08_bench(self):
|
||||||
|
st, d = self.call("GET", "/bench")
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
ids = [t["id"] for t in d["tools"]]
|
||||||
|
self.assertEqual(ids, ["echo", "unported", "hot", "tk", "sc"])
|
||||||
|
st, d = self.call("POST", "/bench/start", {"tool": "unported"})
|
||||||
|
self.assertEqual(st, 409)
|
||||||
|
st, d = self.call("POST", "/bench/start", {"tool": "hot"})
|
||||||
|
self.assertEqual(st, 409)
|
||||||
|
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "yo", "n": "x"}})
|
||||||
|
self.assertEqual(st, 409)
|
||||||
|
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "yo", "n": 5}})
|
||||||
|
self.assertEqual(st, 200, d)
|
||||||
|
state = self.wait_idle()
|
||||||
|
self.assertEqual(state["last_run"]["kind"], "bench")
|
||||||
|
self.assertEqual(state["last_run"]["finished"]["result"], "OK")
|
||||||
|
self.assertTrue(any("['yo', '5']" in l for l in state["last_run"]["log"]))
|
||||||
|
# the bench environment: the machine is local, data under <data>/bench
|
||||||
|
self.assertTrue(any(" env 127.0.0.1 " in l and l.endswith("bench")
|
||||||
|
for l in state["last_run"]["log"]), state["last_run"]["log"])
|
||||||
|
st, d = self.call("GET", "/bench")
|
||||||
|
self.assertEqual(d["tools"][0]["last"]["result"]["result"], "OK")
|
||||||
|
# a failing tool and an aborted one
|
||||||
|
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "fail", "n": 1}})
|
||||||
|
state = self.wait_idle()
|
||||||
|
self.assertEqual(state["last_run"]["finished"]["result"], "EXIT 3")
|
||||||
|
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "slow", "n": 1}})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
time.sleep(0.5)
|
||||||
|
st, d = self.call("POST", "/abort")
|
||||||
|
state = self.wait_idle()
|
||||||
|
self.assertEqual(state["last_run"]["finished"]["result"], "ABORTED")
|
||||||
|
# a takeover tool runs inside the takeover wrapper (init.d is absent on the host: rc 127)
|
||||||
|
st, d = self.call("POST", "/bench/start", {"tool": "tk"})
|
||||||
|
self.assertEqual(st, 200, d)
|
||||||
|
state = self.wait_idle(timeout=40) # two unreachable-forgectrl settle waits
|
||||||
|
log = "\n".join(state["last_run"]["log"])
|
||||||
|
self.assertIn("takeover: pulse device free", log)
|
||||||
|
self.assertIn("takeover: forgectrl start", log)
|
||||||
|
self.assertIn("baseline: forgectrl unreachable for 10 s", log)
|
||||||
|
self.assertFalse(os.path.exists(os.environ["FORGETEST_MARKER"]))
|
||||||
|
# a scope tool is a takeover too
|
||||||
|
st, d = self.call("POST", "/bench/start", {"tool": "sc"})
|
||||||
|
self.assertEqual(st, 200, d)
|
||||||
|
state = self.wait_idle(timeout=40)
|
||||||
|
log = "\n".join(state["last_run"]["log"])
|
||||||
|
self.assertIn("takeover: pulse device free", log)
|
||||||
|
self.assertIn("takeover: forgectrl start", log)
|
||||||
|
# bench runs never touched the acceptance log
|
||||||
|
recs = self.log.read()
|
||||||
|
self.assertFalse(any(r.get("t") == "result" and r.get("test") == "echo" for r in recs))
|
||||||
|
|
||||||
|
def test_09_recovery_marker(self):
|
||||||
|
marker = os.environ["FORGETEST_MARKER"]
|
||||||
|
with open(marker, "w") as f:
|
||||||
|
f.write("x fake.slow\n")
|
||||||
|
import logging
|
||||||
|
from forgetest.runner import journal
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
class Catch(logging.Handler):
|
||||||
|
def emit(self, rec):
|
||||||
|
seen.append(rec.getMessage())
|
||||||
|
h = Catch()
|
||||||
|
journal.addHandler(h)
|
||||||
|
try:
|
||||||
|
r = Runner(self.log, self.man, self.reg, self.bench)
|
||||||
|
finally:
|
||||||
|
journal.removeHandler(h)
|
||||||
|
# the recovery is a journal line, not a page message
|
||||||
|
self.assertTrue(any("recovered" in m for m in seen), seen)
|
||||||
|
self.assertNotIn("messages", r.state()[0])
|
||||||
|
self.assertFalse(os.path.exists(marker))
|
||||||
|
|
||||||
|
def test_10_state_is_conditional(self):
|
||||||
|
"""The page polls; an unchanged state must cost a 304, and the
|
||||||
|
ETag must move as soon as the state does.
|
||||||
|
|
||||||
|
Runs last because it invalidates: timestamps are whole seconds, and
|
||||||
|
a PASS stamped in the same second as an invalidate is deliberately
|
||||||
|
not inheritable, so an invalidate early in this class would decide
|
||||||
|
the inheritance the earlier tests are checking.
|
||||||
|
"""
|
||||||
|
st, d, hdrs = self.call_h("GET", "/state")
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
etag = hdrs.get("ETag")
|
||||||
|
self.assertTrue(etag and etag.startswith('"'), "no ETag on /state")
|
||||||
|
st, _, hdrs2 = self.call_h("GET", "/state", headers={"If-None-Match": etag})
|
||||||
|
self.assertEqual(st, 304)
|
||||||
|
self.assertEqual(hdrs2.get("ETag"), etag)
|
||||||
|
# a stale validator must not be honored
|
||||||
|
st, _, _ = self.call_h("GET", "/state", headers={"If-None-Match": '"stale"'})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
# and the validator moves with the state
|
||||||
|
self.call("POST", "/invalidate", {"reason": "etag check"})
|
||||||
|
st, _, hdrs3 = self.call_h("GET", "/state", headers={"If-None-Match": etag})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
self.assertNotEqual(hdrs3.get("ETag"), etag)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""scripts/source-bundle.py - the source that a release publishes.
|
||||||
|
|
||||||
|
The bundle must hold the source of every recipe of the image whose license
|
||||||
|
makes source necessary. The list comes from the license manifests that the
|
||||||
|
build writes, so a package cannot reach a machine with its source left
|
||||||
|
behind. These tests hold the decision (which recipe needs source), the
|
||||||
|
choice of archive (the version of the image, not an older build), and the
|
||||||
|
refusal that stops a release with source missing."""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tarfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers # noqa: F401 (sys.path)
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
SCRIPT = os.path.join(REPO, "scripts", "source-bundle.py")
|
||||||
|
META_OPENGLOW = os.path.join(REPO, "..", "meta-openglow")
|
||||||
|
IMAGE = "forgefirm-image-glowforge.rootfs-20260101000000"
|
||||||
|
|
||||||
|
|
||||||
|
def load_script():
|
||||||
|
spec = importlib.util.spec_from_file_location("source_bundle", SCRIPT)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def package_block(recipe, version, license_, package=None):
|
||||||
|
return ("PACKAGE NAME: %s\nPACKAGE VERSION: %s\nRECIPE NAME: %s\nLICENSE: %s\n\n"
|
||||||
|
% (package or recipe, version, recipe, license_))
|
||||||
|
|
||||||
|
|
||||||
|
def write(path, text):
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
|
||||||
|
def make_deploy(root, packages, archives, image_packages=()):
|
||||||
|
"""A deploy directory with the license manifests of one image and the
|
||||||
|
archive directories of the archiver pass."""
|
||||||
|
licdir = os.path.join(root, "licenses", "glowforge", IMAGE)
|
||||||
|
# The bundle answers for one image: its license manifests and its build
|
||||||
|
# identity must be in the deploy directory.
|
||||||
|
write(os.path.join(root, "images", "glowforge",
|
||||||
|
IMAGE + ".forgefirm-manifest.json"),
|
||||||
|
'{"content_sha256": "0"}\n')
|
||||||
|
write(os.path.join(licdir, "license.manifest"),
|
||||||
|
"".join(package_block(*p) for p in packages))
|
||||||
|
write(os.path.join(licdir, "image_license.manifest"),
|
||||||
|
"".join("RECIPE NAME: %s\nVERSION: %s\nLICENSE: %s\nFILES: x\n\n" % p
|
||||||
|
for p in image_packages))
|
||||||
|
for name in archives:
|
||||||
|
write(os.path.join(root, "sources", "arm-fslc-linux-gnueabi", name,
|
||||||
|
"%s.tar.gz" % name), "source of %s\n" % name)
|
||||||
|
return licdir
|
||||||
|
|
||||||
|
|
||||||
|
class Policy(unittest.TestCase):
|
||||||
|
"""The archiver pass and the check read one license list."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.mod = load_script()
|
||||||
|
self.policy = self.mod.read_policy(self.mod.KAS_OVERLAY)
|
||||||
|
|
||||||
|
def test_the_overlay_carries_the_filter(self):
|
||||||
|
self.assertIn("GPL*", self.policy["COPYLEFT_LICENSE_INCLUDE"])
|
||||||
|
self.assertIn("LGPL*", self.policy["COPYLEFT_LICENSE_INCLUDE"])
|
||||||
|
self.assertIn("Proprietary", self.policy["COPYLEFT_LICENSE_EXCLUDE"])
|
||||||
|
# The ForgeFIRM components are MIT and travel with the release too.
|
||||||
|
self.assertIn("forgectrl", self.policy["COPYLEFT_PN_INCLUDE"])
|
||||||
|
self.assertIn("gfcloud", self.policy["COPYLEFT_PN_INCLUDE"])
|
||||||
|
|
||||||
|
def test_a_copyleft_license_needs_source(self):
|
||||||
|
for expr in ("GPL-2.0-only",
|
||||||
|
"LGPL-2.1-or-later",
|
||||||
|
"GPL-2.0-only & bzip2-1.0.4",
|
||||||
|
"AFL-2.1 | GPL-2.0-or-later",
|
||||||
|
"(GPL-2.0-or-later | LGPL-3.0-only) & Unicode-DFS-2016",
|
||||||
|
"MIT ; LGPL-2.1-or-later",
|
||||||
|
"GPL-3.0-with-GCC-exception"):
|
||||||
|
self.assertTrue(self.mod.source_needed(expr, self.policy), expr)
|
||||||
|
|
||||||
|
def test_a_permissive_license_does_not(self):
|
||||||
|
for expr in ("MIT", "BSD-3-Clause", "Apache-2.0", "ISC", "PSF-2.0",
|
||||||
|
"Proprietary", "Firmware-imx-sdma_firmware", "PD"):
|
||||||
|
self.assertFalse(self.mod.source_needed(expr, self.policy), expr)
|
||||||
|
|
||||||
|
|
||||||
|
class Manifests(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.mod = load_script()
|
||||||
|
|
||||||
|
def test_a_recipe_collects_the_licenses_of_its_packages(self):
|
||||||
|
path = os.path.join(self.tmp(), "license.manifest")
|
||||||
|
write(path, package_block("avahi", "0.8", "LGPL-2.1-or-later", "avahi-daemon")
|
||||||
|
+ package_block("avahi", "0.8", "GPL-2.0-or-later & LGPL-2.1-or-later",
|
||||||
|
"avahi-locale-en-gb"))
|
||||||
|
recipes = self.mod.parse_manifest(path, "PACKAGE VERSION")
|
||||||
|
self.assertEqual(list(recipes), ["avahi"])
|
||||||
|
self.assertEqual(recipes["avahi"]["version"], "0.8")
|
||||||
|
self.assertEqual(len(recipes["avahi"]["licenses"]), 2)
|
||||||
|
|
||||||
|
def test_the_archive_of_the_image_version_wins(self):
|
||||||
|
index = {"linux-fslc": [("6.11.0", "/old"), ("6.12.20+git0+844aa34", "/new")],
|
||||||
|
"busybox": [("1.36.1", "/b1"), ("1.35.0", "/b0")],
|
||||||
|
"forgectrl": [("1_0.1.4", "/fc")]}
|
||||||
|
# An exact version first.
|
||||||
|
self.assertEqual(self.mod.select_archives(index, "busybox", "1.36.1"), ["/b1"])
|
||||||
|
# Then a version that starts with it: the kernel carries the
|
||||||
|
# revision of its git source in PV, and the manifest does not.
|
||||||
|
self.assertEqual(self.mod.select_archives(index, "linux-fslc", "6.12.20+git"),
|
||||||
|
["/new"])
|
||||||
|
# The name of an archive carries the epoch of the recipe, and a
|
||||||
|
# license manifest does not.
|
||||||
|
self.assertEqual(self.mod.select_archives(index, "forgectrl", "0.1.4"), ["/fc"])
|
||||||
|
self.assertEqual(self.mod.select_archives(index, "curl", "8.7.1"), [])
|
||||||
|
|
||||||
|
def test_a_directory_belongs_to_the_longest_recipe_name(self):
|
||||||
|
root = self.tmp()
|
||||||
|
make_deploy(root, [], ["python3-3.12.13-r0", "python3-certifi-2024.2.2-r0"])
|
||||||
|
index = self.mod.index_archives(os.path.join(root, "sources"),
|
||||||
|
{"python3", "python3-certifi"})
|
||||||
|
self.assertEqual([v for v, _ in index["python3"]], ["3.12.13"])
|
||||||
|
self.assertEqual([v for v, _ in index["python3-certifi"]], ["2024.2.2"])
|
||||||
|
|
||||||
|
def tmp(self):
|
||||||
|
import tempfile
|
||||||
|
d = tempfile.mkdtemp()
|
||||||
|
self.addCleanup(__import__("shutil").rmtree, d, True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.path.isdir(META_OPENGLOW),
|
||||||
|
"the meta-openglow sibling checkout is not here")
|
||||||
|
class Pack(unittest.TestCase):
|
||||||
|
"""The bundle end to end, on a deploy directory of made-up recipes."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import tempfile
|
||||||
|
self.mod = load_script()
|
||||||
|
self.root = tempfile.mkdtemp()
|
||||||
|
self.addCleanup(__import__("shutil").rmtree, self.root, True)
|
||||||
|
self.deploy = os.path.join(self.root, "deploy")
|
||||||
|
self.out = os.path.join(self.root, "out")
|
||||||
|
|
||||||
|
def run_main(self, packages, archives, image_packages=()):
|
||||||
|
make_deploy(self.deploy, packages, archives, image_packages)
|
||||||
|
import sys
|
||||||
|
argv = sys.argv
|
||||||
|
sys.argv = ["source-bundle.py", "0.0.1", "--deploy", self.deploy,
|
||||||
|
"--image-name", IMAGE, "--out", self.out]
|
||||||
|
try:
|
||||||
|
return self.mod.main()
|
||||||
|
finally:
|
||||||
|
sys.argv = argv
|
||||||
|
|
||||||
|
def test_the_bundle_holds_the_source_and_the_accounting(self):
|
||||||
|
self.run_main(
|
||||||
|
packages=[("busybox", "1.36.1", "GPL-2.0-only"),
|
||||||
|
("jansson", "2.14", "MIT"),
|
||||||
|
("forgectrl", "0.1.4", "MIT")],
|
||||||
|
archives=["busybox-1.36.1-r0", "forgectrl-0.1.4-r0", "jansson-2.14-r0",
|
||||||
|
"u-boot-2020.01-r0"],
|
||||||
|
image_packages=[("u-boot", "2020.01", "GPL-2.0-or-later")])
|
||||||
|
path = os.path.join(self.out, "forgefirm-source-v0.0.1.tar.gz")
|
||||||
|
self.assertTrue(os.path.isfile(path))
|
||||||
|
with tarfile.open(path) as tar:
|
||||||
|
names = tar.getnames()
|
||||||
|
manifest = json.loads(tar.extractfile(
|
||||||
|
"forgefirm-source-v0.0.1/MANIFEST.json").read().decode())
|
||||||
|
sums = tar.extractfile(
|
||||||
|
"forgefirm-source-v0.0.1/sha256sums.txt").read().decode()
|
||||||
|
# The copyleft recipe and the ForgeFIRM component are in, the
|
||||||
|
# permissive third party is not.
|
||||||
|
self.assertIn("forgefirm-source-v0.0.1/sources/busybox-1.36.1-r0/busybox-1.36.1-r0.tar.gz",
|
||||||
|
names)
|
||||||
|
self.assertIn("forgefirm-source-v0.0.1/sources/forgectrl-0.1.4-r0/forgectrl-0.1.4-r0.tar.gz",
|
||||||
|
names)
|
||||||
|
self.assertNotIn("forgefirm-source-v0.0.1/sources/jansson-2.14-r0/jansson-2.14-r0.tar.gz",
|
||||||
|
names)
|
||||||
|
# The accounting travels with the source.
|
||||||
|
for name in ("README.md", "SOURCES.txt", "MANIFEST.json",
|
||||||
|
"licenses/license.manifest", "licenses/image_license.manifest",
|
||||||
|
"metadata/LAYERS.txt", "metadata/meta-forgefirm.tar.gz",
|
||||||
|
"metadata/kas/source-bundle.yml"):
|
||||||
|
self.assertIn("forgefirm-source-v0.0.1/" + name, names)
|
||||||
|
self.assertEqual(manifest["release"], "v0.0.1")
|
||||||
|
self.assertEqual(sorted(r["recipe"] for r in manifest["recipes"]),
|
||||||
|
["busybox", "forgectrl", "u-boot"])
|
||||||
|
self.assertEqual(manifest["recipes_without_source"],
|
||||||
|
[{"recipe": "jansson", "version": "2.14", "license": "MIT"}])
|
||||||
|
# Every file of the bundle is in the checksums, and only files that
|
||||||
|
# the bundle holds.
|
||||||
|
listed = sorted(line.split(" ", 1)[1] for line in sums.splitlines())
|
||||||
|
held = sorted(n.split("/", 1)[1] for n in names
|
||||||
|
if n != "forgefirm-source-v0.0.1/sha256sums.txt")
|
||||||
|
self.assertEqual(listed, held)
|
||||||
|
|
||||||
|
def test_a_recipe_with_no_source_stops_the_release(self):
|
||||||
|
with self.assertRaises(SystemExit) as caught:
|
||||||
|
self.run_main(packages=[("busybox", "1.36.1", "GPL-2.0-only"),
|
||||||
|
("gnutls", "3.8.4", "LGPL-2.1-or-later")],
|
||||||
|
archives=["busybox-1.36.1-r0"])
|
||||||
|
self.assertEqual(caught.exception.code, 1)
|
||||||
|
|
||||||
|
def test_the_source_of_gcc_covers_libgcc(self):
|
||||||
|
self.run_main(
|
||||||
|
packages=[("libgcc", "13.4.0", "GPL-3.0-with-GCC-exception"),
|
||||||
|
("glibc", "2.39+git", "GPL-2.0-only & LGPL-2.1-or-later"),
|
||||||
|
("glibc-locale", "2.39+git", "GPL-2.0-only & LGPL-2.1-or-later")],
|
||||||
|
archives=["gcc-source-13.4.0-13.4.0-r0", "glibc-2.39+git-r0"])
|
||||||
|
with tarfile.open(os.path.join(self.out, "forgefirm-source-v0.0.1.tar.gz")) as tar:
|
||||||
|
manifest = json.loads(tar.extractfile(
|
||||||
|
"forgefirm-source-v0.0.1/MANIFEST.json").read().decode())
|
||||||
|
holders = {r["recipe"]: r["holder"] for r in manifest["recipes"]}
|
||||||
|
self.assertEqual(holders["libgcc"], "gcc-source-13.4.0")
|
||||||
|
self.assertEqual(holders["glibc-locale"], "glibc")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""scripts/manifest-from-tree.py - the workstation/CI mirror of the image
|
||||||
|
manifest: recipe pins are read through their pin files, and a layer's
|
||||||
|
content hash leaves the pin files out (a component bump is not a platform
|
||||||
|
change; a recipe-body change is)."""
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers # noqa: F401 (sys.path)
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
SCRIPT = os.path.join(REPO, "scripts", "manifest-from-tree.py")
|
||||||
|
|
||||||
|
|
||||||
|
def load_script():
|
||||||
|
spec = importlib.util.spec_from_file_location("manifest_from_tree", SCRIPT)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def git_ok():
|
||||||
|
try:
|
||||||
|
subprocess.run(["git", "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
||||||
|
return True
|
||||||
|
except (OSError, subprocess.CalledProcessError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
RECIPE = '''DESCRIPTION = "component"
|
||||||
|
LICENSE = "MIT"
|
||||||
|
SRC_URI = "git://github.com/example/comp.git;protocol=https;branch=main"
|
||||||
|
# SRCREV and PV live in the pin file.
|
||||||
|
require comp-pin.inc
|
||||||
|
S = "${WORKDIR}/git"
|
||||||
|
'''
|
||||||
|
PIN_A = 'SRCREV = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\nPV = "1.0"\n'
|
||||||
|
PIN_B = 'SRCREV = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\nPV = "1.1"\n'
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(git_ok(), "git not available")
|
||||||
|
class TreeManifestTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.mod = load_script()
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix="forgetest-tree-")
|
||||||
|
self.layer = os.path.join(self.tmp, "meta-x")
|
||||||
|
self.rdir = os.path.join(self.layer, "recipes-x", "comp")
|
||||||
|
os.makedirs(self.rdir)
|
||||||
|
subprocess.run(["git", "init", "-q"], cwd=self.tmp, check=True,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
self.write("comp.bb", RECIPE)
|
||||||
|
self.write("comp-pin.inc", PIN_A)
|
||||||
|
self.write("README.md", "# docs\n")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def write(self, name, text):
|
||||||
|
with open(os.path.join(self.rdir, name), "w", encoding="utf-8", newline="\n") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
def test_parse_recipe_follows_the_pin_file(self):
|
||||||
|
url, rev = self.mod.parse_recipe(os.path.join(self.rdir, "comp.bb"))
|
||||||
|
self.assertEqual(url, "https://github.com/example/comp.git")
|
||||||
|
self.assertEqual(rev, "a" * 40)
|
||||||
|
self.write("comp-pin.inc", PIN_B)
|
||||||
|
self.assertEqual(self.mod.parse_recipe(os.path.join(self.rdir, "comp.bb"))[1], "b" * 40)
|
||||||
|
|
||||||
|
def test_parse_recipe_inline_pin_still_works(self):
|
||||||
|
self.write("comp.bb", RECIPE.replace("require comp-pin.inc\n", PIN_A))
|
||||||
|
os.remove(os.path.join(self.rdir, "comp-pin.inc"))
|
||||||
|
self.assertEqual(self.mod.parse_recipe(os.path.join(self.rdir, "comp.bb"))[1], "a" * 40)
|
||||||
|
|
||||||
|
def test_layer_hash_ignores_pin_bumps_and_docs(self):
|
||||||
|
base = self.mod.layer_content(self.layer)
|
||||||
|
self.assertIsNotNone(base)
|
||||||
|
self.write("comp-pin.inc", PIN_B)
|
||||||
|
self.assertEqual(self.mod.layer_content(self.layer), base, "a pin bump is not layer content")
|
||||||
|
self.write("README.md", "# docs, revised\n")
|
||||||
|
self.assertEqual(self.mod.layer_content(self.layer), base, "documentation is not layer content")
|
||||||
|
|
||||||
|
def test_layer_hash_sees_recipe_body_changes(self):
|
||||||
|
base = self.mod.layer_content(self.layer)
|
||||||
|
self.write("comp.bb", RECIPE + 'EXTRA_OEMAKE += "KCFLAGS=-Werror"\n')
|
||||||
|
self.assertNotEqual(self.mod.layer_content(self.layer), base)
|
||||||
|
|
||||||
|
def test_layer_hash_sees_a_pin_written_into_the_recipe(self):
|
||||||
|
# The safe direction: a pin that bypasses its pin file still hashes.
|
||||||
|
base = self.mod.layer_content(self.layer)
|
||||||
|
self.write("comp.bb", RECIPE + PIN_B)
|
||||||
|
self.assertNotEqual(self.mod.layer_content(self.layer), base)
|
||||||
|
|
||||||
|
def test_layer_hash_matches_the_bbclass_rule(self):
|
||||||
|
# The same skip list as *.md plus FORGEFIRM_MANIFEST_PIN_SUFFIX and
|
||||||
|
# FORGEFIRM_MANIFEST_VERSION_SUFFIX in
|
||||||
|
# forgefirm-image-manifest.bbclass. The two implementations compute
|
||||||
|
# the same identity - one on the build host, one on a workstation -
|
||||||
|
# so a suffix added to either without the other is the failure this
|
||||||
|
# guards.
|
||||||
|
bbclass = os.path.join(REPO, "meta-forgefirm", "classes", "forgefirm-image-manifest.bbclass")
|
||||||
|
with open(bbclass, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
self.assertIn('FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"', text)
|
||||||
|
self.assertIn('FORGEFIRM_MANIFEST_VERSION_SUFFIX ?= "forgefirm-release.inc"', text)
|
||||||
|
self.assertEqual(self.mod.LAYER_SKIP_SUFFIXES,
|
||||||
|
(".md", "-pin.inc", "forgefirm-release.inc"))
|
||||||
|
|
||||||
|
def test_layer_hash_ignores_the_release_version(self):
|
||||||
|
# The version is metadata: setting the release number must not move
|
||||||
|
# the layer hash, or the bump would invalidate the campaign that
|
||||||
|
# authorizes the release.
|
||||||
|
self.write("forgefirm-release.inc",'FORGEFIRM_RELEASE ?= "0.0.1"\n')
|
||||||
|
base = self.mod.layer_content(self.layer)
|
||||||
|
self.write("forgefirm-release.inc",'FORGEFIRM_RELEASE ?= "0.0.2"\n')
|
||||||
|
self.assertEqual(self.mod.layer_content(self.layer), base)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user