mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08b29fee01 | ||
|
|
f306c9983f | ||
|
|
dc7170d0d1 | ||
|
|
0ceb4abc4a | ||
|
|
aa628d684c | ||
|
|
29b00cc056 | ||
|
|
d482e76402 | ||
|
|
8232c8c9fe | ||
|
|
260f466400 | ||
|
|
cf7b51fb32 | ||
|
|
e2be3ca4c5 | ||
|
|
cd4c176a87 | ||
|
|
8850adcc5e | ||
|
|
f9f4de31c1 | ||
|
|
9ddab969e0 | ||
|
|
12e7d50746 | ||
|
|
b3f0cd54d9 | ||
|
|
fbac17a485 | ||
|
|
0eb764bf75 | ||
|
|
07158e5d7f | ||
|
|
091702c26f | ||
|
|
9a288b175b | ||
|
|
147393322f | ||
|
|
7a9f655916 | ||
|
|
52a91385cd | ||
|
|
b17778f538 | ||
|
|
d5551d7e8a | ||
|
|
41417f820b | ||
|
|
db6015dc81 | ||
|
|
0c87927388 | ||
|
|
ea8a94a7d1 | ||
|
|
79c03641bd | ||
|
|
ea6ac54ab3 | ||
|
|
f080d5d9cb | ||
|
|
d465625a98 | ||
|
|
7089a25721 | ||
|
|
d65a7aa6aa | ||
|
|
70e945a949 | ||
|
|
2f2a4160af | ||
|
|
ea458850f0 | ||
|
|
e9d5f3c842 | ||
|
|
7c0412075c | ||
|
|
d8b8adaef4 | ||
|
|
c0d4302d73 | ||
|
|
884d014d2c | ||
|
|
a9f0ad0d51 | ||
|
|
2dde10cf43 | ||
|
|
f41464cc58 | ||
|
|
20c6256527 | ||
|
|
b7a595ced7 | ||
|
|
5c93e49c8c | ||
|
|
650483c085 | ||
|
|
7b8f72b632 |
@@ -0,0 +1,4 @@
|
|||||||
|
# Every text file is stored and checked out with LF; a patch keeps its bytes.
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.patch -text
|
||||||
|
*.diff -text
|
||||||
@@ -11,6 +11,7 @@ name: fixture-ci
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [master]
|
||||||
paths:
|
paths:
|
||||||
- 'fixture/**'
|
- 'fixture/**'
|
||||||
- '.github/workflows/fixture-ci.yml'
|
- '.github/workflows/fixture-ci.yml'
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ name: forgetest-ci
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [master]
|
||||||
paths:
|
paths:
|
||||||
- 'forgetest/**'
|
- 'forgetest/**'
|
||||||
- 'scripts/acceptance-gate.py'
|
- 'scripts/acceptance-gate.py'
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ contributor is welcome to read it too: it is the same set of rules.
|
|||||||
chain of others.
|
chain of others.
|
||||||
- Never exercise a gate or a limit through the `GFCOOL_*` environment
|
- 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.
|
overrides in a test. Use the settings API, and restore it in the teardown.
|
||||||
|
- A silence-while-armed injection and a crash during `$H` are catalog
|
||||||
|
drills, not one-off bench scripts.
|
||||||
|
|
||||||
### CI couplings that set the push order
|
### CI couplings that set the push order
|
||||||
|
|
||||||
@@ -186,6 +188,9 @@ does.
|
|||||||
test with the fix, in the same commit, never after.
|
test with the fix, in the same commit, never after.
|
||||||
- Position counters, homing anchors, and a homed flag are not proof of
|
- 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.
|
physical motion. The head accelerometer is, and so are the operator's eyes.
|
||||||
|
- Unreadable is fail-closed. A failed read of a safety input, a state file,
|
||||||
|
or a verdict is never treated as "still running" or "keep waiting" without
|
||||||
|
a bound.
|
||||||
|
|
||||||
### Proof before done
|
### Proof before done
|
||||||
|
|
||||||
@@ -320,7 +325,11 @@ docstrings, log and console text, commit messages, issue text, and replies.
|
|||||||
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
|
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
|
`Written by Scott Wiederhold`. Keep the year, or the year range, that
|
||||||
the file already has. The copyright notices of other holders stay
|
the file already has. The copyright notices of other holders stay
|
||||||
unchanged.
|
unchanged. Those two lines are for files this project authors: an
|
||||||
|
outside contributor who writes a new file puts their own name there, or
|
||||||
|
leaves the lines out, and keeps their copyright in what they wrote.
|
||||||
|
Editing an existing file changes no header - a contribution is recorded
|
||||||
|
by its `Signed-off-by` line and by git, not by a copyright line.
|
||||||
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
|
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
|
||||||
under the license of its repository.
|
under the license of its repository.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Patches are welcome. How the project works, what proof a change needs, and the
|
||||||
|
writing rules are on the documentation site:
|
||||||
|
<https://docs.forgefirm.org/developers/contributing/>.
|
||||||
|
|
||||||
|
## Sign your work
|
||||||
|
|
||||||
|
Every commit carries a `Signed-off-by` line:
|
||||||
|
|
||||||
|
Signed-off-by: Your Name <your.email@example.com>
|
||||||
|
|
||||||
|
`git commit -s` adds it, using your `user.name` and `user.email`. Use your real
|
||||||
|
name; an anonymous or pseudonymous sign-off certifies nothing.
|
||||||
|
|
||||||
|
The line certifies the Developer Certificate of Origin, version 1.1, below. It
|
||||||
|
is the same statement the Linux kernel and grblHAL use. It is not a copyright
|
||||||
|
assignment: you keep the copyright in what you write, and your contribution
|
||||||
|
comes in under the license of the repository you are contributing to.
|
||||||
|
|
||||||
|
You do not add a copyright line for a patch. A change to an existing file
|
||||||
|
leaves its header alone. If you write a whole new file, put your own name in
|
||||||
|
its copyright line, or leave the line out; either way the file carries an
|
||||||
|
`SPDX-License-Identifier` for the repository's license.
|
||||||
|
|
||||||
|
## Developer Certificate of Origin 1.1
|
||||||
|
|
||||||
|
By making a contribution to this project, I certify that:
|
||||||
|
|
||||||
|
(a) The contribution was created in whole or in part by me and I
|
||||||
|
have the right to submit it under the open source license
|
||||||
|
indicated in the file; or
|
||||||
|
|
||||||
|
(b) The contribution is based upon previous work that, to the best
|
||||||
|
of my knowledge, is covered under an appropriate open source
|
||||||
|
license and I have the right under that license to submit that
|
||||||
|
work with modifications, whether created in whole or in part
|
||||||
|
by me, under the same open source license (unless I am
|
||||||
|
permitted to submit under a different license), as indicated
|
||||||
|
in the file; or
|
||||||
|
|
||||||
|
(c) The contribution was provided directly to me by some other
|
||||||
|
person who certified (a), (b) or (c) and I have not modified
|
||||||
|
it.
|
||||||
|
|
||||||
|
(d) I understand and agree that this project and the contribution
|
||||||
|
are public and that a record of the contribution (including all
|
||||||
|
personal information I submit with it, including my sign-off) is
|
||||||
|
maintained indefinitely and may be redistributed consistent with
|
||||||
|
this project or the open source license(s) involved.
|
||||||
|
|
||||||
|
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
|
||||||
|
This DCO text is licensed under the terms of the Creative Commons
|
||||||
|
Attribution-ShareAlike 3.0 Unported license.
|
||||||
@@ -5,5 +5,14 @@ the associated recipe (.bb file) unless otherwise stated.
|
|||||||
See:
|
See:
|
||||||
COPYING.MIT (MIT)
|
COPYING.MIT (MIT)
|
||||||
|
|
||||||
|
The code in this repository that is not layer metadata is MIT licensed:
|
||||||
|
|
||||||
|
forgetest/ the release acceptance tool (see forgetest/LICENSE)
|
||||||
|
scripts/ the release, install and bench scripts
|
||||||
|
fixture/ the bench actuator firmware, excluding
|
||||||
|
fixture/managed_components/, which carries its own licenses
|
||||||
|
|
||||||
|
Every file in those directories states MIT in its own header.
|
||||||
|
|
||||||
License information for any other files is either explicitly stated
|
License information for any other files is either explicitly stated
|
||||||
or defaults to GPL version 2.
|
or defaults to GPL version 2.
|
||||||
|
|||||||
@@ -83,4 +83,5 @@ before you install.
|
|||||||
**This is experimental software. Use of it could seriously maim or kill you or
|
**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.**
|
others, and it may void your warranty. Use it at your own risk.**
|
||||||
|
|
||||||
This project is not affiliated with or endorsed by Glowforge.
|
Glowforge is a trademark of Glowforge, Inc. This project is not affiliated
|
||||||
|
with, authorized by, or endorsed by Glowforge, Inc.
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
# forgefixture: build, flash and watch the bench actuator's firmware.
|
# 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 env fixture.env from the example, with a fresh API key
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
/* forgefixture firmware version, reported by GET /. */
|
/* forgefixture firmware version, reported by GET /. */
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
# The host test of the fixture's policy: needs only a C compiler.
|
# The host test of the fixture's policy: needs only a C compiler.
|
||||||
set -e
|
set -e
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
ForgeFIRM release acceptance tool (forgetest/) and the bench scripts it
|
||||||
|
drives (scripts/bench/).
|
||||||
|
|
||||||
|
Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
Written by Scott Wiederhold
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
### BEGIN INIT INFO
|
### BEGIN INIT INFO
|
||||||
# Provides: forgetest
|
# Provides: forgetest
|
||||||
# Required-Start: $network forgectrl
|
# Required-Start: $network forgectrl
|
||||||
@@ -22,13 +26,13 @@ case "$1" in
|
|||||||
start)
|
start)
|
||||||
echo "Starting forgetest"
|
echo "Starting forgetest"
|
||||||
mkdir -p "$DATA"
|
mkdir -p "$DATA"
|
||||||
# The bench seed: the commissioning override lifts the wizard part of
|
# The bench seed: the setup override lifts the wizard part of
|
||||||
# the controller gate until the next reboot (/run is tmpfs), so a
|
# the controller gate until the next reboot (/run is tmpfs), so a
|
||||||
# campaign runs on a machine whose required wizards are not complete.
|
# campaign runs on a machine whose required wizards are not complete.
|
||||||
# Nothing lifts the agreements or the account: the bench accepts them
|
# Nothing lifts the agreements or the account: the bench accepts them
|
||||||
# once, and that stays in /data.
|
# once, and that stays in /data.
|
||||||
mkdir -p /run/forgefirm
|
mkdir -p /run/forgefirm
|
||||||
: > /run/forgefirm/commissioning-override
|
: > /run/forgefirm/setup-override
|
||||||
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
|
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
|
||||||
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
|
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""forgetest - the ForgeFIRM release acceptance tool.
|
"""forgetest - the ForgeFIRM release acceptance tool.
|
||||||
|
|
||||||
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
|
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""forgetest daemon entry point.
|
"""forgetest daemon entry point.
|
||||||
|
|
||||||
python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH]
|
python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH]
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The release artifact (acceptance.json / acceptance.md) and its verification.
|
"""The release artifact (acceptance.json / acceptance.md) and its verification.
|
||||||
|
|
||||||
|
Every test record is redacted before it enters the artifact (redact.py), so
|
||||||
|
the committed file carries no identity of the machine that ran the campaign.
|
||||||
|
It happens inside build(), ahead of the self-hash, because verify() rejects a
|
||||||
|
file edited after the fact. The gate reads none of the redacted fields.
|
||||||
|
|
||||||
The exporter serializes the campaign state with, for every catalog test,
|
The exporter serializes the campaign state with, for every catalog test,
|
||||||
the winning result record (same-campaign PASS or inherited PASS, with its
|
the winning result record (same-campaign PASS or inherited PASS, with its
|
||||||
origin) and the fingerprint it was recorded under. The gate
|
origin) and the fingerprint it was recorded under. The gate
|
||||||
@@ -15,6 +25,7 @@ from . import VERSION
|
|||||||
from . import campaign as _campaign
|
from . import campaign as _campaign
|
||||||
from . import manifest as _manifest
|
from . import manifest as _manifest
|
||||||
from .log import now_ts
|
from .log import now_ts
|
||||||
|
from .redact import scrub
|
||||||
|
|
||||||
FORMAT = 1
|
FORMAT = 1
|
||||||
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
|
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
|
||||||
@@ -47,7 +58,7 @@ def build(state, tests, manifest, records, catalog_hash):
|
|||||||
"fingerprint": st["fingerprint"],
|
"fingerprint": st["fingerprint"],
|
||||||
"satisfied": st["satisfied"],
|
"satisfied": st["satisfied"],
|
||||||
"inherited": st["status"] == "inherited",
|
"inherited": st["status"] == "inherited",
|
||||||
"record": _record(rec) if rec else None,
|
"record": scrub(_record(rec)) if rec else None,
|
||||||
})
|
})
|
||||||
tests_out.append(entry)
|
tests_out.append(entry)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The fresh-boot idle state: what every test starts from and leaves behind.
|
"""The fresh-boot idle state: what every test starts from and leaves behind.
|
||||||
|
|
||||||
The runner checks the machine against this baseline before a run and
|
The runner checks the machine against this baseline before a run and
|
||||||
@@ -48,7 +53,7 @@ from .log import now_ts
|
|||||||
# in the pulse path (a job's Z moves the lens; the driver's Z soft limit
|
# in the pulse path (a job's Z moves the lens; the driver's Z soft limit
|
||||||
# guards it), step_freq its machine tick and ramp_rate the kernel stop
|
# guards it), step_freq its machine tick and ramp_rate the kernel stop
|
||||||
# ramp, both derived from the XY microstep mode (the values here are the
|
# ramp, both derived from the XY microstep mode (the values here are the
|
||||||
# x8 default's; fixed_sysfs() gives the mode's); streaming is only ever 1
|
# x8 base; fixed_sysfs() gives the mode's); streaming is only ever 1
|
||||||
# inside a live job; the head white LED is a camera lamp, off at idle; the
|
# inside a live job; the head white LED is a camera lamp, off at idle; the
|
||||||
# loop heater and TEC are the diagnostics' tools, off at idle.
|
# loop heater and TEC are the diagnostics' tools, off at idle.
|
||||||
FIXED_SYSFS = [
|
FIXED_SYSFS = [
|
||||||
@@ -133,6 +138,7 @@ BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon a
|
|||||||
SETTLE_S = 150 # the supervisor's probe + rail-off ladder
|
SETTLE_S = 150 # the supervisor's probe + rail-off ladder
|
||||||
CAM_IDLE_S = 20 # camera engine idle stop is 10 s
|
CAM_IDLE_S = 20 # camera engine idle stop is 10 s
|
||||||
COOL_IDLE_S = 120 # cooldown after motion
|
COOL_IDLE_S = 120 # cooldown after motion
|
||||||
|
COOL_PUBLISH_S = 2.5 # the engine publishes once a tick (1 Hz): two ticks and a margin
|
||||||
IDLE_S = 30 # cnc/state back to idle after a job
|
IDLE_S = 30 # cnc/state back to idle after a job
|
||||||
GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running
|
GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running
|
||||||
|
|
||||||
@@ -147,11 +153,12 @@ RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most th
|
|||||||
POSITION_DEADBAND_MM = 0.1
|
POSITION_DEADBAND_MM = 0.1
|
||||||
|
|
||||||
# The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset =
|
# The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset =
|
||||||
# 8). The GRBL controller reads it at its start and derives its scale,
|
# 32). The GRBL controller reads it at its start and derives its scale,
|
||||||
# its machine tick and the kernel stop ramp from it (boards/glowforge.h):
|
# its machine tick and the kernel stop ramp from it (boards/glowforge.h):
|
||||||
# the fixed values of x/y_mode, step_freq and ramp_rate are the mode's.
|
# the fixed values of x/y_mode, step_freq and ramp_rate are the mode's.
|
||||||
XY_MODES = (8, 16, 32)
|
XY_MODES = (8, 16, 32)
|
||||||
XY_MODE_DEFAULT = 8
|
XY_MODE_BASE = 8 # the factory x8 reference the tick and ramp scale from
|
||||||
|
XY_MODE_DEFAULT = 32 # the mode an unset or invalid setting reads as
|
||||||
XY_STEPS_PER_MM_OF = {8: 53.333, 16: 106.667, 32: 213.333} # $100/$101 per mode
|
XY_STEPS_PER_MM_OF = {8: 53.333, 16: 106.667, 32: 213.333} # $100/$101 per mode
|
||||||
XY_TICK_X8_HZ = 28160 # the x8 machine tick
|
XY_TICK_X8_HZ = 28160 # the x8 machine tick
|
||||||
XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it
|
XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it
|
||||||
@@ -175,7 +182,7 @@ def fixed_sysfs(mode=XY_MODE_DEFAULT):
|
|||||||
"""FIXED_SYSFS with the mode's own x/y_mode, step_freq and ramp_rate:
|
"""FIXED_SYSFS with the mode's own x/y_mode, step_freq and ramp_rate:
|
||||||
the tick and the ramp scale with the mode (x16 doubles both, x32
|
the tick and the ramp scale with the mode (x16 doubles both, x32
|
||||||
quadruples them)."""
|
quadruples them)."""
|
||||||
k = mode // XY_MODE_DEFAULT
|
k = mode // XY_MODE_BASE
|
||||||
own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode),
|
own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode),
|
||||||
"cnc/step_freq": str(XY_TICK_X8_HZ * k),
|
"cnc/step_freq": str(XY_TICK_X8_HZ * k),
|
||||||
"cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)}
|
"cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)}
|
||||||
@@ -217,17 +224,25 @@ def read_position():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def position_quantized(was, now):
|
def counter_steps_per_mm():
|
||||||
|
"""The scale of the X/Y position counters: the kernel counts the
|
||||||
|
microsteps of the mode it runs (cnc/x_mode), so x16 and x32 count two
|
||||||
|
and four times the x8 steps for the same millimeter. The x8 scale when
|
||||||
|
the attribute is unreadable."""
|
||||||
|
return XY_STEPS_PER_MM_OF.get(hw.sysfs_int("cnc/x_mode"), XY_STEPS_PER_MM)
|
||||||
|
|
||||||
|
|
||||||
|
def position_quantized(was, now, spm=XY_STEPS_PER_MM):
|
||||||
"""True when two step-counter readings differ by no more than the
|
"""True when two step-counter readings differ by no more than the
|
||||||
dead band on X and Y and not at all on Z: the step quantization of a
|
dead band on X and Y and not at all on Z: the step quantization of a
|
||||||
move that landed where it meant to, rather than a leftover. A cancel
|
move that landed where it meant to, rather than a leftover. A cancel
|
||||||
that returns the head to the job start lands within a few hundredths
|
that returns the head to the job start lands within a few hundredths
|
||||||
of a millimeter, which is a step or four, and whether that rounds to
|
of a millimeter, which is a step or four, and whether that rounds to
|
||||||
the same integer is chance."""
|
the same integer is chance. spm is the counters' scale (the mode's)."""
|
||||||
if was is None or now is None or now[2] != was[2]:
|
if was is None or now is None or now[2] != was[2]:
|
||||||
return False
|
return False
|
||||||
return (abs(now[0] - was[0]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM and
|
return (abs(now[0] - was[0]) / spm <= POSITION_DEADBAND_MM and
|
||||||
abs(now[1] - was[1]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM)
|
abs(now[1] - was[1]) / spm <= POSITION_DEADBAND_MM)
|
||||||
|
|
||||||
|
|
||||||
def read_ring_residue():
|
def read_ring_residue():
|
||||||
@@ -321,7 +336,7 @@ class Baseline:
|
|||||||
def wait_settled(self, timeout=SETTLE_S, unreachable_s=10):
|
def wait_settled(self, timeout=SETTLE_S, unreachable_s=10):
|
||||||
"""Block until forgectrl reports a settled supervisor: motion
|
"""Block until forgectrl reports a settled supervisor: motion
|
||||||
verified (the probe passed), motion-fault (the ladder exhausted),
|
verified (the probe passed), motion-fault (the ladder exhausted),
|
||||||
standby (the manual stop lever), or gated (the commissioning gate
|
standby (the manual stop lever), or gated (the setup gate
|
||||||
is closed: no controller spawns until it opens). Gives up after
|
is closed: no controller spawns until it opens). Gives up after
|
||||||
unreachable_s without an answer. Returns the last /mode body (None
|
unreachable_s without an answer. Returns the last /mode body (None
|
||||||
if unreachable)."""
|
if unreachable)."""
|
||||||
@@ -450,6 +465,9 @@ class Baseline:
|
|||||||
preserved state to hand back (post) - None compares nothing."""
|
preserved state to hand back (post) - None compares nothing."""
|
||||||
left = []
|
left = []
|
||||||
self.mode = None
|
self.mode = None
|
||||||
|
# the counters' scale is the mode the run counted under, read
|
||||||
|
# before the kernel side puts the settings' mode back
|
||||||
|
self._counter_spm = counter_steps_per_mm()
|
||||||
self._forgectrl_side(left, captured)
|
self._forgectrl_side(left, captured)
|
||||||
self._kernel_side(left)
|
self._kernel_side(left)
|
||||||
self._lamp_side(left)
|
self._lamp_side(left)
|
||||||
@@ -596,36 +614,60 @@ class Baseline:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
act = "failed: %s" % e
|
act = "failed: %s" % e
|
||||||
left.append(Leftover("laser_locked", False, True, act))
|
left.append(Leftover("laser_locked", False, True, act))
|
||||||
# cooling engine idle, unarmed
|
self._cool_side(left)
|
||||||
|
|
||||||
|
def _cool_side(self, left):
|
||||||
|
"""The cooling engine idle, unarmed, holding nothing."""
|
||||||
st, c = self.fc_get("/cool/status")
|
st, c = self.fc_get("/cool/status")
|
||||||
if st == 200 and isinstance(c, dict):
|
if st != 200 or not isinstance(c, dict):
|
||||||
if c.get("phase") != "idle" or c.get("armed") or c.get("hold"):
|
return
|
||||||
|
if c.get("phase") == "idle" and not c.get("armed") and not c.get("hold"):
|
||||||
|
return
|
||||||
|
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
|
||||||
|
idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731
|
||||||
|
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
||||||
|
# The engine publishes once a tick (1 Hz). A read inside the tick
|
||||||
|
# after a run ended still shows the run: a diagnostic's hold until
|
||||||
|
# the engine has the hardware back, a fail tier's hold until the
|
||||||
|
# session ends with the controller. What the engine's next tick
|
||||||
|
# clears was never left behind, so an arm or a hold is read again
|
||||||
|
# past that tick before it is called the run's doing. One that a
|
||||||
|
# run did leave stands for a job that is never coming back, and it
|
||||||
|
# is still there.
|
||||||
|
if c.get("armed") or c.get("hold"):
|
||||||
|
free = lambda: (lambda x: bool(x) and not x.get("armed") # noqa: E731
|
||||||
|
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
||||||
|
if self._wait("cool publish", free, COOL_PUBLISH_S) is not None:
|
||||||
|
st, c2 = self.fc_get("/cool/status")
|
||||||
|
c = c2 if st == 200 and isinstance(c2, dict) else {}
|
||||||
|
self.log("cool: %s was the engine's last word on the run; its next tick reads "
|
||||||
|
"%s/armed=%s/hold=%s" % (found, c.get("phase"), c.get("armed"), c.get("hold")))
|
||||||
|
if c.get("phase") == "idle":
|
||||||
|
return
|
||||||
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
|
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
|
||||||
idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731
|
# Armed, or holding, is the run's doing. A phase alone is
|
||||||
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
# not: the engine clears smoke at run duty after an armed
|
||||||
# Armed, or holding, is the run's doing. A phase alone is
|
# session and cools down after a hot one, both timed and
|
||||||
# not: the engine clears smoke at run duty after an armed
|
# both ending on their own. Waiting for that is right;
|
||||||
# session and cools down after a hot one, both timed and
|
# calling it a leftover is not, because nothing was left -
|
||||||
# both ending on their own. Waiting for that is right;
|
# the machine was still finishing.
|
||||||
# calling it a leftover is not, because nothing was left -
|
dirt = bool(c.get("armed") or c.get("hold"))
|
||||||
# the machine was still finishing.
|
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
||||||
dirt = bool(c.get("armed") or c.get("hold"))
|
if w is not None and not dirt:
|
||||||
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
|
||||||
if w is not None and not dirt:
|
"work, not a leftover" % (found, w))
|
||||||
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
|
return
|
||||||
"work, not a leftover" % (found, w))
|
# The engine holds for a job. A run that ended without
|
||||||
else:
|
# ending its job leaves one alive, and no amount of
|
||||||
# The engine holds for a job. A run that ended without
|
# waiting ends it: stand the machine down and let the
|
||||||
# ending its job leaves one alive, and no amount of
|
# supervisor bring the controller back clean.
|
||||||
# waiting ends it: stand the machine down and let the
|
act = "waited"
|
||||||
# supervisor bring the controller back clean.
|
if w is None:
|
||||||
act = "waited"
|
self.stand_down("the cooling engine is still %s" % found)
|
||||||
if w is None:
|
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
||||||
self.stand_down("the cooling engine is still %s" % found)
|
act = "restored (stood the machine down)"
|
||||||
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
||||||
act = "restored (stood the machine down)"
|
act if w is not None else "failed: still %s" % found))
|
||||||
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
|
||||||
act if w is not None else "failed: still %s" % found))
|
|
||||||
|
|
||||||
def _lamp_side(self, left):
|
def _lamp_side(self, left):
|
||||||
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
|
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
|
||||||
@@ -693,13 +735,14 @@ class Baseline:
|
|||||||
self.log("leds/%s: target 0, brightness %s; the fade from a level the machine "
|
self.log("leds/%s: target 0, brightness %s; the fade from a level the machine "
|
||||||
"itself ended, not a leftover" % (name, read_led(name, "brightness")))
|
"itself ended, not a leftover" % (name, read_led(name, "brightness")))
|
||||||
|
|
||||||
def _return_head(self, was, now):
|
def _return_head(self, was, now, spm=XY_STEPS_PER_MM):
|
||||||
"""Jog the head back along its own path by the kernel-measured X/Y
|
"""Jog the head back along its own path by the kernel-measured X/Y
|
||||||
delta (Z is never touched), through the GRBL controller. Bounded:
|
delta (Z is never touched), through the GRBL controller. spm is
|
||||||
beyond RETURN_MAX_MM per axis, or without a running GRBL
|
the counters' scale (the mode's). Bounded: beyond RETURN_MAX_MM
|
||||||
controller, the counters are reported and left."""
|
per axis, or without a running GRBL controller, the counters are
|
||||||
dx = (now[0] - was[0]) / XY_STEPS_PER_MM
|
reported and left."""
|
||||||
dy = (now[1] - was[1]) / XY_STEPS_PER_MM
|
dx = (now[0] - was[0]) / spm
|
||||||
|
dy = (now[1] - was[1]) / spm
|
||||||
if abs(dx) < 0.02 and abs(dy) < 0.02:
|
if abs(dx) < 0.02 and abs(dy) < 0.02:
|
||||||
return "unrestorable (Z only)" if now[2] != was[2] else "restored"
|
return "unrestorable (Z only)" if now[2] != was[2] else "restored"
|
||||||
if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM:
|
if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM:
|
||||||
@@ -787,8 +830,9 @@ class Baseline:
|
|||||||
was = captured.get("position")
|
was = captured.get("position")
|
||||||
now = read_position()
|
now = read_position()
|
||||||
if was is not None and now is not None and now != was and not self.cloud_mode():
|
if was is not None and now is not None and now != was and not self.cloud_mode():
|
||||||
act = self._return_head(was, now)
|
spm = self._counter_spm
|
||||||
if position_quantized(was, now):
|
act = self._return_head(was, now, spm)
|
||||||
|
if position_quantized(was, now, spm):
|
||||||
self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, "
|
self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, "
|
||||||
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
|
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The bench diagnostics page: registry of the bench tools and the
|
"""The bench diagnostics page: registry of the bench tools and the
|
||||||
subprocess runner behind the #bench tab.
|
subprocess runner behind the #bench tab.
|
||||||
|
|
||||||
@@ -247,8 +252,11 @@ TOOLS = [
|
|||||||
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
|
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
|
||||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
"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 "
|
"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 "
|
"lens stands, and neither a $20 nor a $132 write frees it. A referenced lens (forgectrl's marker "
|
||||||
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
"and the lens settings) opens it to the window the settings hold, the fallback or the focus "
|
||||||
|
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
|
||||||
|
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
|
||||||
|
"bench-page tool."},
|
||||||
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
|
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
|
||||||
"safety": "dry", "where": "board", "ported": True,
|
"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"])],
|
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Campaign rules: which results apply, what is required, and whether a
|
"""Campaign rules: which results apply, what is required, and whether a
|
||||||
release is authorized.
|
release is authorized.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The acceptance catalog: test definitions and the registry.
|
"""The acceptance catalog: test definitions and the registry.
|
||||||
|
|
||||||
A test is a function decorated with @test(...). The decorator records
|
A test is a function decorated with @test(...). The decorator records
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Coverage lint: every source path in the manifest must be selected by
|
"""Coverage lint: every source path in the manifest must be selected by
|
||||||
some test's coverage globs, except the allowlisted non-behavioral paths,
|
some test's coverage globs, except the allowlisted non-behavioral paths,
|
||||||
and every coverage glob must select at least one path of its component.
|
and every coverage glob must select at least one path of its component.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The bench actuator (forgefixture): the box on the bench network that
|
"""The bench actuator (forgefixture): the box on the bench network that
|
||||||
opens the lid loop, pulls the interlock loop and presses the button on
|
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.
|
request, so an operator test can run with nobody in the room.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Hardware and service access for the suite: forgectrl's HTTP API, the
|
"""Hardware and service access for the suite: forgectrl's HTTP API, the
|
||||||
kernel module's sysfs, the init scripts, and the Grbl TCP port.
|
kernel module's sysfs, the init scripts, and the Grbl TCP port.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The append-only result log (JSONL) and the export helpers.
|
"""The append-only result log (JSONL) and the export helpers.
|
||||||
|
|
||||||
One JSON object per line under the data directory (default
|
One JSON object per line under the data directory (default
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The image manifest and the domain fingerprint.
|
"""The image manifest and the domain fingerprint.
|
||||||
|
|
||||||
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
|
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The single page: acceptance tab + bench tab, assembled from ui/ into
|
"""The single page: acceptance tab + bench tab, assembled from ui/ into
|
||||||
one self-contained response (no external assets). Bootstrap and the
|
one self-contained response (no external assets). Bootstrap and the
|
||||||
OpenGlow theme are inlined; theme.css and the vendor/ files are the same
|
OpenGlow theme are inlined; theme.css and the vendor/ files are the same
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Pulse files for the offline cloud service: a job the machine will run
|
"""Pulse files for the offline cloud service: a job the machine will run
|
||||||
without the service having cut it.
|
without the service having cut it.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""Redaction for the release artifact.
|
||||||
|
|
||||||
|
The artifact is committed to a public repository, so it carries no
|
||||||
|
identity of the machine that ran the campaign. This applies the same two
|
||||||
|
layers forgectrl's log export applies (forgectrl/src/sanitize.c), in the
|
||||||
|
same order, to every value the exporter writes:
|
||||||
|
|
||||||
|
1. Known keys: a value held under a key that names identifying data -
|
||||||
|
the serial, the machine id, the hostname, the wireless credentials,
|
||||||
|
a token - is replaced whole. Naming the key is far more reliable
|
||||||
|
than guessing the shape of what is under it.
|
||||||
|
2. Pattern classes over every remaining string: MAC addresses, IPv4 and
|
||||||
|
IPv6 addresses (loopback and unspecified kept), e-mail addresses,
|
||||||
|
bearer and basic credentials, JWTs, and long hex blobs. Each distinct
|
||||||
|
value in a class keeps a stable number for the life of one export, so
|
||||||
|
"<IP-2>" is the same host wherever it appears and the artifact stays
|
||||||
|
readable as evidence.
|
||||||
|
|
||||||
|
Integrity values are never touched: the self-hash, the manifest and
|
||||||
|
catalog hashes, each test's source hash and fingerprint. Those are what
|
||||||
|
the gate recomputes, and a long hex blob is exactly what they look like.
|
||||||
|
|
||||||
|
Over-redaction is accepted where the alternative is a leak.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Layer 1: the value under one of these keys is replaced whole.
|
||||||
|
REDACT_KEYS = frozenset((
|
||||||
|
"gf_serial", "machine_id", "serial", "serial_number",
|
||||||
|
"hostname", "hostname_file", "host",
|
||||||
|
"ssid", "psk", "passphrase", "password", "passwd",
|
||||||
|
"token", "panel_token", "secret", "api_key", "key",
|
||||||
|
"mac", "macaddr", "mac_suffix", "wlan0", "eth0",
|
||||||
|
"gf_username", "gf_password", "username", "user", "email",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Never touched, by key: the gate recomputes these and they look like
|
||||||
|
# exactly what layer 2 hunts for.
|
||||||
|
KEEP_KEYS = frozenset((
|
||||||
|
"sha256", "manifest_sha", "identity_sha", "catalog_hash",
|
||||||
|
"source_sha", "fingerprint", "content_sha", "srcrev",
|
||||||
|
))
|
||||||
|
|
||||||
|
_KEEP_IP = frozenset(("0.0.0.0", "255.255.255.255"))
|
||||||
|
|
||||||
|
_CLASSES = (
|
||||||
|
("SECRET", re.compile(r"(?i)\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}")),
|
||||||
|
("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
|
||||||
|
("EMAIL", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
|
||||||
|
("MAC", re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b")),
|
||||||
|
# Three or more colons, or a "::" run: two colons is a clock time, and
|
||||||
|
# tagging every timestamp would make the evidence unreadable.
|
||||||
|
("IP6", re.compile(
|
||||||
|
r"(?:[0-9A-Fa-f]{1,4}:){3,7}[0-9A-Fa-f]{1,4}"
|
||||||
|
r"|[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{1,4})*::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?")),
|
||||||
|
("IP", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")),
|
||||||
|
("HEX", re.compile(r"\b[0-9A-Fa-f]{32,}\b")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Redactor:
|
||||||
|
"""Stable per-export numbering, one instance per artifact."""
|
||||||
|
|
||||||
|
# A known value shorter than this is too generic to replace blind.
|
||||||
|
MIN_KNOWN = 4
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._n = {}
|
||||||
|
self._known = {}
|
||||||
|
|
||||||
|
def learn(self, obj, key=None):
|
||||||
|
"""Collect the values held under the layer-1 keys, so they are
|
||||||
|
replaced wherever else they appear - a hostname in a log line is
|
||||||
|
the same leak as a hostname in its own field."""
|
||||||
|
if key is not None and key in KEEP_KEYS:
|
||||||
|
return
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for k, v in obj.items():
|
||||||
|
self.learn(v, k)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for v in obj:
|
||||||
|
self.learn(v)
|
||||||
|
elif key in REDACT_KEYS and isinstance(obj, str) and len(obj) >= self.MIN_KNOWN:
|
||||||
|
if obj not in self._known:
|
||||||
|
self._known[obj] = self._tag("REDACTED", "%s=%s" % (key, obj))
|
||||||
|
|
||||||
|
def _tag(self, cls, value):
|
||||||
|
seen = self._n.setdefault(cls, {})
|
||||||
|
if value not in seen:
|
||||||
|
seen[value] = len(seen) + 1
|
||||||
|
return "<%s-%d>" % (cls, seen[value])
|
||||||
|
|
||||||
|
def text(self, s):
|
||||||
|
for known in sorted(self._known, key=len, reverse=True):
|
||||||
|
if known in s:
|
||||||
|
s = s.replace(known, self._known[known])
|
||||||
|
for cls, rx in _CLASSES:
|
||||||
|
def sub(m, cls=cls):
|
||||||
|
v = m.group(0)
|
||||||
|
if cls == "IP" and (v.startswith("127.") or v in _KEEP_IP):
|
||||||
|
return v
|
||||||
|
if cls == "IP6" and v in ("::", "::1"):
|
||||||
|
return v
|
||||||
|
return self._tag(cls, v)
|
||||||
|
s = rx.sub(sub, s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
def value(self, v, key=None):
|
||||||
|
if key is not None and key in KEEP_KEYS:
|
||||||
|
return v
|
||||||
|
if key is not None and key in REDACT_KEYS and not isinstance(v, (dict, list)):
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, str) and v in self._known:
|
||||||
|
return self._known[v]
|
||||||
|
return self._tag("REDACTED", "%s=%s" % (key, v))
|
||||||
|
if isinstance(v, dict):
|
||||||
|
return {k: self.value(x, k) for k, x in v.items()}
|
||||||
|
if isinstance(v, list):
|
||||||
|
return [self.value(x) for x in v]
|
||||||
|
if isinstance(v, str):
|
||||||
|
return self.text(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def scrub(obj):
|
||||||
|
"""Redact a value tree with one export's numbering."""
|
||||||
|
r = Redactor()
|
||||||
|
r.learn(obj)
|
||||||
|
return r.value(obj)
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The runner: executes one acceptance test or one bench tool at a time.
|
"""The runner: executes one acceptance test or one bench tool at a time.
|
||||||
|
|
||||||
Acceptance tests run in a worker thread with a Context: log lines, an
|
Acceptance tests run in a worker thread with a Context: log lines, an
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""HTTP: the page, the JSON API, and the access rules.
|
"""HTTP: the page, the JSON API, and the access rules.
|
||||||
|
|
||||||
Access follows forgectrl's panel (auth.c): the Host header must be an
|
Access follows forgectrl's panel (auth.c): the Host header must be an
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The acceptance suite: one module per subsystem, imported in display
|
"""The acceptance suite: one module per subsystem, imported in display
|
||||||
order. Each module registers its tests with @catalog.test."""
|
order. Each module registers its tests with @catalog.test."""
|
||||||
|
|
||||||
from . import image # noqa: F401,E402
|
from . import image # noqa: F401,E402
|
||||||
from . import kernel # noqa: F401,E402
|
from . import kernel # noqa: F401,E402
|
||||||
from . import forgectrl # noqa: F401,E402
|
from . import forgectrl # noqa: F401,E402
|
||||||
from . import commission # noqa: F401,E402
|
from . import setup # noqa: F401,E402
|
||||||
from . import commission_dark # noqa: F401,E402
|
from . import setup_dark # noqa: F401,E402
|
||||||
from . import commission_sheet # noqa: F401,E402
|
from . import setup_sheet # noqa: F401,E402
|
||||||
from . import logs # noqa: F401,E402
|
from . import logs # noqa: F401,E402
|
||||||
from . import motion # noqa: F401,E402
|
from . import motion # noqa: F401,E402
|
||||||
from . import cooling # noqa: F401,E402
|
from . import cooling # noqa: F401,E402
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""camera.* - the lid camera pipeline through forgectrl."""
|
"""camera.* - the lid camera pipeline through forgectrl."""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -396,7 +401,7 @@ def lid_privacy(ctx):
|
|||||||
"authorizes a write; a rotation from the panel route stops the old key and "
|
"authorizes a write; a rotation from the panel route stops the old key and "
|
||||||
"the new one reads. The setting is put back after.")
|
"the new one reads. The setting is put back after.")
|
||||||
def key_read(ctx):
|
def key_read(ctx):
|
||||||
from .commission import request
|
from .setup import request
|
||||||
from .forgectrl import lan_ip
|
from .forgectrl import lan_ip
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""cloud.* - the controller mode switch and the optional Glowforge web-service
|
"""cloud.* - the controller mode switch and the optional Glowforge web-service
|
||||||
mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the
|
mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the
|
||||||
grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and
|
grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and
|
||||||
@@ -87,6 +92,9 @@ FORGECTRL_LOG = "/data/log/forgefirm/forgectrl/forgectrl.log"
|
|||||||
LIMITS_MARK = "job limits from the header: "
|
LIMITS_MARK = "job limits from the header: "
|
||||||
EFFECTIVE_MARK = "effective limits: coolant ceiling "
|
EFFECTIVE_MARK = "effective limits: coolant ceiling "
|
||||||
SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
|
SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
|
||||||
|
# The service's connect-time hunt, as the client logs its request: a few
|
||||||
|
# seconds after the controller starts, right behind the session.
|
||||||
|
HUNT_REQUEST = "service action request: hunt"
|
||||||
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
|
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
|
||||||
|
|
||||||
|
|
||||||
@@ -145,7 +153,7 @@ def return_head(ctx, feed=2400):
|
|||||||
"head not back at the start after the return jog: %s", pos)
|
"head not back at the start after the return jog: %s", pos)
|
||||||
|
|
||||||
|
|
||||||
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
|
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90, poll=1.0):
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
last = None
|
last = None
|
||||||
while time.time() - t0 < timeout:
|
while time.time() - t0 < timeout:
|
||||||
@@ -157,7 +165,7 @@ def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
|
|||||||
return m
|
return m
|
||||||
if m.get("controller") == "motion-fault":
|
if m.get("controller") == "motion-fault":
|
||||||
break
|
break
|
||||||
time.sleep(1)
|
time.sleep(poll)
|
||||||
return last
|
return last
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +235,11 @@ def gfhome_homing(ctx, ev, g):
|
|||||||
# homed: the counters are re-anchored at the corner, where the head stays
|
# homed: the counters are re-anchored at the corner, where the head stays
|
||||||
ctx.counters_rezeroed()
|
ctx.counters_rezeroed()
|
||||||
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing")
|
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing")
|
||||||
|
# The driver answers $H when the session ends; that ok (and the
|
||||||
|
# session's messages) sit in the buffer behind the status reports and
|
||||||
|
# would pass for the reply to the caller's next command.
|
||||||
|
ack = " | ".join(ln.strip() for ln in g.drain().splitlines() if ln.strip())
|
||||||
|
ctx.log("$H acknowledged: %s", ack[:160] or "(nothing buffered)")
|
||||||
|
|
||||||
|
|
||||||
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt "
|
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt "
|
||||||
@@ -235,17 +248,22 @@ def gfhome_homing(ctx, ev, g):
|
|||||||
covers=_HOMING_PATH + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"),
|
covers=_HOMING_PATH + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"),
|
||||||
("grblhal-glowforge", "src/**")],
|
("grblhal-glowforge", "src/**")],
|
||||||
requires=["forgectrl.auth", "motion.pacing"], actions=["lid"],
|
requires=["forgectrl.auth", "motion.pacing"], actions=["lid"],
|
||||||
steps=["Bed clear; cloud credentials configured; the machine on the network. The test "
|
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. "
|
||||||
"turns cloud mode and the gfcloud homing on itself when they are off, and puts "
|
"The test turns cloud mode and the gfcloud homing on itself when they are off, and "
|
||||||
"the settings back at the end.",
|
"puts the settings back at the end.",
|
||||||
"Open the lid when told and leave it open through the cloud client's connect and its "
|
"Open the lid the moment you are told, with a hand ready on it: the cloud client's "
|
||||||
"hunt; close it when told. Nothing else: the switch back and the $H homing run on "
|
"first hunt begins a few seconds after its controller starts and must find the lid "
|
||||||
"their own, and the head ends parked at the home corner."],
|
"open. Leave it open through the connect and the hunt; close it when told. Nothing "
|
||||||
|
"else: the switch back and the $H homing run on their own, and the head ends parked "
|
||||||
|
"at the home corner."],
|
||||||
description="One round trip with the two service-driven motions on it. POST /mode switches "
|
description="One round trip with the two service-driven motions on it. POST /mode switches "
|
||||||
"to the cloud controller: gfcloud comes up under supervision, authenticates and "
|
"to the cloud controller with the lid closed (no controller starts with the "
|
||||||
|
"enclosure open): gfcloud comes up under supervision, authenticates and "
|
||||||
"establishes its service session (its own log lines are the evidence; the "
|
"establishes its service session (its own log lines are the evidence; the "
|
||||||
"connect-time firmware probe is recorded when configured). Its connect-time hunt "
|
"connect-time firmware probe is recorded when configured). The lid is opened the "
|
||||||
"runs with the lid OPEN, as the factory's does - it completes, nothing before its "
|
"moment the controller is up, before the client requests its connect-time hunt "
|
||||||
|
"(a hunt requested before the lid was open fails the test), so the hunt runs "
|
||||||
|
"with the lid OPEN, as the factory's does - it completes, nothing before its "
|
||||||
"end is refused for the lid, the lens homes - and with the extraction fans off it "
|
"end is refused for the lid, the lens homes - and with the extraction fans off it "
|
||||||
"is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row "
|
"is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row "
|
||||||
"reads unjudged); the camera service survives the switch. The lid closed, the "
|
"reads unjudged); the camera service survives the switch. The lid closed, the "
|
||||||
@@ -270,17 +288,32 @@ def mode_switch(ctx):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
lamp0 = hw.sysfs_read("pic/lid_led")
|
lamp0 = hw.sysfs_read("pic/lid_led")
|
||||||
|
ctx.check(ctx.switch("lid") is True, "close the lid first: no controller starts with it open")
|
||||||
|
|
||||||
ctx.act("lid", "open", text="Leave it open: the cloud client connects and hunts with the lid open.")
|
# The switch is made with the enclosure closed (the supervisor holds
|
||||||
|
# every spawn until it is). The lid opens the moment the controller is
|
||||||
|
# up: the client requests its connect-time hunt a few seconds after
|
||||||
|
# its start, right behind its session, and the hunt must find the lid
|
||||||
|
# open. The poll is tight so the window is not spent waiting.
|
||||||
log_offset = log_size(GFCLOUD_LOG)
|
log_offset = log_size(GFCLOUD_LOG)
|
||||||
st, body = fc.post("/mode", data={"controller": "cloud"})
|
st, body = fc.post("/mode", data={"controller": "cloud"})
|
||||||
ctx.log("POST /mode controller=cloud -> %s %s", st, body)
|
ctx.log("POST /mode controller=cloud -> %s %s", st, body)
|
||||||
ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body)
|
ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body)
|
||||||
m = wait_mode(ctx, fc, "cloud", timeout=90)
|
m = wait_mode(ctx, fc, "cloud", timeout=90, poll=0.2)
|
||||||
ev["mode_cloud"] = m
|
ev["mode_cloud"] = m
|
||||||
ctx.log("mode after switch: %s", m)
|
ctx.log("mode after switch: %s", m)
|
||||||
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
|
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
|
||||||
"cloud controller did not come up: %s", m)
|
"cloud controller did not come up: %s", m)
|
||||||
|
ctx.act("lid", "open", text="Now, at once: the cloud client's first hunt begins a few seconds "
|
||||||
|
"after its controller starts and must find the lid open. Leave it open through the "
|
||||||
|
"connect and the hunt.")
|
||||||
|
# The order is the proof: the hunt's request is not in the log yet
|
||||||
|
# when the lid reads open.
|
||||||
|
ev["hunt_before_lid_open"] = log_has(log_offset, HUNT_REQUEST)
|
||||||
|
ctx.check(not ev["hunt_before_lid_open"],
|
||||||
|
"the client requested its hunt before the lid was open (the hunt follows the "
|
||||||
|
"controller's start by a few seconds): the lid opened too late, so a hunt with the "
|
||||||
|
"lid open is not proven")
|
||||||
# the client's own session lines are the evidence of a live cloud session
|
# the client's own session lines are the evidence of a live cloud session
|
||||||
session = wait_session(ctx, log_offset)
|
session = wait_session(ctx, log_offset)
|
||||||
probe = None
|
probe = None
|
||||||
@@ -1295,42 +1328,96 @@ def lid_during_button_wait_body(ctx, ev, off, job, offset):
|
|||||||
# ---------------------------------------------------------------- the cooling verdict
|
# ---------------------------------------------------------------- the cooling verdict
|
||||||
|
|
||||||
HOLD_KEYS = ("cool_temp_min", "cool_temp_start")
|
HOLD_KEYS = ("cool_temp_min", "cool_temp_start")
|
||||||
WARMUP_ABOVE_C = 1.0 # the start gate this far above the loop: a few heater minutes
|
REFUSE_KEYS = HOLD_KEYS + ("cloud_hold_max_s",)
|
||||||
WARMUP_RELEASE_S = 720 # the flow heater warms the bulk 0.4 to 0.8 C a minute
|
HOLD_REFUSE_S = 60 # the shortest bound the client takes
|
||||||
|
HOLD_REFUSE_LINE = "cooling hold held 60 s - relocking the laser"
|
||||||
WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP"
|
WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP"
|
||||||
WARMUP_RELEASE_LINE = "cooling verdict clean after WARMUP; starting the run"
|
|
||||||
|
|
||||||
|
|
||||||
@test("cloud.verdict-hold", title="A cloud print armed under the warm-up gate waits for the engine",
|
@test("cloud.dark-print", title="A dark cloud print: the latch locked until the run, unlocked "
|
||||||
subsystem="cloud", kind="operator", est_min=14,
|
"for it, locked after it",
|
||||||
|
subsystem="cloud", kind="operator", est_min=3,
|
||||||
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
||||||
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
|
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
|
||||||
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
|
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
|
||||||
steps=[OFFLINE_STEP,
|
steps=[OFFLINE_STEP,
|
||||||
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate just above "
|
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
||||||
"the coolant and restores it. Press the button when it lights: the print then waits "
|
"when it lights: the print waits for the engine, runs, and finishes."],
|
||||||
"for the warm-up (a few minutes with the loop heater on), runs dark, and finishes."],
|
description="The cooling-engine contract in cloud mode at its ordinary end, on the engine "
|
||||||
description="The cooling-engine contract in cloud mode, on the engine itself: the start gate "
|
"itself: the print arms on the press, waits for the engine's acknowledgment (the "
|
||||||
"set just above the coolant makes the armed session open under the warm-up "
|
"fans at their floors), and runs. The laser latch is locked at the button and "
|
||||||
"(WARMUP: fire blocked, hold), and the client waits it out after the button "
|
"through the wait, unlocks only for the run (immediately before it starts), and "
|
||||||
"instead of canceling - nothing runs, the latch stays unlocked for the armed "
|
"is locked again when the job ends; the print completes. A hold the engine never "
|
||||||
"window - until the engine releases into run, and the release starts the print, "
|
"clears is cloud.verdict-refuse; the engine's own warm-up release is "
|
||||||
"which completes. The hold mid-run (the same laser-off pause as the button's, "
|
"cooling.floor-and-warm-up.")
|
||||||
"resumed on the engine's resume_ok) and the bound on a hold are host-tested: the "
|
def dark_print(ctx):
|
||||||
"gates apply at session open, so no setting can produce a hold mid-run here.")
|
ev = ctx.evidence
|
||||||
def verdict_hold(ctx):
|
fc = ctx.forgectrl
|
||||||
|
offset = enter_offline(ctx)
|
||||||
|
job = offline_job(ctx, "dark.puls", seconds=30)
|
||||||
|
off = Offline().__enter__()
|
||||||
|
try:
|
||||||
|
dark_print_body(ctx, ev, off, job, offset, fc)
|
||||||
|
finally:
|
||||||
|
off.__exit__(None, None, None)
|
||||||
|
offline_cleanup(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def dark_print_body(ctx, ev, off, job, offset, fc):
|
||||||
|
off.print_ready(9005, job)
|
||||||
|
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||||
|
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
||||||
|
ev["latch_locked_at_button"] = latch_locked()
|
||||||
|
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
|
||||||
|
ctx.act("button", "press", text="The button is lit: press it. The print runs dark and finishes.",
|
||||||
|
until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=PRESS_TIMEOUT_S)
|
||||||
|
run = wait_print_running(ctx, offset, 60)
|
||||||
|
ctx.check(run is not None, "the print did not start after the press")
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
ev["latch_locked_in_run"] = latch_locked()
|
||||||
|
ctx.check(ev["latch_locked_in_run"] is False, "the latch is locked during the run")
|
||||||
|
fin = wait_action_finished(ctx, offset, "print", 180)
|
||||||
|
ev["print finished"] = message(fin)
|
||||||
|
ctx.check(fin and COMPLETED in fin, "the print did not complete: %s", message(fin) or "no finish line")
|
||||||
|
st, cs = fc.get("/cool/status")
|
||||||
|
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
||||||
|
ctx.check(not ev["armed_after"], "armed window still open after the print")
|
||||||
|
ev["latch_locked_after"] = latch_locked()
|
||||||
|
ctx.check(ev["latch_locked_after"], "the latch is unlocked after the print")
|
||||||
|
ev["events"] = offline_events(off)
|
||||||
|
ctx.log("PASS: locked at the button, unlocked for the run, locked after it; the print completed")
|
||||||
|
|
||||||
|
|
||||||
|
@test("cloud.verdict-refuse", title="A cloud print held by the engine past the bound is canceled with "
|
||||||
|
"the latch never unlocked",
|
||||||
|
subsystem="cloud", kind="operator", est_min=4,
|
||||||
|
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
||||||
|
("forgectrl", "src/cool.*")],
|
||||||
|
requires=["cloud.dark-print"], actions=["button"],
|
||||||
|
steps=[OFFLINE_STEP,
|
||||||
|
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate well above "
|
||||||
|
"the coolant and the hold bound to its minimum, and restores both. Press the button "
|
||||||
|
"when it lights: nothing runs; a minute later the print cancels by itself."],
|
||||||
|
description="The other end of the cooling-engine contract before a run: a verdict that "
|
||||||
|
"refuses fire and never clears. The start gate far above the coolant keeps the "
|
||||||
|
"armed session under the warm-up for longer than the hold bound (cloud_hold_max_s "
|
||||||
|
"at its minimum, 60 s). The client waits with the laser latch locked the whole "
|
||||||
|
"time - it is never unlocked before the verdict passes, so there is nothing to "
|
||||||
|
"relock - then cancels the print at the bound: no run, the armed window closed, "
|
||||||
|
"the print ':cancelled'. Dark by construction.")
|
||||||
|
def verdict_refuse(ctx):
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
before = fc.settings()
|
before = fc.settings()
|
||||||
orig = {k: before.get(k, "") for k in HOLD_KEYS}
|
orig = {k: before.get(k, "") for k in REFUSE_KEYS}
|
||||||
ev["orig"] = orig
|
ev["orig"] = orig
|
||||||
up = (fc.status().get("coolant") or {}).get("up_c")
|
up = (fc.status().get("coolant") or {}).get("up_c")
|
||||||
ctx.check(up is not None and 10.0 <= up <= 30.0, "coolant outside the test's range (up_c %s)", up)
|
ctx.check(up is not None and 10.0 <= up <= 30.0, "coolant outside the test's range (up_c %s)", up)
|
||||||
offset = enter_offline(ctx)
|
offset = enter_offline(ctx)
|
||||||
job = offline_job(ctx, "hold.puls", seconds=30)
|
job = offline_job(ctx, "refuse.puls", seconds=30)
|
||||||
off = Offline().__enter__()
|
off = Offline().__enter__()
|
||||||
try:
|
try:
|
||||||
verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig)
|
verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig)
|
||||||
finally:
|
finally:
|
||||||
st, body = fc.post("/settings", params=orig)
|
st, body = fc.post("/settings", params=orig)
|
||||||
ctx.log("restore: POST /settings %s -> %s", orig, st)
|
ctx.log("restore: POST /settings %s -> %s", orig, st)
|
||||||
@@ -1338,46 +1425,53 @@ def verdict_hold(ctx):
|
|||||||
offline_cleanup(ctx)
|
offline_cleanup(ctx)
|
||||||
|
|
||||||
|
|
||||||
def verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig):
|
def verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig):
|
||||||
gate = round(up + WARMUP_ABOVE_C, 1)
|
gate = round(up + 8.0, 1) # eight degrees: the heater cannot get there in a minute
|
||||||
st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
|
st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
|
||||||
"cool_temp_min": orig["cool_temp_min"]})
|
"cool_temp_min": orig["cool_temp_min"],
|
||||||
ctx.check(st == 200, "POST /settings cool_temp_start=%s -> %s %s", gate, st, body)
|
"cloud_hold_max_s": str(HOLD_REFUSE_S)})
|
||||||
|
ctx.check(st == 200, "POST /settings cool_temp_start=%s cloud_hold_max_s=%s -> %s %s",
|
||||||
|
gate, HOLD_REFUSE_S, st, body)
|
||||||
ev["gate"] = gate
|
ev["gate"] = gate
|
||||||
off.print_ready(9005, job)
|
off.print_ready(9006, job)
|
||||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||||
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
||||||
ctx.act("button", "press", text="The button is lit: press it. The print then waits for the warm-up.",
|
ev["latch_locked_at_button"] = latch_locked()
|
||||||
|
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
|
||||||
|
ctx.act("button", "press", text="The button is lit: press it. Nothing runs; the print cancels "
|
||||||
|
"itself a minute later.",
|
||||||
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
|
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
|
||||||
got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30)
|
got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30)
|
||||||
ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up")
|
ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up")
|
||||||
st, c = fc.get("/cool/status")
|
t0 = time.time()
|
||||||
ev["warmup"] = c
|
samples = []
|
||||||
ctx.check(isinstance(c, dict) and c.get("verdict") == "WARMUP" and c.get("hold") is True
|
ctx.notice("The engine holds the print; the client gives up at the bound. Do nothing.")
|
||||||
and c.get("fire_ok") is False,
|
|
||||||
"the engine is not holding the armed session on the warm-up: %s", c)
|
|
||||||
ctx.check(wait_print_running(ctx, offset, 2) is None, "the run started under the warm-up hold")
|
|
||||||
ctx.notice("Warm-up: the loop heater brings the coolant to the gate; a few minutes. Do nothing.")
|
|
||||||
try:
|
try:
|
||||||
t0 = time.time()
|
while time.time() - t0 < HOLD_REFUSE_S + 30:
|
||||||
run = wait_print_running(ctx, offset, WARMUP_RELEASE_S)
|
ctx.checkpoint()
|
||||||
|
samples.append(latch_locked())
|
||||||
|
if log_has(offset, HOLD_REFUSE_LINE):
|
||||||
|
break
|
||||||
|
ctx.sleep(2)
|
||||||
finally:
|
finally:
|
||||||
ctx.clear_notice()
|
ctx.clear_notice()
|
||||||
ev["release_s"] = round(time.time() - t0)
|
ev["hold_s"] = round(time.time() - t0)
|
||||||
got = wait_log(ctx, offset, [WARMUP_RELEASE_LINE], 5)
|
ev["latch_samples"] = {"n": len(samples), "unlocked": samples.count(False)}
|
||||||
ev["log"] = {WARMUP_WAIT_LINE: True, WARMUP_RELEASE_LINE: bool(got[WARMUP_RELEASE_LINE])}
|
ctx.check(log_has(offset, HOLD_REFUSE_LINE), "the client did not cancel at the hold bound (%d s)",
|
||||||
ctx.check(got[WARMUP_RELEASE_LINE], "the warm-up did not release into the run within %d s",
|
ev["hold_s"])
|
||||||
WARMUP_RELEASE_S)
|
ctx.check(all(samples), "the latch unlocked during the hold (%d of %d samples)",
|
||||||
ctx.check(run is not None, "the print did not start after the warm-up")
|
samples.count(False), len(samples))
|
||||||
fin = wait_action_finished(ctx, offset, "print", 180)
|
ctx.check(wait_print_running(ctx, offset, 1) is None, "the run started under the refused verdict")
|
||||||
|
fin = wait_action_finished(ctx, offset, "print", 120)
|
||||||
ev["print finished"] = message(fin)
|
ev["print finished"] = message(fin)
|
||||||
ctx.check(fin and COMPLETED in fin, "the print did not complete after the warm-up: %s",
|
ctx.check(fin and CANCELLED in fin, "the held print did not end ':cancelled': %s",
|
||||||
message(fin) or "no finish line")
|
message(fin) or "no finish line")
|
||||||
st, cs = fc.get("/cool/status")
|
st, cs = fc.get("/cool/status")
|
||||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
||||||
ctx.check(not ev["armed_after"], "armed window still open after the print")
|
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
|
||||||
|
ctx.check(latch_locked(), "the latch is unlocked after the cancel")
|
||||||
ev["events"] = offline_events(off)
|
ev["events"] = offline_events(off)
|
||||||
ctx.log("PASS: the armed print waited %s s on the warm-up, then ran and completed", ev["release_s"])
|
ctx.log("PASS: held %s s with the latch locked throughout, then canceled at the bound", ev["hold_s"])
|
||||||
|
|
||||||
|
|
||||||
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
|
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""cooling.* - the cooling engine: flow verification through forgectrl's
|
"""cooling.* - the cooling engine: flow verification through forgectrl's
|
||||||
diagnostics runner (the same check the fire gate runs), the fan profile
|
diagnostics runner (the same check the fire gate runs), the fan profile
|
||||||
returning to idle after motion, the gate settings (a value inside the
|
returning to idle after motion, the gate settings (a value inside the
|
||||||
@@ -5,6 +10,7 @@ legal range trips the gate, the far end of the range turns it off by
|
|||||||
value, and both are said out loud: the settings reply, /status, the
|
value, and both are said out loud: the settings reply, /status, the
|
||||||
engine's run-start log line), and the airflow gates (a fan under its
|
engine's run-start log line), and the airflow gates (a fan under its
|
||||||
floor past the spin-up grace is a fault for the rest of the run)."""
|
floor past the spin-up grace is a fault for the rest of the run)."""
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
@@ -746,7 +752,7 @@ FIRE_WAIT_S = 25 # settings re-read at run start, two-tick breach, 1
|
|||||||
|
|
||||||
@test("cooling.fire-watch-tiers", title="The lid-IR fire watch pauses at its alert and fails at its critical",
|
@test("cooling.fire-watch-tiers", title="The lid-IR fire watch pauses at its alert and fails at its critical",
|
||||||
subsystem="cooling", kind="auto", mode="grbl", est_min=6,
|
subsystem="cooling", kind="auto", mode="grbl", est_min=6,
|
||||||
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"],
|
covers=_COOL_COVERS + [("forgectrl", "src/super.*")], requires=["kernel.latch-locked-idle"],
|
||||||
steps=["Machine idle, lid closed, lid lamp at its resting level. The test moves the flame "
|
steps=["Machine idle, lid closed, lid lamp at its resting level. The test moves the flame "
|
||||||
"thresholds under the lamp's own reading and restores them; three M8/M9 sessions, "
|
"thresholds under the lamp's own reading and restores them; three M8/M9 sessions, "
|
||||||
"no fire."],
|
"no fire."],
|
||||||
@@ -759,6 +765,7 @@ FIRE_WAIT_S = 25 # settings re-read at run start, two-tick breach, 1
|
|||||||
"read as the four flame gates off and the watch reads watch. Restored, the "
|
"read as the four flame gates off and the watch reads watch. Restored, the "
|
||||||
"watch reads armed at OK.")
|
"watch reads armed at OK.")
|
||||||
def fire_watch_tiers(ctx):
|
def fire_watch_tiers(ctx):
|
||||||
|
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
before = fc.settings()
|
before = fc.settings()
|
||||||
@@ -810,7 +817,16 @@ def fire_watch_tiers(ctx):
|
|||||||
ctx.check(c.get("verdict") == "OK", "the alert survived into a fresh session: %s", c)
|
ctx.check(c.get("verdict") == "OK", "the alert survived into a fresh session: %s", c)
|
||||||
|
|
||||||
# Leg 2: the fail tier. The q1 critical under the lamp reading
|
# Leg 2: the fail tier. The q1 critical under the lamp reading
|
||||||
# stops the session and latches FIRE until it ends.
|
# latches FIRE and locks the laser; the fail tier then stops
|
||||||
|
# the controller through the supervisor and starts it again,
|
||||||
|
# so no run start can relight what was locked and the sender
|
||||||
|
# sees the job end. The Grbl socket dies with the restart -
|
||||||
|
# FIRE is read from the engine, the latch from sysfs, the stop
|
||||||
|
# from the supervisor's log, and a fresh session is opened for
|
||||||
|
# the legs that follow.
|
||||||
|
st, m0 = fc.get("/mode")
|
||||||
|
pid0 = m0.get("pid") if isinstance(m0, dict) else None
|
||||||
|
off = _log_offset(FORGECTRL_LOG)
|
||||||
_set_gates(ctx, fc, {"cool_fire_q1_critical": str(max(2, q1 - 5)),
|
_set_gates(ctx, fc, {"cool_fire_q1_critical": str(max(2, q1 - 5)),
|
||||||
"cool_fire_q1_alert": str(max(1, q1 - 10))})
|
"cool_fire_q1_alert": str(max(1, q1 - 10))})
|
||||||
grbl.command("M8")
|
grbl.command("M8")
|
||||||
@@ -821,19 +837,38 @@ def fire_watch_tiers(ctx):
|
|||||||
c = _cool(fc)
|
c = _cool(fc)
|
||||||
if c.get("verdict") == "FIRE":
|
if c.get("verdict") == "FIRE":
|
||||||
break
|
break
|
||||||
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
ev["critical"] = c
|
ev["critical"] = c
|
||||||
ctx.log("critical leg: verdict %s fire_watch %s", c.get("verdict"), c.get("fire_watch"))
|
ev["interlock_circuit"] = ilk
|
||||||
|
ctx.log("critical leg: verdict %s fire_watch %s interlock_circuit %s",
|
||||||
|
c.get("verdict"), c.get("fire_watch"), ilk)
|
||||||
ctx.check(c.get("verdict") == "FIRE", "q1 critical under the lamp did not latch FIRE: %s", c)
|
ctx.check(c.get("verdict") == "FIRE", "q1 critical under the lamp did not latch FIRE: %s", c)
|
||||||
ctx.check(c.get("fire_watch") == "ALARM", "fire_watch %r during FIRE, expected ALARM", c.get("fire_watch"))
|
ctx.check(c.get("fire_watch") == "ALARM", "fire_watch %r during FIRE, expected ALARM", c.get("fire_watch"))
|
||||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
|
||||||
ev["interlock_circuit"] = ilk
|
|
||||||
ctx.check(ilk is not None and (ilk & 8), "the laser latch is not locked under FIRE (interlock_circuit=%s)", ilk)
|
ctx.check(ilk is not None and (ilk & 8), "the laser latch is not locked under FIRE (interlock_circuit=%s)", ilk)
|
||||||
ctx.check(_tail_wait(ctx, fc, "LID IR FIRE SIGNAL (quartiles "),
|
ctx.check(_tail_wait(ctx, fc, "LID IR FIRE SIGNAL (quartiles "),
|
||||||
"the FIRE line is missing from the forgectrl log")
|
"the FIRE line is missing from the forgectrl log")
|
||||||
|
# the fail tier stops the controller and starts it again; the
|
||||||
|
# Grbl socket dies with it. Wait out the restart, prove the
|
||||||
|
# supervisor logged the stop, restore the thresholds, and open
|
||||||
|
# a fresh session on the new controller.
|
||||||
|
grbl.close()
|
||||||
|
m1 = None
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < 60:
|
||||||
|
st, m1 = fc.get("/mode")
|
||||||
|
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
|
||||||
|
break
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
ev["restart"] = {"pid_before": pid0, "mode_after": m1}
|
||||||
|
ctx.log("after FIRE: %s", ev["restart"])
|
||||||
|
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid0,
|
||||||
|
"the controller was not stopped and started again after FIRE: %s", m1)
|
||||||
|
ctx.check(_log_lines(FORGECTRL_LOG, off, "lid IR fire signal - the controller is stopped"),
|
||||||
|
"the supervisor did not log the fail-tier stop after FIRE")
|
||||||
_set_gates(ctx, fc, {"cool_fire_q1_critical": orig["cool_fire_q1_critical"],
|
_set_gates(ctx, fc, {"cool_fire_q1_critical": orig["cool_fire_q1_critical"],
|
||||||
"cool_fire_q1_alert": orig["cool_fire_q1_alert"]})
|
"cool_fire_q1_alert": orig["cool_fire_q1_alert"]})
|
||||||
grbl.command("M9")
|
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the FIRE restart")
|
||||||
_session_ended(ctx, fc, "critical leg")
|
grbl.connect() # a fresh session on the restarted controller
|
||||||
|
|
||||||
# Leg 3: all four at zero: the watch off, said so, session OK.
|
# Leg 3: all four at zero: the watch off, said so, session OK.
|
||||||
_set_gates(ctx, fc, {k: "0" for k in FIRE_KEYS})
|
_set_gates(ctx, fc, {k: "0" for k in FIRE_KEYS})
|
||||||
@@ -891,7 +926,7 @@ CRASH_GATES = ["crash_abort", "crash_x_alert", "crash_y_alert"]
|
|||||||
"read as the three crash gates off by value (gates_off names them) with the "
|
"read as the three crash gates off by value (gates_off names them) with the "
|
||||||
"session OK; a threshold past the register range is refused; restored, the "
|
"session OK; a threshold past the register range is refused; restored, the "
|
||||||
"gates read ok. The tiers themselves (BUMP, CRASH) trip only on a physical "
|
"gates read ok. The tiers themselves (BUMP, CRASH) trip only on a physical "
|
||||||
"knock inside an armed window and are commissioned at the bench, not here.")
|
"knock inside an armed window and are proven at the bench, not here.")
|
||||||
def crash_watch_plumbing(ctx):
|
def crash_watch_plumbing(ctx):
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
@@ -1212,7 +1247,6 @@ LOAD_LINES = 27 # two 30 x 4 mm fills back to back, about 35
|
|||||||
LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window
|
LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window
|
||||||
LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit
|
LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit
|
||||||
LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s
|
LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s
|
||||||
LOAD_LOG_LINES = "300"
|
|
||||||
_LOAD_LINE_RX = None
|
_LOAD_LINE_RX = None
|
||||||
|
|
||||||
|
|
||||||
@@ -1236,9 +1270,19 @@ def _load_fill():
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def _forgectrl_tail(fc):
|
def _log_since(path, offset):
|
||||||
st, body = fc.get("/logs/tail", params={"name": "forgectrl", "lines": LOAD_LOG_LINES})
|
"""The text a log gained since byte `offset`. A count of lines in a
|
||||||
return body.get("text", "") if st == 200 and isinstance(body, dict) else ""
|
tail of fixed length cannot tell a new line from an old one: the new
|
||||||
|
line comes in at the bottom as an old one leaves at the top. A file now
|
||||||
|
shorter than the offset was rotated under the test, and all of it is
|
||||||
|
newer."""
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
if os.fstat(f.fileno()).st_size >= offset:
|
||||||
|
f.seek(offset)
|
||||||
|
return f.read().decode("utf-8", "replace")
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window",
|
@test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window",
|
||||||
@@ -1256,6 +1300,7 @@ def _forgectrl_tail(fc):
|
|||||||
"the tube's share off and read its baseline as a mean.")
|
"the tube's share off and read its baseline as a mean.")
|
||||||
def flow_under_load(ctx):
|
def flow_under_load(ctx):
|
||||||
from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm
|
from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm
|
||||||
|
from .motion import FORGECTRL_LOG, _log_offset
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
state, check_s = _gate_state(fc, "cool_flow_check_s")
|
state, check_s = _gate_state(fc, "cool_flow_check_s")
|
||||||
@@ -1263,7 +1308,7 @@ def flow_under_load(ctx):
|
|||||||
ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s)
|
ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s)
|
||||||
ctx.check(limit, "cool_flow_rise unreadable")
|
ctx.check(limit, "cool_flow_rise unreadable")
|
||||||
ev.update({"check_s": check_s, "limit_c": limit})
|
ev.update({"check_s": check_s, "limit_c": limit})
|
||||||
before = _forgectrl_tail(fc).count("heater rise")
|
off = _log_offset(FORGECTRL_LOG) # the verdict is a line written after this
|
||||||
# The fill ends one line length out and (LOAD_LINES - 1) pitches up;
|
# The fill ends one line length out and (LOAD_LINES - 1) pitches up;
|
||||||
# the job brings the head back so the baseline finds it where it began.
|
# the job brings the head back so the baseline finds it where it began.
|
||||||
back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1))
|
back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1))
|
||||||
@@ -1298,12 +1343,11 @@ def flow_under_load(ctx):
|
|||||||
ctx.checkpoint()
|
ctx.checkpoint()
|
||||||
if hw.sysfs_int("thermal/heater_pwm", 0) > 0:
|
if hw.sysfs_int("thermal/heater_pwm", 0) > 0:
|
||||||
heater_seen = True
|
heater_seen = True
|
||||||
text = _forgectrl_tail(fc)
|
m = None
|
||||||
if text.count("heater rise") > before:
|
for m in _load_verdict_rx().finditer(_log_since(FORGECTRL_LOG, off)):
|
||||||
m = None
|
pass
|
||||||
for m in _load_verdict_rx().finditer(text):
|
if m:
|
||||||
pass
|
line = m.group(0)
|
||||||
line = m.group(0) if m else None
|
|
||||||
break
|
break
|
||||||
ctx.sleep(1)
|
ctx.sleep(1)
|
||||||
ev["heater_seen"] = heater_seen
|
ev["heater_seen"] = heater_seen
|
||||||
@@ -1329,3 +1373,162 @@ def flow_under_load(ctx):
|
|||||||
ctx.check(dt is not None, "the window did not close after M2")
|
ctx.check(dt is not None, "the window did not close after M2")
|
||||||
ctx.log("PASS: lit through the window, raw rise %.1f C, laser %.1f C off, judged %.1f C against %.1f "
|
ctx.log("PASS: lit through the window, raw rise %.1f C, laser %.1f C off, judged %.1f C against %.1f "
|
||||||
"(dT %.1f), window closed %.1f s after M2", raw, share, rise, float(limit), float(dtc), dt)
|
"(dT %.1f), window closed %.1f s after M2", raw, share, rise, float(limit), float(dtc), dt)
|
||||||
|
|
||||||
|
|
||||||
|
@test("cooling.fan-duty-readback", title="A fan duty a device lost is read back and put back inside a tick",
|
||||||
|
subsystem="cooling", kind="auto", mode="grbl", est_min=2,
|
||||||
|
covers=_COOL_COVERS + [("kernel-module-glowforge", "src/head*")],
|
||||||
|
requires=["kernel.latch-locked-idle"],
|
||||||
|
description="M8 opens a run session and every fan reads its run duty back from its device. "
|
||||||
|
"The head's air-assist register is then written back to the idle duty behind the "
|
||||||
|
"engine's back (what a head reset or a dropped transaction leaves) and the exhaust "
|
||||||
|
"PWM to zero: within a few ticks each reads its run duty again, the log names each "
|
||||||
|
"loss and its put-back, and the session stays OK with no airflow fault (a fan put "
|
||||||
|
"back gets its spin-up grace). Then M9 and the idle duties.")
|
||||||
|
def fan_duty_readback(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
run = {"head/air_assist_pwm": 1023, "thermal/exhaust_pwm": 65535, "thermal/intake_pwm": 43278}
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
c = _hold_session(ctx, g, fc, lambda c: c.get("phase") == "run", "readback", 20)
|
||||||
|
try:
|
||||||
|
ctx.check(c.get("phase") == "run", "no run session opened: %s", c)
|
||||||
|
ctx.sleep(2)
|
||||||
|
before = {k: hw.sysfs_int(k) for k in run}
|
||||||
|
ctx.log("duties at run: %s", before)
|
||||||
|
ev["run_duties"] = before
|
||||||
|
ctx.check(all(before.get(k) == v for k, v in run.items()),
|
||||||
|
"not every fan reads its run duty: %s", before)
|
||||||
|
hw.sysfs_write("head/air_assist_pwm", 204)
|
||||||
|
hw.sysfs_write("thermal/exhaust_pwm", 0)
|
||||||
|
lost = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
|
||||||
|
ctx.log("written behind the engine's back: %s", lost)
|
||||||
|
back = {}
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < 6:
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
back = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
|
||||||
|
if back.get("head/air_assist_pwm") == 1023 and back.get("thermal/exhaust_pwm") == 65535:
|
||||||
|
break
|
||||||
|
took = round(time.time() - t0, 1)
|
||||||
|
ev["put_back"] = {"duties": back, "after_s": took}
|
||||||
|
ctx.log("put back after %s s: %s", took, back)
|
||||||
|
ctx.check(back.get("head/air_assist_pwm") == 1023, "the air-assist duty was not put back: %s", back)
|
||||||
|
ctx.check(back.get("thermal/exhaust_pwm") == 65535, "the exhaust duty was not put back: %s", back)
|
||||||
|
ctx.sleep(2)
|
||||||
|
ctx.check(_tail_has(fc, "head/air_assist_pwm read 204 with 1023 commanded: put back"),
|
||||||
|
"the air-assist loss is not named in the log")
|
||||||
|
ctx.check(_tail_has(fc, "thermal/exhaust_pwm read 0 with 65535 commanded: put back"),
|
||||||
|
"the exhaust loss is not named in the log")
|
||||||
|
for _ in range(8):
|
||||||
|
ctx.sleep(1)
|
||||||
|
c = _cool(fc)
|
||||||
|
if c.get("verdict") != "OK":
|
||||||
|
break
|
||||||
|
ev["verdict_after"] = {"verdict": c.get("verdict"), "hold": c.get("hold"), "reason": c.get("reason")}
|
||||||
|
ctx.check(c.get("verdict") == "OK" and not c.get("hold"),
|
||||||
|
"the session did not stay OK after the put-back: %s %s", c.get("verdict"), c.get("reason"))
|
||||||
|
finally:
|
||||||
|
g.command("M9")
|
||||||
|
_session_ended(ctx, fc, "readback")
|
||||||
|
idle = {k: hw.sysfs_int(k) for k in run}
|
||||||
|
ev["idle_duties"] = idle
|
||||||
|
ctx.log("duties at idle: %s", idle)
|
||||||
|
|
||||||
|
|
||||||
|
@test("cooling.fail-tier-stop", title="A fail-tier verdict ends the controller: CRASH stops motion, "
|
||||||
|
"locks the laser, and the supervisor restarts the controller",
|
||||||
|
subsystem="cooling", kind="operator", mode="grbl", est_min=4,
|
||||||
|
covers=_COOL_COVERS + [("forgectrl", "src/super.*")],
|
||||||
|
requires=["cooling.crash-watch-plumbing", "motion.deadman"], actions=["button"],
|
||||||
|
steps=["Bed clear, lid closed, 40 mm of free +X travel. Nothing fires: the job is dark (S0).",
|
||||||
|
"Press the physical button when it lights white, once. The head moves and stops on "
|
||||||
|
"its own; the controller restarts."],
|
||||||
|
description="The engine's fail tiers (a lid IR fire signal, a head crash signal) do not "
|
||||||
|
"leave the job to the controller: after the kernel writes (motion stopped, the "
|
||||||
|
"latch locked) the engine ends the controller through the supervisor, which "
|
||||||
|
"starts it again, so no run start can relight what was locked and the sender "
|
||||||
|
"sees the job end. Provable without emission or a physical crash: the crash "
|
||||||
|
"watch's thresholds at their lowest (1) make the head's own move trip the abort "
|
||||||
|
"generator inside an armed, dark (S0) job. Expected: the engine logs the crash "
|
||||||
|
"signal, the supervisor logs the stop and starts a new controller (new pid), the "
|
||||||
|
"latch is locked, the kernel idle, no emission, the crash fault ends with the run "
|
||||||
|
"session (the engine's next tick after the controller is gone), and the "
|
||||||
|
"thresholds are put back.")
|
||||||
|
def fail_tier_stop(ctx):
|
||||||
|
from .laser import prepare, stream, sample
|
||||||
|
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
before = fc.settings()
|
||||||
|
orig = {k: before.get(k, "") for k in CRASH_KEYS}
|
||||||
|
ev["orig"] = orig
|
||||||
|
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
|
||||||
|
st, m0 = fc.get("/mode")
|
||||||
|
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
|
||||||
|
pid0 = m0.get("pid")
|
||||||
|
_set_gates(ctx, fc, {k: "1" for k in CRASH_KEYS})
|
||||||
|
off = _log_offset(FORGECTRL_LOG)
|
||||||
|
t_arm = None
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
prepare(ctx, g)
|
||||||
|
ctx.ready("DARK JOB (S0, nothing fires). Bed clear with 40 mm of free +X travel, lid closed. "
|
||||||
|
"The head moves 20 mm and the controller restarts on its own.")
|
||||||
|
stream(g, ["G91", "G21", "M3 S0", "G4 P1", "G1 X20 F1200", "G1 X-20 F1200", "M5", "G90", "M2"])
|
||||||
|
ctx.arm_press()
|
||||||
|
t_arm = ctx.wait_for(lambda: bool((sample(ctx) or {}).get("armed")), 240)
|
||||||
|
ctx.clear_notice()
|
||||||
|
ctx.check(t_arm is not None, "the window never opened (arm refused, or no press)")
|
||||||
|
t0 = time.time()
|
||||||
|
tripped = ctx.wait_for(lambda: _cool(fc).get("verdict") == "CRASH"
|
||||||
|
or (fc.get("/mode")[1] or {}).get("pid") not in (None, pid0), 30)
|
||||||
|
ctx.log("the crash tier tripped after %s s", tripped)
|
||||||
|
except (hw.HwError, OSError) as e:
|
||||||
|
ctx.log("the Grbl connection ended with the restart: %s", e)
|
||||||
|
t_kill = time.time()
|
||||||
|
m1 = None
|
||||||
|
while time.time() - t_kill < 60:
|
||||||
|
st, m1 = fc.get("/mode")
|
||||||
|
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
|
||||||
|
break
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
# the daemon's lines reach the log file through syslog, a moment
|
||||||
|
# after the events they name
|
||||||
|
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL"))
|
||||||
|
and bool(_log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")), 15)
|
||||||
|
engine = _log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL")
|
||||||
|
super_ = _log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")
|
||||||
|
smp = sample(ctx)
|
||||||
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
|
ev["result"] = {"mode_after": m1, "engine_lines": [ln[-160:] for ln in engine[-2:]],
|
||||||
|
"super_lines": [ln[-160:] for ln in super_[-2:]],
|
||||||
|
"emission": smp and smp["emission"], "kernel": smp and smp["kstate"],
|
||||||
|
"latch_locked": ilk is not None and bool(ilk & (1 << 3))}
|
||||||
|
ctx.log("after the crash tier: %s", ev["result"])
|
||||||
|
ctx.check(engine, "the engine did not log the crash signal")
|
||||||
|
ctx.check(super_, "the supervisor did not log the fail-tier stop")
|
||||||
|
ctx.check(m1 and m1.get("pid") != pid0, "the controller was not stopped and started again: %s", m1)
|
||||||
|
ctx.check(ev["result"]["latch_locked"], "latch not locked after the crash tier")
|
||||||
|
ctx.check(not (smp and smp["emission"]), "emission during a dark job: %s", smp and smp["emission"])
|
||||||
|
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the restart")
|
||||||
|
# The crash fault is the run session's: the engine ends the session
|
||||||
|
# on its next 1 Hz tick after the controller is gone, and the fault
|
||||||
|
# and its hold go with it. The supervisor has the new controller up
|
||||||
|
# inside that second, so a test that returned here would hand the
|
||||||
|
# machine back with the fault it raised still standing.
|
||||||
|
cleared = ctx.wait_for(lambda: _cool(fc).get("verdict") != "CRASH", 10)
|
||||||
|
ev["result"]["fault_cleared_s"] = cleared
|
||||||
|
ctx.log("the crash fault ended with the run session after %s s: %s", cleared,
|
||||||
|
{k: _cool(fc).get(k) for k in ("phase", "verdict", "hold", "armed")})
|
||||||
|
ctx.check(cleared is not None, "the crash fault did not end with the run session: %s", _cool(fc))
|
||||||
|
finally:
|
||||||
|
fc.wait_idle(30, abort=ctx.aborted)
|
||||||
|
st, body = fc.post("/settings", params=orig)
|
||||||
|
ctx.log("restore thresholds: POST /settings %s -> %s", orig, st)
|
||||||
|
after = fc.settings()
|
||||||
|
ctx.check(all(after.get(k, "") == orig[k] for k in CRASH_KEYS),
|
||||||
|
"thresholds not restored: %s", {k: after.get(k) for k in CRASH_KEYS})
|
||||||
|
ctx.log("PASS: the crash tier stopped the job, locked the latch, and the supervisor restarted "
|
||||||
|
"the controller (pid %s -> %s)", pid0, m1 and m1.get("pid"))
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -56,6 +61,7 @@ def auth(ctx):
|
|||||||
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
|
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
|
||||||
("/diag/flow-verify", None), ("/diag/abort", None),
|
("/diag/flow-verify", None), ("/diag/abort", None),
|
||||||
("/update/apply", None), ("/boot", {"target": "a"}),
|
("/update/apply", None), ("/boot", {"target": "a"}),
|
||||||
|
("/update/check", None), ("/update/dismiss", {"version": "v0.0.0"}),
|
||||||
("/system/reboot", None), ("/restore/factory", None),
|
("/system/reboot", None), ("/restore/factory", None),
|
||||||
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
|
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
|
||||||
("/wiz/advisories/accept", None), ("/wiz/account", None),
|
("/wiz/advisories/accept", None), ("/wiz/account", None),
|
||||||
@@ -151,7 +157,7 @@ def auth(ctx):
|
|||||||
# HTTPS the route itself refuses the peer (403 loopback only). Neither
|
# HTTPS the route itself refuses the peer (403 loopback only). Neither
|
||||||
# request follows the redirect, and the self-signed certificate is
|
# request follows the redirect, and the self-signed certificate is
|
||||||
# not verified.
|
# not verified.
|
||||||
from .commission import request, decode
|
from .setup import request, decode
|
||||||
ip = lan_ip()
|
ip = lan_ip()
|
||||||
ev["lan_ip"] = ip
|
ev["lan_ip"] = ip
|
||||||
ctx.check(ip, "cannot determine the board's LAN address")
|
ctx.check(ip, "cannot determine the board's LAN address")
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""image.* - the post-flash health of the running image (always-required)."""
|
"""image.* - the post-flash health of the running image (always-required)."""
|
||||||
import glob
|
import glob
|
||||||
import gzip
|
import gzip
|
||||||
import mmap
|
import mmap
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import struct
|
import struct
|
||||||
import re
|
import re
|
||||||
import stat
|
import stat
|
||||||
@@ -395,3 +401,140 @@ def license_bundle(ctx):
|
|||||||
text = bytes(body).decode("utf-8", "replace") if body else ""
|
text = bytes(body).decode("utf-8", "replace") if body else ""
|
||||||
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
|
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
|
||||||
"the Licenses page lacks the download link or the manifest")
|
"the Licenses page lacks the download link or the manifest")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- the network boot
|
||||||
|
|
||||||
|
IFSTATE = "/var/run/ifstate" # busybox ifupdown's state file
|
||||||
|
INTERFACES = "/etc/network/interfaces"
|
||||||
|
DHCP6_CLIENTS = ("udhcpc6", "dhcp6c", "odhcp6c")
|
||||||
|
|
||||||
|
|
||||||
|
def ifstate_names(text):
|
||||||
|
"""The interfaces ifup finished configuring: one name=name line each
|
||||||
|
in busybox's state file, written when ifup returns for the name."""
|
||||||
|
return {line.split("=", 1)[0].strip() for line in (text or "").splitlines() if "=" in line}
|
||||||
|
|
||||||
|
|
||||||
|
def dhcp6_in_interfaces(text):
|
||||||
|
"""The lines of an interfaces file that start a DHCPv6 client: an
|
||||||
|
inet6 stanza with a method that runs one, or a command that names
|
||||||
|
one. A comment starts nothing."""
|
||||||
|
bad = []
|
||||||
|
for raw in (text or "").splitlines():
|
||||||
|
words = raw.split("#", 1)[0].split()
|
||||||
|
if not words:
|
||||||
|
continue
|
||||||
|
if words[0] == "iface":
|
||||||
|
if len(words) >= 4 and words[2] == "inet6" and words[3] in ("dhcp", "auto"):
|
||||||
|
bad.append(" ".join(words))
|
||||||
|
elif any(w.split("/")[-1] in DHCP6_CLIENTS for w in words[1:]):
|
||||||
|
bad.append(" ".join(words))
|
||||||
|
return bad
|
||||||
|
|
||||||
|
|
||||||
|
def parse_stat(text):
|
||||||
|
"""(pid, comm, ppid) from a /proc/<pid>/stat line, or None. The comm
|
||||||
|
sits in parentheses and may itself hold spaces and parentheses."""
|
||||||
|
try:
|
||||||
|
head, tail = text.split("(", 1)
|
||||||
|
comm, rest = tail.rsplit(")", 1)
|
||||||
|
return int(head), comm, int(rest.split()[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _procs():
|
||||||
|
"""(pid, ppid, comm, command line) of every process."""
|
||||||
|
out = []
|
||||||
|
for name in os.listdir("/proc"):
|
||||||
|
if not name.isdigit():
|
||||||
|
continue
|
||||||
|
st = parse_stat(_read("/proc/%s/stat" % name, ""))
|
||||||
|
if st is None:
|
||||||
|
continue
|
||||||
|
cmd = (_read("/proc/%s/cmdline" % name, "") or "").replace("\0", " ").strip()
|
||||||
|
out.append((st[0], st[2], st[1], cmd))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def link_addrs(text, iface):
|
||||||
|
"""(link-local, global) IPv6 addresses of one interface, as the hex
|
||||||
|
strings of /proc/net/if_inet6 (address, index, prefix length, scope,
|
||||||
|
flags, name; scope 20 is the link, 00 the world)."""
|
||||||
|
local, world = [], []
|
||||||
|
for line in (text or "").splitlines():
|
||||||
|
f = line.split()
|
||||||
|
if len(f) == 6 and f[5] == iface:
|
||||||
|
if f[3] == "20":
|
||||||
|
local.append(f[0])
|
||||||
|
elif f[3] == "00":
|
||||||
|
world.append(f[0])
|
||||||
|
return local, world
|
||||||
|
|
||||||
|
|
||||||
|
@test("image.network-boot", title="The boot does not wait on the network",
|
||||||
|
subsystem="image", kind="auto", est_min=1,
|
||||||
|
description="The network comes up without the boot waiting on a server. init starts the "
|
||||||
|
"rest of the boot, the console login last, only after the networking script "
|
||||||
|
"returns, so a client that waits in ifup's foreground on a server's answer "
|
||||||
|
"holds the whole machine: no login, no SSH, no control panel. ifup has "
|
||||||
|
"returned for wlan0 (it is in the state file and no ifup is running) and the "
|
||||||
|
"console login is up; the DHCP client is the bounded one (-b, which leaves "
|
||||||
|
"ifup after three unanswered discovers) and has left ifup; no DHCPv6 client "
|
||||||
|
"is named in the interfaces file, running, or on the image; and IPv6 is the "
|
||||||
|
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
|
||||||
|
"address up. A global address is evidence only, because a network whose "
|
||||||
|
"router advertisement offers no SLAAC prefix gives none. What a hostile "
|
||||||
|
"server does to a client is a bench drill, not a test. No component covers "
|
||||||
|
"this: the interfaces file is layer content, in the platform identity of "
|
||||||
|
"every fingerprint.")
|
||||||
|
def network_boot(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
procs = _procs()
|
||||||
|
|
||||||
|
# 1. ifup came back, and the boot went on to the console login
|
||||||
|
state = ifstate_names(_read(IFSTATE, ""))
|
||||||
|
ev["ifstate"] = sorted(state)
|
||||||
|
ctx.check("wlan0" in state, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(state))
|
||||||
|
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
|
||||||
|
ev["ifup_running"] = [p[3] for p in stuck]
|
||||||
|
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
|
||||||
|
logins = [p[3] for p in procs if p[2] == "getty"]
|
||||||
|
ev["getty"] = logins
|
||||||
|
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
|
||||||
|
|
||||||
|
# 2. the DHCP client is the bounded one, and it has left ifup
|
||||||
|
v4 = [p for p in procs if p[2] == "udhcpc"]
|
||||||
|
ev["udhcpc"] = [p[3] for p in v4]
|
||||||
|
ctx.check(v4, "no DHCP client is running")
|
||||||
|
for pid, ppid, _comm, cmd in v4:
|
||||||
|
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
|
||||||
|
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
|
||||||
|
|
||||||
|
# 3. no DHCPv6 client: not started, not running, not on the image
|
||||||
|
text = _read(INTERFACES)
|
||||||
|
ctx.check(text is not None, "%s is missing", INTERFACES)
|
||||||
|
named = dhcp6_in_interfaces(text)
|
||||||
|
ev["interfaces_dhcp6"] = named
|
||||||
|
ctx.check(not named, "%s starts a DHCPv6 client: %s", INTERFACES, named)
|
||||||
|
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
|
||||||
|
ev["dhcp6_running"] = alive
|
||||||
|
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
|
||||||
|
for name in DHCP6_CLIENTS:
|
||||||
|
ctx.check(shutil.which(name) is None, "%s is on the image", name)
|
||||||
|
ctx.check(not os.path.exists("/usr/share/udhcpc/default6.script"),
|
||||||
|
"the DHCPv6 hook script is on the image")
|
||||||
|
|
||||||
|
# 4. IPv6 is the kernel's own
|
||||||
|
conf = "/proc/sys/net/ipv6/conf/wlan0/"
|
||||||
|
ev["disable_ipv6"] = (_read(conf + "disable_ipv6", "") or "").strip()
|
||||||
|
ev["accept_ra"] = (_read(conf + "accept_ra", "") or "").strip()
|
||||||
|
ctx.check(ev["disable_ipv6"] == "0", "IPv6 is off on wlan0 (disable_ipv6=%r)", ev["disable_ipv6"])
|
||||||
|
ctx.check(ev["accept_ra"] not in ("", "0"),
|
||||||
|
"wlan0 does not accept router advertisements (accept_ra=%r)", ev["accept_ra"])
|
||||||
|
local, world = link_addrs(_read("/proc/net/if_inet6", ""), "wlan0")
|
||||||
|
ev["link_local"] = local
|
||||||
|
ev["global"] = world
|
||||||
|
ctx.check(local, "wlan0 has no link-local IPv6 address")
|
||||||
|
ctx.log("wlan0: %d link-local, %d global IPv6 address(es)", len(local), len(world))
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
|
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
|
||||||
|
|
||||||
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
|
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
|
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
|
||||||
|
|
||||||
Every test here can emit, except `laser.power-floor`, which only reads
|
Every test here can emit, except `laser.power-floor`, which only reads
|
||||||
@@ -252,6 +257,26 @@ def kill_trail(ctx, t0, seconds=5.0):
|
|||||||
return trail
|
return trail
|
||||||
|
|
||||||
|
|
||||||
|
def kill_fast_trail(t0, seconds=1.5):
|
||||||
|
"""The latch and the kernel state read from sysfs every few
|
||||||
|
milliseconds after a kill: the supervisor's own reaction, which the
|
||||||
|
HTTP samples of kill_trail cannot resolve. A controller death is a
|
||||||
|
signal to the supervisor, not a poll, so the relock lands within
|
||||||
|
milliseconds; the kernel leaves running once its stop ramp is done."""
|
||||||
|
locked_at = stopped_at = None
|
||||||
|
end = t0 + seconds
|
||||||
|
while time.time() < end and (locked_at is None or stopped_at is None):
|
||||||
|
now = time.time() - t0
|
||||||
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
|
if locked_at is None and ilk is not None and ilk & IL_LASER_LATCH:
|
||||||
|
locked_at = round(now, 3)
|
||||||
|
st = hw.sysfs_read("cnc/state")
|
||||||
|
if stopped_at is None and st is not None and st != "running":
|
||||||
|
stopped_at = round(now, 3)
|
||||||
|
time.sleep(0.005)
|
||||||
|
return {"latch_locked_at_s": locked_at, "kernel_stopped_at_s": stopped_at}
|
||||||
|
|
||||||
|
|
||||||
def judge_kill(ctx, trail, what):
|
def judge_kill(ctx, trail, what):
|
||||||
"""(first zero, tail stayed zero, kernel stopped running) from a trail."""
|
"""(first zero, tail stayed zero, kernel stopped running) from a trail."""
|
||||||
for t in trail:
|
for t in trail:
|
||||||
@@ -715,7 +740,9 @@ def disarm_in_hold(ctx):
|
|||||||
"SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, "
|
"SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, "
|
||||||
"and the restart is a separate operator-judged step. Unexpected: mid-burn SIGKILL "
|
"and the restart is a separate operator-judged step. Unexpected: mid-burn SIGKILL "
|
||||||
"of the controller - the supervisor's exit safing must end the fire tail inside "
|
"of the controller - the supervisor's exit safing must end the fire tail inside "
|
||||||
"the ring's in-flight window, leave the latch locked, and respawn the controller.")
|
"the ring's in-flight window, leave the latch locked, and respawn the controller. "
|
||||||
|
"The death is a signal to the supervisor, not a poll: the latch relocks within "
|
||||||
|
"300 ms of the kill and the kernel is out of running within a second.")
|
||||||
def armed_kill(ctx):
|
def armed_kill(ctx):
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
@@ -769,13 +796,20 @@ def armed_kill(ctx):
|
|||||||
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
|
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
|
||||||
t_kill = time.time()
|
t_kill = time.time()
|
||||||
_os.kill(pid, _signal.SIGKILL)
|
_os.kill(pid, _signal.SIGKILL)
|
||||||
|
fast = kill_fast_trail(t_kill)
|
||||||
trail = kill_trail(ctx, t_kill)
|
trail = kill_trail(ctx, t_kill)
|
||||||
|
ctx.log("after the kill (sysfs): %s", fast)
|
||||||
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "kill")
|
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "kill")
|
||||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
locked = ilk is not None and bool(ilk & (1 << 3))
|
locked = ilk is not None and bool(ilk & (1 << 3))
|
||||||
ev["sigkill"] = {"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero,
|
ev["sigkill"] = {"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero,
|
||||||
"kernel_not_running": not_running, "latch_locked": locked, "trail": trail}
|
"kernel_not_running": not_running, "latch_locked": locked, "trail": trail,
|
||||||
|
"fast": fast}
|
||||||
ctx.log("latch locked after the kill: %s", locked)
|
ctx.log("latch locked after the kill: %s", locked)
|
||||||
|
ctx.check(fast["latch_locked_at_s"] is not None and fast["latch_locked_at_s"] < 0.3,
|
||||||
|
"the latch was not locked within 300 ms of the kill: %s", fast)
|
||||||
|
ctx.check(fast["kernel_stopped_at_s"] is not None and fast["kernel_stopped_at_s"] < 1.0,
|
||||||
|
"the kernel was still running 1 s after the kill: %s", fast)
|
||||||
ctx.check(zero_at is not None and zero_at < 2.5,
|
ctx.check(zero_at is not None and zero_at < 2.5,
|
||||||
"emission did not drop within 2.5 s of the kill (first 0 at %s)", zero_at)
|
"emission did not drop within 2.5 s of the kill (first 0 at %s)", zero_at)
|
||||||
ctx.check(tail_zero, "emission returned after the kill")
|
ctx.check(tail_zero, "emission returned after the kill")
|
||||||
@@ -792,10 +826,187 @@ def armed_kill(ctx):
|
|||||||
ctx.log("/mode after the kill: %s", m1)
|
ctx.log("/mode after the kill: %s", m1)
|
||||||
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
|
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
|
||||||
"supervisor did not respawn the controller: %s", m1)
|
"supervisor did not respawn the controller: %s", m1)
|
||||||
|
# The respawned controller starts locked and stays locked: a latch it
|
||||||
|
# did not unlock is never relit by a run start, and no arm has asked.
|
||||||
|
ctx.check(wait_grbl_port(ctx), "the respawned controller never accepted a Grbl connection")
|
||||||
|
ctx.sleep(2)
|
||||||
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
|
ev["latch_locked_after_respawn"] = ilk is not None and bool(ilk & IL_LASER_LATCH)
|
||||||
|
ctx.check(ev["latch_locked_after_respawn"],
|
||||||
|
"the respawned controller unlocked the latch with no arm (interlock_circuit=%s)", ilk)
|
||||||
ev["beam_at_fire_kill"] = smp.get("beam")
|
ev["beam_at_fire_kill"] = smp.get("beam")
|
||||||
check_button_dark(ctx, ev)
|
check_button_dark(ctx, ev)
|
||||||
ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller "
|
ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller "
|
||||||
"respawned, button dark", ev["expected"]["zero_at_s"], zero_at)
|
"respawned with the latch still locked, button dark", ev["expected"]["zero_at_s"], zero_at)
|
||||||
|
|
||||||
|
|
||||||
|
@test("laser.verdict-cut", title="A blocked verdict mid-cut locks the latch and holds; the clean "
|
||||||
|
"verdict resumes with no press",
|
||||||
|
subsystem="laser", kind="live", mode="grbl", est_min=4,
|
||||||
|
covers=_LASER_COVERS,
|
||||||
|
requires=["laser.emission-witness"], actions=["button"],
|
||||||
|
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
|
||||||
|
"Press the physical button when it lights white (the arm). Nothing else: the test "
|
||||||
|
"takes the cooling verdict away itself and gives it back."],
|
||||||
|
description="A 40 mm line at constant power (M3 S400/F300, 8 s). About 1.5 s in, the test "
|
||||||
|
"pauses the machine-services daemon for 3.5 s, so the cooling verdict the "
|
||||||
|
"controller reads goes stale: the controller's pause tier. The controller must "
|
||||||
|
"hold the job (grbl Hold) under the still-open armed window with the stream "
|
||||||
|
"masked dark, so the kernel's LASER_ON sample count reads 0 before the cut "
|
||||||
|
"resumes, and it must not write the latch: a lock sets the hardware button "
|
||||||
|
"latch, which only a press clears, so both bit 3 (the SoC lock) and bit 2 "
|
||||||
|
"(the button latch) of interlock_circuit stay clear through the whole run. "
|
||||||
|
"When the daemon returns (the verdict fresh, clean, resume_ok), the controller "
|
||||||
|
"must resume the cut lit from where it stopped, with no button press and no "
|
||||||
|
"prompt; the M2 program end then disarms as usual. The daemon's own dead-man "
|
||||||
|
"(5 s of silence) is not reached: the controller keeps reporting through the "
|
||||||
|
"pause. The beam detector witnesses the cut on both sides of the hold.")
|
||||||
|
def verdict_cut(ctx):
|
||||||
|
import os as _os
|
||||||
|
import signal as _signal
|
||||||
|
ev = ctx.evidence
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
# The daemon is frozen long enough for the verdict the controller
|
||||||
|
# caches to expire (VERDICT_MAX_AGE 2.0 s), so the driver's pause tier
|
||||||
|
# holds the job - but not so long that the engine's report dead-man
|
||||||
|
# trips on resume. That dead-man locks the laser latch when the
|
||||||
|
# controller's report is older than REPORT_TIMEOUT_S (5.0 s) while
|
||||||
|
# armed, which is exactly the "latch stayed locked" this test refuses.
|
||||||
|
# The last report is up to 1 s old when the freeze starts, so the
|
||||||
|
# freeze must stay under 4.0 s; 3.0 s clears the stale floor with room
|
||||||
|
# to see the hold and stays a full second under the dead-man.
|
||||||
|
PAUSE_S = 3.0
|
||||||
|
pids = hw.pidof("forgectrl")
|
||||||
|
ctx.check(pids, "no forgectrl process found to pause")
|
||||||
|
ev["daemon_pids"] = pids
|
||||||
|
state_path = "/run/forgefirm/grbl.state"
|
||||||
|
|
||||||
|
def grbl_state_file():
|
||||||
|
try:
|
||||||
|
with open(state_path) as f:
|
||||||
|
return f.read()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def resume_daemon():
|
||||||
|
for p in pids:
|
||||||
|
try:
|
||||||
|
_os.kill(p, _signal.SIGCONT)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
trail = []
|
||||||
|
text = ""
|
||||||
|
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||||
|
prepare(ctx, g)
|
||||||
|
k0 = kernel_start(ctx)
|
||||||
|
base = sample(ctx)
|
||||||
|
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||||
|
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||||
|
ctx.ready(ARM_CUE % "40 mm +X")
|
||||||
|
job = ["G91", "G21", "M3", "S400", "G1 X40 F300", "M5", "G0 X-40", "G90", "M2"]
|
||||||
|
smp = arm_and_fire(ctx, g, room="40 mm +X", job=job)
|
||||||
|
beams = [(smp.get("beam"), smp.get("beam_d"))]
|
||||||
|
ctx.log("emission live (%s); pausing the daemon in 1 s", smp["emission"])
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
g.drain()
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
for p in pids:
|
||||||
|
_os.kill(p, _signal.SIGSTOP)
|
||||||
|
ctx.log("daemon paused (SIGSTOP %s) for %.1f s: the verdict goes stale", pids, PAUSE_S)
|
||||||
|
# No HTTP while the daemon is paused: the controller's socket,
|
||||||
|
# sysfs and the controller's state file are the witnesses.
|
||||||
|
while time.time() - t0 < PAUSE_S:
|
||||||
|
st = g.status_report()["state"]
|
||||||
|
text += g.drain()
|
||||||
|
il = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
|
trail.append({"t": round(time.time() - t0, 2), "gstate": st, "il": il,
|
||||||
|
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
|
||||||
|
"armed": '"armed":true' in grbl_state_file()})
|
||||||
|
time.sleep(0.12)
|
||||||
|
finally:
|
||||||
|
resume_daemon()
|
||||||
|
ctx.log("daemon resumed (SIGCONT) at +%.2f s", time.time() - t0)
|
||||||
|
# Through the resume and to the end of the cut, sampling the same
|
||||||
|
# witnesses (the daemon's own once it answers again).
|
||||||
|
resumed_at = None
|
||||||
|
end = time.time() + 30
|
||||||
|
while time.time() < end:
|
||||||
|
ctx.checkpoint()
|
||||||
|
st = g.status_report()["state"]
|
||||||
|
text += g.drain()
|
||||||
|
il = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
|
row = {"t": round(time.time() - t0, 2), "gstate": st, "il": il,
|
||||||
|
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
|
||||||
|
"armed": '"armed":true' in grbl_state_file()}
|
||||||
|
if time.time() - t0 > PAUSE_S + 1.0:
|
||||||
|
s = sample(ctx)
|
||||||
|
if s:
|
||||||
|
row["beam"] = s.get("beam")
|
||||||
|
beams.append((s.get("beam"), s.get("beam_d")))
|
||||||
|
trail.append(row)
|
||||||
|
if resumed_at is None and st.startswith("Run"):
|
||||||
|
resumed_at = time.time() - t0
|
||||||
|
if st.startswith("Idle") and resumed_at is not None and time.time() - t0 > resumed_at + 3.0:
|
||||||
|
break
|
||||||
|
time.sleep(0.12)
|
||||||
|
for r in trail:
|
||||||
|
ctx.log(" %s", r)
|
||||||
|
ev["trail"] = trail
|
||||||
|
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
|
||||||
|
ctx.log("controller: %s", ev["messages"])
|
||||||
|
dt = wait_disarm(ctx, 75)
|
||||||
|
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
||||||
|
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the job")
|
||||||
|
check_kernel_returned(ctx, ev, k0)
|
||||||
|
|
||||||
|
def locked(r):
|
||||||
|
return r["il"] is not None and bool(r["il"] & IL_LASER_LATCH)
|
||||||
|
|
||||||
|
def button_latched(r):
|
||||||
|
return r["il"] is not None and bool(r["il"] & IL_BUTTON_LATCH)
|
||||||
|
|
||||||
|
held = [r for r in trail if r["gstate"].startswith("Hold")]
|
||||||
|
ev.update({"held_at_s": held[0]["t"] if held else None,
|
||||||
|
"resumed_at_s": round(resumed_at, 2) if resumed_at else None,
|
||||||
|
"latch_locked_samples": sum(1 for r in trail if locked(r)),
|
||||||
|
"button_latch_set_samples": sum(1 for r in trail if button_latched(r))})
|
||||||
|
ctx.check(held and held[0]["t"] < PAUSE_S, "the stale verdict did not hold the job during the "
|
||||||
|
"pause (first Hold at %s)", ev["held_at_s"])
|
||||||
|
ctx.check("fire masked, job held" in text, "the controller did not report the pause tier")
|
||||||
|
ctx.check(ev["latch_locked_samples"] == 0,
|
||||||
|
"the pause tier wrote the latch (locked in %d samples): a lock sets the hardware "
|
||||||
|
"button latch and the resume would need a press", ev["latch_locked_samples"])
|
||||||
|
ctx.check(ev["button_latch_set_samples"] == 0,
|
||||||
|
"the hardware button latch read SET in %d samples: the resume needed a press",
|
||||||
|
ev["button_latch_set_samples"])
|
||||||
|
ctx.check(all(r["armed"] for r in trail if r["t"] < (resumed_at or PAUSE_S + 5)),
|
||||||
|
"the armed window closed during the pause")
|
||||||
|
ctx.check("press the button" not in text, "the resume asked for a button press")
|
||||||
|
ctx.check(DISARMED_MSG not in text.split("resuming")[0], "the pause tier disarmed the job")
|
||||||
|
# Dark from the hold until the resume: the gate masked the stream.
|
||||||
|
span = [r for r in trail if held and r["t"] >= held[0]["t"]
|
||||||
|
and (resumed_at is None or r["t"] < resumed_at)]
|
||||||
|
zero = next((r for r in span if r["emission"] == 0), None)
|
||||||
|
ev["emission_zero_after_hold_s"] = round(zero["t"] - held[0]["t"], 2) if zero else None
|
||||||
|
ctx.check(zero is not None and zero["t"] - held[0]["t"] <= 2.5,
|
||||||
|
"emission did not read 0 within 2.5 s of the hold (before the resume)")
|
||||||
|
ctx.check(resumed_at is not None, "the clean verdict did not resume the cut")
|
||||||
|
ctx.check("resuming" in text, "the controller did not report the resume")
|
||||||
|
after = [r for r in trail if resumed_at and r["t"] >= resumed_at]
|
||||||
|
ctx.check(any(r["emission"] for r in after), "the resumed cut ran dark (no emission after the resume)")
|
||||||
|
ctx.check("ALARM" not in text, "an alarm was raised on the pause tier")
|
||||||
|
ctx.check(dt is not None and dt < 10.0, "the M2 job did not disarm promptly at Idle (%s s)", dt)
|
||||||
|
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base)
|
||||||
|
judge_beam(ctx, ev["beam"], "the cut")
|
||||||
|
check_button_dark(ctx, ev)
|
||||||
|
ctx.log("PASS: held at +%s s with the latch untouched, emission 0 %s s after the hold, resumed "
|
||||||
|
"lit at +%s s with no press, disarmed %.1f s after Idle", ev["held_at_s"],
|
||||||
|
ev["emission_zero_after_hold_s"], ev["resumed_at_s"], dt)
|
||||||
|
|
||||||
|
|
||||||
|
DISARMED_MSG = "laser disarmed - latch locked"
|
||||||
|
|
||||||
|
|
||||||
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
|
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
|
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
|
||||||
the routing path (emitters, relays, rendered rules); the level settings."""
|
the routing path (emitters, relays, rendered rules); the level settings."""
|
||||||
import gzip
|
import gzip
|
||||||
@@ -22,14 +27,46 @@ def _read(path, default=None):
|
|||||||
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
|
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
|
||||||
("forgectrl", "src/main.c")]
|
("forgectrl", "src/main.c")]
|
||||||
|
|
||||||
|
LOGS_ROOT = "/data/log/forgefirm"
|
||||||
|
# What the export test plants in the installer's directory: a line to find
|
||||||
|
# again, and two addresses (documentation ranges) the sanitizer must take.
|
||||||
|
_PROBE_MARK = "forgetest export probe"
|
||||||
|
_PROBE_MAC = "02:00:5e:10:20:30"
|
||||||
|
_PROBE_IP = "192.0.2.77"
|
||||||
|
|
||||||
|
|
||||||
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
|
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
|
||||||
kind="auto", est_min=1,
|
kind="auto", est_min=1,
|
||||||
covers=_LOG_COVERS, requires=["forgectrl.auth"],
|
covers=_LOG_COVERS, requires=["forgectrl.auth"],
|
||||||
description="/logs lists the loggers with their levels and files, /logs/tail returns the "
|
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 "
|
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
|
||||||
"bundle that contains neither the panel token nor the camera key.")
|
"bundle that contains neither the panel token nor the camera key. The bundle "
|
||||||
|
"also carries the installer's directory of the tree (logs/install/), which no "
|
||||||
|
"logger feeds: a probe file planted there for the export comes back in the "
|
||||||
|
"bundle with its addresses redacted, and an install log that the installer "
|
||||||
|
"left is in the bundle too.")
|
||||||
def tree_tail_export(ctx):
|
def tree_tail_export(ctx):
|
||||||
|
install_dir = os.path.join(LOGS_ROOT, "install")
|
||||||
|
probe = os.path.join(install_dir, "forgetest-probe.txt")
|
||||||
|
made_dir = not os.path.isdir(install_dir)
|
||||||
|
try:
|
||||||
|
os.makedirs(install_dir, exist_ok=True)
|
||||||
|
with open(probe, "w") as f:
|
||||||
|
f.write("%s from %s at %s\n" % (_PROBE_MARK, _PROBE_MAC, _PROBE_IP))
|
||||||
|
_tree_tail_export(ctx, probe)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(probe)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if made_dir:
|
||||||
|
try:
|
||||||
|
os.rmdir(install_dir)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _tree_tail_export(ctx, probe):
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
st, body = fc.get("/logs")
|
st, body = fc.get("/logs")
|
||||||
@@ -82,6 +119,21 @@ def tree_tail_export(ctx):
|
|||||||
ev[what.replace(" ", "_") + "_leaks"] = leaked
|
ev[what.replace(" ", "_") + "_leaks"] = leaked
|
||||||
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
|
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
|
||||||
|
|
||||||
|
# the installer's directory: no logger feeds it, the export carries it
|
||||||
|
by_name = dict(contents)
|
||||||
|
want = "logs/install/" + os.path.basename(probe)
|
||||||
|
got = [name for name in by_name if name.endswith(want)]
|
||||||
|
ev["install_members"] = sorted(name for name in by_name if "/logs/install/" in name)
|
||||||
|
ctx.check(got, "the bundle lacks %s: %s", want, ev["install_members"])
|
||||||
|
if got:
|
||||||
|
text = by_name[got[0]].decode("utf-8", "replace")
|
||||||
|
ctx.check(_PROBE_MARK in text, "the probe file came back without its line: %r", text)
|
||||||
|
for what, value in (("MAC address", _PROBE_MAC), ("IPv4 address", _PROBE_IP)):
|
||||||
|
ctx.check(value not in text, "the install directory left the sanitizer with its %s", what)
|
||||||
|
if os.path.isfile(os.path.join(os.path.dirname(probe), "install.log")):
|
||||||
|
ctx.check(any(name.endswith("logs/install/install.log") for name in by_name),
|
||||||
|
"the machine has an install log and the bundle does not")
|
||||||
|
|
||||||
|
|
||||||
# The routing test proves the whole path every logger takes: emitter (or
|
# The routing test proves the whole path every logger takes: emitter (or
|
||||||
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
|
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
|
||||||
@@ -95,8 +147,7 @@ _ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("
|
|||||||
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
|
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
|
||||||
("forgefirm-app", "forgefirm-app/gfhome.py")]
|
("forgefirm-app", "forgefirm-app/gfhome.py")]
|
||||||
|
|
||||||
LOGS_ROOT = "/data/log/forgefirm"
|
LOGGERS =("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
|
||||||
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]+)\] "
|
_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>.*)$")
|
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}
|
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""motion.* - the motion controller under grblHAL: dry motion, no emission.
|
"""motion.* - the motion controller under grblHAL: dry motion, no emission.
|
||||||
|
|
||||||
Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
|
Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
|
||||||
@@ -136,9 +141,68 @@ def clean_slate(ctx, g):
|
|||||||
ctx.log("unlock: %s", g.command("$X"))
|
ctx.log("unlock: %s", g.command("$X"))
|
||||||
st = g.status_report()
|
st = g.status_report()
|
||||||
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
|
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
|
||||||
|
clear_error(ctx, g)
|
||||||
return st
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def clear_error(ctx, g):
|
||||||
|
"""Clear a latched line error before the test's first move.
|
||||||
|
|
||||||
|
Soft limits are armed after a home (the driver's, since the bed has
|
||||||
|
no switches), so a jog past the bed is refused with error:15 - and
|
||||||
|
grblHAL then answers error:15 to every following G-code line, across
|
||||||
|
a fresh connection, until a blank line acknowledges it ($ commands
|
||||||
|
and the ? report are unaffected, so a sender that queries on connect
|
||||||
|
self-clears; a bare G-code stream does not). A prior test's or the
|
||||||
|
baseline hand-back's rejected jog would otherwise make this test's
|
||||||
|
first move fail with a stale error unrelated to the move. A blank
|
||||||
|
line clears it and returns ok. Harmless and idempotent at Idle."""
|
||||||
|
r = g.command("")
|
||||||
|
ctx.check(r and r[-1] == "ok" and not any(x.startswith("error") for x in r),
|
||||||
|
"the controller answered %s to a blank line at the start - not a clean parser", r)
|
||||||
|
|
||||||
|
|
||||||
|
MOVE_START_S = 2.0 # Run is there within 100 ms; a loaded board gets this long
|
||||||
|
|
||||||
|
|
||||||
|
def start_move(ctx, g, line):
|
||||||
|
"""Start a travel job with `line` and see the controller in Run. The
|
||||||
|
wait is bounded, not a fixed sleep. When Run never comes, the failure
|
||||||
|
names what happened instead: the reply (a refused block answers
|
||||||
|
error), the state the controller sits in (Hold, Door, Alarm, or an
|
||||||
|
Idle that never took the block), every line it said meanwhile, and
|
||||||
|
the machine as forgectrl sees it - the evidence a bare "did not
|
||||||
|
start" leaves out. Recorded in the evidence either way."""
|
||||||
|
reply = g.command(line, timeout=1.0)
|
||||||
|
end = time.time() + MOVE_START_S
|
||||||
|
t0 = time.time()
|
||||||
|
said = ""
|
||||||
|
st = None
|
||||||
|
while time.time() < end:
|
||||||
|
ctx.checkpoint()
|
||||||
|
st = g.status_report()
|
||||||
|
if st["state"].startswith("Run"):
|
||||||
|
break
|
||||||
|
said += g.drain()
|
||||||
|
time.sleep(0.1)
|
||||||
|
rec = {"line": line, "reply": reply, "state": st["state"] if st else None,
|
||||||
|
"after_s": round(time.time() - t0, 2)}
|
||||||
|
ctx.evidence.setdefault("move_start", []).append(rec)
|
||||||
|
if st is not None and st["state"].startswith("Run"):
|
||||||
|
return st
|
||||||
|
msgs = [ln.strip() for ln in said.splitlines() if ln.strip()]
|
||||||
|
rec["said"] = msgs
|
||||||
|
s = ctx.forgectrl.status() or {}
|
||||||
|
gr = ((s.get("grbl") or {}).get("report") or {})
|
||||||
|
rec["forgectrl"] = {"state": s.get("state"), "switches": s.get("switches"),
|
||||||
|
"laser_locked": s.get("laser_locked"), "grbl": gr.get("state"),
|
||||||
|
"alarm": gr.get("alarm"), "sender": (gr.get("sender") or {}).get("connected")}
|
||||||
|
ctx.check(False, "the move did not start: %s answered %s; controller %s %.1f s later, said %s; "
|
||||||
|
"forgectrl sees kernel %s, grbl %s (alarm %s), switches %s, laser locked %s",
|
||||||
|
line, reply, st["state"] if st else "no report", rec["after_s"], msgs or "nothing",
|
||||||
|
s.get("state"), gr.get("state"), gr.get("alarm"), s.get("switches"), s.get("laser_locked"))
|
||||||
|
|
||||||
|
|
||||||
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
|
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
|
||||||
subsystem="motion", kind="auto", mode="grbl", est_min=1,
|
subsystem="motion", kind="auto", mode="grbl", est_min=1,
|
||||||
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
|
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
|
||||||
@@ -753,21 +817,30 @@ def _return_x(ctx, delta_mm):
|
|||||||
machine_idle(ctx)
|
machine_idle(ctx)
|
||||||
|
|
||||||
|
|
||||||
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move",
|
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move, "
|
||||||
subsystem="motion", kind="auto", mode="grbl", est_min=4,
|
"a kill during the web-service homing",
|
||||||
|
subsystem="motion", kind="auto", mode="grbl", est_min=6,
|
||||||
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
|
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
|
||||||
requires=["motion.cancel-abort", "kernel.k1-k2"],
|
requires=["motion.cancel-abort", "kernel.k1-k2"],
|
||||||
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail."],
|
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail. "
|
||||||
|
"With cloud mode enabled the test also homes through the web service and kills the "
|
||||||
|
"controller mid-homing (about a minute); the head ends wherever the homing was."],
|
||||||
description="SIGKILL of the controller mid-move: the supervisor reaps it, safes (cnc/stop, "
|
description="SIGKILL of the controller mid-move: the supervisor reaps it, safes (cnc/stop, "
|
||||||
"latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a "
|
"latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a "
|
||||||
"hang) mid-move: the ring drains into a kernel underrun (fast halt, latch "
|
"hang) mid-move: the ring drains into a kernel underrun (fast halt, latch "
|
||||||
"locked); the process resumed from the hang recovers on a soft reset and an unlock "
|
"locked); the process resumed from the hang recovers on a soft reset and an unlock "
|
||||||
"(the alarm is critical, so $X alone is refused; the reset acknowledges the "
|
"(the alarm is critical, so $X alone is refused; the reset acknowledges the "
|
||||||
"fault to the stream and clears the stale ring) and moves again without a "
|
"fault to the stream and clears the stale ring) and moves again without a "
|
||||||
"restart. forgectrl "
|
"restart. A short SIGSTOP (100 ms, inside the kernel's 200 ms queue) mid-move: "
|
||||||
"restart mid-move: the busy controller finishes the move unmanaged and the new "
|
"no underrun, but the step producer comes back later than its lead and its "
|
||||||
"daemon retakes supervision at idle. After each drill the head is jogged back "
|
"late events clamp; unarmed that is a warning in the controller's log and the "
|
||||||
"by the kernel-measured distance.")
|
"move completes with every step (the armed case faults, proven on the host). "
|
||||||
|
"forgectrl restart mid-move: the busy controller finishes the move unmanaged "
|
||||||
|
"and the new daemon retakes supervision at idle. After each drill the head is "
|
||||||
|
"jogged back by the kernel-measured distance. SIGKILL during $H (the web-service "
|
||||||
|
"homing, with cloud mode enabled): the homing runner the controller left behind "
|
||||||
|
"is ended before the respawn, the kernel is idle when the new controller starts, "
|
||||||
|
"and the pulse device has one controller on it.")
|
||||||
def deadman(ctx):
|
def deadman(ctx):
|
||||||
import os as _os
|
import os as _os
|
||||||
import signal as _signal
|
import signal as _signal
|
||||||
@@ -897,6 +970,47 @@ def deadman(ctx):
|
|||||||
x1 = _kernel_x_mm(ctx)
|
x1 = _kernel_x_mm(ctx)
|
||||||
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||||
|
|
||||||
|
# ---- 2b. a short hang (inside the kernel queue): late events clamp, a warning, the move completes
|
||||||
|
m2 = wait_running(10)
|
||||||
|
pid2 = m2["pid"]
|
||||||
|
x0 = _kernel_x_mm(ctx)
|
||||||
|
underruns0 = hw.sysfs_int("cnc/underruns", 0)
|
||||||
|
log0 = _log_offset(GRBLHAL_LOG)
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
clean_slate(ctx, g)
|
||||||
|
g.command("G91")
|
||||||
|
g.command("G1X30F300", timeout=0.5) # ~6 s of motion
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
_os.kill(pid2, _signal.SIGSTOP)
|
||||||
|
time.sleep(0.1)
|
||||||
|
_os.kill(pid2, _signal.SIGCONT)
|
||||||
|
ctx.log("SIGSTOP 100 ms sent to controller pid %d mid-move", pid2)
|
||||||
|
peak, states, st = wait_idle(ctx, g, 30)
|
||||||
|
g.command("G90")
|
||||||
|
machine_idle(ctx) # the kernel's tail plays out behind grbl's Idle
|
||||||
|
warned = None
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < 5 and not warned:
|
||||||
|
lines = _log_lines(GRBLHAL_LOG, log0, "late events clamped")
|
||||||
|
warned = lines[-1] if lines else None
|
||||||
|
if not warned:
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
x1 = _kernel_x_mm(ctx)
|
||||||
|
ev["short_stall"] = {"states": states, "underruns": hw.sysfs_int("cnc/underruns", 0) - underruns0,
|
||||||
|
"warning": warned, "kernel_dx_mm": round((x1 - x0), 3) if (x0 is not None and x1 is not None) else None,
|
||||||
|
"state_after": (st or {}).get("state")}
|
||||||
|
ctx.log("short stall: states %s, underruns +%s, warning %r, kernel dx %s mm", states,
|
||||||
|
ev["short_stall"]["underruns"], warned, ev["short_stall"]["kernel_dx_mm"])
|
||||||
|
ctx.check("TIMEOUT" not in states and str((st or {}).get("state", "")).startswith("Idle"),
|
||||||
|
"the move did not complete after the short stall (states %s)", states)
|
||||||
|
ctx.check(ev["short_stall"]["underruns"] == 0, "the short stall drained the ring (underruns +%s)",
|
||||||
|
ev["short_stall"]["underruns"])
|
||||||
|
ctx.check(warned, "the controller did not warn about the clamped events after the short stall")
|
||||||
|
ctx.check(ev["short_stall"]["kernel_dx_mm"] is not None and abs(ev["short_stall"]["kernel_dx_mm"] - 30.0) < 0.3,
|
||||||
|
"the kernel counted %s mm for a 30 mm move after the short stall", ev["short_stall"]["kernel_dx_mm"])
|
||||||
|
ctx.check(latch_locked(), "latch unlocked after the short stall")
|
||||||
|
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||||
|
|
||||||
# ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle
|
# ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle
|
||||||
m2 = wait_running(10)
|
m2 = wait_running(10)
|
||||||
pid2 = m2["pid"]
|
pid2 = m2["pid"]
|
||||||
@@ -934,8 +1048,226 @@ def deadman(ctx):
|
|||||||
x1 = _kernel_x_mm(ctx)
|
x1 = _kernel_x_mm(ctx)
|
||||||
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||||
machine_idle(ctx)
|
machine_idle(ctx)
|
||||||
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, restart retook supervision (pid %s)",
|
|
||||||
respawn_s, halt_s, m3.get("pid"))
|
# ---- 4. SIGKILL during $H: the homing runner is ended, the kernel idle, one writer
|
||||||
|
homing_kill(ctx, wait_running)
|
||||||
|
|
||||||
|
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, short stall warned and completed, "
|
||||||
|
"restart retook supervision (pid %s), the kill during $H ended the runner (%s)",
|
||||||
|
respawn_s, halt_s, m3.get("pid"), ev.get("homing_kill", {}).get("runner_gone_s", "skipped"))
|
||||||
|
|
||||||
|
|
||||||
|
def _pulse_holders():
|
||||||
|
"""The processes holding the pulse device open, by name."""
|
||||||
|
out = []
|
||||||
|
for pid in os.listdir("/proc"):
|
||||||
|
if not pid.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
for fd in os.listdir("/proc/%s/fd" % pid):
|
||||||
|
if os.readlink("/proc/%s/fd/%s" % (pid, fd)) == "/dev/glowforge":
|
||||||
|
with open("/proc/%s/comm" % pid) as f:
|
||||||
|
out.append(f.read().strip())
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def homing_kill(ctx, wait_running):
|
||||||
|
"""SIGKILL the GRBL controller while gfhome (the web-service homing
|
||||||
|
runner, its own process group on the inherited pulse fd) is moving
|
||||||
|
the head. The supervisor must end the runner before it starts another
|
||||||
|
controller, or two processes write the ring. Skipped when cloud mode
|
||||||
|
is not enabled on the machine (the runner needs the service)."""
|
||||||
|
import os as _os
|
||||||
|
import signal as _signal
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
before = fc.settings()
|
||||||
|
if str(before.get("cloud_enabled", "")).lower() not in ("1", "true", "yes", "on"):
|
||||||
|
ctx.log("kill during $H: skipped, cloud mode is not enabled on this machine")
|
||||||
|
return
|
||||||
|
hm = before.get("homing_mode", "")
|
||||||
|
ev["homing_kill"] = {"homing_mode": hm}
|
||||||
|
if hm != "gfcloud":
|
||||||
|
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
|
||||||
|
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
|
||||||
|
try:
|
||||||
|
m4 = wait_running(10)
|
||||||
|
ctx.check(m4, "controller not running before the homing drill")
|
||||||
|
pid4 = m4["pid"]
|
||||||
|
off = _log_offset(FORGECTRL_LOG)
|
||||||
|
runner_up = None
|
||||||
|
try:
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
clean_slate(ctx, g)
|
||||||
|
g.send_raw(b"$H\n")
|
||||||
|
runner_up = ctx.wait_for(lambda: bool(hw.pidof("gfhome.py")), 30)
|
||||||
|
ctx.check(runner_up is not None, "the homing runner never started for $H")
|
||||||
|
ctx.sleep(3.0) # into the session: the runner drives the head
|
||||||
|
ctx.log("SIGKILL sent to controller pid %d during $H (runner pid %s)",
|
||||||
|
pid4, hw.pidof("gfhome.py"))
|
||||||
|
_os.kill(pid4, _signal.SIGKILL)
|
||||||
|
except (hw.HwError, OSError) as e:
|
||||||
|
ctx.log("the Grbl connection ended with the kill: %s", e)
|
||||||
|
t_kill = time.time()
|
||||||
|
gone_s = ctx.wait_for(lambda: not hw.pidof("gfhome.py"), 15)
|
||||||
|
ev["homing_kill"]["runner_gone_s"] = gone_s
|
||||||
|
ctx.check(gone_s is not None, "the homing runner outlived its controller")
|
||||||
|
m5 = None
|
||||||
|
while time.time() - t_kill < 60:
|
||||||
|
st, m5 = fc.get("/mode")
|
||||||
|
if isinstance(m5, dict) and m5.get("controller") == "running" and m5.get("pid") != pid4:
|
||||||
|
break
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
kstate = hw.sysfs_read("cnc/state")
|
||||||
|
holders = _pulse_holders()
|
||||||
|
lines = _log_lines(FORGECTRL_LOG, off, "homing runner")
|
||||||
|
halted = _log_lines(FORGECTRL_LOG, off, "halting it")
|
||||||
|
ev["homing_kill"].update({"respawn_s": round(time.time() - t_kill, 1), "mode_after": m5,
|
||||||
|
"kernel_state_at_respawn": kstate, "pulse_holders": holders,
|
||||||
|
"super_lines": [ln[-160:] for ln in lines[-3:]]})
|
||||||
|
ctx.log("kill during $H: runner gone in %s s, respawned as %s, kernel %s, pulse held by %s",
|
||||||
|
gone_s, m5 and m5.get("pid"), kstate, holders)
|
||||||
|
ctx.check(m5 and m5.get("pid") != pid4, "no respawn after the kill during $H: %s", m5)
|
||||||
|
ctx.check(lines, "the supervisor did not log the runner's end")
|
||||||
|
ctx.check(not halted, "the kernel had to be halted for the respawn: %s", halted[-1:])
|
||||||
|
ctx.check(kstate in ("idle", "disabled"), "the kernel was %s when the new controller started", kstate)
|
||||||
|
ctx.check("gfhome.py" not in holders and holders.count("grblHAL_glowfor") <= 1,
|
||||||
|
"more than one controller on the pulse device: %s", holders)
|
||||||
|
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the drill")
|
||||||
|
finally:
|
||||||
|
if hm != "gfcloud":
|
||||||
|
st, body = (fc.post("/settings", params={"homing_mode": ""}) if not hm
|
||||||
|
else fc.post("/settings", data={"homing_mode": hm}))
|
||||||
|
ctx.log("restore homing_mode=%r -> %s", hm, st)
|
||||||
|
|
||||||
|
|
||||||
|
@test("motion.respawn-gate", title="A respawn waits for the lid the way a first spawn does",
|
||||||
|
subsystem="motion", kind="operator", mode="grbl", est_min=2,
|
||||||
|
covers=_MOTION_COVERS, requires=["motion.deadman", "motion.gate-waits-for-lid"], actions=["lid"],
|
||||||
|
steps=["Bed clear. Open the lid when told and close it when told; nothing moves."],
|
||||||
|
description="The enclosure check runs before every controller spawn, respawns included. "
|
||||||
|
"With the lid open, the controller is killed: the supervisor safes the machine "
|
||||||
|
"(latch locked), reports waiting with why naming the lid, and starts no controller "
|
||||||
|
"while the lid stays open. When the lid closes the controller comes back verified, "
|
||||||
|
"without a second motion probe (the probe is once per broker hold).")
|
||||||
|
def respawn_gate(ctx):
|
||||||
|
import os as _os
|
||||||
|
import signal as _signal
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
|
||||||
|
st, m0 = fc.get("/mode")
|
||||||
|
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
|
||||||
|
pid0 = m0.get("pid")
|
||||||
|
off = _log_offset(FORGECTRL_LOG)
|
||||||
|
ctx.act("lid", "open")
|
||||||
|
try:
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
_os.kill(pid0, _signal.SIGKILL)
|
||||||
|
t_kill = time.time()
|
||||||
|
ctx.log("SIGKILL sent to controller pid %d with the lid open", pid0)
|
||||||
|
waited = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("controller") == "waiting", 15)
|
||||||
|
st, m1 = fc.get("/mode")
|
||||||
|
ev["after_kill"] = {"waiting_s": waited, "mode": m1}
|
||||||
|
ctx.log("mode %.1f s after the kill: %s", time.time() - t_kill, m1)
|
||||||
|
ctx.check(waited is not None, "the supervisor did not report waiting for the lid: %s", m1)
|
||||||
|
ctx.check("lid" in (m1.get("why") or ""), "why does not name the lid: %r", m1.get("why"))
|
||||||
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
|
ctx.check(ilk is not None and ilk & (1 << 3), "latch not locked after the kill")
|
||||||
|
ctx.sleep(4.0)
|
||||||
|
st, m2 = fc.get("/mode")
|
||||||
|
ctx.check(isinstance(m2, dict) and m2.get("controller") == "waiting" and not m2.get("pid"),
|
||||||
|
"the wait did not hold with the lid open: %s", m2)
|
||||||
|
finally:
|
||||||
|
ctx.act("lid", "close")
|
||||||
|
t_close = time.time()
|
||||||
|
came = ctx.wait_for(lambda: ((fc.get("/mode")[1] or {}).get("controller") == "running"
|
||||||
|
and (fc.get("/mode")[1] or {}).get("pid") != pid0), 60)
|
||||||
|
st, m3 = fc.get("/mode")
|
||||||
|
ev["after_close"] = {"running_s": came, "mode": m3}
|
||||||
|
ctx.log("mode %.1f s after the lid closed: %s", time.time() - t_close, m3)
|
||||||
|
ctx.check(came is not None and m3.get("motion") == "verified",
|
||||||
|
"the controller did not come back verified after the lid closed: %s", m3)
|
||||||
|
lines = _probe_lines(FORGECTRL_LOG, off)
|
||||||
|
ev["probe_lines"] = lines[-2:]
|
||||||
|
ctx.check(not lines, "the motion probe ran again for the respawn: %s", lines[:1])
|
||||||
|
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the end")
|
||||||
|
ctx.log("PASS: the respawn waited %.1f s for the lid, then came back verified in %.1f s with no probe",
|
||||||
|
t_close - t_kill, came)
|
||||||
|
|
||||||
|
|
||||||
|
@test("motion.soft-limits", title="After a home the bed is the X/Y envelope",
|
||||||
|
subsystem="motion", kind="auto", mode="grbl", est_min=3,
|
||||||
|
covers=_MOTION_COVERS, requires=["motion.jog-roundtrip", "cloud.mode-switch"],
|
||||||
|
steps=["Bed clear, lid closed. The test homes the machine through the web service if it is "
|
||||||
|
"not homed (about a minute), then sends moves the controller must refuse."],
|
||||||
|
description="The machine has no limit switches, so the core's $20 cannot be turned on and "
|
||||||
|
"the X/Y soft limits are the driver's: off while the position is not trusted, on "
|
||||||
|
"after a home, when the envelope is the bed ($130 by $131 from the home corner). "
|
||||||
|
"Homed, a program move 5 mm past X max or Y max, or past the near edge, raises "
|
||||||
|
"ALARM:2 before any motion (the kernel counters do not move), a jog past the bed "
|
||||||
|
"is refused with error 15, and a move inside the bed runs. The Z envelope is the "
|
||||||
|
"lens window, as before.")
|
||||||
|
def soft_limits(ctx):
|
||||||
|
from .cloud import gfhome_homing
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
clean_slate(ctx, g)
|
||||||
|
if not fc.status().get("homed"):
|
||||||
|
gfhome_homing(ctx, ev, g)
|
||||||
|
ctx.check(fc.status().get("homed"), "the machine is not homed")
|
||||||
|
x_travel = float(grbl_setting(g, "$130"))
|
||||||
|
y_travel = float(grbl_setting(g, "$131"))
|
||||||
|
ev["travel"] = {"x": x_travel, "y": y_travel}
|
||||||
|
k0 = kernel_xy_mm(ctx)
|
||||||
|
|
||||||
|
def refused(cmd):
|
||||||
|
lines = g.command(cmd, timeout=2)
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
text = "\n".join(lines) + g.drain()
|
||||||
|
k1 = kernel_xy_mm(ctx)
|
||||||
|
moved = max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1]))
|
||||||
|
st = g.status_report()["state"]
|
||||||
|
rec = {"cmd": cmd, "reply": lines, "alarm": "ALARM:2" in text, "moved_mm": round(moved, 3), "state": st}
|
||||||
|
ctx.log("%s", rec)
|
||||||
|
ctx.check(rec["alarm"], "%s was not refused with ALARM:2: %s", cmd, lines)
|
||||||
|
ctx.check(moved < 0.05, "%s moved the kernel %.3f mm before the alarm", cmd, moved)
|
||||||
|
g.realtime(0x18)
|
||||||
|
ctx.sleep(1.5)
|
||||||
|
g.drain()
|
||||||
|
unlock = g.command("$X")
|
||||||
|
ctx.check(unlock and unlock[-1] == "ok", "$X after the soft-limit alarm: %s", unlock)
|
||||||
|
st = g.status_report()["state"]
|
||||||
|
ctx.check(st.startswith("Idle"), "controller is %s after the recovery", st)
|
||||||
|
return rec
|
||||||
|
|
||||||
|
ev["refused"] = [refused("G90 G1 X%.1f F600" % (x_travel + 5)),
|
||||||
|
refused("G90 G1 Y%.1f F600" % (y_travel + 5)),
|
||||||
|
refused("G90 G1 X-1 F600")]
|
||||||
|
# Homed still: the soft-limit alarm and its reset keep the reference.
|
||||||
|
ctx.check(fc.status().get("homed"), "the soft-limit alarm and the reset un-homed the machine")
|
||||||
|
jog = g.command("$J=G91X%.1fF1200" % (x_travel + 5))
|
||||||
|
ev["jog"] = jog
|
||||||
|
ctx.check(any(l.startswith("error:15") for l in jog), "a jog past the bed was not refused with error 15: %s", jog)
|
||||||
|
# The core answers the line after an error with that error again
|
||||||
|
# until an empty line clears it (the sender's acknowledgment).
|
||||||
|
g.command("")
|
||||||
|
g.command("G90 G1 X10 Y10 F600", timeout=0.5)
|
||||||
|
peak, states, st = wait_idle(ctx, g, 20)
|
||||||
|
ev["inside"] = states
|
||||||
|
ctx.check("TIMEOUT" not in states, "a move inside the bed did not run")
|
||||||
|
g.command("G90 G1 X0 Y0 F600", timeout=0.5)
|
||||||
|
wait_idle(ctx, g, 20)
|
||||||
|
k1 = kernel_start(ctx) # at rest: grbl's Idle leads the kernel's tail
|
||||||
|
ev["kernel_back_mm"] = [round(k1[0] - k0[0], 3), round(k1[1] - k0[1], 3)]
|
||||||
|
ctx.check(max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1])) < 0.1, "the head did not come back to the corner: %s",
|
||||||
|
ev["kernel_back_mm"])
|
||||||
|
ctx.log("PASS: X max, Y max and X min refused with ALARM:2 and no motion, the jog refused with "
|
||||||
|
"error 15, a move inside the bed ran")
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------- lid / button (the factory's)
|
# ------------------------------------------------- lid / button (the factory's)
|
||||||
@@ -1066,9 +1398,7 @@ def button_hold_resume(ctx):
|
|||||||
"moves; the job holds and the test sees it.")
|
"moves; the job holds and the test sees it.")
|
||||||
g.command("M5")
|
g.command("M5")
|
||||||
g.command("G91")
|
g.command("G91")
|
||||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain() # the message window opens here
|
g.drain() # the message window opens here
|
||||||
w = Watch(g)
|
w = Watch(g)
|
||||||
ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
|
ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
|
||||||
@@ -1144,9 +1474,7 @@ def lid_cancel_home(ctx):
|
|||||||
"leave it open until the head has come back on its own.")
|
"leave it open until the head has come back on its own.")
|
||||||
g.command("M5")
|
g.command("M5")
|
||||||
g.command("G91")
|
g.command("G91")
|
||||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain() # the message window opens here
|
g.drain() # the message window opens here
|
||||||
ctx.act("lid", "open", text="Leave it open until the head has come back.", timeout=12)
|
ctx.act("lid", "open", text="Leave it open until the head has come back.", timeout=12)
|
||||||
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "lid opened", "running")
|
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "lid opened", "running")
|
||||||
@@ -1239,9 +1567,7 @@ def interlock_cancel_home(ctx):
|
|||||||
"moves and leave it open until the head has come back on its own.")
|
"moves and leave it open until the head has come back on its own.")
|
||||||
g.command("M5")
|
g.command("M5")
|
||||||
g.command("G91")
|
g.command("G91")
|
||||||
g.command("G1X60F300", timeout=0.5) # a 12 s move
|
start_move(ctx, g, "G1X60F300") # a 12 s move
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain()
|
g.drain()
|
||||||
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
|
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
|
||||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
sw = (ctx.forgectrl.status().get("switches") or {})
|
||||||
@@ -1303,9 +1629,7 @@ def lid_policy_hold(ctx):
|
|||||||
"the job parks in the door state and waits.")
|
"the job parks in the door state and waits.")
|
||||||
g.command("M5")
|
g.command("M5")
|
||||||
g.command("G91")
|
g.command("G91")
|
||||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain()
|
g.drain()
|
||||||
ctx.act("lid", "open", timeout=12)
|
ctx.act("lid", "open", timeout=12)
|
||||||
st, text = wait_state_text(ctx, g, "Door", 8)
|
st, text = wait_state_text(ctx, g, "Door", 8)
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"""commission.* - the first-run commissioning: the record and the
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""setup.* - the first-run setup: the record and the
|
||||||
controller gate, the advisories, the account and its login, the HTTPS
|
controller gate, the advisories, the account and its login, the HTTPS
|
||||||
boundary, SSH, the cloud switch, the machine's name, and the factory
|
boundary, SSH, the cloud switch, the machine's name, and the factory
|
||||||
return.
|
return.
|
||||||
|
|
||||||
The daemon reads the commissioning record (commissioning.json in the
|
The daemon reads the setup record (setup.json in the
|
||||||
data directory) and the account record (users) once, at its start, and
|
data directory) and the account record (users) once, at its start, and
|
||||||
keeps them in memory; only its own routes write them. A test that needs
|
keeps them in memory; only its own routes write them. A test that needs
|
||||||
a different record installs the file under a forgectrl restart (a
|
a different record installs the file under a forgectrl restart (a
|
||||||
@@ -34,13 +39,13 @@ from ..catalog import test
|
|||||||
from .. import hw
|
from .. import hw
|
||||||
from .forgectrl import lan_ip
|
from .forgectrl import lan_ip
|
||||||
|
|
||||||
RECORD = "commissioning.json"
|
RECORD = "setup.json"
|
||||||
USERS = "users"
|
USERS = "users"
|
||||||
OVERRIDE = "commissioning-override"
|
OVERRIDE = "setup-override"
|
||||||
SSH_FLAG = "ssh-enabled"
|
SSH_FLAG = "ssh-enabled"
|
||||||
TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests)
|
TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests)
|
||||||
|
|
||||||
# The image's required wizards, all at version 1 (commission.c).
|
# The image's required wizards, all at version 1 (setup.c).
|
||||||
REQUIRED_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
|
REQUIRED_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
|
||||||
ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service")
|
ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service")
|
||||||
SAFETY_PHRASE = "I UNDERSTAND"
|
SAFETY_PHRASE = "I UNDERSTAND"
|
||||||
@@ -176,8 +181,8 @@ def record_without_wizards(record):
|
|||||||
|
|
||||||
|
|
||||||
def complete_record(status, base=None, account_name="bench"):
|
def complete_record(status, base=None, account_name="bench"):
|
||||||
"""A complete commissioning record for a machine that must count as
|
"""A complete setup record for a machine that must count as
|
||||||
commissioned: every advisory at the hash GET /wiz reports, the
|
set up: every advisory at the hash GET /wiz reports, the
|
||||||
press recorded, an account (the one of `base` when it has one),
|
press recorded, an account (the one of `base` when it has one),
|
||||||
every wizard of the catalog at its version, and the run complete.
|
every wizard of the catalog at its version, and the run complete.
|
||||||
`status` is a GET /wiz body; `base` is an existing record to keep
|
`status` is a GET /wiz body; `base` is an existing record to keep
|
||||||
@@ -352,12 +357,12 @@ def gate_is_open():
|
|||||||
|
|
||||||
# -------------------------------------------------------------- the gate
|
# -------------------------------------------------------------- the gate
|
||||||
|
|
||||||
_GATE_COVERS = [("forgectrl", "src/commission.*"), ("forgectrl", "src/super.c"),
|
_GATE_COVERS = [("forgectrl", "src/setup.*"), ("forgectrl", "src/super.c"),
|
||||||
("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")]
|
("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")]
|
||||||
|
|
||||||
|
|
||||||
@test("commission.gate-blocks-controllers", title="The commissioning gate spawns no controller",
|
@test("setup.gate-blocks-controllers", title="The setup gate spawns no controller",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", mode="grbl", est_min=5,
|
subsystem="setup", kind="auto", hardware="takeover", mode="grbl", est_min=5,
|
||||||
covers=_GATE_COVERS, requires=["forgectrl.auth"], precheck=gate_is_open,
|
covers=_GATE_COVERS, requires=["forgectrl.auth"], precheck=gate_is_open,
|
||||||
description="With the override removed and a record that lacks every required wizard "
|
description="With the override removed and a record that lacks every required wizard "
|
||||||
"(installed under a forgectrl restart, the consent and the account kept), "
|
"(installed under a forgectrl restart, the consent and the account kept), "
|
||||||
@@ -374,11 +379,11 @@ def gate_blocks_controllers(ctx):
|
|||||||
ctx.log("before: gate %s, override %s, required %s", before.get("gate"), before.get("override"),
|
ctx.log("before: gate %s, override %s, required %s", before.get("gate"), before.get("override"),
|
||||||
before.get("required"))
|
before.get("required"))
|
||||||
raw = read_file(record_path())
|
raw = read_file(record_path())
|
||||||
ctx.check(raw, "no commissioning record at %s", record_path())
|
ctx.check(raw, "no setup record at %s", record_path())
|
||||||
try:
|
try:
|
||||||
record = json.loads(raw.decode("utf-8"))
|
record = json.loads(raw.decode("utf-8"))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
ctx.fail("the commissioning record is not JSON: %s", e)
|
ctx.fail("the setup record is not JSON: %s", e)
|
||||||
lacking = json.dumps(record_without_wizards(record), indent=1, sort_keys=True).encode() + b"\n"
|
lacking = json.dumps(record_without_wizards(record), indent=1, sort_keys=True).encode() + b"\n"
|
||||||
had_override = override_present()
|
had_override = override_present()
|
||||||
ev["override_before"] = had_override
|
ev["override_before"] = had_override
|
||||||
@@ -391,7 +396,7 @@ def gate_blocks_controllers(ctx):
|
|||||||
ctx.log("/mode after the restart: %s (after %s s)", m, took)
|
ctx.log("/mode after the restart: %s (after %s s)", m, took)
|
||||||
ctx.check(m.get("controller") == "gated" and m.get("gated") is True,
|
ctx.check(m.get("controller") == "gated" and m.get("gated") is True,
|
||||||
"the supervisor did not report the gate: %s", m)
|
"the supervisor did not report the gate: %s", m)
|
||||||
ctx.check("commissioning required" in (m.get("why") or ""),
|
ctx.check("setup required" in (m.get("why") or ""),
|
||||||
"the reason does not name the missing wizards: %r", m.get("why"))
|
"the reason does not name the missing wizards: %r", m.get("why"))
|
||||||
w = wiz(fc)
|
w = wiz(fc)
|
||||||
ev["gated_wiz"] = wiz_summary(w)
|
ev["gated_wiz"] = wiz_summary(w)
|
||||||
@@ -439,11 +444,11 @@ def gate_blocks_controllers(ctx):
|
|||||||
ctx.log("restored: gate %s, controller %s", after.get("gate"), m.get("controller"))
|
ctx.log("restored: gate %s, controller %s", after.get("gate"), m.get("controller"))
|
||||||
|
|
||||||
|
|
||||||
@test("commission.override-until-reboot", title="The override stands until the next reboot",
|
@test("setup.override-until-reboot", title="The override stands until the next reboot",
|
||||||
subsystem="commission", kind="auto", est_min=1,
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
covers=_GATE_COVERS, requires=["forgectrl.auth"],
|
covers=_GATE_COVERS, requires=["forgectrl.auth"],
|
||||||
description="The bench seed: the dev image's forgetest init script writes "
|
description="The bench seed: the dev image's forgetest init script writes "
|
||||||
"/run/forgefirm/commissioning-override at boot. With it present, GET /wiz "
|
"/run/forgefirm/setup-override at boot. With it present, GET /wiz "
|
||||||
"reports the override and an open gate, the reason names the wizards it lifts "
|
"reports the override and an open gate, the reason names the wizards it lifts "
|
||||||
"when any is required, and GET /mode reports the controller running and not "
|
"when any is required, and GET /mode reports the controller running and not "
|
||||||
"gated. The file lives on tmpfs (/proc/mounts), so a reboot removes it by "
|
"gated. The file lives on tmpfs (/proc/mounts), so a reboot removes it by "
|
||||||
@@ -486,9 +491,9 @@ def override_until_reboot(ctx):
|
|||||||
|
|
||||||
# ------------------------------------------------------------ advisories
|
# ------------------------------------------------------------ advisories
|
||||||
|
|
||||||
@test("commission.advisories-rehash", title="An advisory is accepted at its current hash only",
|
@test("setup.advisories-rehash", title="An advisory is accepted at its current hash only",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||||
covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/commission.*"),
|
covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"),
|
||||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/sha256.*"), ("forgectrl", "src/main.c"),
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/sha256.*"), ("forgectrl", "src/main.c"),
|
||||||
("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake")],
|
("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
@@ -504,7 +509,7 @@ def advisories_rehash(ctx):
|
|||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
raw = read_file(record_path())
|
raw = read_file(record_path())
|
||||||
ctx.check(raw, "no commissioning record at %s", record_path())
|
ctx.check(raw, "no setup record at %s", record_path())
|
||||||
before = wiz(fc)
|
before = wiz(fc)
|
||||||
ev["before"] = wiz_summary(before)
|
ev["before"] = wiz_summary(before)
|
||||||
docs = {d.get("id"): d for d in before.get("documents") or []}
|
docs = {d.get("id"): d for d in before.get("documents") or []}
|
||||||
@@ -579,7 +584,7 @@ def advisories_rehash(ctx):
|
|||||||
|
|
||||||
# -------------------------------------------------------------- the login
|
# -------------------------------------------------------------- the login
|
||||||
|
|
||||||
@test("commission.account-login", title="The panel login over HTTPS", subsystem="commission",
|
@test("setup.account-login", title="The panel login over HTTPS", subsystem="setup",
|
||||||
kind="auto", est_min=2,
|
kind="auto", est_min=2,
|
||||||
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
||||||
("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
||||||
@@ -702,7 +707,7 @@ def login_checks(ctx, name, pw):
|
|||||||
|
|
||||||
# ------------------------------------------------------- HTTPS boundary
|
# ------------------------------------------------------- HTTPS boundary
|
||||||
|
|
||||||
@test("commission.https-only-writes", title="Writes from the LAN go to HTTPS", subsystem="commission",
|
@test("setup.https-only-writes", title="Writes from the LAN go to HTTPS", subsystem="setup",
|
||||||
kind="auto", est_min=1,
|
kind="auto", est_min=1,
|
||||||
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"),
|
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"),
|
||||||
("forgectrl", "src/main.c")],
|
("forgectrl", "src/main.c")],
|
||||||
@@ -802,8 +807,8 @@ def restore_ssh(fc, before):
|
|||||||
write_file(flag, b"" if before.get("enabled") else None)
|
write_file(flag, b"" if before.get("enabled") else None)
|
||||||
|
|
||||||
|
|
||||||
@test("commission.ssh-until-reboot", title="SSH is switched from the panel until the next reboot",
|
@test("setup.ssh-until-reboot", title="SSH is switched from the panel until the next reboot",
|
||||||
subsystem="commission", kind="auto", est_min=1,
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")],
|
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
description="GET /system/ssh reports enabled, running, and dev_image, and agrees with the "
|
description="GET /system/ssh reports enabled, running, and dev_image, and agrees with the "
|
||||||
@@ -888,8 +893,8 @@ def ssh_until_reboot(ctx):
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- cloud
|
# ---------------------------------------------------------------- cloud
|
||||||
|
|
||||||
@test("commission.cloud-disabled-surface", title="Nothing points at the cloud while it is off",
|
@test("setup.cloud-disabled-surface", title="Nothing points at the cloud while it is off",
|
||||||
subsystem="commission", kind="auto", mode="grbl", est_min=1,
|
subsystem="setup", kind="auto", mode="grbl", est_min=1,
|
||||||
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/settings.*"),
|
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/settings.*"),
|
||||||
("forgectrl", "src/wiz.c"), ("forgectrl", "src/hooks.h")],
|
("forgectrl", "src/wiz.c"), ("forgectrl", "src/hooks.h")],
|
||||||
requires=["forgectrl.settings-bounds"],
|
requires=["forgectrl.settings-bounds"],
|
||||||
@@ -971,8 +976,8 @@ def cloud_disabled_surface(ctx):
|
|||||||
|
|
||||||
# ---------------------------------------------------------- the operator
|
# ---------------------------------------------------------- the operator
|
||||||
|
|
||||||
@test("commission.factory-return", title="The return to the factory firmware is guarded",
|
@test("setup.factory-return", title="The return to the factory firmware is guarded",
|
||||||
subsystem="commission", kind="auto", est_min=1,
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.js")],
|
covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.js")],
|
||||||
requires=["update.slots-and-signature"],
|
requires=["update.slots-and-signature"],
|
||||||
description="The return itself reboots the machine as a Glowforge, so it never runs from the "
|
description="The return itself reboots the machine as a Glowforge, so it never runs from the "
|
||||||
@@ -1034,8 +1039,8 @@ def cmdlines():
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@test("commission.machine-name", title="The machine names itself from its MAC address",
|
@test("setup.machine-name", title="The machine names itself from its MAC address",
|
||||||
subsystem="commission", kind="auto", est_min=1,
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex "
|
description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex "
|
||||||
"digits of its WiFi MAC address, so two machines on one network answer to "
|
"digits of its WiFi MAC address, so two machines on one network answer to "
|
||||||
@@ -1213,12 +1218,12 @@ def finished_first_run(ctx):
|
|||||||
"the controller did not come back after the setup: %s", m)
|
"the controller did not come back after the setup: %s", m)
|
||||||
|
|
||||||
|
|
||||||
_FIRST_RUN_REQUIRES = ["commission.gate-blocks-controllers", "commission.advisories-rehash"]
|
_FIRST_RUN_REQUIRES = ["setup.gate-blocks-controllers", "setup.advisories-rehash"]
|
||||||
|
|
||||||
|
|
||||||
@test("commission.first-run-flow", title="The first run, driven as the page drives it", subsystem="commission",
|
@test("setup.first-run-flow", title="The first run, driven as the page drives it", subsystem="setup",
|
||||||
kind="operator", hardware="takeover", est_min=3,
|
kind="operator", hardware="takeover", est_min=3,
|
||||||
covers=[("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"),
|
covers=[("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
|
||||||
("forgectrl", "src/session.*"), ("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"),
|
("forgectrl", "src/session.*"), ("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"),
|
||||||
("forgectrl", "src/sheetid.*"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/hooks.h"),
|
("forgectrl", "src/sheetid.*"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/hooks.h"),
|
||||||
("forgectrl", "src/main.c")],
|
("forgectrl", "src/main.c")],
|
||||||
@@ -1309,11 +1314,11 @@ def first_run_flow(ctx):
|
|||||||
ctx.log("the run completed: gate open, the controller back")
|
ctx.log("the run completed: gate open, the controller back")
|
||||||
|
|
||||||
|
|
||||||
@test("commission.first-run-page", title="The first-run page, walked once", subsystem="commission",
|
@test("setup.first-run-page", title="The first-run page, walked once", subsystem="setup",
|
||||||
kind="operator", hardware="takeover", est_min=6,
|
kind="operator", hardware="takeover", est_min=6,
|
||||||
covers=[("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/forms.js"), ("forgectrl", "src/ui/md.js"),
|
covers=[("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/forms.js"), ("forgectrl", "src/ui/md.js"),
|
||||||
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/embed_docs.cmake"), ("forgectrl", "src/tls.*")],
|
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/embed_docs.cmake"), ("forgectrl", "src/tls.*")],
|
||||||
requires=_FIRST_RUN_REQUIRES + ["commission.first-run-flow"],
|
requires=_FIRST_RUN_REQUIRES + ["setup.first-run-flow"],
|
||||||
actions=["button"], hands=["workstation"],
|
actions=["button"], hands=["workstation"],
|
||||||
steps=["From the workstation open https://<address>/ and accept the self-signed certificate.",
|
steps=["From the workstation open https://<address>/ and accept the self-signed certificate.",
|
||||||
"Click through the setup: scroll each document to its end and accept it (type I "
|
"Click through the setup: scroll each document to its end and accept it (type I "
|
||||||
@@ -1328,7 +1333,7 @@ def first_run_flow(ctx):
|
|||||||
"clicks through (the documents accepted, the press step with the LEDs "
|
"clicks through (the documents accepted, the press step with the LEDs "
|
||||||
"breathing teal, the account created, the run complete, the gate open, the "
|
"breathing teal, the account created, the run complete, the gate open, the "
|
||||||
"controller back). The operator confirms the panel opened. Everything the run "
|
"controller back). The operator confirms the panel opened. Everything the run "
|
||||||
"wrote is restored as commission.first-run-flow restores it.")
|
"wrote is restored as setup.first-run-flow restores it.")
|
||||||
def first_run_page(ctx):
|
def first_run_page(ctx):
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
@@ -1364,8 +1369,8 @@ def first_run_page(ctx):
|
|||||||
ctx.confirm("Did the control panel open after Finish?")
|
ctx.confirm("Did the control panel open after Finish?")
|
||||||
|
|
||||||
|
|
||||||
@test("commission.cert-page", title="The certificate is checkable before the warning",
|
@test("setup.cert-page", title="The certificate is checkable before the warning",
|
||||||
subsystem="commission", kind="auto", est_min=1,
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c"),
|
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c"),
|
||||||
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.js")],
|
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.js")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
@@ -1423,9 +1428,9 @@ def flag_of(w, level, wid):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@test("commission.what-changed", title="A replaced part asks for its checks again",
|
@test("setup.what-changed", title="A replaced part asks for its checks again",
|
||||||
subsystem="commission", kind="auto", est_min=2, hardware="takeover",
|
subsystem="setup", kind="auto", est_min=2, hardware="takeover",
|
||||||
covers=[("forgectrl", "src/commission.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"),
|
covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"),
|
||||||
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html"),
|
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html"),
|
||||||
("forgectrl", "src/ui/help.js")],
|
("forgectrl", "src/ui/help.js")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
@@ -1493,9 +1498,9 @@ def what_changed(ctx):
|
|||||||
ctx.check(bool(mode(fc).get("gated")) == gated_before, "the gate does not read as before")
|
ctx.check(bool(mode(fc).get("gated")) == gated_before, "the gate does not read as before")
|
||||||
|
|
||||||
|
|
||||||
@test("commission.record-export", title="The record: JSON, the printable page, and the log bundle",
|
@test("setup.record-export", title="The record: JSON, the printable page, and the log bundle",
|
||||||
subsystem="commission", kind="auto", est_min=2,
|
subsystem="setup", kind="auto", est_min=2,
|
||||||
covers=[("forgectrl", "src/commission.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"),
|
covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"),
|
||||||
("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
|
("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
|
||||||
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/wizard.html")],
|
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/wizard.html")],
|
||||||
requires=["forgectrl.auth", "logs.tree-tail-export"],
|
requires=["forgectrl.auth", "logs.tree-tail-export"],
|
||||||
@@ -1504,7 +1509,7 @@ def what_changed(ctx):
|
|||||||
"named after the sheet id; GET /wiz/record.html is a page with no script that "
|
"named after the sheet id; GET /wiz/record.html is a page with no script that "
|
||||||
"carries the sheet id and every completed wizard; without the token and without "
|
"carries the sheet id and every completed wizard; without the token and without "
|
||||||
"a login both are refused (403); and the sanitized log export carries "
|
"a login both are refused (403); and the sanitized log export carries "
|
||||||
"system/commissioning.json, parseable, with the same sheet id and no panel token.")
|
"system/setup.json, parseable, with the same sheet id and no panel token.")
|
||||||
def record_export(ctx):
|
def record_export(ctx):
|
||||||
import gzip
|
import gzip
|
||||||
import io
|
import io
|
||||||
@@ -1544,7 +1549,7 @@ def record_export(ctx):
|
|||||||
ctx.check(st == 200 and "text/html" in (hdrs.get("content-type") or ""), "GET /wiz/record.html -> %s %s",
|
ctx.check(st == 200 and "text/html" in (hdrs.get("content-type") or ""), "GET /wiz/record.html -> %s %s",
|
||||||
st, hdrs.get("content-type"))
|
st, hdrs.get("content-type"))
|
||||||
ctx.check("<script" not in page.lower(), "the printable page carries a script")
|
ctx.check("<script" not in page.lower(), "the printable page carries a script")
|
||||||
ctx.check(sid in page and "commissioning record" in page, "the page lacks the sheet id or its title")
|
ctx.check(sid in page and "setup record" in page, "the page lacks the sheet id or its title")
|
||||||
titles = {z["id"]: z["title"] for z in w.get("wizards") or []}
|
titles = {z["id"]: z["title"] for z in w.get("wizards") or []}
|
||||||
missing = [d for d in done if titles.get(d) and ("<h3>" + titles[d]) not in page]
|
missing = [d for d in done if titles.get(d) and ("<h3>" + titles[d]) not in page]
|
||||||
ev["missing_on_page"] = missing
|
ev["missing_on_page"] = missing
|
||||||
@@ -1565,11 +1570,11 @@ def record_export(ctx):
|
|||||||
try:
|
try:
|
||||||
tf = tarfile.open(fileobj=io.BytesIO(gzip.decompress(data)))
|
tf = tarfile.open(fileobj=io.BytesIO(gzip.decompress(data)))
|
||||||
for m in tf.getmembers():
|
for m in tf.getmembers():
|
||||||
if m.isfile() and m.name.endswith("system/commissioning.json"):
|
if m.isfile() and m.name.endswith("system/setup.json"):
|
||||||
member = tf.extractfile(m).read()
|
member = tf.extractfile(m).read()
|
||||||
except (OSError, tarfile.TarError, EOFError) as e:
|
except (OSError, tarfile.TarError, EOFError) as e:
|
||||||
ctx.fail("export is not a readable tar.gz: %s", e)
|
ctx.fail("export is not a readable tar.gz: %s", e)
|
||||||
ctx.check(member is not None, "the bundle lacks system/commissioning.json")
|
ctx.check(member is not None, "the bundle lacks system/setup.json")
|
||||||
if member is not None:
|
if member is not None:
|
||||||
try:
|
try:
|
||||||
inside = json.loads(member.decode("utf-8"))
|
inside = json.loads(member.decode("utf-8"))
|
||||||
@@ -1587,12 +1592,12 @@ def record_export(ctx):
|
|||||||
ctx.log("record %d wizards; page %d bytes; bundle carries the record", len(done), ev["page_bytes"])
|
ctx.log("record %d wizards; page %d bytes; bundle carries the record", len(done), ev["page_bytes"])
|
||||||
|
|
||||||
|
|
||||||
@test("commission.mirror", title="One browser drives a check; a second one follows",
|
@test("setup.mirror", title="One browser drives a check; a second one follows",
|
||||||
subsystem="commission", kind="auto", est_min=2, hardware="takeover",
|
subsystem="setup", kind="auto", est_min=2, hardware="takeover",
|
||||||
covers=[("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), ("forgectrl", "src/wiz.*"),
|
covers=[("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), ("forgectrl", "src/wiz.*"),
|
||||||
("forgectrl", "src/session.*"), ("forgectrl", "src/ui/wizard.js"),
|
("forgectrl", "src/session.*"), ("forgectrl", "src/ui/wizard.js"),
|
||||||
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.css")],
|
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.css")],
|
||||||
requires=["forgectrl.auth", "commission.account-login", "commission.check-sensors"],
|
requires=["forgectrl.auth", "setup.account-login", "setup.check-sensors"],
|
||||||
description="With a temporary account (made and removed as the login test does) and two "
|
description="With a temporary account (made and removed as the login test does) and two "
|
||||||
"login sessions: the first session starts the sensors check; GET /wiz/dark "
|
"login sessions: the first session starts the sensors check; GET /wiz/dark "
|
||||||
"reports the run as owned and its own to the first session, not to the second, "
|
"reports the run as owned and its own to the first session, not to the second, "
|
||||||
+46
-31
@@ -1,4 +1,9 @@
|
|||||||
"""commission.check-* - the setup's checks (the dark wizards), driven the
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""setup.check-* - the setup's checks (the dark wizards), driven the
|
||||||
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
|
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
|
the prompts answered from here (the bench fixture works the lid and the
|
||||||
button; a confirmation is answered yes once the snapshot exists), the
|
button; a confirmation is answered yes once the snapshot exists), the
|
||||||
@@ -12,7 +17,7 @@ import time
|
|||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
from .. import hw
|
from .. import hw
|
||||||
from ..baseline import read_position
|
from ..baseline import read_position
|
||||||
from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
|
from .setup import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
|
||||||
|
|
||||||
POLL_S = 1.0
|
POLL_S = 1.0
|
||||||
# What the machine's own press prompt says, used when it sends no text.
|
# What the machine's own press prompt says, used when it sends no text.
|
||||||
@@ -22,7 +27,7 @@ PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your
|
|||||||
# (74) for the first seconds and settles near 630.
|
# (74) for the first seconds and settles near 630.
|
||||||
PURGE_SPINUP_S = 30
|
PURGE_SPINUP_S = 30
|
||||||
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
|
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
|
||||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
||||||
|
|
||||||
|
|
||||||
@@ -113,8 +118,8 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
|||||||
return last
|
return last
|
||||||
|
|
||||||
|
|
||||||
@test("commission.check-switches", title="The switches check follows the lid and the button",
|
@test("setup.check-switches", title="The switches check follows the lid and the button",
|
||||||
subsystem="commission", kind="operator", est_min=3,
|
subsystem="setup", kind="operator", est_min=3,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
||||||
requires=["forgectrl.auth"], actions=["lid", "button"],
|
requires=["forgectrl.auth"], actions=["lid", "button"],
|
||||||
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
||||||
@@ -148,30 +153,40 @@ def check_switches(ctx):
|
|||||||
ctx.log("switches: %s", json.dumps(r))
|
ctx.log("switches: %s", json.dumps(r))
|
||||||
|
|
||||||
|
|
||||||
@test("commission.check-sensors", title="The sensors check reads a plausible machine at rest",
|
@test("setup.check-sensors", title="The sensors check reads a plausible machine at rest",
|
||||||
subsystem="commission", kind="auto", est_min=2,
|
subsystem="setup", kind="auto", est_min=2,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
|
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
|
||||||
("forgectrl", "src/cool.c")],
|
("forgectrl", "src/cool.c")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
description="POST /wiz/sensors/start; ten seconds of readings, then the room-temperature "
|
description="POST /wiz/sensors/start; ten seconds of readings and no question asked. The "
|
||||||
"prompt, answered Skip so no offset is written. The result carries both coolant "
|
"result carries both coolant temperatures, the chassis and SoC, the lid IR "
|
||||||
"temperatures, the chassis and SoC, the lid IR maxima, the accelerometer event "
|
"maxima, the accelerometer event count, the supply power-good, the HV current, "
|
||||||
"count, the supply power-good, the HV current, and the idle fan speeds; the "
|
"and the idle fan speeds; the record carries sensors at version 1 and the "
|
||||||
"record carries sensors at version 1 and cool_temp_offset_c reads as before.")
|
"settings read as before (the check writes none).")
|
||||||
def check_sensors(ctx):
|
def check_sensors(ctx):
|
||||||
with Restore(ctx, ["cool_temp_offset_c"]):
|
fc = ctx.forgectrl
|
||||||
last = run_check(ctx, "sensors", lambda p: "Skip" if p.get("id") == "room-temp" else None, 120)
|
ev = ctx.evidence
|
||||||
r = last.get("result") or {}
|
before = fc.settings() or {}
|
||||||
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
ev["settings_before"] = dict(before)
|
||||||
"exhaust_rpm_idle", "intake_rpm_idle"):
|
|
||||||
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
def on_prompt(p):
|
||||||
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
|
ctx.fail("the sensors check asked a question: %s", p)
|
||||||
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
|
last = run_check(ctx, "sensors", on_prompt, 120)
|
||||||
r.get("lid_ir_max"), r.get("hv_current_max"))
|
r = last.get("result") or {}
|
||||||
|
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
||||||
|
"exhaust_rpm_idle", "intake_rpm_idle"):
|
||||||
|
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
||||||
|
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
|
||||||
|
after = fc.settings() or {}
|
||||||
|
changed = {k: (before.get(k), after.get(k)) for k in set(before) | set(after)
|
||||||
|
if before.get(k) != after.get(k)}
|
||||||
|
ctx.check(not changed, "the sensors check wrote a setting: %s", changed)
|
||||||
|
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
|
||||||
|
r.get("lid_ir_max"), r.get("hv_current_max"))
|
||||||
|
|
||||||
|
|
||||||
@test("commission.check-airflow", title="The airflow check measures the fans and sets the floors",
|
@test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
||||||
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
||||||
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
||||||
@@ -229,8 +244,8 @@ def check_airflow(ctx):
|
|||||||
ctx.check(ok is not None, "the controller did not come back after the check")
|
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",
|
@test("setup.check-cameras", title="The cameras check captures both cameras",
|
||||||
subsystem="commission", kind="operator", est_min=2,
|
subsystem="setup", kind="operator", est_min=2,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
|
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
|
||||||
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
|
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
|
||||||
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
|
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
|
||||||
@@ -264,8 +279,8 @@ def check_cameras(ctx):
|
|||||||
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
|
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",
|
@test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
||||||
("forgectrl", "src/lenshome.*"),
|
("forgectrl", "src/lenshome.*"),
|
||||||
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
|
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
|
||||||
@@ -305,8 +320,8 @@ def check_motion(ctx):
|
|||||||
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
|
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",
|
@test("setup.check-flow-verify", title="The flow check runs as a setup check",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
||||||
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
||||||
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
||||||
@@ -323,8 +338,8 @@ def check_flow_verify(ctx):
|
|||||||
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
|
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",
|
@test("setup.cloud-header-capture", title="The cloud header check takes one print's envelope",
|
||||||
subsystem="commission", kind="operator", hardware="takeover", mode="grbl", est_min=8,
|
subsystem="setup", kind="operator", hardware="takeover", mode="grbl", est_min=8,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
|
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
|
||||||
("python3-gfhardware", "gfhardware/machine.py"),
|
("python3-gfhardware", "gfhardware/machine.py"),
|
||||||
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
|
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
|
||||||
+98
-12
@@ -1,4 +1,9 @@
|
|||||||
"""commission.sheet - the setup's sheet (the live wizards) in one run,
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""setup.sheet - the setup's sheet (the live wizards) in one run,
|
||||||
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
|
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
|
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
|
result judged, and every setting a wizard wrote put back as found. One
|
||||||
@@ -18,13 +23,13 @@ restored. The page is where the real numbers go in.
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
from .commission_dark import run_check, Restore
|
from .setup_dark import run_check, Restore
|
||||||
|
|
||||||
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
||||||
("forgectrl", "src/sheet.*"),
|
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
|
||||||
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
||||||
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
||||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
||||||
("forgectrl", "src/super.c"),
|
("forgectrl", "src/super.c"),
|
||||||
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
||||||
@@ -71,6 +76,22 @@ def preview_ok(ctx, wid):
|
|||||||
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
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)
|
"no program for %s (%s)", wid, st)
|
||||||
ctx.evidence["program_lines"] = body.count(b"\n")
|
ctx.evidence["program_lines"] = body.count(b"\n")
|
||||||
|
# Every Z the program commands is a height the head reaches in the
|
||||||
|
# window the machine holds now: the focus card's own numbers once it
|
||||||
|
# has them, and the conservative fallback on a head whose travel is
|
||||||
|
# not known yet. The controller refuses a Z outside it with ALARM:2,
|
||||||
|
# and it refuses it while the body is still burning, so the card dies
|
||||||
|
# where the parser is and not where the bad line is.
|
||||||
|
lens = (fc.status() or {}).get("lens") or {}
|
||||||
|
lo, hi = lens.get("reach_min"), lens.get("reach_max")
|
||||||
|
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
|
||||||
|
ctx.evidence.setdefault("program_z", {})[wid] = {
|
||||||
|
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
|
||||||
|
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
|
||||||
|
if lo is not None and hi is not None:
|
||||||
|
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
|
||||||
|
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
|
||||||
|
wid, lo, hi, out)
|
||||||
|
|
||||||
|
|
||||||
def emission_ok(ctx, last):
|
def emission_ok(ctx, last):
|
||||||
@@ -89,6 +110,61 @@ def file_card(ctx, wid):
|
|||||||
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
||||||
|
|
||||||
|
|
||||||
|
# The lens settings: the head's numbers, which a fresh machine does not
|
||||||
|
# have. Cleared before the cards so the run is a fresh machine's.
|
||||||
|
LENS_SETTINGS = CARD_SETTINGS[:3]
|
||||||
|
FALLBACK_WINDOW = (10, 12)
|
||||||
|
|
||||||
|
|
||||||
|
def fresh_lens(ctx):
|
||||||
|
"""The lens as a fresh machine has it: the head's numbers unknown, so
|
||||||
|
the frame runs in the fallback window and the focus card has to find
|
||||||
|
the stops and write them before its ladder burns. The machine's own
|
||||||
|
numbers come back with the restore at the end of the run."""
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
for k in LENS_SETTINGS:
|
||||||
|
st, _ = fc.post("/settings", params={k: ""})
|
||||||
|
ctx.check(st == 200, "clearing %s -> %s", k, st)
|
||||||
|
lens = (fc.status() or {}).get("lens") or {}
|
||||||
|
ctx.evidence["lens_fresh"] = lens
|
||||||
|
ctx.check(lens.get("stops_found") is False and (lens.get("below"), lens.get("above")) == FALLBACK_WINDOW,
|
||||||
|
"the lens window is not the fallback after the clear: %s", lens)
|
||||||
|
|
||||||
|
|
||||||
|
def focus_window_ok(ctx):
|
||||||
|
"""The window the focus card burned its ladder in is the one it wrote
|
||||||
|
before its controller started, and the one the machine holds now: the
|
||||||
|
result's window is the stops it found (or the fallback), the settings
|
||||||
|
hold that window, every ladder height lies in its reach, and the
|
||||||
|
program served now agrees with /status."""
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
r = ((ctx.evidence.get("cards") or {}).get("laser.focus") or {}).get("result") or {}
|
||||||
|
w, stops = r.get("window") or {}, r.get("stops") or {}
|
||||||
|
s = fc.settings() or {}
|
||||||
|
held = (str(s.get("lens_stop_below_steps")), str(s.get("lens_stop_above_steps")))
|
||||||
|
ctx.check(held == (str(w.get("below")), str(w.get("above"))),
|
||||||
|
"the settings hold the window %s, the ladder ran in %s/%s", held, w.get("below"), w.get("above"))
|
||||||
|
if stops.get("found"):
|
||||||
|
ctx.check((w.get("below"), w.get("above")) == (stops.get("below"), stops.get("above")),
|
||||||
|
"the ladder's window %s is not the stops found %s", w, stops)
|
||||||
|
else:
|
||||||
|
ctx.check((w.get("below"), w.get("above")) == FALLBACK_WINDOW,
|
||||||
|
"stops not found (%s), but the ladder's window is %s", stops.get("why"), w)
|
||||||
|
zs = r.get("ladder_z") or []
|
||||||
|
lo, hi = w.get("reach_min"), w.get("reach_max")
|
||||||
|
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
|
||||||
|
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
|
||||||
|
ev = ctx.evidence
|
||||||
|
before = dict(ev.get("program_z") or {}).get("laser.focus")
|
||||||
|
preview_ok(ctx, "laser.focus")
|
||||||
|
ev["focus_window"] = {"window": w, "stops": stops, "ladder_z": zs,
|
||||||
|
"settings": {k: s.get(k) for k in LENS_SETTINGS},
|
||||||
|
"program_z_before": before,
|
||||||
|
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
|
||||||
|
"preview_bytes": ev.pop("preview_bytes", None),
|
||||||
|
"program_lines": ev.pop("program_lines", None)}
|
||||||
|
|
||||||
|
|
||||||
def thickness_answer(ctx):
|
def thickness_answer(ctx):
|
||||||
"""The thickness the placement is answered with, in the machine's
|
"""The thickness the placement is answered with, in the machine's
|
||||||
units (the number prompts read ui_units), and the millimeters the
|
units (the number prompts read ui_units), and the millimeters the
|
||||||
@@ -151,10 +227,10 @@ def burn(ctx, wid, answers, want):
|
|||||||
file_card(ctx, wid)
|
file_card(ctx, wid)
|
||||||
|
|
||||||
|
|
||||||
@test("commission.sheet", title="The commissioning sheet: the placement, the frame, and the five cards",
|
@test("setup.sheet", title="The setup sheet: the placement, the frame, and the five cards",
|
||||||
subsystem="commission", kind="live", hardware="takeover", mode="grbl", est_min=35,
|
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=35,
|
||||||
covers=SHEET_COVERS,
|
covers=SHEET_COVERS,
|
||||||
requires=["forgectrl.auth", "commission.check-motion", "laser.emission-witness", "cooling.flow-verify"],
|
requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "cooling.flow-verify"],
|
||||||
actions=["button"], hands=["scrap"],
|
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 "
|
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); "
|
"it goes with its top edge at the top of the cut area (the head's home corner); "
|
||||||
@@ -167,15 +243,22 @@ def burn(ctx, wid, answers, want):
|
|||||||
"Nothing else: the test answers every prompt itself and puts back every setting the "
|
"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 "
|
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
|
||||||
"tails."],
|
"tails."],
|
||||||
description="One run over the sheet's seven wizards on one piece of wood. "
|
description="One run over the sheet's seven wizards on one piece of wood, as a fresh "
|
||||||
|
"machine runs them: the three lens settings are cleared first, so the frame "
|
||||||
|
"runs in the fallback window and the focus card has to find the stops and write "
|
||||||
|
"them before its ladder burns. "
|
||||||
"POST /wiz/sheet.place/start references the lens, takes the controller in "
|
"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 "
|
"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 "
|
"(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 "
|
"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 "
|
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the "
|
||||||
|
"program commands inside the reach /status reports: the frame "
|
||||||
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
|
"(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 "
|
"lens on its hall edge, the stops found by the head accelerometer and written "
|
||||||
"travel; the pick is line 11, the thickness kept); the floor card (twelve "
|
"as the window before the controller starts, twelve lines from the top of the "
|
||||||
|
"window to the bottom; the pick is line 11, the thickness kept; after it the "
|
||||||
|
"settings, the result's window, and the served program must hold the window "
|
||||||
|
"the ladder ran in); the floor card (twelve "
|
||||||
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
|
"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 "
|
"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 "
|
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
|
||||||
@@ -191,6 +274,9 @@ def sheet(ctx):
|
|||||||
"each. The bench presses when the button lights.")
|
"each. The bench presses when the button lights.")
|
||||||
place(ctx)
|
place(ctx)
|
||||||
with Restore(ctx, CARD_SETTINGS):
|
with Restore(ctx, CARD_SETTINGS):
|
||||||
|
fresh_lens(ctx)
|
||||||
for wid, answers, want in CARDS:
|
for wid, answers, want in CARDS:
|
||||||
burn(ctx, wid, answers, want)
|
burn(ctx, wid, answers, want)
|
||||||
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
|
if wid == "laser.focus":
|
||||||
|
focus_window_ok(ctx)
|
||||||
|
ctx.log("PASS: the sheet's seven wizards ran on one piece as a fresh machine's; settings restored")
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""update.* - the A/B slot inventory and the firmware verification path."""
|
"""update.* - the A/B slot inventory and the firmware verification path."""
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import shutil
|
import shutil
|
||||||
@@ -8,6 +14,7 @@ from ..catalog import test
|
|||||||
from .. import hw
|
from .. import hw
|
||||||
|
|
||||||
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
|
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
|
||||||
|
("forgectrl", "src/relcheck.c"), ("forgectrl", "src/relcheck.h"),
|
||||||
("ffboot", "**")]
|
("ffboot", "**")]
|
||||||
|
|
||||||
|
|
||||||
@@ -117,3 +124,76 @@ def slots_and_signature(ctx):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
shutil.rmtree(work, ignore_errors=True)
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
_VERSION_RX = re.compile(r"^v?\d+\.\d+\.\d+")
|
||||||
|
|
||||||
|
|
||||||
|
@test("update.release-check", title="Release check answers from the releases API; the alert dismissal is per release",
|
||||||
|
subsystem="update", kind="auto", est_min=1,
|
||||||
|
covers=_UPDATE_COVERS + [("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html")],
|
||||||
|
requires=["forgectrl.auth"],
|
||||||
|
description="GET /update/release answers the kept answer of the daily check (available, version, "
|
||||||
|
"current, new, checked, dismissed). POST /update/check asks the releases API now and "
|
||||||
|
"answers the same shape; a machine with no route to the API answers 502, which the drill "
|
||||||
|
"records and steps over. A published release is a v<semver> tag with the firmware "
|
||||||
|
"file's size and its notes, and `new` follows the version order: a development build "
|
||||||
|
"sees every release as newer. POST /update/dismiss marks that version dismissed, an "
|
||||||
|
"empty version undoes it, and a version that is not a tag is refused. The dismissal "
|
||||||
|
"the machine had before the drill is put back.")
|
||||||
|
def release_check(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
keys = ("available", "version", "current", "new", "checked", "dismissed", "detail", "bytes")
|
||||||
|
|
||||||
|
st, before = fc.get("/update/release")
|
||||||
|
ctx.log("GET /update/release -> %s %s", st, {k: before.get(k) for k in keys} if isinstance(before, dict) else before)
|
||||||
|
ctx.check(st == 200 and isinstance(before, dict) and "available" in before and "checked" in before,
|
||||||
|
"GET /update/release -> %s", st)
|
||||||
|
ev["before"] = {k: before.get(k) for k in keys}
|
||||||
|
prior = before.get("version", "") if before.get("dismissed") else ""
|
||||||
|
|
||||||
|
st, now = fc.post("/update/check")
|
||||||
|
ctx.log("POST /update/check -> %s %s", st, {k: now.get(k) for k in keys} if isinstance(now, dict) else now)
|
||||||
|
if st == 502:
|
||||||
|
ctx.log("the machine has no route to the releases API; the kept answer stands")
|
||||||
|
now = before
|
||||||
|
else:
|
||||||
|
ctx.check(st == 200 and isinstance(now, dict) and "available" in now, "POST /update/check -> %s", st)
|
||||||
|
ctx.check(isinstance(now.get("checked"), int) and now["checked"] >= before.get("checked", 0),
|
||||||
|
"the check did not move `checked`: %s", now.get("checked"))
|
||||||
|
ev["after_check"] = {k: now.get(k) for k in keys}
|
||||||
|
|
||||||
|
if now.get("available"):
|
||||||
|
ctx.check(_VERSION_RX.match(str(now.get("version", ""))) is not None,
|
||||||
|
"the release version is not a v<semver> tag: %r", now.get("version"))
|
||||||
|
ctx.check(isinstance(now.get("bytes"), int) and now["bytes"] > 0,
|
||||||
|
"the firmware file's size is missing: %r", now.get("bytes"))
|
||||||
|
ctx.check(isinstance(now.get("notes"), str), "the notes are not a string")
|
||||||
|
ctx.check(isinstance(now.get("new"), bool), "`new` is not a bool")
|
||||||
|
if not _VERSION_RX.match(str(now.get("current", ""))):
|
||||||
|
ctx.check(now.get("new") is True,
|
||||||
|
"a development build (%r) must see release %s as newer", now.get("current"), now["version"])
|
||||||
|
else:
|
||||||
|
ctx.log("no release available: %s", now.get("detail"))
|
||||||
|
ctx.check(isinstance(now.get("detail"), str) and now["detail"], "an unavailable release names no reason")
|
||||||
|
|
||||||
|
try:
|
||||||
|
st, body = fc.post("/update/dismiss", params={"version": "v0.0.1; rm -rf /"})
|
||||||
|
ctx.log("POST /update/dismiss version=<not a tag> -> %s %s", st, body)
|
||||||
|
ctx.check(st == 400, "a version that is not a tag was not refused: %s", st)
|
||||||
|
|
||||||
|
tag = now["version"] if now.get("available") else "v0.0.0"
|
||||||
|
st, body = fc.post("/update/dismiss", params={"version": tag})
|
||||||
|
ctx.log("POST /update/dismiss version=%s -> %s %s", tag, st,
|
||||||
|
{k: body.get(k) for k in keys} if isinstance(body, dict) else body)
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict), "POST /update/dismiss -> %s", st)
|
||||||
|
if now.get("available"):
|
||||||
|
ctx.check(body.get("dismissed") is True, "the dismissal of %s was not recorded: %r", tag, body.get("dismissed"))
|
||||||
|
st, body = fc.post("/update/dismiss", params={"version": ""})
|
||||||
|
ctx.log("POST /update/dismiss version= -> %s", st)
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict) and body.get("dismissed") is False,
|
||||||
|
"the empty version did not undo the dismissal: %s %r", st, body.get("dismissed") if isinstance(body, dict) else body)
|
||||||
|
finally:
|
||||||
|
st, body = fc.post("/update/dismiss", params={"version": prior})
|
||||||
|
ctx.log("dismissal put back to %r -> %s", prior, st)
|
||||||
|
|||||||
@@ -206,6 +206,100 @@
|
|||||||
border-left: 4px solid var(--og-red);
|
border-left: 4px solid var(--og-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- The release dialog -------------------------------------------- */
|
||||||
|
#rel-view h3 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--og-dim);
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
}
|
||||||
|
.relnotes {
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--og-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.relnotes h1,
|
||||||
|
.relnotes h2,
|
||||||
|
.relnotes h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
}
|
||||||
|
.relnotes h1:first-child,
|
||||||
|
.relnotes h2:first-child,
|
||||||
|
.relnotes h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.relnotes p,
|
||||||
|
.relnotes ul {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.relnotes ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.relnotes code {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.relnotes table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
}
|
||||||
|
.relnotes th,
|
||||||
|
.relnotes td {
|
||||||
|
border: 1px solid var(--og-line);
|
||||||
|
padding: 2px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.relwarn {
|
||||||
|
background: var(--og-warn-bg);
|
||||||
|
border: 1px solid var(--og-warn);
|
||||||
|
color: var(--og-warn-txt);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.relsteps {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-left: 22px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.relsteps li {
|
||||||
|
color: var(--og-dim);
|
||||||
|
}
|
||||||
|
.relsteps li.cur {
|
||||||
|
color: var(--og-txt);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.relsteps li.done {
|
||||||
|
color: var(--og-ok);
|
||||||
|
}
|
||||||
|
.relsteps li.done::after {
|
||||||
|
content: ' \2713';
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
--bs-progress-bg: var(--og-line-soft);
|
||||||
|
--bs-progress-bar-bg: var(--og-action);
|
||||||
|
--bs-progress-height: 10px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
#rel-phase {
|
||||||
|
min-height: 1.4em;
|
||||||
|
font-size: 12.5px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
#rel-err {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Chrome: header, tabs, footer ---------------------------------- */
|
/* ---- Chrome: header, tabs, footer ---------------------------------- */
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Shared fixtures for the forgetest unit tests: synthetic manifests and
|
"""Shared fixtures for the forgetest unit tests: synthetic manifests and
|
||||||
catalog entries that never touch hardware."""
|
catalog entries that never touch hardware."""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The baseline: fixed resting values are restored, preserved values are
|
"""The baseline: fixed resting values are restored, preserved values are
|
||||||
handed back, every deviation is a recorded leftover. Runs against a fake
|
handed back, every deviation is a recorded leftover. Runs against a fake
|
||||||
sysfs tree; forgectrl is unreachable (service-side checks skip)."""
|
sysfs tree; forgectrl is unreachable (service-side checks skip)."""
|
||||||
@@ -10,6 +15,11 @@ import unittest
|
|||||||
|
|
||||||
from forgetest import baseline
|
from forgetest import baseline
|
||||||
|
|
||||||
|
# The fixed machine tick at the mode an unset xy_microsteps reads as.
|
||||||
|
# Taken from baseline rather than typed, so a change to the default mode
|
||||||
|
# moves the expectations with it.
|
||||||
|
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
|
||||||
|
|
||||||
|
|
||||||
class BaselineTests(unittest.TestCase):
|
class BaselineTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -21,8 +31,11 @@ class BaselineTests(unittest.TestCase):
|
|||||||
for name in baseline.BUTTON_LEDS + ("lid_led",):
|
for name in baseline.BUTTON_LEDS + ("lid_led",):
|
||||||
os.makedirs(self.leds + name)
|
os.makedirs(self.leds + name)
|
||||||
self._led(name, "0")
|
self._led(name, "0")
|
||||||
# a clean machine
|
# A clean machine at the mode an unset xy_microsteps reads as:
|
||||||
for attr, val in baseline.FIXED_SYSFS + baseline.IDLE_READBACKS:
|
# x/y_mode, step_freq and ramp_rate are the mode's, not the x8
|
||||||
|
# literals in FIXED_SYSFS, and that is what enforce() compares
|
||||||
|
# against.
|
||||||
|
for attr, val in baseline.fixed_sysfs() + baseline.IDLE_READBACKS:
|
||||||
self._attr(attr, val)
|
self._attr(attr, val)
|
||||||
self._attr("cnc/interlock_circuit", "45")
|
self._attr("cnc/interlock_circuit", "45")
|
||||||
self._attr("pic/lid_led", "0")
|
self._attr("pic/lid_led", "0")
|
||||||
@@ -90,7 +103,7 @@ class BaselineTests(unittest.TestCase):
|
|||||||
for x in left:
|
for x in left:
|
||||||
self.assertEqual(x.action, "restored", str(x))
|
self.assertEqual(x.action, "restored", str(x))
|
||||||
self.assertEqual(self._read("cnc/motor_lock"), "0")
|
self.assertEqual(self._read("cnc/motor_lock"), "0")
|
||||||
self.assertEqual(self._read("cnc/step_freq"), "28160")
|
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
|
||||||
self.assertEqual(self._read("cnc/streaming"), "0")
|
self.assertEqual(self._read("cnc/streaming"), "0")
|
||||||
self.assertEqual(items["cnc/motor_lock"].found, "15")
|
self.assertEqual(items["cnc/motor_lock"].found, "15")
|
||||||
self.assertEqual(items["cnc/motor_lock"].expected, "0")
|
self.assertEqual(items["cnc/motor_lock"].expected, "0")
|
||||||
@@ -126,6 +139,21 @@ class BaselineTests(unittest.TestCase):
|
|||||||
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
|
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
|
||||||
self.assertEqual(items["position"].found, [1000, 0, 0])
|
self.assertEqual(items["position"].found, [1000, 0, 0])
|
||||||
|
|
||||||
|
def test_the_counters_are_read_at_the_modes_scale(self):
|
||||||
|
# 6400 steps is 30 mm at x32 (a return within the bound; on the
|
||||||
|
# host it stops at the missing controller) and 120 mm at x8
|
||||||
|
# (beyond the bound). Read at the x8 scale, an x32 machine's
|
||||||
|
# displaced head is never jogged back.
|
||||||
|
b = self.bl()
|
||||||
|
cap = b.capture()
|
||||||
|
self._attr("cnc/x_mode", "32")
|
||||||
|
self._pos(6400, 0, 0)
|
||||||
|
items = {x.item: x for x in b.enforce("post", captured=cap)}
|
||||||
|
self.assertEqual(items["position"].action, "unrestorable: no running GRBL controller")
|
||||||
|
self._attr("cnc/x_mode", "8")
|
||||||
|
items = {x.item: x for x in b.enforce("post", captured=cap)}
|
||||||
|
self.assertEqual(items["position"].action, "unrestorable: 120.0/0.0 mm exceeds 100 mm")
|
||||||
|
|
||||||
def test_a_held_controller_is_reset_before_the_return_jog(self):
|
def test_a_held_controller_is_reset_before_the_return_jog(self):
|
||||||
# a pause test that failed while held leaves the controller in
|
# a pause test that failed while held leaves the controller in
|
||||||
# Hold, which refuses a jog: the baseline resets out of it first
|
# Hold, which refuses a jog: the baseline resets out of it first
|
||||||
@@ -138,14 +166,16 @@ class BaselineTests(unittest.TestCase):
|
|||||||
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
|
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
|
||||||
b = self.bl()
|
b = self.bl()
|
||||||
cap = b.capture()
|
cap = b.capture()
|
||||||
self._pos(221, 0, 0) # 4.144 mm into the held move
|
# 4.144 mm into the held move, in the steps of the mode in force
|
||||||
|
held_mm = 4.144
|
||||||
|
self._pos(round(held_mm * baseline.xy_steps_per_mm(baseline.XY_MODE_DEFAULT)), 0, 0)
|
||||||
left = b.enforce("post", captured=cap)
|
left = b.enforce("post", captured=cap)
|
||||||
items = {x.item: x for x in left}
|
items = {x.item: x for x in left}
|
||||||
self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
|
self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
|
||||||
self.assertEqual(dev.sent[0], "^X")
|
self.assertEqual(dev.sent[0], "^X")
|
||||||
jogs = [l for l in dev.sent if l.startswith("$J=")]
|
jogs = [l for l in dev.sent if l.startswith("$J=")]
|
||||||
self.assertEqual(len(jogs), 1)
|
self.assertEqual(len(jogs), 1)
|
||||||
self.assertIn("X-4.144", jogs[0])
|
self.assertIn("X-%.3f" % held_mm, jogs[0])
|
||||||
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
|
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
|
||||||
finally:
|
finally:
|
||||||
dev.stop()
|
dev.stop()
|
||||||
@@ -230,7 +260,7 @@ class BaselineTests(unittest.TestCase):
|
|||||||
def test_fixed_constants_checked_against_a_dump(self):
|
def test_fixed_constants_checked_against_a_dump(self):
|
||||||
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
|
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
|
||||||
diffs = baseline.check_fixed_against(ref, self.lines.append)
|
diffs = baseline.check_fixed_against(ref, self.lines.append)
|
||||||
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=28160"])
|
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=%s" % DEFAULT_TICK])
|
||||||
|
|
||||||
|
|
||||||
# -- the reference is taken after the controller applied its config -----
|
# -- the reference is taken after the controller applied its config -----
|
||||||
@@ -249,7 +279,8 @@ class BaselineTests(unittest.TestCase):
|
|||||||
def sleep(_s):
|
def sleep(_s):
|
||||||
calls["n"] += 1
|
calls["n"] += 1
|
||||||
if calls["n"] == 3: # the controller's init writes land
|
if calls["n"] == 3: # the controller's init writes land
|
||||||
for attr, val in baseline.CONFIGURED_MARKERS:
|
# at the mode wait_controller_configured watches by default
|
||||||
|
for attr, val in baseline.configured_markers():
|
||||||
self._attr(attr, val)
|
self._attr(attr, val)
|
||||||
ok = baseline.wait_controller_configured(
|
ok = baseline.wait_controller_configured(
|
||||||
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
|
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
|
||||||
@@ -287,11 +318,15 @@ class BaselineTests(unittest.TestCase):
|
|||||||
def test_preconfig_reference_is_recognized(self):
|
def test_preconfig_reference_is_recognized(self):
|
||||||
self.assertTrue(baseline.reference_preconfig(
|
self.assertTrue(baseline.reference_preconfig(
|
||||||
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
|
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
|
||||||
|
# The markers are read at the reference's own mode, so a dump taken
|
||||||
|
# on an x8 machine carries the setting that says so.
|
||||||
self.assertFalse(baseline.reference_preconfig(
|
self.assertFalse(baseline.reference_preconfig(
|
||||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"}}))
|
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"},
|
||||||
|
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
|
||||||
# a genuinely different single constant is a machine fact, not pre-config
|
# a genuinely different single constant is a machine fact, not pre-config
|
||||||
self.assertFalse(baseline.reference_preconfig(
|
self.assertFalse(baseline.reference_preconfig(
|
||||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"}}))
|
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"},
|
||||||
|
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
|
||||||
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
|
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
|
||||||
|
|
||||||
# -- the XY microstep mode drives the fixed values ------------------------
|
# -- the XY microstep mode drives the fixed values ------------------------
|
||||||
@@ -310,16 +345,18 @@ class BaselineTests(unittest.TestCase):
|
|||||||
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
|
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
|
||||||
|
|
||||||
def test_the_mode_is_read_from_the_settings_with_a_default(self):
|
def test_the_mode_is_read_from_the_settings_with_a_default(self):
|
||||||
|
# An unset or unusable key reads as the driver's default, x32.
|
||||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
|
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": "32"}), 32)
|
||||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 8)
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "8"}), 8)
|
||||||
self.assertEqual(baseline.xy_mode_of({}), 8)
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 32)
|
||||||
self.assertEqual(baseline.xy_mode_of(None), 8)
|
self.assertEqual(baseline.xy_mode_of({}), 32)
|
||||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 8)
|
self.assertEqual(baseline.xy_mode_of(None), 32)
|
||||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 8)
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 32)
|
||||||
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 32)
|
||||||
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
|
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({"forgectrl": {"/settings": None}}), 32)
|
||||||
self.assertEqual(baseline.ref_xy_mode({}), 8)
|
self.assertEqual(baseline.ref_xy_mode({}), 32)
|
||||||
|
|
||||||
def test_fixed_constants_are_checked_at_the_reference_mode(self):
|
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"},
|
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
|
||||||
@@ -400,6 +437,81 @@ class TransientNotLeftoverTests(BaselineTests):
|
|||||||
self.assertEqual(items[0].found, "1014")
|
self.assertEqual(items[0].found, "1014")
|
||||||
|
|
||||||
|
|
||||||
|
class CoolPublishTests(unittest.TestCase):
|
||||||
|
"""The cooling engine publishes once a tick. A status read inside the
|
||||||
|
tick after a run ended is the engine's last word on the run, not the
|
||||||
|
machine as the run left it. Found on the bench reference: a diagnostic
|
||||||
|
that finished clean (cooling.aa-offset-calibrate) and a fail tier that
|
||||||
|
did its work (cooling.fail-tier-stop) both failed their hand-back on a
|
||||||
|
hold the engine's next tick cleared."""
|
||||||
|
|
||||||
|
IDLE = {"phase": "idle", "armed": False, "hold": False}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.lines = []
|
||||||
|
|
||||||
|
def bl(self, statuses):
|
||||||
|
"""A baseline whose /cool/status answers play in order (the last
|
||||||
|
one repeats) and whose waits count reads, not seconds: three for
|
||||||
|
the engine's next tick, as the real wait makes, and enough for the
|
||||||
|
cooldown to let a slow script run out."""
|
||||||
|
b = baseline.Baseline(self.lines.append)
|
||||||
|
seq = list(statuses)
|
||||||
|
b.fc_get = lambda path: (200, dict(seq.pop(0) if len(seq) > 1 else seq[0]))
|
||||||
|
|
||||||
|
def wait(what, pred, timeout):
|
||||||
|
for i in range(3 if what == "cool publish" else 50):
|
||||||
|
if pred():
|
||||||
|
return float(i)
|
||||||
|
return None
|
||||||
|
b._wait = wait
|
||||||
|
b.stood_down = []
|
||||||
|
b.stand_down = b.stood_down.append
|
||||||
|
return b
|
||||||
|
|
||||||
|
def test_a_diagnostic_hold_the_next_tick_clears_is_not_a_leftover(self):
|
||||||
|
left = []
|
||||||
|
b = self.bl([{"phase": "diag", "armed": False, "hold": True}, self.IDLE])
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual(left, [])
|
||||||
|
self.assertTrue(any("last word on the run" in ln for ln in self.lines), self.lines)
|
||||||
|
|
||||||
|
def test_a_fail_tier_hold_clears_into_the_engines_own_post_job_phase(self):
|
||||||
|
left = []
|
||||||
|
b = self.bl([{"phase": "run", "armed": False, "hold": True},
|
||||||
|
{"phase": "smoke", "armed": False, "hold": False},
|
||||||
|
{"phase": "smoke", "armed": False, "hold": False}, self.IDLE])
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual(left, [])
|
||||||
|
self.assertTrue(any("not a leftover" in ln for ln in self.lines), self.lines)
|
||||||
|
|
||||||
|
def test_a_hold_that_outlives_the_tick_is_the_runs_doing(self):
|
||||||
|
left = []
|
||||||
|
b = self.bl([{"phase": "run", "armed": True, "hold": True}])
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual([x.item for x in left], ["cool"])
|
||||||
|
self.assertEqual(left[0].found, "run/armed=True/hold=True")
|
||||||
|
self.assertTrue(left[0].action.startswith("failed: still"), left[0].action)
|
||||||
|
self.assertEqual(len(b.stood_down), 1)
|
||||||
|
|
||||||
|
def test_a_hold_that_clears_only_later_is_still_recorded(self):
|
||||||
|
left = []
|
||||||
|
held = {"phase": "run", "armed": False, "hold": True}
|
||||||
|
b = self.bl([held] * 10 + [self.IDLE])
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual([x.item for x in left], ["cool"])
|
||||||
|
self.assertEqual(left[0].action, "waited")
|
||||||
|
self.assertEqual(b.stood_down, [])
|
||||||
|
|
||||||
|
def test_an_idle_engine_and_a_silent_daemon_leave_nothing(self):
|
||||||
|
left = []
|
||||||
|
self.bl([self.IDLE])._cool_side(left)
|
||||||
|
b = baseline.Baseline(self.lines.append)
|
||||||
|
b.fc_get = lambda path: (None, None)
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual(left, [])
|
||||||
|
|
||||||
|
|
||||||
class CloudOwnedSysfsTests(unittest.TestCase):
|
class CloudOwnedSysfsTests(unittest.TestCase):
|
||||||
"""In cloud mode the cloud client configures the machine from the
|
"""In cloud mode the cloud client configures the machine from the
|
||||||
pulse header it is playing, the lens included: z_mode comes from
|
pulse header it is playing, the lens included: z_mode comes from
|
||||||
@@ -434,6 +546,13 @@ class PositionDeadbandTests(unittest.TestCase):
|
|||||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
|
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
|
||||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
|
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
|
||||||
|
|
||||||
|
def test_the_dead_band_scales_with_the_counters(self):
|
||||||
|
# the same step count is a quarter of the distance at x32
|
||||||
|
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
|
||||||
|
x32 = baseline.XY_STEPS_PER_MM_OF[32]
|
||||||
|
self.assertTrue(baseline.position_quantized([0, 0, 0], [over, 0, 0], x32))
|
||||||
|
self.assertFalse(baseline.position_quantized([0, 0, 0], [4 * over, 0, 0], x32))
|
||||||
|
|
||||||
def test_z_is_exact_because_the_return_never_moves_it(self):
|
def test_z_is_exact_because_the_return_never_moves_it(self):
|
||||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
|
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
|
||||||
|
|
||||||
@@ -549,7 +668,7 @@ class BaselineModeTests(BaselineTests):
|
|||||||
self._attr("pic/lid_led", "77")
|
self._attr("pic/lid_led", "77")
|
||||||
left = self.bl().enforce("pre", captured=None)
|
left = self.bl().enforce("pre", captured=None)
|
||||||
self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"])
|
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("cnc/step_freq"), DEFAULT_TICK)
|
||||||
self.assertEqual(self._read("pic/lid_led"), "236")
|
self.assertEqual(self._read("pic/lid_led"), "236")
|
||||||
|
|
||||||
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
|
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The bench registry against scripts/bench: every python tool is
|
"""The bench registry against scripts/bench: every python tool is
|
||||||
registered (or named as not-a-tool), every registered script exists,
|
registered (or named as not-a-tool), every registered script exists,
|
||||||
every argument spec builds a command line, every script compiles, and
|
every argument spec builds a command line, every script compiles, and
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import helpers
|
import helpers
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Every phrase the cloud suite greps for in the gfcloud log must be a
|
"""Every phrase the cloud suite greps for in the gfcloud log must be a
|
||||||
phrase the pinned cloud app can actually log.
|
phrase the pinned cloud app can actually log.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The cloud.* job-behavior tests against a fake forgectrl and a replayed
|
"""The cloud.* job-behavior tests against a fake forgectrl and a replayed
|
||||||
gfcloud log: the real test functions run under the real runner Context,
|
gfcloud log: the real test functions run under the real runner Context,
|
||||||
the operator prompts answered - and the standing notices of the machine
|
the operator prompts answered - and the standing notices of the machine
|
||||||
@@ -558,12 +563,16 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
|
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
|
||||||
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
|
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
|
||||||
|
|
||||||
def mode_switch_setup(self, hunt_lines=None, home_complete=True):
|
def mode_switch_setup(self, hunt_lines=None, home_complete=True, lid_late=False):
|
||||||
"""The fakes a mode-switch run needs: grbl to answer $H, the lid
|
"""The fakes a mode-switch run needs: grbl to answer $H, the lid
|
||||||
lamp attr, homing_mode = gfcloud, the service lines landing on
|
lamp attr, homing_mode = gfcloud, the service lines landing on
|
||||||
the switch to cloud (the hunt reads as a run to the cooling
|
the switch to cloud - the client's start at once, its session and
|
||||||
engine while it lasts), the re-hunt on the lid close, and gfhome
|
hunt once the lid reads open, as on the bench where the lid opens
|
||||||
finishing the homing after $H."""
|
behind the controller's start and ahead of the hunt (the hunt
|
||||||
|
reads as a run to the cooling engine while it lasts) - the re-hunt
|
||||||
|
on the lid close, and gfhome finishing the homing after $H. With
|
||||||
|
lid_late the hunt is requested before the lid opens: the race the
|
||||||
|
test must call."""
|
||||||
self.grbl = helpers.FakeGrbl().start()
|
self.grbl = helpers.FakeGrbl().start()
|
||||||
os.makedirs(self.sysfs + "pic", exist_ok=True)
|
os.makedirs(self.sysfs + "pic", exist_ok=True)
|
||||||
self._attr("pic/lid_led", "236")
|
self._attr("pic/lid_led", "236")
|
||||||
@@ -575,22 +584,35 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
hunt_part = hunt_lines(hunt_part)
|
hunt_part = hunt_lines(hunt_part)
|
||||||
fc = self.fc
|
fc = self.fc
|
||||||
|
|
||||||
|
def hunt():
|
||||||
|
self.append(hunt_part, delay=0.0)
|
||||||
|
time.sleep(4.0) # the hunt outlasts the session wait on the bench
|
||||||
|
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
|
||||||
|
|
||||||
def on_post(path, form):
|
def on_post(path, form):
|
||||||
if path == "/mode" and form.get("controller") == "cloud":
|
if path == "/mode" and form.get("controller") == "cloud":
|
||||||
fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE)
|
fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE)
|
||||||
|
|
||||||
def land():
|
def land():
|
||||||
self.append(pre, delay=0.0)
|
self.append(pre, delay=0.0)
|
||||||
|
if lid_late:
|
||||||
|
return
|
||||||
|
t0 = time.time()
|
||||||
|
while fc.state["status"]["switches"]["lid"] and time.time() - t0 < 5:
|
||||||
|
time.sleep(0.02)
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
self.append(hunt_part, delay=0.0)
|
hunt()
|
||||||
time.sleep(4.0) # the hunt outlasts the session wait on the bench
|
|
||||||
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
|
|
||||||
threading.Thread(target=land, daemon=True).start()
|
threading.Thread(target=land, daemon=True).start()
|
||||||
elif path == "/mode" and form.get("controller") == "grbl":
|
elif path == "/mode" and form.get("controller") == "grbl":
|
||||||
self.grbl.state = "Idle"
|
self.grbl.state = "Idle"
|
||||||
return None
|
return None
|
||||||
self.fc.on_post = on_post
|
self.fc.on_post = on_post
|
||||||
|
|
||||||
|
def lid_opened():
|
||||||
|
if lid_late:
|
||||||
|
self.append(hunt_part, delay=0.0)
|
||||||
|
self.lid(False)
|
||||||
|
|
||||||
def lid_closed():
|
def lid_closed():
|
||||||
self.lid(True)
|
self.lid(True)
|
||||||
self.append(close_part, delay=0.2)
|
self.append(close_part, delay=0.2)
|
||||||
@@ -605,7 +627,7 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete "
|
f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete "
|
||||||
b"(service quiet 8s, 3 motion windows)\n")
|
b"(service quiet 8s, 3 motion windows)\n")
|
||||||
self.home_hook = homing
|
self.home_hook = homing
|
||||||
return {"Open the lid.": lambda: self.lid(False), "Close the lid.": lid_closed}
|
return {"Open the lid.": lid_opened, "Close the lid.": lid_closed}
|
||||||
|
|
||||||
def wait_home_command(self):
|
def wait_home_command(self):
|
||||||
while "$H" not in self.grbl.sent:
|
while "$H" not in self.grbl.sent:
|
||||||
@@ -633,6 +655,29 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.script.asked, [])
|
self.assertEqual(self.script.asked, [])
|
||||||
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
|
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
|
||||||
|
|
||||||
|
def test_mode_switch_opens_the_lid_behind_the_controller_and_ahead_of_the_hunt(self):
|
||||||
|
# No controller starts with the enclosure open: the switch is made
|
||||||
|
# lid closed, the lid opens once the controller is up, and the
|
||||||
|
# hunt's request lands after that - the order the test records.
|
||||||
|
hooks = self.mode_switch_setup()
|
||||||
|
threading.Thread(target=self.wait_home_command, daemon=True).start()
|
||||||
|
run = self.run_test(cloud.mode_switch, hooks=hooks, test_id="cloud.mode-switch")
|
||||||
|
self.assertFalse(run.evidence["hunt_before_lid_open"])
|
||||||
|
acts = [(a["channel"], a["state"]) for a in run.evidence["actions"]]
|
||||||
|
self.assertEqual(acts, [("lid", "open"), ("lid", "close")])
|
||||||
|
i_post = next(i for i, l in enumerate(run.lines) if "POST /mode controller=cloud" in l)
|
||||||
|
i_open = next(i for i, l in enumerate(run.lines) if "ACT lid open" in l)
|
||||||
|
self.assertLess(i_post, i_open)
|
||||||
|
|
||||||
|
def test_mode_switch_refuses_to_start_with_the_lid_open(self):
|
||||||
|
hooks = self.mode_switch_setup()
|
||||||
|
self.lid(False)
|
||||||
|
self.assertFails(cloud.mode_switch, "close the lid first", hooks=hooks)
|
||||||
|
|
||||||
|
def test_mode_switch_fails_when_the_hunt_was_requested_before_the_lid_opened(self):
|
||||||
|
hooks = self.mode_switch_setup(lid_late=True)
|
||||||
|
self.assertFails(cloud.mode_switch, "before the lid was open", hooks=hooks)
|
||||||
|
|
||||||
def test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self):
|
def test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self):
|
||||||
def refused(hunt_part):
|
def refused(hunt_part):
|
||||||
i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)
|
i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)
|
||||||
@@ -914,22 +959,63 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.fc.posts, [])
|
self.assertEqual(self.fc.posts, [])
|
||||||
self.assertTrue(any("PASS: lid open at the button prompt" in l for l in run.lines))
|
self.assertTrue(any("PASS: lid open at the button prompt" in l for l in run.lines))
|
||||||
|
|
||||||
# -- the cooling verdict: the armed print waits on the warm-up ------------
|
# -- the cooling verdict: locked until the run, a hold that never clears --
|
||||||
def test_verdict_hold_on_the_bench_excerpt(self):
|
def dark_parts(self):
|
||||||
self.in_offline()
|
"""(the print prologue through the button prompt, the run and the
|
||||||
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
print's end with no pause in it)."""
|
||||||
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"})
|
|
||||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
|
|
||||||
lines = fixture("pause")
|
lines = fixture("pause")
|
||||||
pre, rest = cut(lines, "waiting for button")
|
pre, rest = cut(lines, "waiting for button")
|
||||||
pre = pre + [rest[0]]
|
pre = pre + [rest[0]]
|
||||||
# the run and the print's end, without the pause in the middle
|
|
||||||
_, run = cut(rest, "machine:_run_loop starting run")
|
_, run = cut(rest, "machine:_run_loop starting run")
|
||||||
run = [l for l in run if "paus" not in l and "resum" not in l]
|
run = [l for l in run if "paus" not in l and "resum" not in l]
|
||||||
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] INFO machine:_verdict_wait "
|
return pre, run
|
||||||
wait = [stamp % 100000 + "waiting on the cooling engine: WARMUP (WARM-UP: coolant 21.5 C under "
|
|
||||||
"the 22.5 C start gate - heater on, hold)"]
|
def test_dark_print_on_the_bench_excerpt(self):
|
||||||
release = [stamp % 200000 + "cooling verdict clean after WARMUP; starting the run"]
|
self.in_offline()
|
||||||
|
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
|
||||||
|
pre, run = self.dark_parts()
|
||||||
|
self.offline_print_hooks(pre)
|
||||||
|
|
||||||
|
def press():
|
||||||
|
# the run: the client unlocks the latch right before it, and
|
||||||
|
# relocks when the job ends
|
||||||
|
self._attr("cnc/interlock_circuit", "37")
|
||||||
|
self.append(run[:1], delay=0.05)
|
||||||
|
|
||||||
|
def ended():
|
||||||
|
self._attr("cnc/interlock_circuit", "45")
|
||||||
|
self.append(run[1:], delay=0.0)
|
||||||
|
threading.Timer(2.0, ended).start()
|
||||||
|
hooks = {"press it. The print runs dark": press}
|
||||||
|
run_ = self.run_test(cloud.dark_print, hooks=hooks, test_id="cloud.dark-print")
|
||||||
|
ev = run_.evidence
|
||||||
|
self.assertTrue(ev["latch_locked_at_button"])
|
||||||
|
self.assertFalse(ev["latch_locked_in_run"])
|
||||||
|
self.assertTrue(ev["latch_locked_after"])
|
||||||
|
self.assertIn(":completed", ev["print finished"])
|
||||||
|
self.assertTrue(any("PASS: locked at the button" in l for l in run_.lines))
|
||||||
|
|
||||||
|
def test_dark_print_fails_when_the_latch_is_open_at_the_button(self):
|
||||||
|
self.in_offline()
|
||||||
|
pre, run = self.dark_parts()
|
||||||
|
self._attr("cnc/interlock_circuit", "37") # unlocked before the press
|
||||||
|
self.offline_print_hooks(pre)
|
||||||
|
hooks = {"press it. The print runs dark": lambda: self.append(run, delay=0.05)}
|
||||||
|
self.assertFails(cloud.dark_print, "the latch is unlocked at the button wait", hooks=hooks)
|
||||||
|
|
||||||
|
def test_verdict_refuse_on_the_bench_excerpt(self):
|
||||||
|
self.in_offline()
|
||||||
|
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
||||||
|
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16",
|
||||||
|
"cloud_hold_max_s": ""})
|
||||||
|
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
|
||||||
|
pre, run = self.dark_parts()
|
||||||
|
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] %s machine:_verdict_wait "
|
||||||
|
wait = [stamp % (100000, "INFO") + "waiting on the cooling engine: WARMUP (WARM-UP: coolant "
|
||||||
|
"21.5 C under the 29.5 C start gate - heater on, hold)"]
|
||||||
|
refuse = [stamp % (200000, "WARNING") + "cooling hold held 60 s - relocking the laser",
|
||||||
|
"2026-08-17T09:44:15.000000+00:00 gfcloud[1927] INFO basemachine:_finish_action print "
|
||||||
|
"[9006]: finished with event \":cancelled\""]
|
||||||
|
|
||||||
def on_post(path, form):
|
def on_post(path, form):
|
||||||
if path == "/settings" and form.get("cool_temp_start") not in (None, "0", "16"):
|
if path == "/settings" and form.get("cool_temp_start") not in (None, "0", "16"):
|
||||||
@@ -942,37 +1028,44 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
def press():
|
def press():
|
||||||
self.append(wait, delay=0.05)
|
self.append(wait, delay=0.05)
|
||||||
|
|
||||||
def released():
|
def refused():
|
||||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True,
|
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True,
|
||||||
"armed": False, "phase": "run"})
|
"armed": False, "phase": "idle"})
|
||||||
self.append(release + run, delay=0.0)
|
self.append(refuse, delay=0.0)
|
||||||
threading.Timer(3.0, released).start()
|
threading.Timer(2.5, refused).start()
|
||||||
hooks = {"press it. The print then waits": press}
|
hooks = {"press it. Nothing runs": press}
|
||||||
run_ = self.run_test(cloud.verdict_hold, hooks=hooks, test_id="cloud.verdict-hold")
|
run_ = self.run_test(cloud.verdict_refuse, hooks=hooks, test_id="cloud.verdict-refuse")
|
||||||
ev = run_.evidence
|
ev = run_.evidence
|
||||||
self.assertEqual(ev["gate"], 22.5)
|
self.assertEqual(ev["gate"], 29.5)
|
||||||
self.assertEqual(ev["warmup"]["verdict"], "WARMUP")
|
self.assertTrue(ev["latch_locked_at_button"])
|
||||||
self.assertTrue(ev["log"]["cooling verdict clean after WARMUP; starting the run"])
|
self.assertEqual(ev["latch_samples"]["unlocked"], 0)
|
||||||
self.assertIn(":completed", ev["print finished"])
|
self.assertGreaterEqual(ev["latch_samples"]["n"], 1)
|
||||||
posted = [f for p, f in self.fc.posts if p == "/settings"]
|
self.assertIn(":cancelled", ev["print finished"])
|
||||||
self.assertEqual(posted[0]["cool_temp_start"], "22.5")
|
posted = [f for p_, f in self.fc.posts if p_ == "/settings"]
|
||||||
self.assertEqual(posted[-1], {"cool_temp_min": "5", "cool_temp_start": "16"})
|
self.assertEqual(posted[0]["cool_temp_start"], "29.5")
|
||||||
self.assertTrue(any("PASS: the armed print waited" in l for l in run_.lines))
|
self.assertEqual(posted[0]["cloud_hold_max_s"], "60")
|
||||||
|
self.assertEqual(posted[-1], {"cool_temp_min": "5", "cool_temp_start": "16", "cloud_hold_max_s": ""})
|
||||||
|
self.assertTrue(any("PASS: held" in l for l in run_.lines))
|
||||||
|
|
||||||
def test_verdict_hold_fails_when_the_print_runs_under_the_hold(self):
|
def test_verdict_refuse_fails_when_the_latch_unlocks_during_the_hold(self):
|
||||||
self.in_offline()
|
self.in_offline()
|
||||||
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
||||||
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"})
|
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"})
|
||||||
self.fc.state["cool"].update({"verdict": "WARMUP", "hold": True, "fire_ok": False, "armed": True})
|
self.fc.state["cool"].update({"verdict": "WARMUP", "hold": True, "fire_ok": False, "armed": True})
|
||||||
lines = fixture("pause")
|
pre, run = self.dark_parts()
|
||||||
pre, rest = cut(lines, "waiting for button")
|
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] %s machine:_verdict_wait "
|
||||||
pre = pre + [rest[0]]
|
wait = [stamp % (100000, "INFO") + "waiting on the cooling engine: WARMUP (no reason given)"]
|
||||||
_, run = cut(rest, "machine:_run_loop starting run")
|
refuse = [stamp % (200000, "WARNING") + "cooling hold held 60 s - relocking the laser",
|
||||||
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] INFO machine:_verdict_wait "
|
"2026-08-17T09:44:15.000000+00:00 gfcloud[1927] INFO basemachine:_finish_action print "
|
||||||
wait = [stamp % 100000 + "waiting on the cooling engine: WARMUP (no reason given)"]
|
"[9006]: finished with event \":cancelled\""]
|
||||||
self.offline_print_hooks(pre)
|
self.offline_print_hooks(pre)
|
||||||
hooks = {"press it. The print then waits": lambda: self.append(wait + run[:1], delay=0.05)}
|
|
||||||
self.assertFails(cloud.verdict_hold, "the run started under the warm-up hold", hooks=hooks)
|
def press():
|
||||||
|
self.append(wait, delay=0.05)
|
||||||
|
self._attr("cnc/interlock_circuit", "37") # unlocked under the hold
|
||||||
|
threading.Timer(2.5, lambda: self.append(refuse, delay=0.0)).start()
|
||||||
|
hooks = {"press it. Nothing runs": press}
|
||||||
|
self.assertFails(cloud.verdict_refuse, "the latch unlocked during the hold", hooks=hooks)
|
||||||
|
|
||||||
# -- a paused print canceled by the lid, a running one by the app --------
|
# -- a paused print canceled by the lid, a running one by the app --------
|
||||||
def cancel_parts(self):
|
def cancel_parts(self):
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""cooling.aa-offset-calibrate is registered before the heater tools, so
|
"""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
|
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
|
the coolant sensors for minutes, and the tool's edges read it as
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
|
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
|
||||||
under the real runner Context against a scripted machine: a fake
|
under the real runner Context against a scripted machine: a fake
|
||||||
forgectrl (/status fans, /cool/status phase and verdict, /settings with
|
forgectrl (/status fans, /cool/status phase and verdict, /settings with
|
||||||
@@ -729,3 +734,51 @@ class FanGateTests(unittest.TestCase):
|
|||||||
self.run_test()
|
self.run_test()
|
||||||
self.assertIn("lacks exhaust", str(cm.exception))
|
self.assertIn("lacks exhaust", str(cm.exception))
|
||||||
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
|
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
|
||||||
|
|
||||||
|
|
||||||
|
class FlowVerdictLogTests(unittest.TestCase):
|
||||||
|
"""cooling.flow-under-load finds the engine's verdict in what the log
|
||||||
|
gained since the job began, not in a count over a tail of fixed length:
|
||||||
|
there a new verdict line comes in at the bottom as an old one leaves at
|
||||||
|
the top, the count does not move, and a check that verified reads as
|
||||||
|
one that never judged."""
|
||||||
|
|
||||||
|
VERDICT = ("2026-09-19T21:37:46.869674+00:00 forgectrl[2796] INFO cool: coolant flow verified "
|
||||||
|
"(heater rise 11.5 C, dT 9.5 C; laser 1.6 off 13.1)\n")
|
||||||
|
OLD = ("2026-09-19T21:17:27.200840+00:00 forgectrl[965] INFO cool: coolant flow verified "
|
||||||
|
"(heater rise 9.5 C, dT 8.7 C)\n")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.mkdtemp(prefix="flowlog.")
|
||||||
|
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||||
|
self.path = os.path.join(self.dir, "forgectrl.log")
|
||||||
|
|
||||||
|
def write(self, text, mode="w"):
|
||||||
|
with open(self.path, mode, newline="\n") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
def test_only_the_text_after_the_offset_comes_back(self):
|
||||||
|
self.write(self.OLD + "filler\n" * 400)
|
||||||
|
off = os.path.getsize(self.path)
|
||||||
|
self.assertEqual(cooling._log_since(self.path, off), "")
|
||||||
|
self.write(self.VERDICT, mode="a")
|
||||||
|
text = cooling._log_since(self.path, off)
|
||||||
|
self.assertEqual(text, self.VERDICT)
|
||||||
|
m = cooling._load_verdict_rx().search(text)
|
||||||
|
self.assertEqual(m.group(1), "coolant flow verified")
|
||||||
|
self.assertEqual((m.group(2), m.group(5)), ("11.5", "1.6"))
|
||||||
|
|
||||||
|
def test_an_old_verdict_before_the_offset_is_not_the_new_one(self):
|
||||||
|
self.write(self.OLD)
|
||||||
|
off = os.path.getsize(self.path)
|
||||||
|
self.write("2026-09-19T21:36:40+00:00 forgectrl[2796] INFO cool: crash watch armed\n", mode="a")
|
||||||
|
self.assertIsNone(cooling._load_verdict_rx().search(cooling._log_since(self.path, off)))
|
||||||
|
|
||||||
|
def test_a_log_rotated_under_the_test_is_read_whole(self):
|
||||||
|
self.write(self.OLD + "filler\n" * 400)
|
||||||
|
off = os.path.getsize(self.path)
|
||||||
|
self.write(self.VERDICT) # the rotation: a new, shorter file
|
||||||
|
self.assertEqual(cooling._log_since(self.path, off), self.VERDICT)
|
||||||
|
|
||||||
|
def test_a_missing_log_reads_as_nothing(self):
|
||||||
|
self.assertEqual(cooling._log_since(os.path.join(self.dir, "none.log"), 0), "")
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The bench actuator as the tool sees it.
|
"""The bench actuator as the tool sees it.
|
||||||
|
|
||||||
The fixture is a box on the network that opens the lid loop, pulls the
|
The fixture is a box on the network that opens the lid loop, pulls the
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from forgetest import catalog
|
||||||
from forgetest.suite import image
|
from forgetest.suite import image
|
||||||
|
|
||||||
|
|
||||||
@@ -20,5 +26,61 @@ class KernelIdentTests(unittest.TestCase):
|
|||||||
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
|
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkBootTests(unittest.TestCase):
|
||||||
|
STANZA = ("auto wlan0\n"
|
||||||
|
"iface wlan0 inet dhcp\n"
|
||||||
|
"\twpa-driver nl80211\n"
|
||||||
|
"\tudhcpc_opts -x hostname:$(hostname)\n")
|
||||||
|
|
||||||
|
def test_the_test_is_registered_and_covers_nothing_by_design(self):
|
||||||
|
t = catalog.load_suite()["image.network-boot"]
|
||||||
|
self.assertEqual(t.kind, "auto")
|
||||||
|
self.assertEqual(t.covers, ()) # the interfaces file is layer content
|
||||||
|
|
||||||
|
def test_ifstate_names_the_interfaces_ifup_finished(self):
|
||||||
|
self.assertEqual(image.ifstate_names("lo=lo\nwlan0=wlan0\n"), {"lo", "wlan0"})
|
||||||
|
self.assertEqual(image.ifstate_names("lo=lo\n"), {"lo"})
|
||||||
|
self.assertEqual(image.ifstate_names(""), set())
|
||||||
|
self.assertEqual(image.ifstate_names(None), set())
|
||||||
|
|
||||||
|
def test_an_interfaces_file_without_a_dhcpv6_client_is_clean(self):
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces(self.STANZA), [])
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces(None), [])
|
||||||
|
|
||||||
|
def test_a_command_that_starts_a_dhcpv6_client_is_named(self):
|
||||||
|
text = self.STANZA + ("iface wlan0 inet6 manual\n"
|
||||||
|
"\tup udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid\n")
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces(text),
|
||||||
|
["up udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid"])
|
||||||
|
detached = "\tup start-stop-daemon -S -b -x /usr/bin/udhcpc6 -- -f -i wlan0\n"
|
||||||
|
self.assertEqual(len(image.dhcp6_in_interfaces(self.STANZA + detached)), 1)
|
||||||
|
|
||||||
|
def test_an_inet6_method_that_runs_a_client_is_named(self):
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 dhcp\n"),
|
||||||
|
["iface wlan0 inet6 dhcp"])
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 static\n"), [])
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet dhcp\n"), [])
|
||||||
|
|
||||||
|
def test_a_comment_starts_nothing(self):
|
||||||
|
text = self.STANZA + "# No DHCPv6 client runs; udhcpc6 is not on the image.\n"
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces(text), [])
|
||||||
|
|
||||||
|
def test_parse_stat_reads_the_parent_past_an_awkward_comm(self):
|
||||||
|
self.assertEqual(image.parse_stat("371 (udhcpc) S 1 371 371 0 -1 4194624"),
|
||||||
|
(371, "udhcpc", 1))
|
||||||
|
self.assertEqual(image.parse_stat("9 (a (b) c) S 7 9 9 0"), (9, "a (b) c", 7))
|
||||||
|
self.assertIsNone(image.parse_stat(""))
|
||||||
|
self.assertIsNone(image.parse_stat("12 (x) S"))
|
||||||
|
|
||||||
|
def test_link_addrs_sorts_one_interface_by_scope(self):
|
||||||
|
text = ("fe800000000000002e6b7dfffe0db00a 02 40 20 80 wlan0\n"
|
||||||
|
"26001700000000000000000000001234 02 80 00 00 wlan0\n"
|
||||||
|
"00000000000000000000000000000001 01 80 10 80 lo\n")
|
||||||
|
local, world = image.link_addrs(text, "wlan0")
|
||||||
|
self.assertEqual(local, ["fe800000000000002e6b7dfffe0db00a"])
|
||||||
|
self.assertEqual(world, ["26001700000000000000000000001234"])
|
||||||
|
self.assertEqual(image.link_addrs(text, "eth0"), ([], []))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""scripts/install-forgefirm.sh - the download that retries, and the log.
|
||||||
|
|
||||||
|
The installer runs once per machine, on factory firmware, over whatever
|
||||||
|
network the owner has. These tests run its own functions under sh with a
|
||||||
|
scripted curl in front of them: a transfer that fails resumes and is
|
||||||
|
tried again, the failures that waiting cannot fix end the tries at once,
|
||||||
|
and every step leaves a line in the install log in the log tree's own
|
||||||
|
format."""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
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", "install-forgefirm.sh")
|
||||||
|
MARK = "# The install log, before anything can fail."
|
||||||
|
|
||||||
|
# The log tree's line format (forgetest/suite/logs.py keeps the same one).
|
||||||
|
LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d install\[\d+\] "
|
||||||
|
r"(EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) .+$")
|
||||||
|
|
||||||
|
# A curl that plays a script: each call takes the next "rc http bytes"
|
||||||
|
# line of $CURL_PLAN, appends that many bytes to the --output file the way
|
||||||
|
# a resumed transfer does, prints the HTTP code as -w '%{http_code}'
|
||||||
|
# would, records its arguments, and exits rc.
|
||||||
|
FAKE_CURL = r"""#!/bin/sh
|
||||||
|
echo "$*" >> "$CURL_CALLS"
|
||||||
|
OUT=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
[ "$1" = "--output" ] && OUT="$2"
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
LINE=$(sed -n 1p "$CURL_PLAN")
|
||||||
|
sed -i 1d "$CURL_PLAN"
|
||||||
|
set -- $LINE
|
||||||
|
[ "${3:-0}" -gt 0 ] && dd if=/dev/zero bs=1 count="$3" 2>/dev/null | tr '\0' 'x' >> "$OUT"
|
||||||
|
printf '%s' "$2"
|
||||||
|
exit "$1"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def functions():
|
||||||
|
"""The installer up to its first action: constants and functions."""
|
||||||
|
with open(SCRIPT, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
head, mark, _ = text.partition(MARK)
|
||||||
|
assert mark, "the installer lost the line the tests cut it at"
|
||||||
|
return head
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("sh") and os.name == "posix", "needs a POSIX sh")
|
||||||
|
class InstallerTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix="ffinstall-test.")
|
||||||
|
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
|
||||||
|
self.bin = os.path.join(self.tmp, "bin")
|
||||||
|
os.mkdir(self.bin)
|
||||||
|
for name, body in (("curl", FAKE_CURL), ("nslookup", "#!/bin/sh\nexit 1\n"),
|
||||||
|
("ip", "#!/bin/sh\nexit 0\n"), ("sleep", "#!/bin/sh\nexit 0\n")):
|
||||||
|
path = os.path.join(self.bin, name)
|
||||||
|
with open(path, "w", newline="\n") as f:
|
||||||
|
f.write(body)
|
||||||
|
os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR)
|
||||||
|
self.fw = os.path.join(self.tmp, "forgefirm.fw")
|
||||||
|
self.log = os.path.join(self.tmp, "log", "install.log")
|
||||||
|
self.calls = os.path.join(self.tmp, "curl.calls")
|
||||||
|
self.plan = os.path.join(self.tmp, "curl.plan")
|
||||||
|
|
||||||
|
def run_sh(self, body, plan=()):
|
||||||
|
with open(self.plan, "w", newline="\n") as f:
|
||||||
|
f.write("".join("%s\n" % line for line in plan))
|
||||||
|
open(self.calls, "w").close()
|
||||||
|
script = "%s\nFW_FILE='%s'\nLOG_DIR='%s'\nLOG_FILE='%s'\nmkdir -p \"$LOG_DIR\"\nLOG_OK=yes\n%s\n" % (
|
||||||
|
functions(), self.fw, os.path.dirname(self.log), self.log, body)
|
||||||
|
env = dict(os.environ, PATH=self.bin + os.pathsep + os.environ["PATH"],
|
||||||
|
CURL_PLAN=self.plan, CURL_CALLS=self.calls)
|
||||||
|
return subprocess.run(["sh", "-c", script], env=env, capture_output=True, text=True, timeout=60)
|
||||||
|
|
||||||
|
def download(self, plan):
|
||||||
|
r = self.run_sh('download_fw; echo "rc=$? why=$DL_WHY"', plan)
|
||||||
|
with open(self.calls) as f:
|
||||||
|
calls = f.read().splitlines()
|
||||||
|
return r.stdout, calls, self.log_lines()
|
||||||
|
|
||||||
|
def log_lines(self):
|
||||||
|
try:
|
||||||
|
with open(self.log) as f:
|
||||||
|
return f.read().splitlines()
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def test_a_clean_download_takes_one_try_and_its_name(self):
|
||||||
|
out, calls, log = self.download(["0 200 5000"])
|
||||||
|
self.assertIn("rc=0", out)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
self.assertEqual(os.path.getsize(self.fw), 5000)
|
||||||
|
self.assertFalse(os.path.exists(self.fw + ".part"))
|
||||||
|
self.assertTrue(any("complete on try 1, 5000 bytes" in line for line in log), log)
|
||||||
|
|
||||||
|
def test_a_failed_transfer_resumes_and_is_tried_again(self):
|
||||||
|
out, calls, log = self.download(["6 000 0", "56 200 3000", "0 206 2000"])
|
||||||
|
self.assertIn("rc=0", out)
|
||||||
|
self.assertEqual(len(calls), 3)
|
||||||
|
for c in calls: # every try resumes, bounded in time
|
||||||
|
self.assertIn("-C -", c)
|
||||||
|
self.assertIn("--connect-timeout", c)
|
||||||
|
self.assertIn("--speed-time", c)
|
||||||
|
self.assertEqual(os.path.getsize(self.fw), 5000) # 3000 kept, 2000 resumed
|
||||||
|
warns = [line for line in log if " WARNING download: try " in line]
|
||||||
|
self.assertEqual(len(warns), 2, log)
|
||||||
|
self.assertIn("did not resolve", warns[0])
|
||||||
|
self.assertIn("dropped mid-transfer (3000 bytes so far)", warns[1])
|
||||||
|
self.assertIn("Trying again in 5s (try 2 of 5)", out)
|
||||||
|
self.assertIn("Trying again in 15s (try 3 of 5)", out)
|
||||||
|
|
||||||
|
def test_the_tries_run_out(self):
|
||||||
|
out, calls, log = self.download(["28 000 0"] * 5)
|
||||||
|
self.assertIn("rc=1", out)
|
||||||
|
self.assertIn("timed out or stalled", out)
|
||||||
|
self.assertEqual(len(calls), 5)
|
||||||
|
self.assertFalse(os.path.exists(self.fw))
|
||||||
|
self.assertFalse(os.path.exists(self.fw + ".part"))
|
||||||
|
# each failed try leaves what the network looked like
|
||||||
|
self.assertEqual(sum("github.com does not resolve" in line for line in log), 5, log)
|
||||||
|
|
||||||
|
def test_a_missing_release_and_a_full_disk_end_the_tries_at_once(self):
|
||||||
|
out, calls, _ = self.download(["22 404 0"])
|
||||||
|
self.assertIn("rc=1", out)
|
||||||
|
self.assertIn("HTTP 404", out)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
out, calls, _ = self.download(["23 200 100"])
|
||||||
|
self.assertIn("rc=1", out)
|
||||||
|
self.assertIn("could not be written", out)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
|
||||||
|
def test_a_partial_file_the_server_will_not_resume_starts_over(self):
|
||||||
|
out, calls, _ = self.download(["56 200 4000", "22 416 0", "0 200 5000"])
|
||||||
|
self.assertIn("rc=0", out)
|
||||||
|
self.assertEqual(len(calls), 3)
|
||||||
|
self.assertEqual(os.path.getsize(self.fw), 5000) # not 9000: the stale part went
|
||||||
|
|
||||||
|
def test_a_tls_failure_names_the_clock(self):
|
||||||
|
out, _, _ = self.download(["60 000 0"] * 5)
|
||||||
|
self.assertRegex(out, r"TLS handshake failed \(a wrong clock does this: \d{4}-\d\d-\d\d")
|
||||||
|
|
||||||
|
def test_every_log_line_is_in_the_tree_format(self):
|
||||||
|
self.download(["7 000 0", "0 200 10"])
|
||||||
|
self.run_sh('log NOTICE "operator declined to continue; no changes made"')
|
||||||
|
lines = self.log_lines()
|
||||||
|
self.assertGreater(len(lines), 5)
|
||||||
|
for line in lines:
|
||||||
|
self.assertRegex(line, LINE_RE)
|
||||||
|
|
||||||
|
def test_die_leaves_the_reason_in_the_log(self):
|
||||||
|
r = self.run_sh('die "archiving slot 1 failed"; echo not-reached')
|
||||||
|
self.assertEqual(r.returncode, 1)
|
||||||
|
self.assertNotIn("not-reached", r.stdout)
|
||||||
|
self.assertTrue(any(" ERR install failed: archiving slot 1 failed" in line
|
||||||
|
for line in self.log_lines()))
|
||||||
|
|
||||||
|
def test_no_writable_log_never_fails_the_install(self):
|
||||||
|
r = self.run_sh('LOG_OK=""; log INFO "x"; LOG_OK=yes; LOG_FILE=/nonexistent/dir/x.log; '
|
||||||
|
'log INFO "y"; echo "still-running rc=$?"')
|
||||||
|
self.assertIn("still-running rc=0", r.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class InstallerShapeTests(unittest.TestCase):
|
||||||
|
"""What holds on any host, with no shell to run."""
|
||||||
|
|
||||||
|
def test_the_log_lives_in_the_tree_the_export_carries(self):
|
||||||
|
head = functions()
|
||||||
|
self.assertIn('LOG_DIR="/data/log/forgefirm/install"', head)
|
||||||
|
self.assertIn('LOG_FILE="$LOG_DIR/install.log"', head)
|
||||||
|
|
||||||
|
def test_the_download_is_the_retrying_one(self):
|
||||||
|
with open(SCRIPT, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
body = text.partition(MARK)[2]
|
||||||
|
self.assertIn("download_fw", body)
|
||||||
|
self.assertNotRegex(body, r"(?m)^\s*curl ") # no bare curl past the functions
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""wait_hv_off against a scripted charge-pump readback: a chain that
|
"""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,
|
releases inside the window passes, a chain held past it is the refusal,
|
||||||
and a chain already off costs no wait."""
|
and a chain already off costs no wait."""
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The dwell-gap verdict of laser.emission-witness over synthetic trails:
|
"""The dwell-gap verdict of laser.emission-witness over synthetic trails:
|
||||||
the button latch judged from the first emission through every sample
|
the button latch judged from the first emission through every sample
|
||||||
whose readback word shows the laser latch unlocked, both bits from that
|
whose readback word shows the laser latch unlocked, both bits from that
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The controller mode a test declares: the runner puts the machine there
|
"""The controller mode a test declares: the runner puts the machine there
|
||||||
before the test and the baseline keeps it there afterward.
|
before the test and the baseline keeps it there afterward.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The operator channel: notices, `ready`, `act`, and the precheck.
|
"""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
|
A test asks for the operator's part in a way the page can show before
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The page assembly: the ui/ files inlined in order, the gzipped install
|
"""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,
|
(what the dev image carries) assembling to the same bytes as a checkout,
|
||||||
the token placeholder carried exactly once, and a missing marker refused
|
the token placeholder carried exactly once, and a missing marker refused
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The two queues: what they select, the order they run in, and where
|
"""The two queues: what they select, the order they run in, and where
|
||||||
they stop.
|
they stop.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""The release artifact carries no machine identity (redact.py).
|
||||||
|
|
||||||
|
The artifact is committed to a public repository. These hold the exporter
|
||||||
|
to that, and to not redacting what the gate recomputes.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from forgetest.redact import scrub
|
||||||
|
|
||||||
|
|
||||||
|
class RedactTests(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_keyed_identity_is_replaced(self):
|
||||||
|
out = scrub({"gf_serial": "15232020", "machine_id": "JTY-876",
|
||||||
|
"hostname": "forgefirm-b00a"})
|
||||||
|
self.assertNotIn("15232020", json.dumps(out))
|
||||||
|
self.assertNotIn("JTY-876", json.dumps(out))
|
||||||
|
self.assertNotIn("forgefirm-b00a", json.dumps(out))
|
||||||
|
|
||||||
|
def test_a_known_value_is_replaced_in_free_text_too(self):
|
||||||
|
# A hostname in a log line is the same leak as one in its own field.
|
||||||
|
out = scrub({"evidence": {"hostname": "forgefirm-b00a"},
|
||||||
|
"log": ["set hostname 'forgefirm-b00a' ok"]})
|
||||||
|
self.assertNotIn("forgefirm-b00a", json.dumps(out))
|
||||||
|
|
||||||
|
def test_addresses_and_macs_go(self):
|
||||||
|
out = scrub(["ping 172.16.1.97", "wlan0 2C:6B:7D:0D:B0:0A up"])
|
||||||
|
t = json.dumps(out)
|
||||||
|
self.assertNotIn("172.16.1.97", t)
|
||||||
|
self.assertNotIn("2C:6B:7D:0D:B0:0A", t)
|
||||||
|
|
||||||
|
def test_loopback_is_kept(self):
|
||||||
|
self.assertIn("127.0.0.1", json.dumps(scrub(["curl http://127.0.0.1:8090/"])))
|
||||||
|
|
||||||
|
def test_numbering_is_stable_within_an_export(self):
|
||||||
|
out = scrub(["a 10.0.0.5", "b 10.0.0.6", "c 10.0.0.5"])
|
||||||
|
self.assertEqual(out[0].split()[1], out[2].split()[1])
|
||||||
|
self.assertNotEqual(out[0].split()[1], out[1].split()[1])
|
||||||
|
|
||||||
|
def test_integrity_values_survive(self):
|
||||||
|
# The gate recomputes these; a long hex blob is what they look like.
|
||||||
|
rec = {"fingerprint": "a" * 64, "manifest_sha": "b" * 64,
|
||||||
|
"source_sha": "c" * 64, "sha256": "d" * 64,
|
||||||
|
"catalog_hash": "e" * 64, "identity_sha": "f" * 64}
|
||||||
|
self.assertEqual(scrub(rec), rec)
|
||||||
|
|
||||||
|
def test_a_clock_time_is_not_an_address(self):
|
||||||
|
out = scrub(["2026-09-18T18:50:11Z baseline: pre: clean"])
|
||||||
|
self.assertIn("18:50:11", out[0])
|
||||||
|
|
||||||
|
def test_result_fields_the_gate_reads_are_untouched(self):
|
||||||
|
rec = {"result": "PASS", "ts": "2026-09-18T18:50:11Z",
|
||||||
|
"campaign": "c-1", "fingerprint": "a" * 64}
|
||||||
|
self.assertEqual(scrub(rec), rec)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""What keeps the page answering the operator instead of the timer.
|
"""What keeps the page answering the operator instead of the timer.
|
||||||
|
|
||||||
Two things went wrong on the bench and are pinned here:
|
Two things went wrong on the bench and are pinned here:
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Runner + HTTP API end to end on localhost with a fake catalog and a
|
"""Runner + HTTP API end to end on localhost with a fake catalog and a
|
||||||
fake bench tool. No hardware, no forgectrl."""
|
fake bench tool. No hardware, no forgectrl."""
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The setup's checks in the catalog: registered as automatic tests with
|
"""The setup's checks in the catalog: registered as automatic tests with
|
||||||
no precheck, the takeover ones marked so, every covers map naming the
|
no precheck, the takeover ones marked so, every covers map naming the
|
||||||
check runner; and the check driver itself against the fake daemon: a
|
check runner; and the check driver itself against the fake daemon: a
|
||||||
start, a prompt answered, the result taken, the record read at version
|
start, the run polled to its end with no question asked, the result
|
||||||
1, and the settings a check may write put back as found."""
|
taken, the record read at version 1, and the settings read as before."""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
@@ -12,8 +17,8 @@ import helpers # noqa: E402
|
|||||||
from forgetest import catalog # noqa: E402
|
from forgetest import catalog # noqa: E402
|
||||||
from forgetest.runner import Context, Run # noqa: E402
|
from forgetest.runner import Context, Run # noqa: E402
|
||||||
|
|
||||||
IDS = ["commission.check-switches", "commission.check-sensors", "commission.check-airflow",
|
IDS = ["setup.check-switches", "setup.check-sensors", "setup.check-airflow",
|
||||||
"commission.check-cameras", "commission.check-motion", "commission.check-flow-verify"]
|
"setup.check-cameras", "setup.check-motion", "setup.check-flow-verify"]
|
||||||
|
|
||||||
|
|
||||||
class RegistrationTests(unittest.TestCase):
|
class RegistrationTests(unittest.TestCase):
|
||||||
@@ -25,11 +30,11 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
# bench fixture performs; the rest need no hands at all.
|
# bench fixture performs; the rest need no hands at all.
|
||||||
for tid in IDS:
|
for tid in IDS:
|
||||||
self.assertIn(tid, self.reg)
|
self.assertIn(tid, self.reg)
|
||||||
for tid in ("commission.check-switches", "commission.check-cameras"):
|
for tid in ("setup.check-switches", "setup.check-cameras"):
|
||||||
self.assertEqual(self.reg[tid].kind, "operator", tid)
|
self.assertEqual(self.reg[tid].kind, "operator", tid)
|
||||||
self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid)
|
self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid)
|
||||||
for tid in ("commission.check-sensors", "commission.check-airflow", "commission.check-motion",
|
for tid in ("setup.check-sensors", "setup.check-airflow", "setup.check-motion",
|
||||||
"commission.check-flow-verify"):
|
"setup.check-flow-verify"):
|
||||||
self.assertEqual(self.reg[tid].kind, "auto", tid)
|
self.assertEqual(self.reg[tid].kind, "auto", tid)
|
||||||
|
|
||||||
def test_no_check_asks_the_operator_to_prepare_anything(self):
|
def test_no_check_asks_the_operator_to_prepare_anything(self):
|
||||||
@@ -37,7 +42,7 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
|
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
|
||||||
|
|
||||||
def test_the_checks_that_take_the_machine_say_so(self):
|
def test_the_checks_that_take_the_machine_say_so(self):
|
||||||
for tid in ("commission.check-airflow", "commission.check-motion", "commission.check-flow-verify"):
|
for tid in ("setup.check-airflow", "setup.check-motion", "setup.check-flow-verify"):
|
||||||
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||||
|
|
||||||
def test_every_check_covers_the_runner_and_the_page(self):
|
def test_every_check_covers_the_runner_and_the_page(self):
|
||||||
@@ -62,9 +67,17 @@ class SensorsCheckTests(unittest.TestCase):
|
|||||||
"shots": {"lid": False, "head": False}}
|
"shots": {"lid": False, "head": False}}
|
||||||
self.versions = {}
|
self.versions = {}
|
||||||
self.answers = []
|
self.answers = []
|
||||||
|
self.polls = 0
|
||||||
|
|
||||||
def on_get(path, q):
|
def on_get(path, q):
|
||||||
if path == "/wiz/dark":
|
if path == "/wiz/dark":
|
||||||
|
# One poll sees the sampling; the next sees the result.
|
||||||
|
if self.dark["running"]:
|
||||||
|
self.polls += 1
|
||||||
|
if self.polls >= 2:
|
||||||
|
self.dark.update({"running": False, "prompt": None, "progress": 100,
|
||||||
|
"result": dict(self.RESULT)})
|
||||||
|
self.versions["sensors"] = 1
|
||||||
return 200, dict(self.dark)
|
return 200, dict(self.dark)
|
||||||
if path == "/wiz":
|
if path == "/wiz":
|
||||||
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
|
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
|
||||||
@@ -74,19 +87,11 @@ class SensorsCheckTests(unittest.TestCase):
|
|||||||
s = self.fake.state["settings"]
|
s = self.fake.state["settings"]
|
||||||
if path == "/wiz/sensors/start":
|
if path == "/wiz/sensors/start":
|
||||||
self.dark.update({"id": "sensors", "running": True, "phase": "sampling",
|
self.dark.update({"id": "sensors", "running": True, "phase": "sampling",
|
||||||
"prompt": {"seq": 7, "id": "room-temp", "kind": "number",
|
"prompt": None})
|
||||||
"text": "Optional: the room temperature.",
|
|
||||||
"options": ["Set", "Skip"]}})
|
|
||||||
return 200, {"started": True, "id": "sensors"}
|
return 200, {"started": True, "id": "sensors"}
|
||||||
if path == "/wiz/sensors/answer":
|
if path == "/wiz/sensors/answer":
|
||||||
self.answers.append(dict(form))
|
self.answers.append(dict(form))
|
||||||
if form.get("seq") != "7":
|
return 409, {"error": "no such prompt is open"}
|
||||||
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":
|
if path == "/settings":
|
||||||
for k, v in form.items():
|
for k, v in form.items():
|
||||||
if v == "":
|
if v == "":
|
||||||
@@ -101,15 +106,35 @@ class SensorsCheckTests(unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.fake.stop()
|
self.fake.stop()
|
||||||
|
|
||||||
def test_start_prompt_answer_result_and_restore(self):
|
def test_start_no_question_result_and_settings_untouched(self):
|
||||||
t = catalog.load_suite()["commission.check-sensors"]
|
t = catalog.load_suite()["setup.check-sensors"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
t.fn(Context(run, None, t))
|
t.fn(Context(run, None, t))
|
||||||
self.assertIn(("/wiz/sensors/start", {}), [(p, f) for p, f in self.fake.posts])
|
self.assertIn(("/wiz/sensors/start", {}), [(p, f) for p, f in self.fake.posts])
|
||||||
self.assertEqual(self.answers, [{"seq": "7", "value": "Skip"}])
|
self.assertEqual(self.answers, [])
|
||||||
self.assertEqual(run.evidence["result"]["laser_pgood"], 1)
|
self.assertEqual(run.evidence["result"]["laser_pgood"], 1)
|
||||||
self.assertEqual(run.evidence["settings_before"], {"cool_temp_offset_c": "0.5"})
|
self.assertEqual(run.evidence["settings_before"].get("cool_temp_offset_c"), "0.5")
|
||||||
self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5")
|
self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5")
|
||||||
|
self.assertNotIn("/settings", [p for p, _ in self.fake.posts])
|
||||||
|
|
||||||
|
def test_a_question_fails_the_check(self):
|
||||||
|
t = catalog.load_suite()["setup.check-sensors"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
real_get = self.fake.on_get
|
||||||
|
|
||||||
|
def on_get(path, q):
|
||||||
|
if path == "/wiz/dark" and self.dark["running"]:
|
||||||
|
d = dict(self.dark)
|
||||||
|
d["prompt"] = {"seq": 3, "id": "room-temp", "kind": "number", "text": "?",
|
||||||
|
"options": ["Set", "Skip"]}
|
||||||
|
return 200, d
|
||||||
|
return real_get(path, q)
|
||||||
|
self.fake.on_get = on_get
|
||||||
|
with self.assertRaises(Exception) as cm:
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
self.assertIn("asked a question", str(cm.exception))
|
||||||
|
self.assertEqual(self.answers, [])
|
||||||
|
self.assertIn(("/wiz/sensors/abort", {}), [(p, f) for p, f in self.fake.posts])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The setup's sheet in the catalog: one live test on the button with a
|
"""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
|
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
|
renderer, the sender, the page, and the driver's laser module; and the
|
||||||
@@ -14,10 +19,47 @@ sys.path.insert(0, os.path.dirname(__file__))
|
|||||||
import helpers # noqa: E402
|
import helpers # noqa: E402
|
||||||
from forgetest import catalog # noqa: E402
|
from forgetest import catalog # noqa: E402
|
||||||
from forgetest.runner import Context, Run # noqa: E402
|
from forgetest.runner import Context, Run # noqa: E402
|
||||||
from forgetest.suite import commission_sheet # noqa: E402
|
from forgetest.suite import setup_sheet # noqa: E402
|
||||||
|
|
||||||
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
|
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
|
||||||
|
# The focus card's program is read again after it ran: the served ladder
|
||||||
|
# must then lie in the window the card wrote.
|
||||||
|
PREVIEWS = ["sheet.frame", "laser.focus", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner",
|
||||||
|
"cooling.flow-load"]
|
||||||
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
|
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
|
||||||
|
SPM = 2.922 # the lens screw, half-steps per mm ($102)
|
||||||
|
EDGE_DEFAULT = 3.35
|
||||||
|
|
||||||
|
|
||||||
|
def lens_block(s):
|
||||||
|
"""The /status lens block as the daemon builds it from the settings: a
|
||||||
|
stop count from 1 to 40 stands, anything else is the fallback."""
|
||||||
|
def count(key, fallback):
|
||||||
|
try:
|
||||||
|
n = float(s.get(key, ""))
|
||||||
|
except ValueError:
|
||||||
|
return fallback, False
|
||||||
|
return (int(n), True) if 1 <= n <= 40 else (fallback, False)
|
||||||
|
try:
|
||||||
|
edge = float(s.get("lens_hall_edge_z_mm", ""))
|
||||||
|
except ValueError:
|
||||||
|
edge = EDGE_DEFAULT
|
||||||
|
below, a = count("lens_stop_below_steps", 10)
|
||||||
|
above, b = count("lens_stop_above_steps", 12)
|
||||||
|
return {"edge_z": round(edge, 2), "below": below, "above": above, "stops_found": a and b,
|
||||||
|
"reach_min": round(edge - below / SPM, 2), "reach_max": round(edge + above / SPM, 2)}
|
||||||
|
|
||||||
|
|
||||||
|
def ladder_z(edge, below, above):
|
||||||
|
"""The focus ladder the daemon builds: twelve whole half-steps from
|
||||||
|
the top of the window to the bottom."""
|
||||||
|
return [round(edge + round(above - (above + below) * i / 11) / SPM, 2) for i in range(12)]
|
||||||
|
|
||||||
|
|
||||||
|
# The window the focus card finds and writes before its controller starts.
|
||||||
|
STOPS = {"lens_stop_below_steps": "14", "lens_stop_above_steps": "20"}
|
||||||
|
FOCUS_WINDOW = {"below": 14, "above": 20, "reach_min": round(EDGE_DEFAULT - 14 / SPM, 2),
|
||||||
|
"reach_max": round(EDGE_DEFAULT + 20 / SPM, 2)}
|
||||||
|
|
||||||
# Each wizard as the daemon runs it: its prompts in order (id, kind,
|
# 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
|
# options) and the result it ends with. A "jog" prompt repeats until the
|
||||||
@@ -37,7 +79,9 @@ SCRIPT = {
|
|||||||
"steps_per_mm": 2.922, "max_height_mm": 13.81,
|
"steps_per_mm": 2.922, "max_height_mm": 13.81,
|
||||||
"focus_range_mm": {"min": 2.17, "max": 13.81},
|
"focus_range_mm": {"min": 2.17, "max": 13.81},
|
||||||
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
|
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
|
||||||
"contact_above": 22, "why": ""}, "emission": EMISSION}),
|
"contact_above": 22, "why": ""},
|
||||||
|
"window": dict(FOCUS_WINDOW), "ladder_z": ladder_z(EDGE_DEFAULT, 14, 20),
|
||||||
|
"emission": EMISSION}),
|
||||||
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
|
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
|
||||||
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
|
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
|
||||||
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
|
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
|
||||||
@@ -50,10 +94,11 @@ SCRIPT = {
|
|||||||
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
|
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
|
||||||
"k_cw": 2.78e-5, "emission": EMISSION}),
|
"k_cw": 2.78e-5, "emission": EMISSION}),
|
||||||
}
|
}
|
||||||
# What each card writes when it ends.
|
# What a card writes when it starts (the focus card's window, before its
|
||||||
|
# controller starts) and when it ends.
|
||||||
|
START_WRITES = {"laser.focus": dict(STOPS)}
|
||||||
WRITES = {
|
WRITES = {
|
||||||
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14",
|
"laser.focus": {"lens_hall_edge_z_mm": "6.96"},
|
||||||
"lens_stop_above_steps": "20"},
|
|
||||||
"laser.floor": {"laser_floor_density": "10"},
|
"laser.floor": {"laser_floor_density": "10"},
|
||||||
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
|
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
|
||||||
"laser.corner": {"laser_corner_gamma": "1.5"},
|
"laser.corner": {"laser_corner_gamma": "1.5"},
|
||||||
@@ -67,7 +112,7 @@ FOUND = {"lens_hall_edge_z_mm": "3.35", "lens_stop_below_steps": "10", "lens_sto
|
|||||||
|
|
||||||
class RegistrationTests(unittest.TestCase):
|
class RegistrationTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.t = catalog.load_suite()["commission.sheet"]
|
self.t = catalog.load_suite()["setup.sheet"]
|
||||||
|
|
||||||
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
|
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
|
||||||
t = self.t
|
t = self.t
|
||||||
@@ -78,12 +123,12 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
self.assertTrue(t.hands)
|
self.assertTrue(t.hands)
|
||||||
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
|
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
|
||||||
self.assertIsNone(getattr(t, "precheck", None))
|
self.assertIsNone(getattr(t, "precheck", None))
|
||||||
for want in ("commission.check-motion", "laser.emission-witness", "cooling.flow-verify"):
|
for want in ("setup.check-motion", "laser.emission-witness", "cooling.flow-verify"):
|
||||||
self.assertIn(want, t.requires)
|
self.assertIn(want, t.requires)
|
||||||
|
|
||||||
def test_no_other_sheet_test_remains(self):
|
def test_no_other_sheet_test_remains(self):
|
||||||
ids = [i for i in catalog.load_suite() if i.startswith("commission.sheet")]
|
ids = [i for i in catalog.load_suite() if i.startswith("setup.sheet")]
|
||||||
self.assertEqual(ids, ["commission.sheet"])
|
self.assertEqual(ids, ["setup.sheet"])
|
||||||
|
|
||||||
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
|
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
|
||||||
covers = set(self.t.covers)
|
covers = set(self.t.covers)
|
||||||
@@ -97,7 +142,7 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
text = " ".join(self.t.steps).lower()
|
text = " ".join(self.t.steps).lower()
|
||||||
self.assertIn("press the machine's button once", text)
|
self.assertIn("press the machine's button once", text)
|
||||||
self.assertNotIn("when the page", text)
|
self.assertNotIn("when the page", text)
|
||||||
self.assertEqual(len(commission_sheet.CARDS), 6)
|
self.assertEqual(len(setup_sheet.CARDS), 6)
|
||||||
|
|
||||||
|
|
||||||
class ScriptedDaemon:
|
class ScriptedDaemon:
|
||||||
@@ -115,9 +160,11 @@ class ScriptedDaemon:
|
|||||||
self.versions = {}
|
self.versions = {}
|
||||||
self.answers = {} # wid -> [values]
|
self.answers = {} # wid -> [values]
|
||||||
self.order = [] # the wizards as started
|
self.order = [] # the wizards as started
|
||||||
|
self.settings_at_start = {} # wid -> the settings as the wizard found them
|
||||||
self.previews = []
|
self.previews = []
|
||||||
self.queue = []
|
self.queue = []
|
||||||
self.seq = 0
|
self.seq = 0
|
||||||
|
self.stray_z = None # a Z the served program commands beyond the reach, when set
|
||||||
fake.on_get = self.on_get
|
fake.on_get = self.on_get
|
||||||
fake.on_post = self.on_post
|
fake.on_post = self.on_post
|
||||||
|
|
||||||
@@ -130,6 +177,10 @@ class ScriptedDaemon:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def on_get(self, path, q):
|
def on_get(self, path, q):
|
||||||
|
s = self.fake.state["settings"]
|
||||||
|
if path == "/status":
|
||||||
|
# the lens block follows the settings, as the daemon's does
|
||||||
|
return 200, dict(self.fake.state["status"], lens=lens_block(s))
|
||||||
if path == "/wiz/dark":
|
if path == "/wiz/dark":
|
||||||
return 200, dict(self.dark)
|
return 200, dict(self.dark)
|
||||||
if path == "/wiz":
|
if path == "/wiz":
|
||||||
@@ -140,8 +191,18 @@ class ScriptedDaemon:
|
|||||||
return 404, {"error": "no such card"}
|
return 404, {"error": "no such card"}
|
||||||
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
|
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
|
||||||
if path == "/wiz/sheet.gcode":
|
if path == "/wiz/sheet.gcode":
|
||||||
# the frame and the text are M4 at the mark dose, as the daemon serves them
|
# the frame and the text are M4 at the mark dose, as the daemon
|
||||||
return 200, b"; mock\nM4 S400\nG0 X60 Y42\nG1 X120 Y42 F3000\nM5\n", "text/plain"
|
# serves them; the head's Z is the edge, the tail goes back to
|
||||||
|
# it, and the focus card's ladder spans the window the settings
|
||||||
|
# hold now
|
||||||
|
lens = lens_block(s)
|
||||||
|
lines = ["; mock", "G0 Z%.2f" % lens["edge_z"], "M4 S400", "G0 X60 Y42", "G1 X120 Y42 F3000"]
|
||||||
|
if q.get("card") == "laser.focus":
|
||||||
|
lines += ["G0 Z%.2f" % z for z in ladder_z(lens["edge_z"], lens["below"], lens["above"])]
|
||||||
|
if self.stray_z is not None:
|
||||||
|
lines.append("G0 Z%.2f" % self.stray_z)
|
||||||
|
lines += ["M5", "G0 Z%.2f" % lens["edge_z"], "G0 X0 Y0", "M2"]
|
||||||
|
return 200, ("\n".join(lines) + "\n").encode(), "text/plain"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def on_post(self, path, form):
|
def on_post(self, path, form):
|
||||||
@@ -151,6 +212,8 @@ class ScriptedDaemon:
|
|||||||
if wid not in SCRIPT:
|
if wid not in SCRIPT:
|
||||||
return 404, {"error": "no such wizard"}
|
return 404, {"error": "no such wizard"}
|
||||||
self.order.append(wid)
|
self.order.append(wid)
|
||||||
|
self.settings_at_start[wid] = dict(s)
|
||||||
|
s.update(START_WRITES.get(wid, {}))
|
||||||
self.queue = list(SCRIPT[wid][0])
|
self.queue = list(SCRIPT[wid][0])
|
||||||
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
|
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
|
||||||
self.open_next()
|
self.open_next()
|
||||||
@@ -188,7 +251,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
self.fake.stop()
|
self.fake.stop()
|
||||||
|
|
||||||
def test_the_whole_sheet_on_one_piece(self):
|
def test_the_whole_sheet_on_one_piece(self):
|
||||||
t = catalog.load_suite()["commission.sheet"]
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
run.unattended = True # the ready gate passes; the arm cue is a notice
|
run.unattended = True # the ready gate passes; the arm cue is a notice
|
||||||
t.fn(Context(run, None, t))
|
t.fn(Context(run, None, t))
|
||||||
@@ -199,7 +262,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
|
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
|
||||||
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
|
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
|
||||||
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
|
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
|
||||||
self.assertEqual(d.previews, LIVE)
|
self.assertEqual(d.previews, PREVIEWS)
|
||||||
cards = run.evidence["cards"]
|
cards = run.evidence["cards"]
|
||||||
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
|
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
|
||||||
for wid in LIVE:
|
for wid in LIVE:
|
||||||
@@ -208,6 +271,24 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
self.assertNotIn("emission", run.evidence)
|
self.assertNotIn("emission", run.evidence)
|
||||||
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
|
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
|
||||||
self.assertEqual(run.evidence["settings_before"], FOUND)
|
self.assertEqual(run.evidence["settings_before"], FOUND)
|
||||||
|
# A fresh machine's run: the lens settings were gone before the
|
||||||
|
# frame, the frame's program ran in the fallback window, the focus
|
||||||
|
# card's window was in the settings when the floor card started,
|
||||||
|
# and the window check filed what it compared.
|
||||||
|
for k in setup_sheet.LENS_SETTINGS:
|
||||||
|
self.assertNotIn(k, d.settings_at_start["sheet.frame"], k)
|
||||||
|
self.assertEqual(run.evidence["lens_fresh"]["stops_found"], False)
|
||||||
|
self.assertEqual(run.evidence["program_z"]["sheet.frame"]["reach"], [-0.07, 7.46])
|
||||||
|
floor_start = d.settings_at_start["laser.floor"]
|
||||||
|
self.assertEqual({k: floor_start[k] for k in setup_sheet.LENS_SETTINGS},
|
||||||
|
{"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14", "lens_stop_above_steps": "20"})
|
||||||
|
fw = run.evidence["focus_window"]
|
||||||
|
self.assertEqual(fw["window"], FOCUS_WINDOW)
|
||||||
|
self.assertEqual(fw["settings"]["lens_stop_below_steps"], "14")
|
||||||
|
self.assertEqual(len(fw["ladder_z"]), 12)
|
||||||
|
self.assertEqual(fw["program_z_before"]["reach"], [-0.07, 7.46]) # the fallback ladder
|
||||||
|
self.assertEqual(fw["program_z_after"]["reach"], [2.17, 13.8]) # the written window at the new edge
|
||||||
|
self.assertEqual(len(fw["program_z_after"]["z"]), 14) # head, twelve lines, tail
|
||||||
for k, v in FOUND.items():
|
for k, v in FOUND.items():
|
||||||
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
|
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
|
||||||
|
|
||||||
@@ -218,7 +299,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
place_prompts, place_result = SCRIPT["sheet.place"]
|
place_prompts, place_result = SCRIPT["sheet.place"]
|
||||||
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=3.175))
|
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=3.175))
|
||||||
try:
|
try:
|
||||||
t = catalog.load_suite()["commission.sheet"]
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
run.unattended = True
|
run.unattended = True
|
||||||
t.fn(Context(run, None, t))
|
t.fn(Context(run, None, t))
|
||||||
@@ -234,7 +315,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
place_prompts, place_result = SCRIPT["sheet.place"]
|
place_prompts, place_result = SCRIPT["sheet.place"]
|
||||||
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=0.0))
|
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=0.0))
|
||||||
try:
|
try:
|
||||||
t = catalog.load_suite()["commission.sheet"]
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
run.unattended = True
|
run.unattended = True
|
||||||
with self.assertRaises(Failed) as cm:
|
with self.assertRaises(Failed) as cm:
|
||||||
@@ -250,7 +331,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
SCRIPT_DARK["emission"] = {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0}
|
SCRIPT_DARK["emission"] = {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0}
|
||||||
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], SCRIPT_DARK)
|
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], SCRIPT_DARK)
|
||||||
try:
|
try:
|
||||||
t = catalog.load_suite()["commission.sheet"]
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
run.unattended = True
|
run.unattended = True
|
||||||
with self.assertRaises(Failed):
|
with self.assertRaises(Failed):
|
||||||
@@ -261,6 +342,44 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
|
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
|
||||||
|
|
||||||
|
def test_a_focus_window_the_settings_do_not_hold_fails_the_run(self):
|
||||||
|
# The defect the bench found: the focus card burned its ladder over
|
||||||
|
# the stops it found while the controller's Z limit stood on the
|
||||||
|
# settings, which did not hold them. A daemon whose focus result
|
||||||
|
# names a window the settings do not hold fails the run before the
|
||||||
|
# floor card, and the settings still go back.
|
||||||
|
from forgetest.runner import Failed
|
||||||
|
saved = dict(START_WRITES["laser.focus"])
|
||||||
|
START_WRITES["laser.focus"] = {}
|
||||||
|
try:
|
||||||
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
run.unattended = True
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
self.assertIn("the settings hold", str(cm.exception))
|
||||||
|
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus"])
|
||||||
|
for k, v in FOUND.items():
|
||||||
|
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||||
|
finally:
|
||||||
|
START_WRITES["laser.focus"] = saved
|
||||||
|
|
||||||
|
def test_a_program_z_beyond_the_reach_fails_before_the_burn(self):
|
||||||
|
# A served program that commands a Z the lens does not reach is
|
||||||
|
# refused before the card starts: nothing burns.
|
||||||
|
from forgetest.runner import Failed
|
||||||
|
self.daemon.stray_z = 10.6
|
||||||
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
run.unattended = True
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
self.assertIn("outside the lens window", str(cm.exception))
|
||||||
|
self.assertIn("10.6", str(cm.exception))
|
||||||
|
self.assertEqual(self.daemon.order, ["sheet.place"])
|
||||||
|
for k, v in FOUND.items():
|
||||||
|
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
"""The commission.* suite on the host: the registration (ids, kinds, the
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""The setup.* suite on the host: the registration (ids, kinds, the
|
||||||
takeover tests, the operator tests' hands), the record builders, the
|
takeover tests, the operator tests' hands), the record builders, the
|
||||||
machine's name, the cookie parsing, the LED cue, the settle rule for a
|
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
|
gated supervisor, and the cloud-off surface test driven end to end
|
||||||
@@ -12,15 +17,15 @@ import unittest
|
|||||||
import helpers
|
import helpers
|
||||||
from forgetest import baseline, catalog
|
from forgetest import baseline, catalog
|
||||||
from forgetest.runner import Context, Run
|
from forgetest.runner import Context, Run
|
||||||
from forgetest.suite import commission
|
from forgetest.suite import setup
|
||||||
|
|
||||||
IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot",
|
IDS = ("setup.gate-blocks-controllers", "setup.override-until-reboot",
|
||||||
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes",
|
"setup.advisories-rehash", "setup.account-login", "setup.https-only-writes",
|
||||||
"commission.ssh-until-reboot", "commission.cloud-disabled-surface",
|
"setup.ssh-until-reboot", "setup.cloud-disabled-surface",
|
||||||
"commission.factory-return", "commission.machine-name", "commission.first-run-flow",
|
"setup.factory-return", "setup.machine-name", "setup.first-run-flow",
|
||||||
"commission.first-run-page", "commission.what-changed", "commission.record-export",
|
"setup.first-run-page", "setup.what-changed", "setup.record-export",
|
||||||
"commission.mirror")
|
"setup.mirror")
|
||||||
OPERATOR = ("commission.first-run-flow", "commission.first-run-page")
|
OPERATOR = ("setup.first-run-flow", "setup.first-run-page")
|
||||||
|
|
||||||
|
|
||||||
class RegistrationTests(unittest.TestCase):
|
class RegistrationTests(unittest.TestCase):
|
||||||
@@ -35,49 +40,49 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
for tid in IDS:
|
for tid in IDS:
|
||||||
if tid not in OPERATOR:
|
if tid not in OPERATOR:
|
||||||
self.assertEqual(kinds[tid], "auto", tid)
|
self.assertEqual(kinds[tid], "auto", tid)
|
||||||
self.assertNotIn("commission.root-ssh-refused", self.reg) # folded into ssh-until-reboot
|
self.assertNotIn("setup.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
|
self.assertNotIn("setup.wizard-first-run", self.reg) # split into the flow and the page
|
||||||
|
|
||||||
def test_the_record_swapping_tests_are_takeovers(self):
|
def test_the_record_swapping_tests_are_takeovers(self):
|
||||||
for tid in ("commission.gate-blocks-controllers", "commission.advisories-rehash",
|
for tid in ("setup.gate-blocks-controllers", "setup.advisories-rehash",
|
||||||
"commission.first-run-flow", "commission.first-run-page",
|
"setup.first-run-flow", "setup.first-run-page",
|
||||||
"commission.what-changed", "commission.mirror"):
|
"setup.what-changed", "setup.mirror"):
|
||||||
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||||
self.assertEqual(self.reg["commission.record-export"].hardware, "api")
|
self.assertEqual(self.reg["setup.record-export"].hardware, "api")
|
||||||
|
|
||||||
def test_the_lifecycle_tests_cover_their_files(self):
|
def test_the_lifecycle_tests_cover_their_files(self):
|
||||||
changed = set(self.reg["commission.what-changed"].covers)
|
changed = set(self.reg["setup.what-changed"].covers)
|
||||||
export = set(self.reg["commission.record-export"].covers)
|
export = set(self.reg["setup.record-export"].covers)
|
||||||
mirror = set(self.reg["commission.mirror"].covers)
|
mirror = set(self.reg["setup.mirror"].covers)
|
||||||
self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
|
self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
|
||||||
self.assertIn(("forgectrl", "src/recordhtml.*"), export)
|
self.assertIn(("forgectrl", "src/recordhtml.*"), export)
|
||||||
self.assertIn(("forgectrl", "src/logs.*"), export)
|
self.assertIn(("forgectrl", "src/logs.*"), export)
|
||||||
self.assertIn(("forgectrl", "src/wizdark.*"), mirror)
|
self.assertIn(("forgectrl", "src/wizdark.*"), mirror)
|
||||||
self.assertIn("commission.check-sensors", self.reg["commission.mirror"].requires)
|
self.assertIn("setup.check-sensors", self.reg["setup.mirror"].requires)
|
||||||
|
|
||||||
def test_flag_of_reads_the_status_document(self):
|
def test_flag_of_reads_the_status_document(self):
|
||||||
w = {"required": ["airflow", {"id": "laser.floor", "reason": "the tube was replaced"}],
|
w = {"required": ["airflow", {"id": "laser.floor", "reason": "the tube was replaced"}],
|
||||||
"recommended": [{"id": "laser.focus", "reason": "the tray 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(setup.flag_of(w, "required", "laser.floor"), "the tube was replaced")
|
||||||
self.assertEqual(commission.flag_of(w, "recommended", "laser.focus"), "the tray was replaced")
|
self.assertEqual(setup.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(setup.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(setup.flag_of(w, "required", "laser.focus"))
|
||||||
self.assertIsNone(commission.flag_of({}, "recommended", "x"))
|
self.assertIsNone(setup.flag_of({}, "recommended", "x"))
|
||||||
|
|
||||||
def test_the_first_run_flow_runs_unattended_with_the_fixture_and_the_page_walk_does_not(self):
|
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"]
|
flow = self.reg["setup.first-run-flow"]
|
||||||
self.assertEqual(list(flow.actions), ["button"])
|
self.assertEqual(list(flow.actions), ["button"])
|
||||||
self.assertFalse(flow.hands)
|
self.assertFalse(flow.hands)
|
||||||
self.assertTrue(flow.fixture_runnable(("button", "lid", "interlock")))
|
self.assertTrue(flow.fixture_runnable(("button", "lid", "interlock")))
|
||||||
page = self.reg["commission.first-run-page"]
|
page = self.reg["setup.first-run-page"]
|
||||||
self.assertTrue(page.hands)
|
self.assertTrue(page.hands)
|
||||||
self.assertFalse(page.fixture_runnable(("button", "lid", "interlock")))
|
self.assertFalse(page.fixture_runnable(("button", "lid", "interlock")))
|
||||||
self.assertIn("commission.first-run-flow", page.requires)
|
self.assertIn("setup.first-run-flow", page.requires)
|
||||||
|
|
||||||
def test_the_first_run_split_keeps_the_backend_and_the_page_apart(self):
|
def test_the_first_run_split_keeps_the_backend_and_the_page_apart(self):
|
||||||
flow = set(self.reg["commission.first-run-flow"].covers)
|
flow = set(self.reg["setup.first-run-flow"].covers)
|
||||||
page = set(self.reg["commission.first-run-page"].covers)
|
page = set(self.reg["setup.first-run-page"].covers)
|
||||||
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"),
|
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
|
||||||
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
|
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
|
||||||
self.assertIn(want, flow, want)
|
self.assertIn(want, flow, want)
|
||||||
self.assertNotIn(want, page, want)
|
self.assertNotIn(want, page, want)
|
||||||
@@ -85,17 +90,17 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
self.assertIn(want, page, want)
|
self.assertIn(want, page, want)
|
||||||
|
|
||||||
def test_the_only_attended_tests_need_a_workstation_or_the_sheet(self):
|
def test_the_only_attended_tests_need_a_workstation_or_the_sheet(self):
|
||||||
# With the bench actuator up, three commissioning tests keep a
|
# With the bench actuator up, three setup tests keep a
|
||||||
# person: the page walk, the print from the Glowforge app, and
|
# person: the page walk, the print from the Glowforge app, and
|
||||||
# the sheet. Everything else runs from the queue.
|
# the sheet. Everything else runs from the queue.
|
||||||
attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("commission.")
|
attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("setup.")
|
||||||
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
|
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
|
||||||
self.assertEqual(attended, ["commission.cloud-header-capture", "commission.first-run-page",
|
self.assertEqual(attended, ["setup.cloud-header-capture", "setup.first-run-page",
|
||||||
"commission.sheet"])
|
"setup.sheet"])
|
||||||
|
|
||||||
def test_the_factory_return_never_runs_the_return(self):
|
def test_the_factory_return_never_runs_the_return(self):
|
||||||
import inspect
|
import inspect
|
||||||
t = self.reg["commission.factory-return"]
|
t = self.reg["setup.factory-return"]
|
||||||
src = inspect.getsource(t.fn)
|
src = inspect.getsource(t.fn)
|
||||||
self.assertNotIn('"confirm": "1"', src) # the only argument that starts the return
|
self.assertNotIn('"confirm": "1"', src) # the only argument that starts the return
|
||||||
self.assertIn('"confirm": "0"', src)
|
self.assertIn('"confirm": "0"', src)
|
||||||
@@ -103,15 +108,15 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
self.assertFalse(t.hands)
|
self.assertFalse(t.hands)
|
||||||
|
|
||||||
def test_the_machine_name_covers_nothing_by_design(self):
|
def test_the_machine_name_covers_nothing_by_design(self):
|
||||||
self.assertEqual(self.reg["commission.machine-name"].covers, ())
|
self.assertEqual(self.reg["setup.machine-name"].covers, ())
|
||||||
|
|
||||||
def test_the_login_test_makes_its_own_account(self):
|
def test_the_login_test_makes_its_own_account(self):
|
||||||
# No bench credentials, no precheck: the test installs a temporary
|
# No bench credentials, no precheck: the test installs a temporary
|
||||||
# account under a takeover and restores the real one.
|
# account under a takeover and restores the real one.
|
||||||
t = self.reg["commission.account-login"]
|
t = self.reg["setup.account-login"]
|
||||||
self.assertIsNone(getattr(t, "precheck", None))
|
self.assertIsNone(getattr(t, "precheck", None))
|
||||||
self.assertEqual(t.hardware, "takeover")
|
self.assertEqual(t.hardware, "takeover")
|
||||||
with open(commission.__file__) as f:
|
with open(setup.__file__) as f:
|
||||||
src = f.read()
|
src = f.read()
|
||||||
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
|
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
|
||||||
self.assertNotIn(k, src)
|
self.assertNotIn(k, src)
|
||||||
@@ -124,8 +129,8 @@ class RecordTests(unittest.TestCase):
|
|||||||
"wizards": [{"id": "advisories", "version": 1}, {"id": "account", "version": 1},
|
"wizards": [{"id": "advisories", "version": 1}, {"id": "account", "version": 1},
|
||||||
{"id": "machine", "version": 2}]}
|
{"id": "machine", "version": 2}]}
|
||||||
|
|
||||||
def test_complete_record_counts_as_commissioned(self):
|
def test_complete_record_counts_as_set_up(self):
|
||||||
rec = commission.complete_record(self.STATUS)
|
rec = setup.complete_record(self.STATUS)
|
||||||
self.assertEqual(rec["schema"], 1)
|
self.assertEqual(rec["schema"], 1)
|
||||||
self.assertEqual(rec["advisories"]["safety-and-risk"]["hash"], "a" * 64)
|
self.assertEqual(rec["advisories"]["safety-and-risk"]["hash"], "a" * 64)
|
||||||
self.assertEqual(rec["advisories"]["safety-and-risk"]["method"], "typed")
|
self.assertEqual(rec["advisories"]["safety-and-risk"]["method"], "typed")
|
||||||
@@ -139,16 +144,16 @@ class RecordTests(unittest.TestCase):
|
|||||||
def test_complete_record_keeps_the_base_account_and_machine(self):
|
def test_complete_record_keeps_the_base_account_and_machine(self):
|
||||||
base = {"account": {"name": "owner", "uid": 1000, "created": "x"}, "machine": {"model": "pro"},
|
base = {"account": {"name": "owner", "uid": 1000, "created": "x"}, "machine": {"model": "pro"},
|
||||||
"sheet_id": "KKKKK-LLLLL", "created": "then"}
|
"sheet_id": "KKKKK-LLLLL", "created": "then"}
|
||||||
rec = commission.complete_record(self.STATUS, base)
|
rec = setup.complete_record(self.STATUS, base)
|
||||||
self.assertEqual(rec["account"]["name"], "owner")
|
self.assertEqual(rec["account"]["name"], "owner")
|
||||||
self.assertEqual(rec["machine"], {"model": "pro"})
|
self.assertEqual(rec["machine"], {"model": "pro"})
|
||||||
self.assertEqual(rec["sheet_id"], "KKKKK-LLLLL")
|
self.assertEqual(rec["sheet_id"], "KKKKK-LLLLL")
|
||||||
self.assertEqual(rec["created"], "then")
|
self.assertEqual(rec["created"], "then")
|
||||||
|
|
||||||
def test_without_wizards_keeps_consent_and_account(self):
|
def test_without_wizards_keeps_consent_and_account(self):
|
||||||
rec = commission.complete_record(self.STATUS)
|
rec = setup.complete_record(self.STATUS)
|
||||||
rec["flags"] = {"machine": {"level": "required", "reason": "x"}}
|
rec["flags"] = {"machine": {"level": "required", "reason": "x"}}
|
||||||
out = commission.record_without_wizards(rec)
|
out = setup.record_without_wizards(rec)
|
||||||
self.assertEqual(out["wizards"], {})
|
self.assertEqual(out["wizards"], {})
|
||||||
self.assertEqual(out["flags"], {})
|
self.assertEqual(out["flags"], {})
|
||||||
self.assertEqual(out["advisories"], rec["advisories"])
|
self.assertEqual(out["advisories"], rec["advisories"])
|
||||||
@@ -158,14 +163,14 @@ class RecordTests(unittest.TestCase):
|
|||||||
def test_write_file_and_remove(self):
|
def test_write_file_and_remove(self):
|
||||||
tmp = tempfile.mkdtemp(prefix="forgetest-comm-")
|
tmp = tempfile.mkdtemp(prefix="forgetest-comm-")
|
||||||
try:
|
try:
|
||||||
p = os.path.join(tmp, "sub", "commissioning.json")
|
p = os.path.join(tmp, "sub", "setup.json")
|
||||||
commission.write_file(p, b"{}\n")
|
setup.write_file(p, b"{}\n")
|
||||||
self.assertEqual(commission.read_file(p), b"{}\n")
|
self.assertEqual(setup.read_file(p), b"{}\n")
|
||||||
if os.name == "posix": # a mode means nothing on a Windows host
|
if os.name == "posix": # a mode means nothing on a Windows host
|
||||||
self.assertEqual(oct(os.stat(p).st_mode & 0o777), oct(0o600))
|
self.assertEqual(oct(os.stat(p).st_mode & 0o777), oct(0o600))
|
||||||
commission.write_file(p, None)
|
setup.write_file(p, None)
|
||||||
self.assertIsNone(commission.read_file(p))
|
self.assertIsNone(setup.read_file(p))
|
||||||
commission.write_file(p, None) # a second remove is silent
|
setup.write_file(p, None) # a second remove is silent
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmp, ignore_errors=True)
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
@@ -173,10 +178,10 @@ class RecordTests(unittest.TestCase):
|
|||||||
os.environ["FORGECTRL_DATA_DIR"] = "/tmp/ffdata"
|
os.environ["FORGECTRL_DATA_DIR"] = "/tmp/ffdata"
|
||||||
os.environ["GF_RUN_DIR"] = "/tmp/ffrun"
|
os.environ["GF_RUN_DIR"] = "/tmp/ffrun"
|
||||||
try:
|
try:
|
||||||
self.assertEqual(commission.record_path(), "/tmp/ffdata/commissioning.json")
|
self.assertEqual(setup.record_path(), "/tmp/ffdata/setup.json")
|
||||||
self.assertEqual(commission.users_path(), "/tmp/ffdata/users")
|
self.assertEqual(setup.users_path(), "/tmp/ffdata/users")
|
||||||
self.assertEqual(commission.override_path(), "/tmp/ffrun/commissioning-override")
|
self.assertEqual(setup.override_path(), "/tmp/ffrun/setup-override")
|
||||||
self.assertEqual(commission.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
|
self.assertEqual(setup.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
|
||||||
finally:
|
finally:
|
||||||
os.environ.pop("FORGECTRL_DATA_DIR", None)
|
os.environ.pop("FORGECTRL_DATA_DIR", None)
|
||||||
os.environ.pop("GF_RUN_DIR", None)
|
os.environ.pop("GF_RUN_DIR", None)
|
||||||
@@ -188,14 +193,14 @@ class MachineNameTests(unittest.TestCase):
|
|||||||
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||||
|
|
||||||
def _net(self, **devs):
|
def _net(self, **devs):
|
||||||
"""A /sys/class/net tree; commission.mac_suffix reads it through
|
"""A /sys/class/net tree; setup.mac_suffix reads it through
|
||||||
commission.read_file, which takes an absolute path."""
|
setup.read_file, which takes an absolute path."""
|
||||||
for dev, mac in devs.items():
|
for dev, mac in devs.items():
|
||||||
d = os.path.join(self.root, dev)
|
d = os.path.join(self.root, dev)
|
||||||
os.makedirs(d)
|
os.makedirs(d)
|
||||||
with open(os.path.join(d, "address"), "w") as f:
|
with open(os.path.join(d, "address"), "w") as f:
|
||||||
f.write(mac + "\n")
|
f.write(mac + "\n")
|
||||||
real = commission.read_file
|
real = setup.read_file
|
||||||
|
|
||||||
def read(path):
|
def read(path):
|
||||||
head = "/sys/class/net/"
|
head = "/sys/class/net/"
|
||||||
@@ -203,52 +208,52 @@ class MachineNameTests(unittest.TestCase):
|
|||||||
return real(os.path.join(self.root, path[len(head):]))
|
return real(os.path.join(self.root, path[len(head):]))
|
||||||
return real(path)
|
return real(path)
|
||||||
|
|
||||||
commission.read_file = read
|
setup.read_file = read
|
||||||
self.addCleanup(setattr, commission, "read_file", real)
|
self.addCleanup(setattr, setup, "read_file", real)
|
||||||
|
|
||||||
def test_the_wifi_address_names_the_machine(self):
|
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._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
|
||||||
self.assertEqual(commission.mac_suffix(), "b00a")
|
self.assertEqual(setup.mac_suffix(), "b00a")
|
||||||
|
|
||||||
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
|
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
|
||||||
self._net(eth0="00:11:22:33:44:55")
|
self._net(eth0="00:11:22:33:44:55")
|
||||||
self.assertEqual(commission.mac_suffix(), "4455")
|
self.assertEqual(setup.mac_suffix(), "4455")
|
||||||
|
|
||||||
def test_an_unread_address_is_no_address(self):
|
def test_an_unread_address_is_no_address(self):
|
||||||
self._net(wlan0="00:00:00:00:00:00")
|
self._net(wlan0="00:00:00:00:00:00")
|
||||||
self.assertEqual(commission.mac_suffix(), "")
|
self.assertEqual(setup.mac_suffix(), "")
|
||||||
|
|
||||||
def test_no_interface_is_no_address(self):
|
def test_no_interface_is_no_address(self):
|
||||||
self._net()
|
self._net()
|
||||||
self.assertEqual(commission.mac_suffix(), "")
|
self.assertEqual(setup.mac_suffix(), "")
|
||||||
|
|
||||||
|
|
||||||
class SmallHelpersTests(unittest.TestCase):
|
class SmallHelpersTests(unittest.TestCase):
|
||||||
def test_cookie_parsing(self):
|
def test_cookie_parsing(self):
|
||||||
sid = "ab" * 32
|
sid = "ab" * 32
|
||||||
value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid
|
value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid
|
||||||
self.assertEqual(commission.session_from_cookie(value), sid)
|
self.assertEqual(setup.session_from_cookie(value), sid)
|
||||||
self.assertEqual(commission.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
|
self.assertEqual(setup.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
|
||||||
self.assertIsNone(commission.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
|
self.assertIsNone(setup.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
|
||||||
self.assertIsNone(commission.session_from_cookie(""))
|
self.assertIsNone(setup.session_from_cookie(""))
|
||||||
|
|
||||||
def test_the_teal_breathe_cue(self):
|
def test_the_teal_breathe_cue(self):
|
||||||
self.assertTrue(commission.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
|
self.assertTrue(setup.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
|
||||||
"pulse_off": [0, 1400, 1400]}))
|
"pulse_off": [0, 1400, 1400]}))
|
||||||
self.assertFalse(commission.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0],
|
self.assertFalse(setup.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0],
|
||||||
"pulse_off": [0, 0, 0]})) # solid green
|
"pulse_off": [0, 0, 0]})) # solid green
|
||||||
self.assertFalse(commission.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800],
|
self.assertFalse(setup.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800],
|
||||||
"pulse_off": [1800] * 3})) # white
|
"pulse_off": [1800] * 3})) # white
|
||||||
self.assertFalse(commission.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
|
self.assertFalse(setup.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
|
||||||
self.assertFalse(commission.breathes_teal({}))
|
self.assertFalse(setup.breathes_teal({}))
|
||||||
|
|
||||||
def test_decode(self):
|
def test_decode(self):
|
||||||
self.assertEqual(commission.decode(b'{"a": 1}'), {"a": 1})
|
self.assertEqual(setup.decode(b'{"a": 1}'), {"a": 1})
|
||||||
self.assertEqual(commission.decode(b"cloud mode is not enabled"), "cloud mode is not enabled")
|
self.assertEqual(setup.decode(b"cloud mode is not enabled"), "cloud mode is not enabled")
|
||||||
|
|
||||||
|
|
||||||
class GatedSettleTests(unittest.TestCase):
|
class GatedSettleTests(unittest.TestCase):
|
||||||
"""A supervisor that reports the commissioning gate closed is settled:
|
"""A supervisor that reports the setup gate closed is settled:
|
||||||
it spawns nothing until the gate opens, so a takeover that installs
|
it spawns nothing until the gate opens, so a takeover that installs
|
||||||
a gating record must not wait the whole settle timeout for it."""
|
a gating record must not wait the whole settle timeout for it."""
|
||||||
|
|
||||||
@@ -261,7 +266,7 @@ class GatedSettleTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_gated_returns_at_once(self):
|
def test_gated_returns_at_once(self):
|
||||||
self.fake.state["mode"] = {"mode": "grbl", "controller": "gated", "pid": 0, "motion": "unverified",
|
self.fake.state["mode"] = {"mode": "grbl", "controller": "gated", "pid": 0, "motion": "unverified",
|
||||||
"gated": True, "why": "commissioning required: machine"}
|
"gated": True, "why": "setup required: machine"}
|
||||||
lines = []
|
lines = []
|
||||||
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
|
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
|
||||||
self.assertEqual(body["controller"], "gated")
|
self.assertEqual(body["controller"], "gated")
|
||||||
@@ -284,7 +289,7 @@ class FirstRunSeedTests(unittest.TestCase):
|
|||||||
"advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"},
|
"advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"},
|
||||||
"account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True},
|
"account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True},
|
||||||
"wizards": {"advisories": {"version": 1}, "switches": {"version": 1, "result": {"lid": True}}}}
|
"wizards": {"advisories": {"version": 1}, "switches": {"version": 1, "result": {"lid": True}}}}
|
||||||
rec = commission.first_run_record(self.STATUS, base)
|
rec = setup.first_run_record(self.STATUS, base)
|
||||||
self.assertNotIn("completed", rec)
|
self.assertNotIn("completed", rec)
|
||||||
self.assertNotIn("acceptance", rec)
|
self.assertNotIn("acceptance", rec)
|
||||||
self.assertNotIn("account", rec)
|
self.assertNotIn("account", rec)
|
||||||
@@ -296,7 +301,7 @@ class FirstRunSeedTests(unittest.TestCase):
|
|||||||
self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version
|
self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version
|
||||||
|
|
||||||
def test_without_a_base_record(self):
|
def test_without_a_base_record(self):
|
||||||
rec = commission.first_run_record(self.STATUS, None)
|
rec = setup.first_run_record(self.STATUS, None)
|
||||||
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
|
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
|
||||||
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
|
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
|
||||||
|
|
||||||
@@ -312,7 +317,7 @@ class SshdPolicyTests(unittest.TestCase):
|
|||||||
real = hw.run
|
real = hw.run
|
||||||
hw.run = fake_run
|
hw.run = fake_run
|
||||||
try:
|
try:
|
||||||
self.assertEqual(commission.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
|
self.assertEqual(setup.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
|
||||||
"permitemptypasswords": "no"})
|
"permitemptypasswords": "no"})
|
||||||
finally:
|
finally:
|
||||||
hw.run = real
|
hw.run = real
|
||||||
@@ -323,7 +328,7 @@ class SshdPolicyTests(unittest.TestCase):
|
|||||||
real = hw.run
|
real = hw.run
|
||||||
hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available")
|
hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available")
|
||||||
try:
|
try:
|
||||||
self.assertEqual(commission.sshd_policy(), {"error": "sshd: no hostkeys available"})
|
self.assertEqual(setup.sshd_policy(), {"error": "sshd: no hostkeys available"})
|
||||||
finally:
|
finally:
|
||||||
hw.run = real
|
hw.run = real
|
||||||
|
|
||||||
@@ -375,7 +380,7 @@ class CloudDisabledSurfaceTests(unittest.TestCase):
|
|||||||
self.fake.stop()
|
self.fake.stop()
|
||||||
|
|
||||||
def run_test(self):
|
def run_test(self):
|
||||||
t = catalog.load_suite()["commission.cloud-disabled-surface"]
|
t = catalog.load_suite()["setup.cloud-disabled-surface"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
t.fn(Context(run, None, t))
|
t.fn(Context(run, None, t))
|
||||||
return run
|
return run
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""scripts/source-bundle.py - the source that a release publishes.
|
"""scripts/source-bundle.py - the source that a release publishes.
|
||||||
|
|
||||||
The bundle must hold the source of every recipe of the image whose license
|
The bundle must hold the source of every recipe of the image whose license
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""scripts/manifest-from-tree.py - the workstation/CI mirror of the image
|
"""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
|
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
|
content hash leaves the pin files out (a component bump is not a platform
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ overrides:
|
|||||||
meta-freescale-distro:
|
meta-freescale-distro:
|
||||||
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
||||||
meta-openglow:
|
meta-openglow:
|
||||||
commit: ced2af274ba270fd8fc9a6601bbd3fc6247110a6
|
commit: 71fad52e8ff5695bce3b9c6bc8530a1468426819
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# forgefirm-image-manifest.bbclass - assemble /etc/forgefirm-manifest.json
|
# forgefirm-image-manifest.bbclass - assemble /etc/forgefirm-manifest.json
|
||||||
#
|
#
|
||||||
# Inherited by the ForgeFIRM images. At rootfs postprocess it collects the
|
# Inherited by the ForgeFIRM images. At rootfs postprocess it collects the
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# forgefirm-manifest.bbclass - source fingerprint of one ForgeFIRM component
|
# forgefirm-manifest.bbclass - source fingerprint of one ForgeFIRM component
|
||||||
#
|
#
|
||||||
# A component recipe that inherits this class records what source it was
|
# A component recipe that inherits this class records what source it was
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
require conf/distro/include/fslc-base.inc
|
require conf/distro/include/fslc-base.inc
|
||||||
|
|
||||||
DISTRO = "forgefirm"
|
DISTRO = "forgefirm"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# We have a conf and classes directory, add to BBPATH
|
# We have a conf and classes directory, add to BBPATH
|
||||||
BBPATH .= ":${LAYERDIR}"
|
BBPATH .= ":${LAYERDIR}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# NXP distributes the i.MX VPU/EPDC firmware blobs under its firmware EULA
|
# NXP distributes the i.MX VPU/EPDC firmware blobs under its firmware EULA
|
||||||
# (accepted with ACCEPT_FSL_EULA), so an image carrying those blobs has to
|
# (accepted with ACCEPT_FSL_EULA), so an image carrying those blobs has to
|
||||||
# carry the license text with them.
|
# carry the license text with them.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# ForgeFIRM SSH policy, set in the installed files so the release image
|
# ForgeFIRM SSH policy, set in the installed files so the release image
|
||||||
# carries it as built:
|
# carries it as built:
|
||||||
# PermitRootLogin no root logs in at the serial console only
|
# PermitRootLogin no root logs in at the serial console only
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# ForgeFIRM image manifest: source fingerprint of the hardware library
|
# ForgeFIRM image manifest: source fingerprint of the hardware library
|
||||||
# (forgefirm-manifest.bbclass in this layer).
|
# (forgefirm-manifest.bbclass in this layer).
|
||||||
inherit forgefirm-manifest
|
inherit forgefirm-manifest
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# ForgeFIRM image manifest: source fingerprint of the cloud-mode service
|
# ForgeFIRM image manifest: source fingerprint of the cloud-mode service
|
||||||
# layer (forgefirm-manifest.bbclass in this layer).
|
# layer (forgefirm-manifest.bbclass in this layer).
|
||||||
inherit forgefirm-manifest
|
inherit forgefirm-manifest
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# urllib3's optional pyOpenSSL/cryptography backend is not used here: the cloud
|
# urllib3's optional pyOpenSSL/cryptography backend is not used here: the cloud
|
||||||
# client and the machine glue talk TLS through the standard library's ssl
|
# client and the machine glue talk TLS through the standard library's ssl
|
||||||
# module (nothing imports OpenSSL or cryptography). Dropping the two runtime
|
# module (nothing imports OpenSSL or cryptography). Dropping the two runtime
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
SUMMARY = "Configurable embedded Linux firmware update creator and runner"
|
SUMMARY = "Configurable embedded Linux firmware update creator and runner"
|
||||||
DESCRIPTION = "Applies and creates signed .fw firmware archives. ForgeFIRM \
|
DESCRIPTION = "Applies and creates signed .fw firmware archives. ForgeFIRM \
|
||||||
uses the factory's own update format: fwup applies ForgeFIRM upgrades and \
|
uses the factory's own update format: fwup applies ForgeFIRM upgrades and \
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Potluck with different functions for C programs (babelouest base library)"
|
DESCRIPTION = "Potluck with different functions for C programs (babelouest base library)"
|
||||||
HOMEPAGE = "https://github.com/babelouest/orcania"
|
HOMEPAGE = "https://github.com/babelouest/orcania"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# /etc/rsyslog.conf - ForgeFIRM
|
# /etc/rsyslog.conf - ForgeFIRM
|
||||||
#
|
#
|
||||||
# rsyslog is the only log writer on the machine. Every ForgeFIRM process
|
# rsyslog is the only log writer on the machine. Every ForgeFIRM process
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# rsyslog is the ForgeFIRM system logger: the only log writer, one
|
# rsyslog is the ForgeFIRM system logger: the only log writer, one
|
||||||
# directory per logger under /data/log/forgefirm. This appends the
|
# directory per logger under /data/log/forgefirm. This appends the
|
||||||
# ForgeFIRM /etc/rsyslog.conf (inputs, line format, and the include of
|
# ForgeFIRM /etc/rsyslog.conf (inputs, line format, and the include of
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Web Framework to build REST APIs, Webservices or any HTTP endpoint in C"
|
DESCRIPTION = "Web Framework to build REST APIs, Webservices or any HTTP endpoint in C"
|
||||||
HOMEPAGE = "https://github.com/babelouest/ulfius"
|
HOMEPAGE = "https://github.com/babelouest/ulfius"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Logging library for C applications (babelouest)"
|
DESCRIPTION = "Logging library for C applications (babelouest)"
|
||||||
HOMEPAGE = "https://github.com/babelouest/yder"
|
HOMEPAGE = "https://github.com/babelouest/yder"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
SUMMARY = "Boot-slot selection and inventory for Glowforge factory hardware"
|
SUMMARY = "Boot-slot selection and inventory for Glowforge factory hardware"
|
||||||
DESCRIPTION = "Inventories the bootable partitions (ffboot -l) and switches \
|
DESCRIPTION = "Inventories the bootable partitions (ffboot -l) and switches \
|
||||||
the boot target by rewriting the saved U-Boot environment with read-back \
|
the boot target by rewriting the saved U-Boot environment with read-back \
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# forgectrl pin. Bump deliberately after pushing forgectrl changes; keep
|
# forgectrl pin. Bump deliberately after pushing forgectrl changes; keep
|
||||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||||
# layer content hash because the component entry already identifies the
|
# layer content hash because the component entry already identifies the
|
||||||
# pinned source (forgefirm-image-manifest.bbclass).
|
# pinned source (forgefirm-image-manifest.bbclass).
|
||||||
SRCREV = "a9e7073b2325c8c9d89d490fe6d067abfac22da5"
|
SRCREV = "0dae758cb93e1461034aaa58f4b9fc22edc68367"
|
||||||
PV = "0.1.17"
|
PV = "0.1.27"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "System Control Daemon for ForgeFIRM powered Glowforge"
|
DESCRIPTION = "System Control Daemon for ForgeFIRM powered Glowforge"
|
||||||
HOMEPAGE = "https://github.com/openglow-org/forgectrl"
|
HOMEPAGE = "https://github.com/openglow-org/forgectrl"
|
||||||
|
|
||||||
LICENSE = "MIT"
|
LICENSE = "MIT"
|
||||||
LIC_FILES_CHKSUM = "file://LICENSE;md5=fe9dfc6caad62544b4b1d0fa55c4b92f"
|
LIC_FILES_CHKSUM = "file://LICENSE;md5=785c97b59b518e7ad943cd9e8b15fc97"
|
||||||
|
|
||||||
PE = "1"
|
PE = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# forgefirm-app pin (python3-gfhardware repository, forgefirm-app/ tree).
|
# forgefirm-app pin (python3-gfhardware repository, forgefirm-app/ tree).
|
||||||
# Bump deliberately (AUTOREV is not reproducible); keep only SRCREV and PV
|
# Bump deliberately (AUTOREV is not reproducible); keep only SRCREV and PV
|
||||||
# here - the image manifest leaves *-pin.inc out of the layer content hash
|
# here - the image manifest leaves *-pin.inc out of the layer content hash
|
||||||
# because the component entry already identifies the pinned source
|
# because the component entry already identifies the pinned source
|
||||||
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
||||||
# meta-glowforge-bsp pins the same repository; move both together.
|
# meta-glowforge-bsp pins the same repository; move both together.
|
||||||
SRCREV = "56f134a1e6393a2096c16e8226027fbbd38d79c7"
|
SRCREV = "6cc4f45d311ab451c7d72dee009103e55a749675"
|
||||||
|
|
||||||
# Bump PV with every SRCREV move: the hash-derived package version is not
|
# Bump PV with every SRCREV move: the hash-derived package version is not
|
||||||
# monotonic on its own and buildhistory QA fails the build when it sorts
|
# monotonic on its own and buildhistory QA fails the build when it sorts
|
||||||
# backwards.
|
# backwards.
|
||||||
PV = "0.1.28+git"
|
PV = "0.1.30+git"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# Shared fetch for the ForgeFIRM web-service apps (gfhome, python3-ffmachine,
|
# Shared fetch for the ForgeFIRM web-service apps (gfhome, python3-ffmachine,
|
||||||
# gfcloud). Their sources live in the forgefirm-app/ directory of the
|
# gfcloud). Their sources live in the forgefirm-app/ directory of the
|
||||||
# python3-gfhardware repository.
|
# python3-gfhardware repository.
|
||||||
@@ -7,7 +9,7 @@ HOMEPAGE = "https://github.com/openglow-org/python3-gfhardware"
|
|||||||
# repository's LGPL component (the Bayer decoder compiled into
|
# repository's LGPL component (the Bayer decoder compiled into
|
||||||
# gfhardware._cam) is not part of them; python3-gfhardware declares it.
|
# gfhardware._cam) is not part of them; python3-gfhardware declares it.
|
||||||
LICENSE = "MIT"
|
LICENSE = "MIT"
|
||||||
LIC_FILES_CHKSUM = "file://LICENSE;md5=7029670f29302d9352647c6c89614bc2"
|
LIC_FILES_CHKSUM = "file://LICENSE;md5=314d21c23b626bdadbb4801c47b16266"
|
||||||
|
|
||||||
SRC_URI = "git://github.com/openglow-org/python3-gfhardware.git;protocol=https;branch=master"
|
SRC_URI = "git://github.com/openglow-org/python3-gfhardware.git;protocol=https;branch=master"
|
||||||
# SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass).
|
# SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass).
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Full Glowforge web-service controller daemon for ForgeFIRM (cloud mode)"
|
DESCRIPTION = "Full Glowforge web-service controller daemon for ForgeFIRM (cloud mode)"
|
||||||
|
|
||||||
require forgefirm-app.inc
|
require forgefirm-app.inc
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "One-shot Glowforge web-service homing for ForgeFIRM"
|
DESCRIPTION = "One-shot Glowforge web-service homing for ForgeFIRM"
|
||||||
|
|
||||||
require forgefirm-app.inc
|
require forgefirm-app.inc
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Shared ForgeFIRM web-service hardware-machine glue (gfhome + gfcloud)"
|
DESCRIPTION = "Shared ForgeFIRM web-service hardware-machine glue (gfhome + gfcloud)"
|
||||||
|
|
||||||
require forgefirm-app.inc
|
require forgefirm-app.inc
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
### BEGIN INIT INFO
|
### BEGIN INIT INFO
|
||||||
# Provides: forgefirm-banner
|
# Provides: forgefirm-banner
|
||||||
# Required-Start: $network
|
# Required-Start: $network
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
|
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
|
||||||
DESCRIPTION = "Keeps an address block in the serial-console login banner \
|
DESCRIPTION = "Keeps an address block in the serial-console login banner \
|
||||||
(/etc/issue): one control panel URL per global address of wlan0 and eth0. \
|
(/etc/issue): one control panel URL per global address of wlan0 and eth0. \
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
### BEGIN INIT INFO
|
### BEGIN INIT INFO
|
||||||
# Provides: forgefirm-hostname
|
# Provides: forgefirm-hostname
|
||||||
# Required-Start: $local_fs
|
# Required-Start: $local_fs
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
SUMMARY = "ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address"
|
SUMMARY = "ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address"
|
||||||
DESCRIPTION = "Names the machine forgefirm-<xxxx>, where xxxx is the last \
|
DESCRIPTION = "Names the machine forgefirm-<xxxx>, where xxxx is the last \
|
||||||
four hex digits of the wlan0 MAC address (eth0 on a machine with no WiFi). \
|
four hex digits of the wlan0 MAC address (eth0 on a machine with no WiFi). \
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
Glowforge factory keyring
|
||||||
|
=========================
|
||||||
|
|
||||||
|
Eleven Ed25519 *public* keys, 32 raw bytes each, recovered from Glowforge
|
||||||
|
factory firmware. They are the factory trust anchors: ForgeFIRM ships them so
|
||||||
|
the machine can verify the signature on a factory `.fw` archive during a
|
||||||
|
factory restore. They verify archives and nothing else.
|
||||||
|
|
||||||
|
Public key material only. No private or secret key belongs in this directory,
|
||||||
|
and nothing here can be used to sign anything. ForgeFIRM's own release-signing
|
||||||
|
public key is `../forgefirm-release.pub`; its private half is held offline by
|
||||||
|
the operator and is not in this repository, in CI, or in any cloud-synced
|
||||||
|
store.
|
||||||
|
|
||||||
|
Provenance and rights
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
These keys are Glowforge, Inc.'s. They are bare cryptographic public keys --
|
||||||
|
algorithm output, carrying no authorship -- so no copyright subsists in them
|
||||||
|
and none is claimed. 514 LLC d/b/a OpenGlow asserts no rights over them and
|
||||||
|
grants none; the recipe's `LICENSE = "MIT"` covers the recipe, not this key
|
||||||
|
material. Glowforge has not published these keys itself, so there is no
|
||||||
|
upstream license to point to and none is implied.
|
||||||
|
|
||||||
|
For how the trust anchors are used, see "The update manager" and "Decisions"
|
||||||
|
on the documentation site:
|
||||||
|
https://docs.forgefirm.org/technical/forgefirm/install-and-update/
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user