commissioning: the layer, the acceptance tests, the harness rule, the docs, and the bench drills

meta-forgefirm: the forgefirm-users init replays the account at boot;
sshd refuses root and empty passwords and runs only while the panel
turns it on; the release image keeps an empty root password for the
console; the console banner; avahi announces forgefirm.local; https in
libmicrohttpd and ulfius; the panel on 80 and 443; the license bundle on
the rootfs; release.sh checks the root policy on the built rootfs.

forgetest: the commission suites (commission, commission_dark,
commission_sheet: 23 cases); the runner turns cloud mode on with the
typed phrase for a test that declares it; the baseline's motor_lock is
0; the log-export test checks the bundle for the camera key; the record
helpers write bytes as given and join the daemon's paths as POSIX. The
stream harness gains rule 24: a hold verdict is held again after a
resume. Bench drills: lens_travel.py and lens_stop_accel.py.

Docs: BRINGUP carries the present state; CAMPAIGN-LOG carries the dated
record.
This commit is contained in:
ScottW514
2026-09-06 19:56:05 -04:00
parent ff9796cde6
commit 97287aa6a9
59 changed files with 15113 additions and 9139 deletions
+3 -2
View File
@@ -34,8 +34,9 @@ machine is idle:**
EPIT), fed live by a local planner. M3/M4 dynamic laser power, coolant-flow EPIT), fed live by a local planner. M3/M4 dynamic laser power, coolant-flow
verification, over-temp holds, and an operator button press to arm the laser verification, over-temp holds, and an operator button press to arm the laser
for each job. for each job.
* **Cloud mode**: The machine presents itself as a stock Glowforge to the * **Cloud mode**: The machine signs in to the Glowforge web service with its
Glowforge web service, so the phone and web apps work as they always did. own identity, names its software as ForgeFIRM, and uses the service the way
a stock machine does, so the phone and web apps work as they always did.
Optional, and off by default. GRBL mode jogs and cuts without it; the one Optional, and off by default. GRBL mode jogs and cuts without it; the one
GRBL-mode function that still reaches the Glowforge service is GRBL-mode function that still reaches the Glowforge service is
camera-referenced homing (below), until limit-switch homing lands. camera-referenced homing (below), until limit-switch homing lands.
+132 -18
View File
@@ -158,9 +158,18 @@ core mutex stands in for interrupt masking. `GFSINK` unset = null-sink mode
every takeover runs the `rail_settle_s` off-period; under the broker it every takeover runs the `rail_settle_s` off-period; under the broker it
inherits the fd and skips the settle (the rail never dropped). The driver inherits the fd and skips the settle (the rail never dropped). The driver
applies the full analog machine config at init either way (×8 modes, decay 1, applies the full analog machine config at init either way (×8 modes, decay 1,
motor_lock 8, laser latched, PIC hold currents) and swaps PIC run/hold motor_lock 0 with every axis in the pulse path and the Z soft limit always
on, laser latched, PIC hold currents) and swaps PIC run/hold
currents around motion. Each motion run logs a producer-stats line currents around motion. Each motion run logs a producer-stats line
(callbacks, µs/call, max-behind, clamped) - `clamped` should stay 0. (callbacks, µs/call, max-behind, clamped) - `clamped` should stay 0.
The driver reports Idle when the stream is produced; the kernel plays it
a queue depth behind, and a jog sent at Idle while the kernel still drains
the last one adds up to a depth of pad slots ahead of its own bytes, so
the physical end runs further behind each time (measured 160, 290, 500
and 540 ms after Idle across four chained 50 mm jogs). A `cnc/stop` at
Idle discards that tail; the position counters stay true to what was
played. Anything that must keep position waits for `cnc/state` to read
idle before it stops the controller.
4. Connect LightBurn/UGS to `<machine-ip>:23`, or jog raw: `$J=G91X40F1200`. 4. Connect LightBurn/UGS to `<machine-ip>:23`, or jog raw: `$J=G91X40F1200`.
`^X` mid-motion aborts via kernel `cnc/stop` (controlled decel) and raises an `^X` mid-motion aborts via kernel `cnc/stop` (controlled decel) and raises an
alarm; TCP disconnects never kill the process (the dead-man fd stays held). alarm; TCP disconnects never kill the process (the dead-man fd stays held).
@@ -303,7 +312,10 @@ coolant temperature blocks arming and suppresses fire mid-job with a loud
warning. While armed, the run fan profile and flow interrogation are forced on warning. While armed, the run fan profile and flow interrogation are forced on
regardless of the sender's M8/M9; a SUSPECT/FAULT verdict inside an armed regardless of the sender's M8/M9; a SUSPECT/FAULT verdict inside an armed
window takes the safe posture (feed hold + run airflow). SUSPECT auto-resumes window takes the safe posture (feed hold + run airflow). SUSPECT auto-resumes
on a clean re-check; FAULT leaves the hold and the gate for the operator. on a clean re-check; FAULT leaves the hold and the gate for the operator. A
job resumed under a standing hold (the button, `~`, a sender) is held again
within the client's next poll, dark in between: a verdict with no resume is a
reset, never a pause (stream rule 24 on the null-sink build).
**The controller publishes its state for the daemon.** forgectrl can **The controller publishes its state for the daemon.** forgectrl can
never open the Grbl socket (a connection displaces the sender), so the never open the Grbl socket (a connection displaces the sender), so the
@@ -405,12 +417,28 @@ the lens against the hall for a deterministic Z. **A quiet service without an
accel-witnessed motion window is a failure, not a homing.** accel-witnessed motion window is a failure, not a homing.**
Position semantics: factory home = machine origin (back-left corner, +Y = Position semantics: factory home = machine origin (back-left corner, +Y =
FRONT, workspace all-positive 0..495 × 0..279); Z top-of-travel = 10.6. FRONT, workspace all-positive 0..495 × 0..279); Z = the focal point's height
`gfcloud_home_x/y/z` calibrate the post-home coordinates once measured above the tray (Z 0 on the bed, +Z = lens up); $102 is the screw's constant
(defaults 0 / 0 / Z max). GRBL mode permits unhomed cutting - position shows 2.922 half-steps per mm; the one per-head number is `lens_hall_edge_z_mm`,
the focal height with the lens on the hall's rising edge (3.35 on the bench
reference machine, Z 3.42 on the step grid), which the focus card measures
without touching a stop; the card also finds the head's stops by the head
accelerometer, one half-step at a time and without a slip
(`lens_stop_below_steps` / `lens_stop_above_steps`, 14 / 20 on the bench
reference machine; a fallback window of 10 / 12 and a notice when they
cannot be found), which bounds every lens move; a home leaves the lens on
the edge, sets Z, and parks the focus at `lens_park_z_mm` (default 3). The
lens is in the pulse path (motor_lock 0, the drive current during runs), so
a job's Z moves it; the driver's Z soft limit is always on: Z held where it
is until referenced (a home, or a card's `M103 Z P Q`), then the free travel
with a half-step of slack. The gfcloud
homing session answers the service's hunt as done without moving the lens
and takes its own hall reference after the service goes quiet.
`gfcloud_home_x/y` calibrate the post-home XY once measured
(defaults 0 / 0). GRBL mode permits unhomed cutting - position shows
counters-only and painted red until anchored. counters-only and painted red until anchored.
## The machine-services daemon (forgectrl, port 8080) ## The machine-services daemon (forgectrl, ports 80 and 443)
Source: the `forgectrl` sibling repo (github.com/openglow-org/forgectrl, branch Source: the `forgectrl` sibling repo (github.com/openglow-org/forgectrl, branch
`main`, MIT). It is the ForgeFIRM machine-services daemon: **controller-mode `main`, MIT). It is the ForgeFIRM machine-services daemon: **controller-mode
@@ -432,7 +460,11 @@ Every state-changing endpoint requires the first-boot bearer token in `/data`
(embedded in the panel), a Host address-literal check, and (embedded in the panel), a Host address-literal check, and
`Sec-Fetch-Site`/`Origin` validation (CSRF and DNS-rebinding refusal). `Sec-Fetch-Site`/`Origin` validation (CSRF and DNS-rebinding refusal).
`/cool/state` is loopback-only. `/fuse-identity` and unsigned-firmware installs `/cool/state` is loopback-only. `/fuse-identity` and unsigned-firmware installs
additionally require the physical button held. additionally require the physical button held. After the setup creates the
account, every state-changing call needs a login session: HTTPS-only cookie,
12 h idle expiry, 30 s lockout after five failures. Loopback and a dev image
write with the token alone; port 80 serves only the read-only routes
(`panel_open_reads=0` closes them) and redirects writes to HTTPS.
One ulfius daemon serves it all: One ulfius daemon serves it all:
@@ -461,8 +493,12 @@ One ulfius daemon serves it all:
`gfcloud_home_timeout_s`, `gf_serial`, `gf_password`, `ui_units`, `gfcloud_home_timeout_s`, `gf_serial`, `gf_password`, `ui_units`,
`wifi_country`, the thirty `cool_*` tunables, `laser_button_timeout_s`, `wifi_country`, the thirty `cool_*` tunables, `laser_button_timeout_s`,
`laser_disarm_s`, `rail_settle_s`, `lid_lamp_idle`, `lid_policy`, `laser_disarm_s`, `rail_settle_s`, `lid_lamp_idle`, `lid_policy`,
`cloud_pause_backtrack_ticks`, `cloud_resume_lead_ticks`, `cloud_hold_max_s`, the twelve `cloud_pause_backtrack_ticks`, `cloud_resume_lead_ticks`, `cloud_hold_max_s`,
`cloud_enabled`, `panel_open_reads`, the twelve
`log_<logger>_disk|_remote` levels and `syslog_server|port|proto`. `log_<logger>_disk|_remote` levels and `syslog_server|port|proto`.
`cloud_enabled=1` from 0 takes `phrase=I UNDERSTAND` (400 without it),
and `cloud_enabled=0` takes `homing_mode` to `none` and `controller_mode`
to `grbl` when they point at the cloud, as the setup's cloud step does.
- `GET /mode`, `POST /mode?controller=grbl|cloud` - the supervisor: current - `GET /mode`, `POST /mode?controller=grbl|cloud` - the supervisor: current
mode, controller state (`running | stopped | standby | motion-fault`), pid, mode, controller state (`running | stopped | standby | motion-fault`), pid,
and the motion-liveness verdict (`verified | unverified | fault`); the POST is and the motion-liveness verdict (`verified | unverified | fault`); the POST is
@@ -495,6 +531,17 @@ One ulfius daemon serves it all:
(below). (below).
- `GET /fuse-identity` - serial, derived hostname and the SRK password, behind - `GET /fuse-identity` - serial, derived hostname and the SRK password, behind
the token AND the physical button; fetched on demand only. the token AND the physical button; fetched on demand only.
- `GET /login`, `POST /login|logout`, `GET /setup`, `GET /wiz`, `GET /wiz/record`,
`GET /advisories/<id>`, `POST /wiz/advisories/accept|press|press/cancel`,
`GET /wiz/advisories/press`, `POST /wiz/account|preferences|machine|cloud|complete`
- the login and the first-run setup (served at `/` until complete, `/setup`
after; the record is `/data/forgefirm/commissioning.json`; the gate: `GET
/mode` reports `gated` with `why` until it completes, and
`/run/forgefirm/commissioning-override` lifts the hardware gate until reboot).
- `GET/POST /system/ssh?enable=0|1` (SSH until the next reboot; off at every
boot, a dev image keeps it on) and `POST /restore/factory-return?confirm=1`
(the setup's exit: the archived factory image into the other slot, the boot
selection moved, reboot).
**Panel conventions:** the header identifies the machine by its **fuse **Panel conventions:** the header identifies the machine by its **fuse
identity** (the factory hostname derived from the OCOTP serial), regardless of identity** (the factory hostname derived from the OCOTP serial), regardless of
@@ -648,7 +695,7 @@ from nothing with the bench actuator in the loop (the record is in
authorizes a release; it is not one until `releases/v<version>/acceptance.json` authorizes a release; it is not one until `releases/v<version>/acceptance.json`
is committed. is committed.
- **Catalog: 54 tests** in `forgetest/forgetest/suite/`, every one a port of a - **Catalog: 81 tests** in `forgetest/forgetest/suite/`, every one a port of a
proven bench drill or a bench-verified check: the always-required core proven bench drill or a bench-verified check: the always-required core
(`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`, (`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`,
`kernel.deadman-close`, `kernel.backtrack-bounds`, `kernel.fire-line`, `kernel.deadman-close`, `kernel.backtrack-bounds`, `kernel.fire-line`,
@@ -665,8 +712,13 @@ is committed.
the cloud client driven from a local socket with a synthesized the cloud client driven from a local socket with a synthesized
laser-free job, no account, no network, nothing on the bed). Tests that laser-free job, no account, no network, nothing on the bed). Tests that
share a setup are merged; the `auto` tests stay separate for failure share a setup are merged; the `auto` tests stay separate for failure
isolation. 34 are `auto`, 10 `operator`, 10 `live`; with the bench actuator up, isolation. 52 are `auto`, 15 `operator`, 11 `live`; with the bench actuator up,
eight of the operator tests run in the unattended queue. twelve of the operator tests run in the unattended queue. The operator's
part is kept minimal by rule: a test that needs a person asks for one
thing (a press, a Print in the app, a piece of wood on the bed), and
never an errand (a reboot into another image, a return to the factory
firmware) or a page to watch; the commissioning sheet is one live run
with one press, not a test per card.
- **The operator's part is asked for by name, not by popup** - **The operator's part is asked for by name, not by popup**
(the site, Developers, "Acceptance", "The operator's part"): a Ready prompt before a (the site, Developers, "Acceptance", "The operator's part"): a Ready prompt before a
timed step, a standing notice the test takes down when the machine shows timed step, a standing notice the test takes down when the machine shows
@@ -675,9 +727,11 @@ is committed.
emission witness's mark). The head accelerometer, the beam detector, the emission witness's mark). The head accelerometer, the beam detector, the
button LEDs, and a lid-lamp toggle between two snapshots replaced the button LEDs, and a lid-lamp toggle between two snapshots replaced the
other eyeball confirmations; `kernel.fire-line` and `camera.snapshot` are other eyeball confirmations; `kernel.fire-line` and `camera.snapshot` are
`auto`. With the bench actuator up the attended block is the ten tests `auto`. With the bench actuator up the attended block is the fourteen
that need a person (five laser live, five cloud): 12 minutes on dev image tests that need a person: the eleven `live` tests (one press each at the
`20260824230512`. A test's implementation hash is its own function plus its ready gate, the actuator arms), the cloud service protocol and the header
capture (a Print in the Glowforge app), and the first-run page walk. A
test's implementation hash is its own function plus its
module's shared code, so a fix inside one test re-requires that test module's shared code, so a fix inside one test re-requires that test
alone. alone.
- **Machine identity is content-defined.** Every component recipe contributes - **Machine identity is content-defined.** Every component recipe contributes
@@ -891,9 +945,36 @@ is committed.
contradicts this arithmetic. contradicts this arithmetic.
- Byte layout and stream rules: see [the pulse feeder contract](https://docs.forgefirm.org/technical/forgefirm/pulse-feeder-contract/) - Byte layout and stream rules: see [the pulse feeder contract](https://docs.forgefirm.org/technical/forgefirm/pulse-feeder-contract/)
(authoritative). (authoritative).
- **Z**: bit 6 SET = lens UP = +Z (hardware-verified). Home = hall trigger at - **Z**: bit 6 SET = lens UP = +Z (hardware-verified). The lens is a 2 in
TOP; usable travel ≈ 30 half-steps ≈ 10.6 mm ≈ 0.417"; 0.3534 mm/half-step. lens under a collimated beam (Glowforge's own figure), so it moves with its
Never blind-drive Z - hall-supervised only. focal point, 1:1, and its carriage travels 0.485 in (12.32 mm, the head
drawing): that travel is the focal range. The bench head's carriage counts
36 half-steps stop to stop (37 on a second count), 0.34 mm each, with the
hall sensor's edge (the first position reading home going up) 13 above the
BOTTOM stop and 23 below the top; the focus card homes the lens on the
bottom stop to place the edge (the travel itself is the screw's constant,
36) and places the half-step where the lens is 2 in
from the bed from the user's pick (`laser_focus_bed_steps`, 3.3 on the
bench head, and `laser_focus_steps_per_mm`, 2.92): the tallest material
the lens focuses is the travel above that step, 11.2 mm (0.44 in) here. The
service's Z commands are full steps (its header's z_mode 0 is full-step
mode): 15 for 0.5 in, 4 for 0.1 in, about 2.8 half-steps per mm of material
from the bottom stop, and an early community capture of the same law in
half-steps (8 at 0.1 in, 28 at 0.4 in, 30 at 0.5 in) shows it saturating
at 30, the service's own idea of the usable travel. The factory's hunt
drives the lens 4 full steps down from the hall edge: that point is its Z
zero (5 half-steps above the bottom stop on the bench head; it moves with
the edge from head to head), and its prints move up from it. The pulse path
drives Z only with `cnc/motor_lock` bit 3 clear; the driver and the cloud
client set the lock at their start (the factory's idle posture) and lift it
for a motion. The lens rises only at the head driver's high current
(`head/z_current` 0): at the low (hold) current it lifts two steps into
the service's ramp and stalls, while lowering works at either (measured
with single steps at the ramp's timing); every lens move (the homing
sweeps, the focus card, the cloud client's motions) sets the high current
first and the low current after. The pick on 0.110 in plywood (lines 8 and 9 of 12, 11.5
half-steps above the bottom stop) sits about one line above the service's
own law for that height.
- **XY**: 0.15 mm per full step; DIR bit set = −X / +Y (Y1/Y2 complementary). - **XY**: 0.15 mm per full step; DIR bit set = −X / +Y (Y1/Y2 complementary).
**+Y physically moves the gantry toward the FRONT.** Home corner (convention, **+Y physically moves the gantry toward the FRONT.** Home corner (convention,
for the planned limit-switch homing) = back-left (X min, Y min), workspace for the planned limit-switch homing) = back-left (X min, Y min), workspace
@@ -1339,7 +1420,30 @@ feature requests, enhancements) will eventually be tracked as GitHub issues.
air-assist offset; and if a lit check still trips, the air-assist offset; and if a lit check still trips, the
void-on-emission design with the tube as its own flow tracer. void-on-emission design with the tube as its own flow tracer.
8. **Initial commissioning: measure and set the machine's own numbers 8. **Initial commissioning: measure and set the machine's own numbers
methodically.** Every tunable that was measured on the bench machine methodically.** Phase 1 (consent, account, preferences, machine facts,
the cloud decision, the gate, HTTPS on 443, the factory return),
phase 2 (the setup checks: switches, sensors, airflow, motion, cameras,
the coolant offset and the flow calibration as checks, the cloud header
capture, the Commissioning tab, the engine-raised flags), and phase 3
(the sheet: the stroke font and renderer, the daemon's own sender with
the emission witnesses, the placement, the frame, the focus, floor,
dose-curve, corner, and flow-load cards, M102 in the driver) are in
forgectrl and the driver. Phase 1 passes the
first-run walk-through and the commission acceptance set on the bench;
phase 2 passes its six automated checks (`commission.check-*`) and the
cloud header capture; phase 3 passes `commission.sheet`, one live run
over the whole sheet on one piece. Phase 4 (the lifecycle: the
what-changed menu, the record as a download and a printable page and
inside the log bundle, the button LED choreography, the second-browser
mirror) is built in forgectrl with its three unattended cases. The
commissioning acceptance set is 23 cases, three of them attended with
the bench actuator up (the page walk, one Print in the Glowforge app,
and the sheet with one press), and every one has passed on the bench.
The usability pass over the cards (every wait named and counted, the
press prompted when the button lights, the result as a sentence with
the settings written and the numbers under a fold) is bench-proven with
the content-sized layout. All of it waits for its push and pin. Every tunable
that was measured on the bench machine
and shipped as a default varies from machine to machine: the flow and shipped as a default varies from machine to machine: the flow
check's bands and `cool_flow_rise`, the tube's heat coefficients check's bands and `cool_flow_rise`, the tube's heat coefficients
(`cool_laser_heat_cw`, `cool_laser_heat_density`), the air-assist (`cool_laser_heat_cw`, `cool_laser_heat_density`), the air-assist
@@ -1363,6 +1467,16 @@ feature requests, enhancements) will eventually be tracked as GitHub issues.
is the proof case (this bench settled at 1.5 and may go lower, so the is the proof case (this bench settled at 1.5 and may go lower, so the
shipped default of 2 is a starting point, not a truth), and the same shipped default of 2 is a starting point, not a truth), and the same
shape fits any by-eye tunable the commissioning flow meets. shape fits any by-eye tunable the commissioning flow meets.
9. **Idle before the kernel drains.** The driver reports Idle when the
stream is produced, up to about 550 ms before the pulse engine finishes
playing it after chained jogs (the fact under "Running the controller").
A sender or a service that stops the controller at Idle loses that tail
silently. Decide whether Idle should hold until the kernel drains (the
stream engine has `gf_stream_kernel_idle`) or the continuation pads
should stop growing the lag; either is a driver change with a
`motion.*` catalog case. Until then every forgectrl path that stops
the controller after motion waits for `cnc/state` idle first (the
motion check does).
**Deliberately not gated:** an armed GRBL job after an underrun cuts at the **Deliberately not gated:** an armed GRBL job after an underrun cuts at the
stale origin unless homing is required (GRBL mode permits unhomed cutting; the stale origin unless homing is required (GRBL mode permits unhomed cutting; the
+9157 -7971
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -16,7 +16,7 @@ inheritance, the gate, the coverage rule - is
## Run the daemon on a workstation (against a mock or a manifest file) ## Run the daemon on a workstation (against a mock or a manifest file)
FORGETEST_DATA=/tmp/ft FORGETEST_MANIFEST=../tree-manifest.json \ FORGETEST_DATA=/tmp/ft FORGETEST_MANIFEST=../tree-manifest.json \
FORGECTRL_URL=http://<machine>:8080 python3 -m forgetest --port 8090 FORGECTRL_URL=http://<machine> python3 -m forgetest --port 8090
`scripts/manifest-from-tree.py` produces `tree-manifest.json` from the recipe `scripts/manifest-from-tree.py` produces `tree-manifest.json` from the recipe
pins; the coverage lint is `python3 -m forgetest.coverage --manifest ...`. pins; the coverage lint is `python3 -m forgetest.coverage --manifest ...`.
@@ -31,7 +31,8 @@ pins; the coverage lint is `python3 -m forgetest.coverage --manifest ...`.
| `FORGETEST_BENCH_DIR` | `/usr/share/forgetest/bench` | the installed bench scripts | | `FORGETEST_BENCH_DIR` | `/usr/share/forgetest/bench` | the installed bench scripts |
| `FORGETEST_BENCH_DATA` | `<FORGETEST_DATA>/bench` | passed to bench tools: where they keep their data files (with `GF_HOST=127.0.0.1` and the panel token in `GF_TOKEN`) | | `FORGETEST_BENCH_DATA` | `<FORGETEST_DATA>/bench` | passed to bench tools: where they keep their data files (with `GF_HOST=127.0.0.1` and the panel token in `GF_TOKEN`) |
| `FORGETEST_MARKER` | `/run/forgetest.active` | takeover marker | | `FORGETEST_MARKER` | `/run/forgetest.active` | takeover marker |
| `FORGECTRL_URL`, `FORGECTRL_TOKEN_FILE` | `http://127.0.0.1:8080`, `/data/forgefirm/panel.token` | forgectrl client | | `FORGECTRL_URL`, `FORGECTRL_TOKEN_FILE` | `http://127.0.0.1`, `/data/forgefirm/panel.token` | forgectrl client (HTTP; the token authorizes writes from the board) |
| `FORGECTRL_TLS_URL` | `https://127.0.0.1` | forgectrl over HTTPS (self-signed, unverified), for the login test |
| `GF_SYSFS_ROOT` | `/sys/glowforge/` | kernel module sysfs | | `GF_SYSFS_ROOT` | `/sys/glowforge/` | kernel module sysfs |
| `GRBL_HOST`, `GRBL_PORT` | 127.0.0.1, 23 | Grbl TCP | | `GRBL_HOST`, `GRBL_PORT` | 127.0.0.1, 23 | Grbl TCP |
+7
View File
@@ -22,6 +22,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 controller gate until the next reboot (/run is tmpfs), so a
# campaign runs on a machine whose required wizards are not complete.
# Nothing lifts the agreements or the account: the bench accepts them
# once, and that stays in /data.
mkdir -p /run/forgefirm
: > /run/forgefirm/commissioning-override
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \ 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"
;; ;;
+34 -15
View File
@@ -44,12 +44,14 @@ from .log import now_ts
# GRBL-mode resting values (kernel attribute -> value as read back), as a # GRBL-mode resting values (kernel attribute -> value as read back), as a
# fresh boot of the dev image leaves them (2026-08-16 bench dump). # fresh boot of the dev image leaves them (2026-08-16 bench dump).
# motor_lock/x_mode/y_mode/x_decay/y_decay and the hold currents are the # motor_lock/x_mode/y_mode/x_decay/y_decay and the hold currents are the
# GRBL controller's init writes (glowforge_io.c), step_freq its default # GRBL controller's init writes (glowforge_io.c): motor_lock 0, every axis
# machine tick, ramp_rate the module default; streaming is only ever 1 # in the pulse path (a job's Z moves the lens; the driver's Z soft limit
# inside a live job; the head white LED is a camera lamp, off at idle; the # guards it), step_freq its default machine tick, ramp_rate the module
# loop heater and TEC are the diagnostics' tools, off at idle. # default; streaming is only ever 1 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.
FIXED_SYSFS = [ FIXED_SYSFS = [
("cnc/motor_lock", "8"), ("cnc/motor_lock", "0"),
("cnc/x_mode", "8"), ("cnc/x_mode", "8"),
("cnc/y_mode", "8"), ("cnc/y_mode", "8"),
("cnc/x_decay", "1"), ("cnc/x_decay", "1"),
@@ -229,8 +231,10 @@ 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),
or standby (the manual stop lever). Gives up after unreachable_s standby (the manual stop lever), or gated (the commissioning gate
without an answer. Returns the last /mode body (None if unreachable).""" is closed: no controller spawns until it opens). Gives up after
unreachable_s without an answer. Returns the last /mode body (None
if unreachable)."""
t0 = time.time() t0 = time.time()
deadline = t0 + timeout deadline = t0 + timeout
last = seen = heard = None last = seen = heard = None
@@ -252,7 +256,7 @@ class Baseline:
seen = key seen = key
self.log("/mode controller=%s motion=%s" % key) self.log("/mode controller=%s motion=%s" % key)
ctl = body.get("controller") ctl = body.get("controller")
if ctl in ("motion-fault", "standby") or (ctl == "running" and body.get("motion") == "verified"): if ctl in ("motion-fault", "standby", "gated") or (ctl == "running" and body.get("motion") == "verified"):
if ctl == "motion-fault": if ctl == "motion-fault":
self.log("WARNING - motion liveness ladder failed, controllers are " self.log("WARNING - motion liveness ladder failed, controllers are "
"down (motion-fault); retry via POST /mode") "down (motion-fault); retry via POST /mode")
@@ -304,6 +308,20 @@ class Baseline:
return True, "already in %s mode" % want return True, "already in %s mode" % want
self.log("switching to %s mode (found %s, controller %s)" self.log("switching to %s mode (found %s, controller %s)"
% (want, mode.get("mode"), mode.get("controller"))) % (want, mode.get("mode"), mode.get("controller")))
if want == "cloud":
# Cloud mode exists only while cloud_enabled is 1. A test that
# needs it gets it turned on here, with a line in the log; it
# stays on afterward, as a cloud job the owner ran would leave it.
st, settings = self.fc_get("/settings")
if st == 200 and isinstance(settings, dict) and settings.get("cloud_enabled") != "1":
# the typed phrase the cloud step asks for: the runner
# gives it under the operator's rule for cloud tests
st, body = self.fc_post("/settings", data={"cloud_enabled": "1",
"phrase": "I UNDERSTAND"})
if st != 200:
return False, "cloud_enabled=1 for the test -> %s %s" % (st, body)
self.log("cloud mode turned on for the test (cloud_enabled was %r)"
% (settings.get("cloud_enabled") or ""))
self.nohunt_on(want) self.nohunt_on(want)
st, body = self.fc_post("/mode", data={"controller": want}) st, body = self.fc_post("/mode", data={"controller": want})
if st != 200: if st != 200:
@@ -697,19 +715,20 @@ def check_fixed_against(ref, log):
# The attributes the GRBL controller writes at its own start (its analog # The attributes the GRBL controller writes at its own start (its analog
# config + machine tick): once they read the fixed values the controller # config + machine tick): once they read the fixed values the controller
# has configured the machine. Before that the kernel shows the supervisor's # has configured the machine. Before that the kernel shows the supervisor's
# motion-probe leftovers (motor_lock 0, step_freq 10000, y_mode at the # motion-probe leftovers (step_freq 10000, y_mode at the module default) -
# module default) - the state /mode already calls "running", because # the state /mode already calls "running", because "running" is the spawn,
# "running" is the spawn, not the config. # not the config. motor_lock is no marker: the probe and the controller
CONFIGURED_MARKERS = [(a, dict(FIXED_SYSFS)[a]) for a in ("cnc/step_freq", "cnc/motor_lock", "cnc/y_mode")] # both leave it 0.
CONFIGURED_MARKERS = [(a, dict(FIXED_SYSFS)[a]) for a in ("cnc/step_freq", "cnc/y_mode")]
CONFIGURED_TIMEOUT_S = 20 CONFIGURED_TIMEOUT_S = 20
CONFIGURED_SETTLE_S = 1.0 CONFIGURED_SETTLE_S = 1.0
def reference_preconfig(ref): def reference_preconfig(ref):
"""True when a saved reference shows the pre-controller state: every """True when a saved reference shows the pre-controller state: every
marker present differs from its fixed value (the probe's step_freq / marker present differs from its fixed value (the probe's step_freq and
motor_lock and the module's y_mode together), i.e. it was dumped the module's y_mode together), i.e. it was dumped before the
before the controller's init writes landed.""" controller's init writes landed."""
sysfs = (ref or {}).get("sysfs") or {} sysfs = (ref or {}).get("sysfs") or {}
seen = [(sysfs.get(a), want) for a, want in CONFIGURED_MARKERS if sysfs.get(a) is not None] seen = [(sysfs.get(a), want) for a, want in CONFIGURED_MARKERS if sysfs.get(a) is not None]
return bool(seen) and all(got != want for got, want in seen) return bool(seen) and all(got != want for got, want in seen)
+2 -2
View File
@@ -3,7 +3,7 @@ kernel module's sysfs, the init scripts, and the Grbl TCP port.
Everything is reachable through environment overrides so the suite can be Everything is reachable through environment overrides so the suite can be
exercised against a mock on a host: exercised against a mock on a host:
FORGECTRL_URL default http://127.0.0.1:8080 FORGECTRL_URL default http://127.0.0.1
FORGECTRL_TOKEN_FILE default /data/forgefirm/panel.token FORGECTRL_TOKEN_FILE default /data/forgefirm/panel.token
GF_SYSFS_ROOT default /sys/glowforge/ (must end with '/') GF_SYSFS_ROOT default /sys/glowforge/ (must end with '/')
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23 GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
@@ -41,7 +41,7 @@ class Forgectrl:
"""Thin client for the machine-services daemon.""" """Thin client for the machine-services daemon."""
def __init__(self, base=None, token=None, timeout=10.0, slow_timeout=SLOW_TIMEOUT_S): def __init__(self, base=None, token=None, timeout=10.0, slow_timeout=SLOW_TIMEOUT_S):
self.base = (base or os.environ.get("FORGECTRL_URL") or "http://127.0.0.1:8080").rstrip("/") self.base = (base or os.environ.get("FORGECTRL_URL") or "http://127.0.0.1").rstrip("/")
self.timeout = timeout self.timeout = timeout
self.slow_timeout = slow_timeout self.slow_timeout = slow_timeout
self._token = token self._token = token
+10 -8
View File
@@ -424,15 +424,17 @@ class Context:
self.log("ACT %s %s: done after %.1f s", channel, state, dt) self.log("ACT %s %s: done after %.1f s", channel, state, dt)
return dt return dt
def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press."): def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.",
lit_timeout=60):
"""The arm cue of a live test. A person's press by default: a """The arm cue of a live test. A person's press by default: a
standing notice until the caller clears it. The fixture presses standing notice until the caller clears it. The fixture presses
only where the bench opted in (arm_press in its config) and its only where the bench opted in (arm_press in its config) and its
button channel is enabled: a thread waits for the button to light button channel is enabled: a thread waits up to `lit_timeout`
(the job may still be on its way to the arm wait) and presses seconds for the button to light (the job may still be on its way
once, recorded as the fixture's; if the button never lights or to the arm wait; a card that settles the coolant first takes
the press fails, the notice goes up for a person. Returns True minutes) and presses once, recorded as the fixture's; if the
when the fixture has been asked.""" button never lights or the press fails, the notice goes up for a
person. Returns True when the fixture has been asked."""
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()} rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
self.evidence.setdefault("actions", []).append(rec) self.evidence.setdefault("actions", []).append(rec)
@@ -452,9 +454,9 @@ class Context:
self.log("ARM: the fixture presses when the button lights (the bench's arm_press opt-in)") self.log("ARM: the fixture presses when the button lights (the bench's arm_press opt-in)")
def press(): def press():
lit = self.wait_for(hw.button_lit, 60) lit = self.wait_for(hw.button_lit, lit_timeout)
if lit is None: if lit is None:
self.log("ARM: the button never lit within 60 s - asking the operator") self.log("ARM: the button never lit within %d s - asking the operator", lit_timeout)
self.notice(text) self.notice(text)
return return
try: try:
+3
View File
@@ -4,6 +4,9 @@ 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 commission_dark # noqa: F401,E402
from . import commission_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
+65
View File
@@ -1,4 +1,6 @@
"""camera.* - the lid camera pipeline through forgectrl.""" """camera.* - the lid camera pipeline through forgectrl."""
import json
from ..catalog import test from ..catalog import test
from .. import hw from .. import hw
from ..baseline import LID_LAMP_ATTR from ..baseline import LID_LAMP_ATTR
@@ -380,3 +382,66 @@ def lid_privacy(ctx):
ctx.check(st == 200 and data[:2] == b"\xff\xd8", ctx.check(st == 200 and data[:2] == b"\xff\xd8",
"snapshot after closing the lid -> %s", st) "snapshot after closing the lid -> %s", st)
ctx.log("lid closed again: snapshot %d bytes", len(data)) ctx.log("lid closed again: snapshot %d bytes", len(data))
@test("camera.key-read", title="The camera key reads without a login", subsystem="camera",
kind="auto", est_min=1,
covers=[("forgectrl", "src/camkey.*"), ("forgectrl", "src/auth.c"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/panel.js"),
("forgectrl", "src/ui/index.html"), ("forgectrl", "src/ui/help.js")],
requires=["forgectrl.auth"],
description="With the read routes closed to the network (panel_open_reads=0), a camera "
"status read from the board's LAN address is refused without the key and "
"served with it, on HTTP and on HTTPS; a wrong key is refused; the key never "
"authorizes a write; a rotation from the panel route stops the old key and "
"the new one reads. The setting is put back after.")
def key_read(ctx):
from .commission import request
from .forgectrl import lan_ip
fc = ctx.forgectrl
ev = ctx.evidence
ip = lan_ip()
ctx.check(ip, "cannot determine the board's LAN address")
http_base = "http://%s" % ip
tls_base = "https://%s" % ip
token = {"X-ForgeFIRM-Token": fc.token}
st, body, _ = request(fc.base, "GET", "/system/camera-key", headers=token)
ctx.check(st == 200, "GET /system/camera-key from loopback -> %s", st)
key = json.loads(body)["key"]
ctx.check(len(key) == 32, "the key is not 32 characters: %r", key)
ev["key_len"] = len(key)
before = fc.settings().get("panel_open_reads", "")
st, _ = fc.post("/settings", params={"panel_open_reads": "0"})
ctx.check(st == 200, "closing the reads -> %s", st)
try:
for base in (http_base, tls_base):
st, _, _ = request(base, "GET", "/cam/status")
ctx.log("GET %s/cam/status (no key) -> %s", base, st)
ctx.check(st == 403, "a closed read without the key -> %s, expected 403", st)
st, _, _ = request(base, "GET", "/cam/status?key=" + key)
ctx.log("GET %s/cam/status?key=... -> %s", base, st)
ctx.check(st == 200, "a read with the key -> %s, expected 200", st)
st, _, _ = request(base, "GET", "/cam/status", headers={"X-ForgeFIRM-Camera-Key": key})
ctx.check(st == 200, "a read with the key header -> %s, expected 200", st)
wrong = ("0" if key[0] != "0" else "1") + key[1:]
st, _, _ = request(base, "GET", "/cam/status?key=" + wrong)
ctx.check(st == 403, "a wrong key -> %s, expected 403", st)
# the key is a read credential only
st, _, _ = request(tls_base, "POST", "/settings", data={"panel_open_reads": "0"},
headers={"X-ForgeFIRM-Camera-Key": key})
ctx.log("POST /settings with the camera key -> %s", st)
ctx.check(st == 403, "the camera key authorized a write (%s)", st)
# rotation
st, body, _ = request(fc.base, "POST", "/system/camera-key?rotate=1", headers=token)
ctx.check(st == 200, "rotate -> %s", st)
new = json.loads(body)["key"]
ctx.check(new != key, "rotation kept the same key")
st, _, _ = request(http_base, "GET", "/cam/status?key=" + key)
ctx.check(st == 403, "the old key still reads after rotation (%s)", st)
st, _, _ = request(http_base, "GET", "/cam/status?key=" + new)
ctx.check(st == 200, "the new key does not read (%s)", st)
ev["rotated"] = True
finally:
fc.post("/settings", params={"panel_open_reads": before})
+14 -17
View File
@@ -165,17 +165,6 @@ GFHOME_LOG = "/data/log/forgefirm/gfhome/gfhome.log"
HOMING_TIMEOUT_S = 600 HOMING_TIMEOUT_S = 600
def homing_mode_is_gfcloud():
"""Precheck: the web-service homing needs homing_mode = gfcloud."""
try:
hm = (hw.Forgectrl().settings() or {}).get("homing_mode")
except hw.HwError as e:
return "forgectrl unreachable: %s" % e
if hm != "gfcloud":
return "homing_mode is %r; the web-service homing needs gfcloud" % (hm,)
return None
def judge_hunt_with_lid_open(ctx, ev, offset): def judge_hunt_with_lid_open(ctx, ev, offset):
"""The connect-time hunt from `offset` on: its terminal line is """The connect-time hunt from `offset` on: its terminal line is
:completed, nothing before it was refused for the lid, and the lens :completed, nothing before it was refused for the lid, and the lens
@@ -246,9 +235,9 @@ 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"],
precheck=homing_mode_is_gfcloud, steps=["Bed clear; cloud credentials configured; the machine on the network. The test "
steps=["Bed clear; cloud credentials configured and homing_mode = gfcloud; the machine on " "turns cloud mode and the gfcloud homing on itself when they are off, and puts "
"the network.", "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 when told and leave it open through the cloud client's connect and its "
"hunt; close it when told. Nothing else: the switch back and the $H homing run on " "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."], "their own, and the head ends parked at the home corner."],
@@ -374,9 +363,17 @@ def mode_switch(ctx):
# -- the web-service homing from grbl mode -------------------------------- # -- the web-service homing from grbl mode --------------------------------
ev["homing_mode"] = (fc.settings() or {}).get("homing_mode") ev["homing_mode"] = (fc.settings() or {}).get("homing_mode")
ctx.check(ev["homing_mode"] == "gfcloud", "homing_mode is %r; $H needs gfcloud", ev["homing_mode"]) if ev["homing_mode"] != "gfcloud":
with ctx.grbl() as g: st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
gfhome_homing(ctx, ev, g) ctx.log("homing_mode=gfcloud for the homing -> %s %s", st, body if isinstance(body, str) else "")
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
try:
with ctx.grbl() as g:
gfhome_homing(ctx, ev, g)
finally:
if ev["homing_mode"] != "gfcloud":
st, body = fc.post("/settings", data={"homing_mode": ev["homing_mode"] or "none"})
ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st)
ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> " ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> "
"grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"]) "grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,327 @@
"""commission.check-* - the setup's checks (the dark wizards), driven the
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
the prompts answered from here (the bench fixture works the lid and the
button; a confirmation is answered yes once the snapshot exists), the
result judged, and every setting a check wrote put back as found. Each
check re-runs on the real record: a completed check completes again at
the same version, so the record reads as before.
"""
import json
import os
import time
from ..catalog import test
from .. import hw
from ..baseline import read_position
from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
POLL_S = 1.0
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
def dark(fc):
st, body = fc.get("/wiz/dark")
if st != 200 or not isinstance(body, dict):
raise hw.HwError("GET /wiz/dark -> %s" % st)
return body
def start(ctx, fc, wid):
ev = ctx.evidence
st, body = fc.post("/wiz/%s/start" % wid)
if st == 409 and dark(fc).get("id") == wid and dark(fc).get("running"):
# The same check left running by an earlier run of this test (an
# abort mid-wait): this run owns the machine, so it ends that one
# and starts its own.
ctx.log("the %s check is still running from an earlier run: aborting it", wid)
fc.post("/wiz/%s/abort" % wid)
ctx.wait_for(lambda: not dark(fc).get("running"), 30)
st, body = fc.post("/wiz/%s/start" % wid)
ctx.log("POST /wiz/%s/start -> %s %s", wid, st, body if isinstance(body, dict) else "")
ctx.check(st == 200, "the %s check did not start (%s %s)", wid, st, body)
ev["started"] = st == 200
def answer(fc, wid, prompt, value):
return fc.post("/wiz/%s/answer" % wid, data={"seq": str(prompt["seq"]), "value": value})
def run_check(ctx, wid, on_prompt, timeout_s):
"""Start `wid` and follow it to its end. on_prompt(prompt) is called
once per prompt (by sequence number) and returns the value to answer
with, or None when it handled the prompt another way (a fixture
action). Returns the final status document."""
fc = ctx.forgectrl
ev = ctx.evidence
start(ctx, fc, wid)
t0 = time.time()
seen = set()
phases = []
last = None
try:
while time.time() - t0 < timeout_s:
ctx.checkpoint()
d = dark(fc)
if d.get("id") != wid:
raise hw.HwError("another check took the slot: %s" % d.get("id"))
if d.get("phase") and (not phases or phases[-1] != d["phase"]):
phases.append(d["phase"])
ctx.log("%s: %s", wid, d["phase"])
p = d.get("prompt")
if p and p.get("seq") not in seen:
seen.add(p["seq"])
ctx.log("prompt %s (%s): %s", p.get("id"), p.get("kind"), p.get("text"))
value = on_prompt(p)
if value is not None:
st, body = answer(fc, wid, p, value)
ctx.check(st == 200, "the answer to %s -> %s %s", p.get("id"), st, body)
if not d.get("running"):
last = d
break
time.sleep(POLL_S)
except BaseException:
# An abort or a failure on the way out leaves no check running
# behind this test: the next run must be able to start its own.
fc.post("/wiz/%s/abort" % wid)
raise
ev["elapsed_s"] = round(time.time() - t0, 1)
ev["phases"] = phases[-12:]
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
if last is None:
fc.post("/wiz/%s/abort" % wid)
return {}
ev["log"] = last.get("log", [])[-12:]
ev["error"] = last.get("error")
ev["result"] = last.get("result")
ctx.check(not last.get("error"), "the %s check failed: %s", wid, last.get("error"))
ctx.check(isinstance(last.get("result"), dict), "the %s check ended without a result", wid)
w = wiz(fc)
ctx.check((w.get("versions") or {}).get(wid) == 1, "the record does not carry %s at version 1", wid)
return last
@test("commission.check-switches", title="The switches check follows the lid and the button",
subsystem="commission", kind="operator", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
requires=["forgectrl.auth"], actions=["lid", "button"],
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
"satisfied without a prompt. The result says lid, button, head present, and "
"whether the HV enable readback followed the lid; the record carries switches at "
"version 1.")
def check_switches(ctx):
def on_prompt(p):
pid = p.get("id", "")
if pid in ("lid-open", "lid-close-first", "lid-close"):
ctx.act("lid", "open" if pid == "lid-open" else "close",
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
text=p.get("text", ""))
elif pid == "button-press":
ctx.act("button", "press",
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("id") != "button-press",
text=p.get("text", ""))
elif pid == "button-release":
pass # the fixture's press releases on its own
elif pid.startswith("interlock-"):
ctx.act("interlock", "open" if pid == "interlock-open" else "close",
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
text=p.get("text", ""))
return None
last = run_check(ctx, "switches", on_prompt, 600)
r = last.get("result") or {}
ctx.check(r.get("lid") is True and r.get("button") is True, "lid or button not proven: %s", r)
ctx.check(r.get("head_present") is True, "the head did not read present: %s", r)
ctx.log("switches: %s", json.dumps(r))
@test("commission.check-sensors", title="The sensors check reads a plausible machine at rest",
subsystem="commission", kind="auto", est_min=2,
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
("forgectrl", "src/cool.c")],
requires=["forgectrl.auth"],
description="POST /wiz/sensors/start; ten seconds of readings, then the room-temperature "
"prompt, answered Skip so no offset is written. The result carries both coolant "
"temperatures, the chassis and SoC, the lid IR maxima, the accelerometer event "
"count, the supply power-good, the HV current, and the idle fan speeds; the "
"record carries sensors at version 1 and cool_temp_offset_c reads as before.")
def check_sensors(ctx):
with Restore(ctx, ["cool_temp_offset_c"]):
last = run_check(ctx, "sensors", lambda p: "Skip" if p.get("id") == "room-temp" else None, 120)
r = last.get("result") or {}
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
"exhaust_rpm_idle", "intake_rpm_idle"):
ctx.check(k in r, "the result lacks %s: %s", k, r)
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
r.get("lid_ir_max"), r.get("hv_current_max"))
@test("commission.check-airflow", title="The airflow check measures the fans and sets the floors",
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
"for 35 s, the purge is measured on and off, and five settings are written: the "
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
"fan must read at least 1000 rpm. The five settings are put back as found at the "
"end and the controller must be running again.")
def check_airflow(ctx):
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
"cool_purge_min_current", "cool_fan_grace_s"]
with Restore(ctx, keys):
last = run_check(ctx, "airflow", lambda p: None, 240)
r = last.get("result") or {}
fans = r.get("fans") or {}
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
f = fans.get(name) or {}
ctx.check(f.get("ok") is True, "%s: %s", name, f)
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
floors = r.get("floors") or {}
for k in keys:
ctx.check(k in floors, "no floor for %s: %s", k, floors)
s = ctx.forgectrl.settings() or {}
for k in keys:
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
ctx.log("floors: %s", json.dumps(floors))
ok = ctx.wait_for(lambda: (ctx.forgectrl.get("/mode")[1] or {}).get("controller") == "running", 60)
ctx.check(ok is not None, "the controller did not come back after the check")
@test("commission.check-cameras", title="The cameras check captures both cameras",
subsystem="commission", kind="operator", est_min=2,
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
"head snapshot, the question. Each question is answered yes once GET /wiz/shot "
"serves the snapshot as image/jpeg. The result names the sensor and says both "
"views were accepted.")
def check_cameras(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.act("lid", "close")
shots = {}
def on_prompt(p):
pid = p.get("id", "")
if pid in ("lid-view", "head-view"):
cam = "lid" if pid == "lid-view" else "head"
st, body = fc.get("/wiz/shot", params={"cam": cam}, raw=True)
shots[cam] = {"status": st, "bytes": len(body) if body else 0,
"jpeg": bool(body) and body[:2] == b"\xff\xd8"}
ctx.check(st == 200 and shots[cam]["jpeg"], "no %s snapshot to judge: %s", cam, shots[cam])
return "Yes"
if pid == "lid-close":
ctx.act("lid", "close", until=lambda: (dark(fc).get("prompt") or {}).get("seq") != p["seq"])
return None
last = run_check(ctx, "cameras", on_prompt, 180)
ev["shots"] = shots
r = last.get("result") or {}
ctx.check(r.get("lid_ok") is True and r.get("head_ok") is True, "a view was not accepted: %s", r)
ctx.check(bool(r.get("sensor")) and r.get("sensor") != "unknown", "no sensor named: %s", r)
ctx.log("cameras: %s, lid %d bytes, head %d bytes", r.get("sensor"),
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
@test("commission.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
requires=["forgectrl.auth", "motion.pacing"],
description="POST /wiz/motion/start: the controller stops, the liveness probe runs, the lens "
"finds the hall reference in five agreeing passes, the controller comes back in "
"loopback posture, and the head jogs 50 mm each way on X and Y with the "
"accelerometer as the witness. The one prompt (the jogs are about to move) is "
"answered Continue. The result carries the probe line, the passes, and a "
"witnessed reading for each jog; the controller must be back in its normal "
"posture, and the kernel position counters must read what they read before: "
"every move played to its end (the probe's return leg under the cooling "
"engine's dead-man, the last jog under the controller stop).")
def check_motion(ctx):
fc = ctx.forgectrl
before = read_position()
ctx.notice("The head moves 50 mm each way on X and on Y. Keep the bed clear.")
last = run_check(ctx, "motion", lambda p: "Continue" if p.get("id") == "jogs" else None, 420)
ctx.clear_notice()
r = last.get("result") or {}
ctx.check(r.get("z_referenced") is True, "the lens was not referenced: %s", r)
ctx.check(len(r.get("z_passes") or []) == 5, "not five reference passes: %s", r.get("z_passes"))
moves = r.get("moves") or {}
for name in ("+X", "-X", "+Y", "-Y"):
m = moves.get(name) or {}
ctx.check(m.get("witnessed") is True, "the %s jog was not witnessed: %s", name, m)
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("local") is False
and (fc.get("/mode")[1] or {}).get("controller") == "running", 90)
ctx.check(ok is not None, "the controller is not back in its normal posture")
after = read_position()
if before is not None and after is not None:
ctx.check(all(abs(a - b) <= 2 for a, b in zip(after[:2], before[:2])),
"the check lost motion: position %s before, %s after (steps)", before, after)
ctx.log("motion: probe '%s', passes %s, rest %s, moves %s", r.get("probe"), r.get("z_passes"),
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
@test("commission.check-flow-verify", title="The flow check runs as a setup check",
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
"check runner: the diagnostic's phases show as the check's, its result becomes the "
"check's result with pass true, and the record carries cooling.flow-verify at "
"version 1. A thin margin recommends the calibration in the record's flags. The "
"air-assist offset calibration is named first among the prerequisites so a queue "
"keeps it ahead of every heater tool this check pulls forward.")
def check_flow_verify(ctx):
last = run_check(ctx, "cooling.flow-verify", lambda p: None, 600)
r = last.get("result") or {}
ctx.check(r.get("pass") is True, "the flow check did not pass: %s", r)
ctx.log("flow check: threshold %s, flow %s, no-flow %s, thin %s", r.get("threshold"),
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
@test("commission.cloud-header-capture", title="The cloud header check takes one print's envelope",
subsystem="commission", kind="operator", hardware="takeover", mode="grbl", est_min=8,
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
("python3-gfhardware", "gfhardware/machine.py"),
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
("python3-gfhardware", "forgefirm-app/ffmachine.py")],
requires=["forgectrl.auth", "cloud.mode-switch"], hands=["workstation"],
steps=["The Glowforge app open on the workstation, signed in to this machine. The test "
"turns cloud mode on itself and puts it back.",
"Once the app shows the machine online, place any small design on the bed image "
"and press Print. That is the only action: the check takes the print's header and "
"cancels the print before it arms. Do not press the machine's button.",
"The machine returns to GRBL mode on its own."],
description="POST /wiz/cloud.header/start with cloud mode on: the wizard writes the "
"client's one-start capture marker, starts cloud mode in the wizard's posture, "
"asks for a print, and takes the header the client writes to the run directory "
"before it cancels the print. The result carries the tag count, the "
"calibration-bearing tags, the limits, and the machine's own numbers; the log "
"has the tagged block; the machine is back in GRBL mode with the marker gone.")
def cloud_header_capture(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
with Restore(ctx, ["cloud_enabled", "homing_mode", "controller_mode"]):
s = fc.settings() or {}
if s.get("cloud_enabled") != "1":
st, body = fc.post("/settings", data={"cloud_enabled": "1"})
ctx.check(st == 200, "cloud_enabled=1 -> %s %s", st, body)
ctx.log("cloud mode turned on for the test")
ctx.notice("Print any small design from the Glowforge app once it shows the machine "
"online. Do not press the machine's button.")
last = run_check(ctx, "cloud.header",
lambda p: "Continue" if p.get("id") == "print" else None, 900)
ctx.clear_notice()
r = last.get("result") or {}
ctx.check(isinstance(r.get("tag_count"), int) and r["tag_count"] > 100,
"the header carried %s tags", r.get("tag_count"))
kept = r.get("calibration_tags") or {}
ctx.check(len(kept) > 10, "only %d calibration-bearing tags kept", len(kept))
ev["kept"] = sorted(kept)[:40]
ctx.check(not os.path.exists("/run/gfcloud-capture"), "the capture marker is still down")
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("mode") == "grbl"
and (fc.get("/mode")[1] or {}).get("controller") == "running", 120)
ctx.check(ok is not None, "the machine did not return to GRBL mode")
ctx.log("header: %d tags, %d kept; job %s", r.get("tag_count", 0), len(kept), r.get("job_id"))
@@ -0,0 +1,195 @@
"""commission.sheet - the setup's sheet (the live wizards) in one run,
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
polled, the prompts answered from here, each burn armed by a press, the
result judged, and every setting a wizard wrote put back as found. One
piece of wood at least 200 x 150 mm (8 x 6 in) carries the placement,
the frame, and the five cards. Each wizard re-runs on the real record,
so a completed one completes again at the same version.
The operator's part is one press on the machine at the start (the
presence check of the ready gate; with the bench actuator up it then
makes every arm press) and nothing else. The judgments a person makes
by eye on the sheet (the thinnest line, the faintest rung, the best
corner, the frame's fit) are answered here with a middle option or the
witnesses: the test proves each wizard's mechanics and the emission
witnesses, not the operator's eye, and the settings it writes are
restored. The page is where the real numbers go in.
"""
import re
from ..catalog import test
from .commission_dark import run_check, Restore
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/sheet.*"),
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
("forgectrl", "tools/hershey_gen.py"), ("forgectrl", "src/super.c"),
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
("forgectrl", "src/accel.*"), ("forgectrl", "src/wizcalc.*"),
("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/glowforge_status.c"),
("grblhal-glowforge", "src/glowforge_homing.*"),
("grblhal-glowforge", "src/glowforge_io.*")]
# The live wizards in the order the sheet burns them: the wizard id, the
# answers to its prompts (a middle pick where a person would look at the
# sheet), the keys its result must carry, and how long the bench actuator
# waits for the button to light before the arm press goes to a person.
# The flow-load card settles the coolant loop before it lights the button
# (60 to 240 s); the others light within seconds of the lens reference.
CARDS = [
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"], 120),
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"},
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
"focus_range_mm", "stops"], 120),
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"], 120),
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"], 120),
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"], 120),
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"], 420),
]
# Every setting a card writes, restored as found when the run ends.
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
"laser_floor_density", "laser_dose_curve", "laser_corner_gamma",
"cool_laser_heat_density", "cool_laser_heat_cw"]
# The evidence run_check and the witnesses write at the top level, moved
# under the card's own key once it is done.
CARD_EVIDENCE = ("started", "elapsed_s", "phases", "log", "error", "result",
"preview_bytes", "program_lines", "emission")
def preview_ok(ctx, wid):
"""The page's preview and the program for the wizard are served."""
fc = ctx.forgectrl
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
ctx.evidence["preview_bytes"] = len(body)
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
# The text and the frame are M4 at the mark dose; the patterns bring
# their own M3 or M4 lines. A program is one with a laser-on command.
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
"no program for %s (%s)", wid, st)
ctx.evidence["program_lines"] = body.count(b"\n")
def emission_ok(ctx, last):
"""The three witnesses of the burn, from the wizard's result."""
e = (last.get("result") or {}).get("emission") or {}
ctx.evidence["emission"] = e
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
ctx.check(e.get("thermopile_delta", 0) >= 100, "the thermopile did not see the beam (%s)",
e.get("thermopile_delta"))
def file_card(ctx, wid):
"""Move the card's evidence under its own key."""
ev = ctx.evidence
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
def thickness_answer(ctx):
"""The thickness the placement is answered with, in the machine's
units (the number prompts read ui_units), and the millimeters the
record must carry for it."""
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
def place(ctx):
"""The placement: the lens reference, two jogs, the origin at the
head's position, the full sheet, a thickness. Nothing fires."""
jogs = ["X+10", "X-10"]
answer, want_mm = thickness_answer(ctx)
ctx.evidence["thickness_answer"] = answer
def on_prompt(p):
# Each jog answer closes the prompt and the wizard asks again with
# the next sequence number, so the pad is answered one move at a
# time until the origin is set.
if p.get("id") == "place":
return jogs.pop(0) if jogs else "Set origin"
if p.get("id") == "sheet-kind":
return "Full sheet"
if p.get("id") == "thickness":
return answer
return None
last = run_check(ctx, "sheet.place", on_prompt, 300)
r = last.get("result") or {}
ctx.check("origin_x" in r and "origin_y" in r, "no origin in the result: %s", r)
ctx.check((r.get("steps_per_mm") or {}).get("x", 0) > 0, "no steps per mm: %s", r)
ctx.check(r.get("z_referenced") is True, "the lens was not referenced")
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
ctx.check(r.get("alone") is False, "alone %s", r.get("alone"))
ctx.log("placed: datum at %s, %s; steps per mm %s", r.get("origin_x"), r.get("origin_y"),
r.get("steps_per_mm"))
file_card(ctx, "sheet.place")
def burn(ctx, wid, answers, want, lit_s):
"""One live card: the preview, the burn after the press with the
prompts answered from `answers`, the witnesses, the keys of the
result in `want`. The actuator waits `lit_s` for the button to light."""
preview_ok(ctx, wid)
ctx.arm_press(lit_timeout=lit_s)
def on_prompt(p):
return answers.get(p.get("id"))
try:
last = run_check(ctx, wid, on_prompt, 900)
finally:
ctx.clear_notice()
emission_ok(ctx, last)
r = last.get("result") or {}
missing = [k for k in want if k not in r]
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
ctx.log("%s: %s, emission %s", wid, {k: r.get(k) for k in want}, ctx.evidence.get("emission"))
file_card(ctx, wid)
@test("commission.sheet", title="The commissioning sheet: the placement, the frame, and the five cards",
subsystem="commission", kind="live", hardware="takeover", mode="grbl", est_min=35,
covers=SHEET_COVERS,
requires=["forgectrl.auth", "commission.check-motion", "laser.emission-witness", "cooling.flow-verify"],
actions=["button"], hands=["scrap"],
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
"it goes with its top edge at the top of the cut area (the head's home corner); "
"lid closed. Eye protection on, exhaust on, extinguisher in reach.",
"Start the test and press the machine's button once. That press says you are at the "
"machine, and the bench actuator makes every arm press after it. With no actuator "
"the start is the Ready answer instead, and you press the button each time it lights "
"white: six times, the frame and the five cards.",
"Nothing else: the test answers every prompt itself and puts back every setting the "
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
"tails."],
description="One run over the sheet's seven wizards on one piece of wood. "
"POST /wiz/sheet.place/start references the lens, takes the controller in "
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
"0.125 in). Then, each after the press and each with its preview "
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode: the frame "
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
"lens homed on its bottom stop, the hall edge counted, twelve lines down the "
"travel; the pick is line 11, the thickness kept); the floor card (twelve "
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
"through M102 inside one armed job; the pick is 1.50); and the flow-load card "
"(the coolant loop settled, the box and the patch at 60 percent, the "
"downstream peak over 100 s of dark as the coefficient). Every burn must be "
"seen by the three witnesses (the tube current, the kernel's LASER_ON samples, "
"the thermopile rise); every result must carry its keys; the record must "
"carry each wizard at version 1; the seven settings the cards write are "
"restored at the end.")
def sheet(ctx):
ctx.ready("The sheet: the placement, then the frame and the five cards burn after a press "
"each. The bench presses when the button lights.")
place(ctx)
with Restore(ctx, CARD_SETTINGS):
for wid, answers, want, lit_s in CARDS:
burn(ctx, wid, answers, want, lit_s)
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
+49 -13
View File
@@ -6,7 +6,12 @@ import time
from ..catalog import test from ..catalog import test
from .. import hw from .. import hw
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c")] # The write guard reads the account and the session store (a machine with
# an account needs a session from the LAN; this host and the dev image
# write with the token), so both are the guard's domain.
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/ui/login.js"), ("forgectrl", "src/ui/wizard.js")]
def lan_ip(): def lan_ip():
@@ -27,12 +32,15 @@ def lan_ip():
@test("forgectrl.auth", title="API access control", subsystem="forgectrl", kind="auto", est_min=1, @test("forgectrl.auth", title="API access control", subsystem="forgectrl", kind="auto", est_min=1,
covers=_COVERS_AUTH, covers=_COVERS_AUTH,
description="Every state-changing endpoint refuses an unauthenticated write; a non-literal " description="Every state-changing endpoint refuses an unauthenticated write (the factory "
"return, the SSH switch and the wizard's own routes included); a non-literal "
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused; the " "Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused; the "
"cooling report channel accepts the loopback peer and refuses a non-loopback " "cooling report channel accepts the loopback peer and refuses a non-loopback "
"one; the fuse view is two-factor " "one (over HTTP the write is sent to HTTPS first, 302; over HTTPS the route "
"answers 403 loopback only); the fuse view is two-factor "
"(token and the physical button) and refused without either; " "(token and the physical button) and refused without either; "
"the flash and factory-restore chain is refused unauthenticated.") "the flash and factory-restore chain is refused unauthenticated; a page "
"asked for without a session is sent to the login carrying its path.")
def auth(ctx): def auth(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
@@ -46,7 +54,10 @@ 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"}),
("/system/reboot", None), ("/restore/factory", None)): ("/system/reboot", None), ("/restore/factory", None),
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
("/wiz/advisories/accept", None), ("/wiz/account", None),
("/wiz/complete", None)):
st, body = fc.post(path, params=params, auth=False) st, body = fc.post(path, params=params, auth=False)
ctx.log("POST %s (no token) -> %s %s", path, st, body if isinstance(body, dict) else "") ctx.log("POST %s (no token) -> %s %s", path, st, body if isinstance(body, dict) else "")
ev["noauth " + path] = st ev["noauth " + path] = st
@@ -92,7 +103,7 @@ def auth(ctx):
ev["sfs_cross"] = st ev["sfs_cross"] = st
ctx.log("GET /status Sec-Fetch-Site=cross-site -> %s", st) ctx.log("GET /status Sec-Fetch-Site=cross-site -> %s", st)
ctx.check(st == 403, "a cross-site fetch was accepted (%s)", st) ctx.check(st == 403, "a cross-site fetch was accepted (%s)", st)
st, body = fc.get("/status", headers={"Sec-Fetch-Site": "same-origin", "Origin": "http://127.0.0.1:8080"}) st, body = fc.get("/status", headers={"Sec-Fetch-Site": "same-origin", "Origin": "http://127.0.0.1"})
ctx.check(st == 200, "same-origin literal Origin refused (%s)", st) ctx.check(st == 200, "same-origin literal Origin refused (%s)", st)
# the fuse view is two-factor: the token AND the physical button held. # the fuse view is two-factor: the token AND the physical button held.
@@ -122,17 +133,42 @@ def auth(ctx):
ctx.check(st == 200, "/cool/state refused the loopback peer (%s %r): the controller's " ctx.check(st == 200, "/cool/state refused the loopback peer (%s %r): the controller's "
"reports never reach the engine", st, body) "reports never reach the engine", st, body)
# ...and a non-loopback peer is refused, even with a token # ...and a non-loopback peer is refused, even with a token. Over HTTP
# the write is sent to HTTPS first (302, the listener's rule); over
# HTTPS the route itself refuses the peer (403 loopback only). Neither
# request follows the redirect, and the self-signed certificate is
# not verified.
from .commission import request, decode
ip = lan_ip() 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")
port = fc.base.rsplit(":", 1)[-1] token = {"X-ForgeFIRM-Token": fc.token}
lan = hw.Forgectrl("http://%s:%s" % (ip, port), token=fc.token) report = {"mode": "idle", "armed": "0"}
st, body = lan.post("/cool/state", params={"mode": "idle", "armed": "0"}) st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token)
loc = hdrs.get("location", "")
ev["cool_state_from_lan_http"] = {"status": st, "location": loc}
ctx.log("POST http://%s/cool/state -> %s %s", ip, st, loc)
ctx.check(st == 302 and loc.startswith("https://"),
"a LAN write over HTTP -> %s %r, expected a 302 to HTTPS", st, loc)
st, body, hdrs = request("https://%s" % ip, "POST", "/cool/state", data=report, headers=token)
b = decode(body)
ev["cool_state_from_lan"] = st ev["cool_state_from_lan"] = st
ctx.log("POST /cool/state from %s -> %s %s", ip, st, body if isinstance(body, dict) else "") ctx.log("POST https://%s/cool/state -> %s %s", ip, st, b if isinstance(b, dict) else "")
ctx.check(st == 403 and isinstance(body, dict) and body.get("error") == "loopback only", ctx.check(st == 403 and isinstance(b, dict) and b.get("error") == "loopback only",
"/cool/state accepted a non-loopback peer (%s %r)", st, body) "/cool/state accepted a non-loopback peer (%s %r)", st, b)
# the login return path: a page asked for from the LAN without a
# session is sent to the login carrying the path it asked for (with
# its query), so the login can come back to it. Before the setup has
# made the account the page is served instead (200).
st, body, hdrs = request("https://%s" % ip, "GET", "/setup?step=laser.focus")
loc = hdrs.get("location", "")
ev["setup_from_lan"] = {"status": st, "location": loc}
ctx.log("GET https://%s/setup?step=laser.focus (no session) -> %s %s", ip, st, loc)
ctx.check(st in (200, 302), "the setup page from the LAN -> %s", st)
if st == 302:
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
"the login redirect does not carry the path: %r", loc)
@test("forgectrl.settings-bounds", title="Settings validation and restore", subsystem="forgectrl", @test("forgectrl.settings-bounds", title="Settings validation and restore", subsystem="forgectrl",
+54
View File
@@ -252,3 +252,57 @@ def image_health(ctx):
ctx.check("kernel-module-glowforge" in manifest.components, "manifest lacks kernel-module-glowforge") ctx.check("kernel-module-glowforge" in manifest.components, "manifest lacks kernel-module-glowforge")
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry") ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components)) ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components))
@test("image.license-bundle", title="The license texts travel with the image",
subsystem="image", kind="auto", est_min=1,
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/login.html")],
requires=["forgectrl.panel-serves"],
description="The image carries /usr/share/forgefirm/licenses.tar.gz, a gzip tar with "
"the license manifest (every installed package with its license) and the "
"license texts, and no loose common-licenses tree; the panel serves the "
"bundle, the manifest as text, and the Licenses page every footer links, "
"each without a login.")
def license_bundle(ctx):
import io
import tarfile
fc = ctx.forgectrl
ev = ctx.evidence
path = "/usr/share/forgefirm/licenses.tar.gz"
ctx.check(os.path.isfile(path), "%s is missing", path)
ctx.check(not os.path.isdir("/usr/share/common-licenses"),
"the loose common-licenses tree is still on the rootfs")
size = os.path.getsize(path)
ev["bundle_bytes"] = size
ctx.log("%s: %d bytes", path, size)
with tarfile.open(path, "r:gz") as t:
names = t.getnames()
ctx.check("common-licenses/license.manifest" in names, "no license.manifest in the bundle")
manifest = t.extractfile("common-licenses/license.manifest").read().decode("utf-8", "replace")
packages = manifest.count("PACKAGE NAME: ")
texts = sum(1 for n in names if "/generic_" in n and not n.endswith("/"))
ev["packages"] = packages
ev["generic_texts"] = texts
ctx.log("manifest lists %d packages; %d generic license entries", packages, texts)
ctx.check(packages >= 40, "only %d packages in the manifest", packages)
ctx.check(texts >= 5, "only %d generic license entries", texts)
for name in ("forgectrl", "grblhal-glowforge", "kernel-module-glowforge"):
ctx.check("RECIPE NAME: %s\n" % name in manifest, "%s is not in the manifest", name)
st, body = fc.get("/system/licenses/manifest", raw=True)
ctx.check(st == 200, "GET /system/licenses/manifest -> %s", st)
ctx.check(isinstance(body, (bytes, bytearray)) and b"PACKAGE NAME: " in body,
"the served manifest is not the manifest")
st, body = fc.get("/system/licenses", raw=True)
ctx.check(st == 200, "GET /system/licenses -> %s", st)
ctx.check(isinstance(body, (bytes, bytearray)) and len(body) == size,
"the served bundle is %s bytes, the file %d", len(body) if body else None, size)
with tarfile.open(fileobj=io.BytesIO(bytes(body)), mode="r:gz") as t:
ctx.check("common-licenses/license.manifest" in t.getnames(),
"the served bundle holds no manifest")
st, body = fc.get("/licenses", raw=True)
ctx.check(st == 200, "GET /licenses -> %s", st)
text = bytes(body).decode("utf-8", "replace") if body else ""
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
"the Licenses page lacks the download link or the manifest")
+16
View File
@@ -93,6 +93,7 @@ def sample(ctx):
"verdict": cs.get("verdict"), "verdict": cs.get("verdict"),
"beam": hw.sysfs_int("head/beam_detect_analog"), "beam": hw.sysfs_int("head/beam_detect_analog"),
"beam_d": hw.sysfs_int("head/beam_detect_digital"), "beam_d": hw.sysfs_int("head/beam_detect_digital"),
"exhaust": (st.get("fans") or {}).get("exhaust"),
"button_lit": hw.button_lit(), "button_lit": hw.button_lit(),
"hv_enable": (st.get("switches") or {}).get("hv_enable"), "hv_enable": (st.get("switches") or {}).get("hv_enable"),
"button_latch": hw.sysfs_int("cnc/button_latch"), "button_latch": hw.sysfs_int("cnc/button_latch"),
@@ -494,6 +495,18 @@ def emission_witness(ctx):
"HV_ENABLE dipped=%s, back with emission after it=%s", gap["button_latch_unlocked_max"], "HV_ENABLE dipped=%s, back with emission after it=%s", gap["button_latch_unlocked_max"],
gap["button_latch_unlocked_samples"], gap["button_latch_set_at"], gap["hv_enable_dipped"], gap["button_latch_unlocked_samples"], gap["button_latch_set_at"], gap["hv_enable_dipped"],
gap["hv_enable_back_lit"]) gap["hv_enable_back_lit"])
# The first fire waits for the airflow: the exhaust reads at or
# above its floor on the first sample that shows emission.
floor = (ctx.forgectrl.settings() or {}).get("cool_tach_exhaust_min_rpm") or ""
try:
floor = float(floor) if floor else 6400.0
except ValueError:
floor = 6400.0
first_lit = next((s for s in samples if s["emission"]), None)
ev["exhaust_at_first_fire"] = first_lit["exhaust"] if first_lit else None
ev["exhaust_floor"] = floor
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
ev["exhaust_at_first_fire"], floor)
# X-3: job-based disarm at Idle after M2 # X-3: job-based disarm at Idle after M2
dt = wait_disarm(ctx, 75) dt = wait_disarm(ctx, 75)
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
@@ -506,6 +519,9 @@ def emission_witness(ctx):
"their idle duty (phase %s at t=%s s, emission %s)", "their idle duty (phase %s at t=%s s, emission %s)",
first_idle_fire[1], first_idle_fire[0], first_idle_fire[2]) first_idle_fire[1], first_idle_fire[0], first_idle_fire[2])
ctx.check(end == 0, "emission_samples did not return to 0 at Idle (%s)", end) ctx.check(end == 0, "emission_samples did not return to 0 at Idle (%s)", end)
ctx.check(ev["exhaust_at_first_fire"] is not None and ev["exhaust_at_first_fire"] >= floor,
"the beam started before the exhaust reached its floor (%s rpm at the first "
"emission sample, floor %.0f)", ev["exhaust_at_first_fire"], floor)
ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)", ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)",
ev["hv_min"], ev["hv_max"]) ev["hv_min"], ev["hv_max"])
ctx.check(dt is not None and dt < 10.0, ctx.check(dt is not None and dt < 10.0,
+16 -11
View File
@@ -28,7 +28,7 @@ _LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forg
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 no panel token.") "bundle that contains neither the panel token nor the camera key.")
def tree_tail_export(ctx): def tree_tail_export(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
@@ -66,16 +66,21 @@ def tree_tail_export(ctx):
ctx.log("bundle: %d members, e.g. %s", len(members), members[:5]) ctx.log("bundle: %d members, e.g. %s", len(members), members[:5])
ctx.check(members, "empty bundle") ctx.check(members, "empty bundle")
ctx.check(any(m.endswith("README.txt") for m in members), "sanitized bundle lacks README.txt") ctx.check(any(m.endswith("README.txt") for m in members), "sanitized bundle lacks README.txt")
token = fc.token # the two secrets the sanitizer knows by value and a bundle could
if token: # carry: the panel token and the camera key (a URL parameter that
leaked = [] # lands in sender logs)
for m in tf.getmembers(): secrets = {}
if m.isfile(): if fc.token:
content = tf.extractfile(m).read() secrets["panel token"] = fc.token
if token.encode() in content: st, body = fc.get("/system/camera-key")
leaked.append(m.name) if st == 200 and isinstance(body, dict) and body.get("key"):
ev["token_leaks"] = leaked secrets["camera key"] = body["key"]
ctx.check(not leaked, "the sanitized bundle contains the panel token: %s", leaked) ctx.check("camera key" in secrets, "GET /system/camera-key -> %s: no key to check for", st)
contents = [(m.name, tf.extractfile(m).read()) for m in tf.getmembers() if m.isfile()]
for what, value in secrets.items():
leaked = [name for name, content in contents if value.encode() in content]
ev[what.replace(" ", "_") + "_leaks"] = leaked
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
# The routing test proves the whole path every logger takes: emitter (or # The routing test proves the whole path every logger takes: emitter (or
+1 -1
View File
@@ -392,7 +392,7 @@ def _liveness_masked_restart(ctx, fc, ev):
ctx.check(lines and "MOTION OK" in lines[0], ctx.check(lines and "MOTION OK" in lines[0],
"the first probe after the restart was not MOTION OK: %s", lines[:1]) "the first probe after the restart was not MOTION OK: %s", lines[:1])
ctx.check(len(lines) == 1, "the probe needed the recovery ladder (%d probes) - a false dead verdict", len(lines)) ctx.check(len(lines) == 1, "the probe needed the recovery ladder (%d probes) - a false dead verdict", len(lines))
ctx.check(ctx.sysfs("cnc/motor_lock") == "8", "motor_lock reads %s after the controller start (expected 8)", ctx.check(ctx.sysfs("cnc/motor_lock") == "0", "motor_lock reads %s after the controller start (expected 0)",
ctx.sysfs("cnc/motor_lock")) ctx.sysfs("cnc/motor_lock"))
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the probe") ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the probe")
x1 = _kernel_x_mm(ctx) x1 = _kernel_x_mm(ctx)
+13 -1
View File
@@ -108,6 +108,7 @@ class FakeForgectrl:
} }
self.posts = [] self.posts = []
self.on_post = None self.on_post = None
self.on_get = None # (path, query) -> (status, body) | (status, bytes, ctype) | None
fake = self fake = self
class H(http.server.BaseHTTPRequestHandler): class H(http.server.BaseHTTPRequestHandler):
@@ -123,7 +124,18 @@ class FakeForgectrl:
self.wfile.write(data) self.wfile.write(data)
def do_GET(self): def do_GET(self):
path = self.path.split("?", 1)[0] path, _, query = self.path.partition("?")
if fake.on_get:
r = fake.on_get(path, dict(_up.parse_qsl(query, keep_blank_values=True)))
if r is not None:
if isinstance(r[1], (bytes, bytearray)):
self.send_response(r[0])
self.send_header("Content-Type", r[2] if len(r) > 2 else "application/octet-stream")
self.send_header("Content-Length", str(len(r[1])))
self.end_headers()
self.wfile.write(r[1])
return
return self._send(r[0], r[1])
key = {"/mode": "mode", "/status": "status", "/cool/status": "cool", "/cam/status": "cam", key = {"/mode": "mode", "/status": "status", "/cool/status": "cool", "/cam/status": "cam",
"/diag/status": "diag", "/settings": "settings", "/diag/status": "diag", "/settings": "settings",
"/logs/tail": "logs_tail"}.get(path) "/logs/tail": "logs_tail"}.get(path)
+19 -5
View File
@@ -82,11 +82,11 @@ class BaselineTests(unittest.TestCase):
self.assertEqual(set(items), {"cnc/motor_lock", "cnc/step_freq", "cnc/streaming"}) self.assertEqual(set(items), {"cnc/motor_lock", "cnc/step_freq", "cnc/streaming"})
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"), "8") self.assertEqual(self._read("cnc/motor_lock"), "0")
self.assertEqual(self._read("cnc/step_freq"), "28160") self.assertEqual(self._read("cnc/step_freq"), "28160")
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, "8") self.assertEqual(items["cnc/motor_lock"].expected, "0")
def test_unlocked_latch_is_relocked(self): def test_unlocked_latch_is_relocked(self):
self._attr("cnc/interlock_circuit", "5") # bit 3 clear = unlocked self._attr("cnc/interlock_circuit", "5") # bit 3 clear = unlocked
@@ -194,7 +194,7 @@ class BaselineTests(unittest.TestCase):
else: else:
# a young host: the reference is taken from the fake tree # a young host: the reference is taken from the fake tree
self.assertIsNotNone(ref) self.assertIsNotNone(ref)
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "8") self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
self.assertTrue(os.path.exists(os.path.join(self.tmp, "boot-test-boot.json"))) self.assertTrue(os.path.exists(os.path.join(self.tmp, "boot-test-boot.json")))
# and loaded back the second time # and loaded back the second time
self.lines[:] = [] self.lines[:] = []
@@ -205,7 +205,7 @@ class BaselineTests(unittest.TestCase):
os.environ.pop("FORGETEST_BOOT_ID", None) os.environ.pop("FORGETEST_BOOT_ID", None)
def test_fixed_constants_checked_against_a_dump(self): def test_fixed_constants_checked_against_a_dump(self):
ref = {"sysfs": {"cnc/motor_lock": "8", "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=28160"])
@@ -286,7 +286,7 @@ class BaselineTests(unittest.TestCase):
self.assertEqual(ref["ts"], "old") self.assertEqual(ref["ts"], "old")
else: else:
self.assertTrue(any("retaking" in l for l in self.lines)) self.assertTrue(any("retaking" in l for l in self.lines))
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "8") self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
finally: finally:
os.environ.pop("FORGETEST_BOOT_ID", None) os.environ.pop("FORGETEST_BOOT_ID", None)
@@ -325,6 +325,7 @@ class BaselineModeTests(BaselineTests):
The fake client takes the marker down as gfcloud does, first thing The fake client takes the marker down as gfcloud does, first thing
at its start; with refuse the POST is refused and no client starts.""" at its start; with refuse the POST is refused and no client starts."""
baseline.NOHUNT_MARKER = os.path.join(self.tmp, "nohunt-marker") baseline.NOHUNT_MARKER = os.path.join(self.tmp, "nohunt-marker")
self.fc.state["settings"].setdefault("cloud_enabled", "1")
seen = [] seen = []
def on_post(path, form): def on_post(path, form):
@@ -355,6 +356,19 @@ class BaselineModeTests(BaselineTests):
self.assertEqual(seen, [("/mode", {"controller": "cloud"}, True)]) self.assertEqual(seen, [("/mode", {"controller": "cloud"}, True)])
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER)) # one start, never left behind self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER)) # one start, never left behind
def test_a_switch_to_cloud_turns_cloud_mode_on_when_it_is_off(self):
# A test that declares cloud mode gets it: cloud_enabled goes to 1
# first, with the typed phrase the daemon asks for and a log line,
# and only then the mode switch.
lines = []
seen = self.nohunt_marker()
self.fc.state["settings"]["cloud_enabled"] = "0"
ok, detail = baseline.Baseline(lines.append).switch_mode("cloud")
self.assertTrue(ok, detail)
self.assertEqual(seen[0], ("/settings", {"cloud_enabled": "1", "phrase": "I UNDERSTAND"}, False))
self.assertEqual(seen[1], ("/mode", {"controller": "cloud"}, True))
self.assertTrue(any("cloud mode turned on for the test" in ln for ln in lines))
def test_a_switch_to_grbl_sets_no_marker(self): def test_a_switch_to_grbl_sets_no_marker(self):
seen = self.nohunt_marker() seen = self.nohunt_marker()
self.cloud() self.cloud()
+7 -5
View File
@@ -650,11 +650,13 @@ class CloudSuiteTests(unittest.TestCase):
threading.Thread(target=self.wait_home_command, daemon=True).start() threading.Thread(target=self.wait_home_command, daemon=True).start()
self.assertFails(cloud.mode_switch, "no 'homing complete' line", hooks=hooks) self.assertFails(cloud.mode_switch, "no 'homing complete' line", hooks=hooks)
def test_mode_switch_precheck_needs_gfcloud_homing(self): def test_mode_switch_needs_no_setting_from_the_operator(self):
self.fc.state["settings"]["homing_mode"] = "switches" # The test sets the gfcloud homing itself and restores it: no precheck
self.assertIn("needs gfcloud", cloud.homing_mode_is_gfcloud()) # names a setting the operator should change first.
self.fc.state["settings"]["homing_mode"] = "gfcloud" from forgetest import catalog
self.assertIsNone(cloud.homing_mode_is_gfcloud()) t = catalog.load_suite()["cloud.mode-switch"]
self.assertIsNone(getattr(t, "precheck", None))
self.assertNotIn("homing_mode_is_gfcloud", dir(cloud))
# -- pause / resume --------------------------------------------------------- # -- pause / resume ---------------------------------------------------------
def replay_print(self, name, at_run, at_end, tail_delay=0.3): def replay_print(self, name, at_run, at_end, tail_delay=0.3):
+116
View File
@@ -0,0 +1,116 @@
"""The setup's checks in the catalog: registered as automatic tests with
no precheck, the takeover ones marked so, every covers map naming the
check runner; and the check driver itself against the fake daemon: a
start, a prompt answered, the result taken, the record read at version
1, and the settings a check may write put back as found."""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(__file__))
import helpers # noqa: E402
from forgetest import catalog # noqa: E402
from forgetest.runner import Context, Run # noqa: E402
IDS = ["commission.check-switches", "commission.check-sensors", "commission.check-airflow",
"commission.check-cameras", "commission.check-motion", "commission.check-flow-verify"]
class RegistrationTests(unittest.TestCase):
def setUp(self):
self.reg = catalog.load_suite()
def test_every_check_runs_unattended_with_the_fixture(self):
# The lid and button checks name their machine actions, which the
# bench fixture performs; the rest need no hands at all.
for tid in IDS:
self.assertIn(tid, self.reg)
for tid in ("commission.check-switches", "commission.check-cameras"):
self.assertEqual(self.reg[tid].kind, "operator", tid)
self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid)
for tid in ("commission.check-sensors", "commission.check-airflow", "commission.check-motion",
"commission.check-flow-verify"):
self.assertEqual(self.reg[tid].kind, "auto", tid)
def test_no_check_asks_the_operator_to_prepare_anything(self):
for tid in IDS:
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
def test_the_checks_that_take_the_machine_say_so(self):
for tid in ("commission.check-airflow", "commission.check-motion", "commission.check-flow-verify"):
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
def test_every_check_covers_the_runner_and_the_page(self):
for tid in IDS:
covers = set(self.reg[tid].covers)
self.assertIn(("forgectrl", "src/wizdark.*"), covers, tid)
self.assertIn(("forgectrl", "src/ui/wizard.*"), covers, tid)
class SensorsCheckTests(unittest.TestCase):
"""The check driver against a scripted daemon."""
RESULT = {"coolant_down_c": 22.4, "coolant_up_c": 21.9, "chassis_c": 28.0, "soc_c": 45.2,
"lid_ir_max": [110, 120, 105, 118], "accel_events": 0, "laser_pgood": 1,
"hv_current_max": 0, "exhaust_rpm_idle": 3400, "intake_rpm_idle": 2200}
def setUp(self):
self.fake = helpers.FakeForgectrl().start()
self.fake.state["settings"].update({"cool_temp_offset_c": "0.5"})
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
"log": [], "prompt": None, "result": None, "error": "",
"shots": {"lid": False, "head": False}}
self.versions = {}
self.answers = []
def on_get(path, q):
if path == "/wiz/dark":
return 200, dict(self.dark)
if path == "/wiz":
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
return None
def on_post(path, form):
s = self.fake.state["settings"]
if path == "/wiz/sensors/start":
self.dark.update({"id": "sensors", "running": True, "phase": "sampling",
"prompt": {"seq": 7, "id": "room-temp", "kind": "number",
"text": "Optional: the room temperature.",
"options": ["Set", "Skip"]}})
return 200, {"started": True, "id": "sensors"}
if path == "/wiz/sensors/answer":
self.answers.append(dict(form))
if form.get("seq") != "7":
return 409, {"error": "no such prompt is open"}
if form.get("value") == "Skip":
self.dark.update({"running": False, "prompt": None, "progress": 100,
"result": dict(self.RESULT)})
self.versions["sensors"] = 1
return 200, {"ok": True}
if path == "/settings":
for k, v in form.items():
if v == "":
s.pop(k, None)
else:
s[k] = v
return 200, dict(s)
return None
self.fake.on_get = on_get
self.fake.on_post = on_post
def tearDown(self):
self.fake.stop()
def test_start_prompt_answer_result_and_restore(self):
t = catalog.load_suite()["commission.check-sensors"]
run = Run("test", t.id, t.title)
t.fn(Context(run, None, t))
self.assertIn(("/wiz/sensors/start", {}), [(p, f) for p, f in self.fake.posts])
self.assertEqual(self.answers, [{"seq": "7", "value": "Skip"}])
self.assertEqual(run.evidence["result"]["laser_pgood"], 1)
self.assertEqual(run.evidence["settings_before"], {"cool_temp_offset_c": "0.5"})
self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5")
if __name__ == "__main__":
unittest.main()
+266
View File
@@ -0,0 +1,266 @@
"""The setup's sheet in the catalog: one live test on the button with a
piece of wood at hand, its covers map naming the live runner, the
renderer, the sender, the page, and the driver's laser module; and the
whole run against a scripted daemon: the placement's jogs and origin,
then each card's preview and program read, its start, the arm prompt and
the pick answered, the witnesses judged, the record read at version 1,
every card's evidence filed under its own key, and the settings the cards
wrote put back as found at the end."""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(__file__))
import helpers # noqa: E402
from forgetest import catalog # noqa: E402
from forgetest.runner import Context, Run # noqa: E402
from forgetest.suite import commission_sheet # noqa: E402
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
# Each wizard as the daemon runs it: its prompts in order (id, kind,
# options) and the result it ends with. A "jog" prompt repeats until the
# origin is set.
SCRIPT = {
"sheet.place": ([("place", "jog", ["X-10", "X+10", "Set origin"]),
("sheet-kind", "choice", ["Full sheet", "One card"]),
("thickness", "number", ["Set", "Skip"])],
{"origin_x": 100, "origin_y": 200, "origin_z": 0, "steps_per_mm": {"x": 40.0, "y": 40.0},
"z_referenced": True, "thickness_mm": 3.2, "alone": False}),
"sheet.frame": ([("frame-arm", "continue", ["Continue"]), ("frame-ok", "choice", ["Yes", "No", "Again"])],
{"mark_s": 400, "mark_feed": 3000, "emission": EMISSION}),
"laser.focus": ([("focus-arm", "continue", ["Continue"]),
("focus-pick", "multichoice", [str(i) for i in range(1, 13)]),
("thickness", "number", ["Keep", "Set"])],
{"pick": 11, "thickness_mm": 3.2, "pick_half_steps": -11, "edge_z_mm": 6.96,
"steps_per_mm": 2.922, "max_height_mm": 13.81,
"focus_range_mm": {"min": 2.17, "max": 13.81},
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
"contact_above": 22, "why": ""}, "emission": EMISSION}),
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
"laser.dose-curve": ([("dose-arm", "continue", ["Continue"])],
{"points": [[10, 0.44], [100, 100]], "curve": "10:0.44,100:100", "emission": EMISSION}),
"laser.corner": ([("corner-arm", "continue", ["Continue"]),
("corner-pick", "choice", ["1.00", "1.25", "1.50", "1.75", "2.00"])],
{"gamma": 1.5, "emission": EMISSION}),
"cooling.flow-load": ([("load-arm", "continue", ["Continue"])],
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
"k_cw": 2.78e-5, "emission": EMISSION}),
}
# What each card writes when it ends.
WRITES = {
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14",
"lens_stop_above_steps": "20"},
"laser.floor": {"laser_floor_density": "10"},
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
"laser.corner": {"laser_corner_gamma": "1.5"},
"cooling.flow-load": {"cool_laser_heat_density": "2.14e-5", "cool_laser_heat_cw": "2.78e-5"},
}
FOUND = {"lens_hall_edge_z_mm": "3.35", "lens_stop_below_steps": "10", "lens_stop_above_steps": "12",
"laser_floor_density": "12",
"laser_dose_curve": "off", "laser_corner_gamma": "2", "cool_laser_heat_density": "1e-5",
"cool_laser_heat_cw": "1e-5"}
class RegistrationTests(unittest.TestCase):
def setUp(self):
self.t = catalog.load_suite()["commission.sheet"]
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
t = self.t
self.assertEqual(t.kind, "live")
self.assertEqual(t.mode, "grbl")
self.assertEqual(t.hardware, "takeover")
self.assertIn("button", t.actions)
self.assertTrue(t.hands)
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
self.assertIsNone(getattr(t, "precheck", None))
for want in ("commission.check-motion", "laser.emission-witness", "cooling.flow-verify"):
self.assertIn(want, t.requires)
def test_no_other_sheet_test_remains(self):
ids = [i for i in catalog.load_suite() if i.startswith("commission.sheet")]
self.assertEqual(ids, ["commission.sheet"])
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
covers = set(self.t.covers)
for want in (("forgectrl", "src/wizlive.*"), ("forgectrl", "src/sheet.*"), ("forgectrl", "src/jobstream.*"),
("forgectrl", "src/curverec.*"), ("forgectrl", "src/font_hershey.*"),
("forgectrl", "src/ui/wizard.*"), ("forgectrl", "tools/hershey_gen.py"),
("grblhal-glowforge", "src/glowforge_laser.c")):
self.assertIn(want, covers, "lacks %s" % (want,))
def test_the_steps_ask_for_one_press_and_no_page(self):
text = " ".join(self.t.steps).lower()
self.assertIn("press the machine's button once", text)
self.assertNotIn("when the page", text)
self.assertEqual(len(commission_sheet.CARDS), 6)
class ScriptedDaemon:
"""The wizard runner as the suite sees it: /wiz/<id>/start opens the
first prompt, each answer opens the next or ends the wizard with its
result and its writes, /wiz/dark reports it, GET /wiz carries the
versions, the sheet preview and program are served per card."""
def __init__(self, fake):
self.fake = fake
fake.state["settings"].update(FOUND)
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
"log": [], "prompt": None, "result": None, "error": "",
"shots": {"lid": False, "head": False}}
self.versions = {}
self.answers = {} # wid -> [values]
self.order = [] # the wizards as started
self.previews = []
self.queue = []
self.seq = 0
fake.on_get = self.on_get
fake.on_post = self.on_post
def open_next(self):
if not self.queue:
return False
pid, kind, options = self.queue[0]
self.seq += 1
self.dark["prompt"] = {"seq": self.seq, "id": pid, "kind": kind, "text": pid, "options": options}
return True
def on_get(self, path, q):
if path == "/wiz/dark":
return 200, dict(self.dark)
if path == "/wiz":
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
if path == "/wiz/sheet.svg":
self.previews.append(q.get("card"))
if q.get("card") not in LIVE:
return 404, {"error": "no such card"}
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
if path == "/wiz/sheet.gcode":
# the frame and the text are M4 at the mark dose, as the daemon serves them
return 200, b"; mock\nM4 S400\nG0 X60 Y42\nG1 X120 Y42 F3000\nM5\n", "text/plain"
return None
def on_post(self, path, form):
s = self.fake.state["settings"]
if path.startswith("/wiz/") and path.endswith("/start"):
wid = path[len("/wiz/"):-len("/start")]
if wid not in SCRIPT:
return 404, {"error": "no such wizard"}
self.order.append(wid)
self.queue = list(SCRIPT[wid][0])
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
self.open_next()
return 200, {"started": True, "id": wid}
if path.startswith("/wiz/") and path.endswith("/answer"):
wid = path[len("/wiz/"):-len("/answer")]
p = self.dark.get("prompt")
if not p or form.get("seq") != str(p["seq"]):
return 409, {"error": "no such prompt is open"}
self.answers.setdefault(wid, []).append(form.get("value"))
if not (p["kind"] == "jog" and form.get("value") != "Set origin"):
self.queue.pop(0)
if not self.open_next():
s.update(WRITES.get(wid, {}))
self.dark.update({"running": False, "prompt": None, "progress": 100,
"result": dict(SCRIPT[wid][1])})
self.versions[wid] = 1
return 200, {"ok": True}
if path == "/settings":
for k, v in form.items():
if v == "":
s.pop(k, None)
else:
s[k] = v
return 200, dict(s)
return None
class SheetRunTests(unittest.TestCase):
def setUp(self):
self.fake = helpers.FakeForgectrl().start()
self.daemon = ScriptedDaemon(self.fake)
def tearDown(self):
self.fake.stop()
def test_the_whole_sheet_on_one_piece(self):
t = catalog.load_suite()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True # the ready gate passes; the arm cue is a notice
t.fn(Context(run, None, t))
d = self.daemon
self.assertEqual(d.order, ["sheet.place"] + LIVE)
self.assertEqual(d.answers["sheet.place"], ["X+10", "X-10", "Set origin", "Full sheet", "3.2"])
self.assertEqual(d.answers["sheet.frame"], ["Continue", "Yes"])
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
self.assertEqual(d.previews, LIVE)
cards = run.evidence["cards"]
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
for wid in LIVE:
self.assertEqual(cards[wid]["emission"]["hv_max"], 610, wid)
self.assertEqual(cards[wid]["result"], SCRIPT[wid][1], wid)
self.assertNotIn("emission", run.evidence)
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
self.assertEqual(run.evidence["settings_before"], FOUND)
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
def test_imperial_units_answer_the_thickness_in_inches(self):
# The number prompts read ui_units: on an imperial machine the
# placement's thickness is 0.125 in and the record carries 3.175 mm.
self.fake.state["settings"]["ui_units"] = "imperial"
place_prompts, place_result = SCRIPT["sheet.place"]
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=3.175))
try:
t = catalog.load_suite()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
t.fn(Context(run, None, t))
self.assertEqual(self.daemon.answers["sheet.place"][-1], "0.125")
self.assertEqual(run.evidence["thickness_answer"], "0.125")
finally:
SCRIPT["sheet.place"] = (place_prompts, place_result)
def test_a_metric_answer_on_an_imperial_machine_fails_the_placement(self):
# The defect the bench found: 3.2 read as inches is over the 30 mm
# cap and the wizard records 0; the test must not read that as a pass.
from forgetest.runner import Failed
place_prompts, place_result = SCRIPT["sheet.place"]
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=0.0))
try:
t = catalog.load_suite()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed) as cm:
t.fn(Context(run, None, t))
self.assertIn("thickness 0.0 mm", str(cm.exception))
self.assertEqual(self.daemon.order, ["sheet.place"]) # nothing burned
finally:
SCRIPT["sheet.place"] = (place_prompts, place_result)
def test_a_dark_burn_fails_the_run_and_still_restores(self):
from forgetest.runner import Failed
SCRIPT_DARK = dict(SCRIPT["laser.floor"][1])
SCRIPT_DARK["emission"] = {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0}
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], SCRIPT_DARK)
try:
t = catalog.load_suite()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed):
t.fn(Context(run, None, t))
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus", "laser.floor"])
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k)
finally:
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
if __name__ == "__main__":
unittest.main()
+415
View File
@@ -0,0 +1,415 @@
"""The commission.* suite on the host: the registration (ids, kinds, the
takeover tests, the operator tests' hands), the record builders, the
mDNS packet code, the cookie parsing, the LED cue, the settle rule for a
gated supervisor, and the cloud-off surface test driven end to end
against the fake daemon."""
import json
import os
import shutil
import socket
import struct
import tempfile
import unittest
import helpers
from forgetest import baseline, catalog
from forgetest.runner import Context, Run
from forgetest.suite import commission
IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot",
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes",
"commission.ssh-until-reboot", "commission.cloud-disabled-surface",
"commission.factory-return", "commission.mdns-announce", "commission.first-run-flow",
"commission.first-run-page", "commission.what-changed", "commission.record-export",
"commission.mirror")
OPERATOR = ("commission.first-run-flow", "commission.first-run-page")
class RegistrationTests(unittest.TestCase):
def setUp(self):
self.reg = catalog.load_suite()
def test_every_test_is_registered_with_its_kind(self):
kinds = {tid: self.reg[tid].kind for tid in IDS if tid in self.reg}
self.assertEqual(sorted(kinds), sorted(IDS))
for tid in OPERATOR:
self.assertEqual(kinds[tid], "operator", tid)
for tid in IDS:
if tid not in OPERATOR:
self.assertEqual(kinds[tid], "auto", tid)
self.assertNotIn("commission.root-ssh-refused", self.reg) # folded into ssh-until-reboot
self.assertNotIn("commission.wizard-first-run", self.reg) # split into the flow and the page
def test_the_record_swapping_tests_are_takeovers(self):
for tid in ("commission.gate-blocks-controllers", "commission.advisories-rehash",
"commission.first-run-flow", "commission.first-run-page",
"commission.what-changed", "commission.mirror"):
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
self.assertEqual(self.reg["commission.record-export"].hardware, "api")
def test_the_lifecycle_tests_cover_their_files(self):
changed = set(self.reg["commission.what-changed"].covers)
export = set(self.reg["commission.record-export"].covers)
mirror = set(self.reg["commission.mirror"].covers)
self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
self.assertIn(("forgectrl", "src/recordhtml.*"), export)
self.assertIn(("forgectrl", "src/logs.*"), export)
self.assertIn(("forgectrl", "src/wizdark.*"), mirror)
self.assertIn("commission.check-sensors", self.reg["commission.mirror"].requires)
def test_flag_of_reads_the_status_document(self):
w = {"required": ["airflow", {"id": "laser.floor", "reason": "the tube was replaced"}],
"recommended": [{"id": "laser.focus", "reason": "the tray was replaced"}]}
self.assertEqual(commission.flag_of(w, "required", "laser.floor"), "the tube was replaced")
self.assertEqual(commission.flag_of(w, "recommended", "laser.focus"), "the tray was replaced")
self.assertIsNone(commission.flag_of(w, "required", "airflow")) # a plain id is the table, not a flag
self.assertIsNone(commission.flag_of(w, "required", "laser.focus"))
self.assertIsNone(commission.flag_of({}, "recommended", "x"))
def test_the_first_run_flow_runs_unattended_with_the_fixture_and_the_page_walk_does_not(self):
flow = self.reg["commission.first-run-flow"]
self.assertEqual(list(flow.actions), ["button"])
self.assertFalse(flow.hands)
self.assertTrue(flow.fixture_runnable(("button", "lid", "interlock")))
page = self.reg["commission.first-run-page"]
self.assertTrue(page.hands)
self.assertFalse(page.fixture_runnable(("button", "lid", "interlock")))
self.assertIn("commission.first-run-flow", page.requires)
def test_the_first_run_split_keeps_the_backend_and_the_page_apart(self):
flow = set(self.reg["commission.first-run-flow"].covers)
page = set(self.reg["commission.first-run-page"].covers)
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
self.assertIn(want, flow, want)
self.assertNotIn(want, page, want)
for want in (("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/md.js"), ("forgectrl", "src/tls.*")):
self.assertIn(want, page, want)
def test_the_only_attended_tests_need_a_workstation_or_the_sheet(self):
# With the bench actuator up, three commissioning tests keep a
# person: the page walk, the print from the Glowforge app, and
# the sheet. Everything else runs from the queue.
attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("commission.")
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
self.assertEqual(attended, ["commission.cloud-header-capture", "commission.first-run-page",
"commission.sheet"])
def test_the_factory_return_never_runs_the_return(self):
import inspect
t = self.reg["commission.factory-return"]
src = inspect.getsource(t.fn)
self.assertNotIn('"confirm": "1"', src) # the only argument that starts the return
self.assertIn('"confirm": "0"', src)
self.assertEqual(t.kind, "auto") # the return itself is a bench drill, not a test
self.assertFalse(t.hands)
def test_mdns_covers_nothing_by_design(self):
self.assertEqual(self.reg["commission.mdns-announce"].covers, ())
def test_the_login_test_makes_its_own_account(self):
# No bench credentials, no precheck: the test installs a temporary
# account under a takeover and restores the real one.
t = self.reg["commission.account-login"]
self.assertIsNone(getattr(t, "precheck", None))
self.assertEqual(t.hardware, "takeover")
with open(commission.__file__) as f:
src = f.read()
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
self.assertNotIn(k, src)
class RecordTests(unittest.TestCase):
STATUS = {"sheet_id": "ABCDE-FGHIJ",
"documents": [{"id": "safety-and-risk", "hash": "a" * 64, "consent": "typed"},
{"id": "licenses", "hash": "b" * 64, "consent": "check"}],
"wizards": [{"id": "advisories", "version": 1}, {"id": "account", "version": 1},
{"id": "machine", "version": 2}]}
def test_complete_record_counts_as_commissioned(self):
rec = commission.complete_record(self.STATUS)
self.assertEqual(rec["schema"], 1)
self.assertEqual(rec["advisories"]["safety-and-risk"]["hash"], "a" * 64)
self.assertEqual(rec["advisories"]["safety-and-risk"]["method"], "typed")
self.assertIn("pressed_at", rec["acceptance"])
self.assertEqual(rec["account"]["name"], "bench")
self.assertEqual(rec["wizards"]["machine"]["version"], 2)
self.assertEqual(rec["sheet_id"], "ABCDE-FGHIJ")
self.assertTrue(rec["completed"])
json.dumps(rec)
def test_complete_record_keeps_the_base_account_and_machine(self):
base = {"account": {"name": "owner", "uid": 1000, "created": "x"}, "machine": {"model": "pro"},
"sheet_id": "KKKKK-LLLLL", "created": "then"}
rec = commission.complete_record(self.STATUS, base)
self.assertEqual(rec["account"]["name"], "owner")
self.assertEqual(rec["machine"], {"model": "pro"})
self.assertEqual(rec["sheet_id"], "KKKKK-LLLLL")
self.assertEqual(rec["created"], "then")
def test_without_wizards_keeps_consent_and_account(self):
rec = commission.complete_record(self.STATUS)
rec["flags"] = {"machine": {"level": "required", "reason": "x"}}
out = commission.record_without_wizards(rec)
self.assertEqual(out["wizards"], {})
self.assertEqual(out["flags"], {})
self.assertEqual(out["advisories"], rec["advisories"])
self.assertEqual(out["account"], rec["account"])
self.assertEqual(rec["flags"], {"machine": {"level": "required", "reason": "x"}}) # a copy
def test_write_file_and_remove(self):
tmp = tempfile.mkdtemp(prefix="forgetest-comm-")
try:
p = os.path.join(tmp, "sub", "commissioning.json")
commission.write_file(p, b"{}\n")
self.assertEqual(commission.read_file(p), b"{}\n")
if os.name == "posix": # a mode means nothing on a Windows host
self.assertEqual(oct(os.stat(p).st_mode & 0o777), oct(0o600))
commission.write_file(p, None)
self.assertIsNone(commission.read_file(p))
commission.write_file(p, None) # a second remove is silent
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_paths_follow_the_daemon_environment(self):
os.environ["FORGECTRL_DATA_DIR"] = "/tmp/ffdata"
os.environ["GF_RUN_DIR"] = "/tmp/ffrun"
try:
self.assertEqual(commission.record_path(), "/tmp/ffdata/commissioning.json")
self.assertEqual(commission.users_path(), "/tmp/ffdata/users")
self.assertEqual(commission.override_path(), "/tmp/ffrun/commissioning-override")
self.assertEqual(commission.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
finally:
os.environ.pop("FORGECTRL_DATA_DIR", None)
os.environ.pop("GF_RUN_DIR", None)
class MdnsTests(unittest.TestCase):
def test_query_is_a_unicast_response_question(self):
q = commission.mdns_query("forgefirm.local", qid=0x1234)
qid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", q[:12])
self.assertEqual((qid, flags, qd, an, ns, ar), (0x1234, 0, 1, 0, 0, 0))
self.assertEqual(q[12:], b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 0x8001))
def _response(self, qid, name_bytes, addr, flags=0x8400, extra=b""):
q = b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 1)
rr = name_bytes + struct.pack(">HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(addr)
return struct.pack(">HHHHHH", qid, flags, 1, 1, 0, 0) + q + rr + extra
def test_answers_follow_a_compression_pointer(self):
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
self.assertEqual(commission.mdns_answers(pkt, 7), [("forgefirm.local", "192.168.1.9")])
def test_answers_with_the_name_spelled_out(self):
pkt = self._response(0, b"\x09forgefirm\x05local\x00", "10.0.0.5")
self.assertEqual(commission.mdns_answers(pkt), [("forgefirm.local", "10.0.0.5")])
def test_wrong_id_or_a_query_yields_nothing(self):
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
self.assertEqual(commission.mdns_answers(pkt, 8), [])
self.assertEqual(commission.mdns_answers(self._response(7, b"\xc0\x0c", "1.2.3.4", flags=0), 7), [])
self.assertEqual(commission.mdns_answers(b"\x00" * 5), [])
def test_a_truncated_packet_yields_nothing(self):
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
self.assertEqual(commission.mdns_answers(pkt[:20], 7), [])
class SmallHelpersTests(unittest.TestCase):
def test_cookie_parsing(self):
sid = "ab" * 32
value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid
self.assertEqual(commission.session_from_cookie(value), sid)
self.assertEqual(commission.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
self.assertIsNone(commission.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
self.assertIsNone(commission.session_from_cookie(""))
def test_the_teal_breathe_cue(self):
self.assertTrue(commission.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
"pulse_off": [0, 1400, 1400]}))
self.assertFalse(commission.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0],
"pulse_off": [0, 0, 0]})) # solid green
self.assertFalse(commission.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800],
"pulse_off": [1800] * 3})) # white
self.assertFalse(commission.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
self.assertFalse(commission.breathes_teal({}))
def test_decode(self):
self.assertEqual(commission.decode(b'{"a": 1}'), {"a": 1})
self.assertEqual(commission.decode(b"cloud mode is not enabled"), "cloud mode is not enabled")
class GatedSettleTests(unittest.TestCase):
"""A supervisor that reports the commissioning gate closed is settled:
it spawns nothing until the gate opens, so a takeover that installs
a gating record must not wait the whole settle timeout for it."""
def setUp(self):
self.fake = helpers.FakeForgectrl().start()
baseline.Baseline._unreachable_until = 0.0
def tearDown(self):
self.fake.stop()
def test_gated_returns_at_once(self):
self.fake.state["mode"] = {"mode": "grbl", "controller": "gated", "pid": 0, "motion": "unverified",
"gated": True, "why": "commissioning required: machine"}
lines = []
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
self.assertEqual(body["controller"], "gated")
self.assertTrue(any("controller=gated" in ln for ln in lines))
class FirstRunSeedTests(unittest.TestCase):
"""The record the first-run tests install: a first run behind the
checks and the sheet."""
STATUS = {"wizards": [{"id": "advisories", "version": 1, "class": "form"},
{"id": "account", "version": 1, "class": "form"},
{"id": "cloud", "version": 1, "class": "form"},
{"id": "switches", "version": 1, "class": "dark"},
{"id": "motion", "version": 2, "class": "dark"},
{"id": "sheet.frame", "version": 1, "class": "live"}]}
def test_no_consent_no_account_not_complete_and_every_check_done(self):
base = {"created": "2026-09-01T00:00:00Z", "completed": "2026-09-02T00:00:00Z",
"advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"},
"account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True},
"wizards": {"advisories": {"version": 1}, "switches": {"version": 1, "result": {"lid": True}}}}
rec = commission.first_run_record(self.STATUS, base)
self.assertNotIn("completed", rec)
self.assertNotIn("acceptance", rec)
self.assertNotIn("account", rec)
self.assertEqual(rec["advisories"], {})
self.assertEqual(rec["created"], "2026-09-01T00:00:00Z")
self.assertEqual(rec["flags"], {"flow_thin": True})
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
self.assertEqual(rec["wizards"]["switches"]["result"], {"lid": True}) # kept from the base
self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version
def test_without_a_base_record(self):
rec = commission.first_run_record(self.STATUS, None)
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
class SshdPolicyTests(unittest.TestCase):
def test_the_three_keys_out_of_sshd_t(self):
from forgetest import hw
calls = []
def fake_run(cmd, timeout=60):
calls.append(cmd)
return 0, "port 22\npermitrootlogin no\npasswordauthentication yes\npermitemptypasswords no\nx11forwarding no\n"
real = hw.run
hw.run = fake_run
try:
self.assertEqual(commission.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
"permitemptypasswords": "no"})
finally:
hw.run = real
self.assertEqual(calls, [["/usr/sbin/sshd", "-T"]])
def test_a_failing_sshd_reports_the_error(self):
from forgetest import hw
real = hw.run
hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available")
try:
self.assertEqual(commission.sshd_policy(), {"error": "sshd: no hostkeys available"})
finally:
hw.run = real
class CloudDisabledSurfaceTests(unittest.TestCase):
"""The cloud-off surface test against the fake daemon: cloud_enabled
is turned off with one write that sweeps the cloud choices, the three
cloud-pointing writes and the phrase-less cloud_enabled=1 are refused,
the settings are left alone, and the prior values come back."""
def setUp(self):
self.fake = helpers.FakeForgectrl().start()
self.fake.state["settings"].update({"controller_mode": "grbl", "homing_mode": "none",
"cloud_enabled": "1", "ui_units": "metric"})
def on_post(path, form):
s = self.fake.state["settings"]
if path == "/settings":
# as the daemon rules it: on from off takes the typed phrase
if form.get("cloud_enabled") == "1" and s.get("cloud_enabled") != "1" \
and form.get("phrase") != "I UNDERSTAND":
return 400, "type I UNDERSTAND to turn cloud mode on"
# the request's own cloud choices, as main.c checks them
enabled = form.get("cloud_enabled", s.get("cloud_enabled")) == "1"
if not enabled and form.get("controller_mode") == "cloud":
return 409, "cloud mode is not enabled on this machine"
if not enabled and form.get("homing_mode") == "gfcloud":
return 409, "cloud homing needs cloud mode enabled"
for k, v in form.items():
if k == "phrase":
continue
if v == "":
s.pop(k, None)
else:
s[k] = v
# off sweeps what pointed at the cloud, as the cloud step does
if form.get("cloud_enabled") == "0":
if "homing_mode" not in form and s.get("homing_mode") == "gfcloud":
s["homing_mode"] = "none"
if "controller_mode" not in form and s.get("controller_mode") == "cloud":
s["controller_mode"] = "grbl"
return 200, dict(s)
if path == "/mode" and form.get("controller") == "cloud" and s.get("cloud_enabled") != "1":
return 409, "cloud mode is not enabled on this machine"
return None
self.fake.on_post = on_post
def tearDown(self):
self.fake.stop()
def run_test(self):
t = catalog.load_suite()["commission.cloud-disabled-surface"]
run = Run("test", t.id, t.title)
t.fn(Context(run, None, t))
return run
def test_refusals_and_restore(self):
run = self.run_test()
s = self.fake.state["settings"]
self.assertEqual(s["cloud_enabled"], "1") # restored, with the phrase
self.assertEqual(s["controller_mode"], "grbl")
self.assertEqual(s["homing_mode"], "none")
self.assertNotIn("phrase", s)
posts = [(p, f) for p, f in self.fake.posts]
self.assertIn(("/settings", {"cloud_enabled": "0"}), posts)
self.assertIn(("/settings", {"cloud_enabled": "1"}), posts) # refused: no phrase
self.assertIn(("/settings", {"cloud_enabled": "1", "phrase": "I UNDERSTAND"}), posts)
self.assertIn(("/mode", {"controller": "cloud"}), posts)
self.assertEqual(run.evidence["mode_cloud"]["status"], 409)
self.assertEqual(run.evidence["settings cloud_enabled=1 no phrase"], 400)
self.assertEqual(run.evidence["found"]["cloud_enabled"], "1")
def test_cloud_homing_and_boot_mode_are_swept_by_the_one_write_and_go_back(self):
# As the daemon rules it: cloud_enabled=0 takes the gfcloud homing
# and the cloud boot mode down with it, so the test writes the one
# key, checks the sweep, and puts the three back, cloud mode first.
self.fake.state["settings"].update({"controller_mode": "cloud", "homing_mode": "gfcloud"})
run = self.run_test()
s = self.fake.state["settings"]
self.assertEqual((s["cloud_enabled"], s["homing_mode"], s["controller_mode"]), ("1", "gfcloud", "cloud"))
posts = [(p, f) for p, f in self.fake.posts if p == "/settings"]
order = [f for _, f in posts]
self.assertEqual(order[:1], [{"cloud_enabled": "0"}])
self.assertEqual(order[-3:], [{"cloud_enabled": "1", "phrase": "I UNDERSTAND"},
{"homing_mode": "gfcloud"}, {"controller_mode": "cloud"}])
self.assertEqual(run.evidence["found"], {"cloud_enabled": "1", "homing_mode": "gfcloud",
"controller_mode": "cloud"})
def test_an_unset_value_is_cleared_back(self):
self.fake.state["settings"].pop("cloud_enabled")
self.run_test()
self.assertNotIn("cloud_enabled", self.fake.state["settings"])
self.assertIn(("/settings", {"cloud_enabled": ""}), self.fake.posts)
if __name__ == "__main__":
unittest.main()
+27
View File
@@ -586,6 +586,33 @@ class RoutingTests(unittest.TestCase):
finally: finally:
runner_mod.hw.button_lit = saved runner_mod.hw.button_lit = saved
def test_the_arm_press_waits_as_long_as_the_card_says_for_the_light(self):
"""A card that settles the coolant before it lights the button
takes minutes; the caller names the wait, and the actuator presses
when the light comes instead of handing the press to a person at
the default 60 s (found on the bench by the flow-load card)."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
run.fixture_takeover = True
lit_at = time.time() + 0.6
saved = runner_mod.hw.button_lit
runner_mod.hw.button_lit = lambda: time.time() >= lit_at
seen = []
run.set_notice = lambda text: seen.append(text)
try:
self.assertTrue(ctx.arm_press(lit_timeout=5))
deadline = time.time() + 5
while ("button", "press") not in self.stub.acts and time.time() < deadline:
time.sleep(0.05)
self.assertIn(("button", "press"), self.stub.acts)
self.assertEqual([n for n in seen if n], []) # nobody was asked
rec = run.evidence["actions"][-1]
self.assertEqual(rec["by"], "fixture")
self.assertGreaterEqual(rec["took_s"], 0.5)
finally:
runner_mod.hw.button_lit = saved
def test_an_actuator_lost_after_the_takeover_is_said_out_loud(self): def test_an_actuator_lost_after_the_takeover_is_said_out_loud(self):
"""Falling back to the operator without a word is how one dropped """Falling back to the operator without a word is how one dropped
actuator becomes a press nobody can account for afterwards.""" actuator becomes a press nobody can account for afterwards."""
+2 -1
View File
@@ -115,7 +115,8 @@ local_conf_header:
# would apply to every target built from this config, including the release # would apply to every target built from this config, including the release
# forgefirm-image. It lives in forgefirm-image-dev.bb's IMAGE_FEATURES so a # forgefirm-image. It lives in forgefirm-image-dev.bb's IMAGE_FEATURES so a
# single build yields a hardened release image and a debug dev image. # single build yields a hardened release image and a debug dev image.
# release.sh gates the release rootfs against a passwordless root entry. # release.sh checks the built release rootfs for the root policy (an
# empty root field, sshd refusing root and empty passwords).
build-tweaks: | build-tweaks: |
# Parallelism sized for a 12-core / 16 GB build VM; raise on larger hosts. # Parallelism sized for a 12-core / 16 GB build VM; raise on larger hosts.
+30
View File
@@ -8,6 +8,36 @@ DISTRO_FEATURES:remove = " \
3g alsa avahi bluetooth bluez5 ext2 irda nfc nfs pci pcmcia \ 3g alsa avahi bluetooth bluez5 ext2 irda nfc nfs pci pcmcia \
pulseaudio vulkan wayland x11 zeroconf " pulseaudio vulkan wayland x11 zeroconf "
# mDNS: the image installs avahi-daemon by name (forgefirm-image.bb), so
# the control panel answers at forgefirm.local. The avahi and zeroconf
# features stay removed above: zeroconf would install
# packagegroup-base-zeroconf (the daemon plus libnss-mdns), and avahi
# would switch other recipes' avahi options on. The build is trimmed to
# the daemon: no D-Bus (so no libavahi-client, no avahi-utils, no bus
# activation; the daemon reads /etc/avahi/services itself), no GTK or Qt
# front ends, no libdns_sd compatibility library, no libevent binding.
# Python bindings are off in the recipe itself.
PACKAGECONFIG:pn-avahi = ""
# libnss-mdns is what avahi-daemon recommends: an NSS module so the
# machine itself resolves .local names. Nothing on the machine does.
BAD_RECOMMENDATIONS += "libnss-mdns"
# TLS: forgectrl serves HTTPS on 443 with a self-signed certificate
# through libmicrohttpd and ulfius, and links GnuTLS itself. GnuTLS is
# trimmed to the library:
# no seccomp the distro default adds libseccomp for the gnutls test
# programs; the library does not use it
# no libidn internationalized host names in certificates; the
# machine's own certificate is plain ASCII (saves libidn2)
# no p11-kit PKCS#11 tokens and the system trust store; the machine
# presents its own certificate and verifies no peer
# through GnuTLS (saves p11-kit and its modules)
# libtasn1 stays a shared library: the bundled copy is the same
# code without poky's CVE patches
# no tpm, fips, dane, and no certificate compression (brotli, zlib,
# zstd), none of which the panel uses
PACKAGECONFIG:pn-gnutls = "libtasn1"
# opengl stays: forgectrl's camera demosaic runs as GLES2 fragment # opengl stays: forgectrl's camera demosaic runs as GLES2 fragment
# shaders on the GC880 (etnaviv), reached through surfaceless EGL with # shaders on the GC880 (etnaviv), reached through surfaceless EGL with
# no display stack. Mesa is trimmed to exactly that: the etnaviv # no display stack. Mesa is trimmed to exactly that: the etnaviv
@@ -0,0 +1,33 @@
# ForgeFIRM avahi-daemon configuration (avahi-daemon.conf(5)).
# The machine answers forgefirm.local on the WiFi link (and on eth0 when
# the machine has one), over IPv4 and IPv6. It publishes its addresses
# and the services in /etc/avahi/services, nothing else: no workstation
# record, no host information, no wide-area lookups, no reflector.
[server]
host-name=forgefirm
use-ipv4=yes
use-ipv6=yes
allow-interfaces=wlan0,eth0
ratelimit-interval-usec=1000000
ratelimit-burst=1000
[wide-area]
enable-wide-area=no
[publish]
publish-hinfo=no
publish-workstation=no
publish-addresses=yes
publish-domain=yes
[reflector]
enable-reflector=no
[rlimits]
rlimit-core=0
rlimit-data=8388608
rlimit-fsize=0
rlimit-nofile=768
rlimit-stack=8388608
rlimit-nproc=3
@@ -0,0 +1,21 @@
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<!-- ForgeFIRM control panel: HTTPS on 443 and HTTP on 80, served by
forgectrl. %h is the host name (avahi.service(5)). -->
<service-group>
<name replace-wildcards="yes">ForgeFIRM on %h</name>
<service>
<type>_https._tcp</type>
<port>443</port>
</service>
<service>
<type>_http._tcp</type>
<port>80</port>
</service>
</service-group>
@@ -0,0 +1,17 @@
FILESEXTRAPATHS:prepend := "${THISDIR}/${BPN}:"
# mDNS for the control panel: the machine answers forgefirm.local and
# advertises the panel on HTTPS 443 and HTTP 80. Only avahi-daemon is
# installed (forgefirm-image.bb); the build options that keep it to the
# daemon are in conf/distro/forgefirm.conf. The daemon reads the service
# file itself: no D-Bus is involved.
SRC_URI += " \
file://avahi-daemon.conf \
file://forgefirm.service \
"
do_install:append() {
install -m 0644 ${WORKDIR}/avahi-daemon.conf ${D}${sysconfdir}/avahi/avahi-daemon.conf
install -d ${D}${sysconfdir}/avahi/services
install -m 0644 ${WORKDIR}/forgefirm.service ${D}${sysconfdir}/avahi/services/forgefirm.service
}
@@ -0,0 +1,39 @@
# ForgeFIRM SSH policy, set in the installed files so the release image
# carries it as built:
# PermitRootLogin no root logs in at the serial console only
# PermitEmptyPasswords no an account without a password cannot log in
# PasswordAuthentication yes operator accounts log in with a password
# The dev image's debug-tweaks turns PermitRootLogin and
# PermitEmptyPasswords back to yes at rootfs time (ssh_allow_root_login
# and ssh_allow_empty_password in rootfs-postcommands.bbclass match the
# active lines too), so the bench keeps root over SSH.
#
# The init script starts sshd only when the control panel has turned it
# on (/run/forgefirm/ssh-enabled, tmpfs, gone at reboot) or on the dev
# image (/etc/forgefirm-dev). The guard sits in check_for_no_start, which
# start, reload and restart call; stop is never gated.
do_install:append() {
for config in sshd_config sshd_config_readonly; do
f=${D}${sysconfdir}/ssh/$config
[ -e "$f" ] || continue
sed -i \
-e 's/^[#[:space:]]*PermitRootLogin .*/PermitRootLogin no/' \
-e 's/^[#[:space:]]*PermitEmptyPasswords .*/PermitEmptyPasswords no/' \
-e 's/^[#[:space:]]*PasswordAuthentication .*/PasswordAuthentication yes/' \
"$f"
grep -q '^PermitRootLogin no$' "$f" \
&& grep -q '^PermitEmptyPasswords no$' "$f" \
&& grep -q '^PasswordAuthentication yes$' "$f" \
|| bbfatal "$config: the ForgeFIRM policy lines did not land"
done
init=${D}${sysconfdir}/init.d/sshd
sed -i '/^check_for_no_start() {$/a\
[ -e /run/forgefirm/ssh-enabled ] || [ -e /etc/forgefirm-dev ] || {\
echo "sshd: not enabled (turn it on from the ForgeFIRM control panel)"\
exit 0\
}' "$init"
grep -q 'forgefirm/ssh-enabled' "$init" \
|| bbfatal "init.d/sshd: the check_for_no_start anchor was not found"
}
@@ -23,12 +23,14 @@ S = "${WORKDIR}/git"
inherit cmake pkgconfig inherit cmake pkgconfig
# curl backs the default-ON WITH_CURL client-request API (find_package REQUIRED). # curl backs the default-ON WITH_CURL client-request API (find_package REQUIRED).
DEPENDS = "jansson libmicrohttpd curl orcania yder" # GnuTLS backs the secure framework: forgectrl serves HTTPS on 443 with
# a self-signed certificate.
DEPENDS = "jansson libmicrohttpd curl orcania yder gnutls"
# No systemd journald on the forgefirm image. forgectrl serves plain HTTP # No systemd journald on the forgefirm image. GnuTLS is on for HTTPS
# and no websockets: no GnuTLS (which keeps nettle, gmp, libunistring and # (the trimmed GnuTLS build is set in conf/distro/forgefirm.conf); no
# libtasn1 off the rootfs) and no websocket support, which depends on it. # websocket support, which forgectrl does not use.
EXTRA_OECMAKE += "-DWITH_JOURNALD=off -DWITH_GNUTLS=off -DWITH_WEBSOCKET=off" EXTRA_OECMAKE += "-DWITH_JOURNALD=off -DWITH_GNUTLS=on -DWITH_WEBSOCKET=off"
# ulfius builds itself with -Werror -Wconversion; under Yocto's arm32 # ulfius builds itself with -Werror -Wconversion; under Yocto's arm32
# time64 ABI (-D_TIME_BITS=64) a long-long-to-long time conversion in # time64 ABI (-D_TIME_BITS=64) a long-long-to-long time conversion in
@@ -14,7 +14,9 @@ S = "${WORKDIR}/git"
inherit cmake update-rc.d forgefirm-manifest inherit cmake update-rc.d forgefirm-manifest
DEPENDS += "ulfius jpeg" # jansson (settings, API bodies) and gnutls (the panel certificate) are
# linked directly, not only through ulfius.
DEPENDS += "ulfius jpeg jansson gnutls libxcrypt"
# media-ctl / v4l2-ctl configure the imx-media pipeline at runtime; # media-ctl / v4l2-ctl configure the imx-media pipeline at runtime;
# the update manager drives ffboot + fwup and verifies against the # the update manager drives ffboot + fwup and verifies against the
# shipped keyring; release checks and downloads use curl; the WiFi # shipped keyring; release checks and downloads use curl; the WiFi
@@ -0,0 +1,12 @@
#!/bin/sh
# udhcpc hook: /usr/share/udhcpc/default.script runs every file in
# /etc/udhcpc.d with the lease event as $1; 50default applies the lease
# first. Refresh the address block of the console banner on every event.
case "$1" in
bound|renew|deconfig|leasefail|nak)
/usr/sbin/forgefirm-banner
;;
esac
exit 0
@@ -0,0 +1,63 @@
#!/bin/sh
# Rewrites the address block of /etc/issue, the serial-console login
# banner: the control panel by mDNS name, then one https:// URL per
# global address of wlan0 (and eth0 when the machine has one). Every
# other line of /etc/issue stays as the image build wrote it. The block
# sits between the marker lines "# ForgeFIRM addresses" and "# end" and
# is appended when absent. Called by the init script at boot and by the
# udhcpc hook on every lease event. Idempotent: the file is written only
# when the block changes.
PATH=/sbin:/usr/sbin:/bin:/usr/bin
ISSUE=/etc/issue
MARK_BEGIN='# ForgeFIRM addresses'
MARK_END='# end'
PANEL='Control panel: https://forgefirm.local/'
# One URL per global address; an IPv6 address gets its URL brackets.
# Tentative, deprecated and temporary addresses are left out.
addresses () {
command -v ip >/dev/null 2>&1 || return 0
for dev in wlan0 eth0; do
[ -d "/sys/class/net/$dev" ] || continue
ip addr show dev "$dev" 2>/dev/null | awk '
($1 == "inet" || $1 == "inet6") && / scope global/ \
&& !/tentative/ && !/deprecated/ && !/temporary/ {
a = $2
sub(/\/.*/, "", a)
if ($1 == "inet6") a = "[" a "]"
print "https://" a "/"
}'
done
}
block () {
echo "$MARK_BEGIN"
echo "$PANEL"
addrs=$(addresses)
if [ -n "$addrs" ]; then
echo "$addrs"
else
echo "no network address yet"
fi
echo "$MARK_END"
}
[ -f "$ISSUE" ] || exit 0
new=$(block)
old=$(awk -v b="$MARK_BEGIN" -v e="$MARK_END" \
'$0 == b { p = 1 } p { print } $0 == e { p = 0 }' "$ISSUE")
[ "$new" = "$old" ] && exit 0
tmp="$ISSUE.tmp.$$"
awk -v b="$MARK_BEGIN" -v e="$MARK_END" -v blk="$new" '
$0 == b { print blk; seen = 1; skip = 1; next }
$0 == e && skip { skip = 0; next }
!skip { print }
END { if (!seen) print blk }
' "$ISSUE" > "$tmp" || { rm -f "$tmp"; exit 1; }
chmod 0644 "$tmp"
mv -f "$tmp" "$ISSUE"
exit 0
@@ -0,0 +1,26 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: forgefirm-banner
# Required-Start: $network
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop:
# Short-Description: ForgeFIRM console banner: control panel addresses
### END INIT INFO
# Writes the address block of /etc/issue once at boot; the udhcpc hook
# (/etc/udhcpc.d/60forgefirm-banner) keeps it current afterward.
case "$1" in
start|restart|reload|force-reload)
/usr/sbin/forgefirm-banner
;;
stop)
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1
;;
esac
exit 0
@@ -0,0 +1,30 @@
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
DESCRIPTION = "Keeps an address block in the serial-console login banner \
(/etc/issue): the control panel by mDNS name and by every global address \
of wlan0 and eth0. Refreshed at boot and on every DHCP lease event."
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
SRC_URI = " \
file://forgefirm-banner \
file://forgefirm-banner.init \
file://60forgefirm-banner \
"
S = "${WORKDIR}"
inherit update-rc.d
INITSCRIPT_NAME = "forgefirm-banner"
# 99: after networking (S01); an address the lease brings later arrives
# through the udhcpc hook.
INITSCRIPT_PARAMS = "start 99 2 3 4 5 ."
do_install() {
install -Dm 0755 ${WORKDIR}/forgefirm-banner ${D}${sbindir}/forgefirm-banner
install -Dm 0755 ${WORKDIR}/forgefirm-banner.init ${D}${sysconfdir}/init.d/forgefirm-banner
# busybox udhcpc runs /etc/udhcpc.d/* (run-parts) on every lease event
install -Dm 0755 ${WORKDIR}/60forgefirm-banner ${D}${sysconfdir}/udhcpc.d/60forgefirm-banner
}
FILES:${PN} += "${sysconfdir}/udhcpc.d"
@@ -0,0 +1,11 @@
# ForgeFIRM: an interactive root shell (the serial console, or SSH on
# the dev image) starts with a warning. Non-interactive shells (scp,
# rsync, ssh with a command) print nothing.
case "$-" in
*i*)
if [ "$(id -u 2>/dev/null)" = "0" ]; then
echo "You are root on a laser cutter."
echo "A wrong command here can damage the machine or hurt someone. Take care."
fi
;;
esac
@@ -0,0 +1,152 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: forgefirm-users
# Required-Start: $local_fs
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop:
# Short-Description: ForgeFIRM accounts: replay the record into the system files
### END INIT INFO
# The account record is the source of truth for the operator accounts;
# the account files on the rootfs are rebuilt from it. forgectrl writes
# the record and runs "reload" here. At boot this runs at S05: /data is
# mounted (mountall, rcS) and sshd (S09) is not up yet.
#
# Record: /data/forgefirm/users, one line per account
# name:hash:uid
# hash is a sha512-crypt string ($6$...), uid is 1000 or more.
# Home directory /data/forgefirm/home/<name>, mode 0700. Shell /bin/sh.
#
# Rules:
# - idempotent: a second run changes nothing;
# - no record: nothing happens (a machine before the first-run wizard,
# or a bench image without /data);
# - every local account with a uid from 1000 to 65533 that the record
# does not name is removed, so an account reset removes the old
# account; root and the system accounts are never touched.
PATH=/sbin:/usr/sbin:/bin:/usr/bin
RECORD=/data/forgefirm/users
HOMES=/data/forgefirm/home
LOGIN_SHELL=/bin/sh
UID_LOW=1000
UID_HIGH=65533
log () {
echo "forgefirm-users: $*"
{ echo "forgefirm-users: $*" > /dev/kmsg; } 2>/dev/null
}
# A portable account name: a letter or underscore, then letters, digits,
# underscore, dash.
valid_name () {
case "$1" in
''|[!a-z_]*|*[!a-z0-9_-]*) return 1 ;;
esac
return 0
}
valid_uid () {
case "$1" in
''|*[!0-9]*) return 1 ;;
esac
[ "$1" -ge "$UID_LOW" ] && [ "$1" -le "$UID_HIGH" ]
}
passwd_uid () {
awk -F: -v n="$1" '$1 == n { print $3; exit }' /etc/passwd
}
group_exists () {
awk -F: -v n="$1" '$1 == n { f = 1 } END { exit !f }' /etc/group
}
# ensure_account name hash uid
ensure_account () {
name=$1
hash=$2
uid=$3
home="$HOMES/$name"
cur=$(passwd_uid "$name")
if [ -n "$cur" ] && [ "$cur" != "$uid" ]; then
log "account $name has uid $cur, the record says $uid: recreating it"
userdel -f "$name" >/dev/null 2>&1
groupdel "$name" >/dev/null 2>&1
cur=""
fi
if ! group_exists "$name"; then
groupadd -g "$uid" "$name" || { log "groupadd $name failed"; return 1; }
fi
if [ -z "$cur" ]; then
useradd -M -u "$uid" -g "$uid" -d "$home" -s "$LOGIN_SHELL" "$name" \
|| { log "useradd $name failed"; return 1; }
log "account $name created (uid $uid)"
fi
if [ ! -d "$home" ]; then
mkdir -p "$home" && chmod 0700 "$home"
fi
chown "$uid:$uid" "$home"
# -p stores the hash as it is. Home and shell are set again so an
# account file edited by hand converges on the record.
usermod -d "$home" -s "$LOGIN_SHELL" -p "$hash" "$name" \
|| log "usermod $name failed"
}
# Remove every local account in the operator uid range that the record
# does not name. KEEP holds the record's names, space separated.
prune () {
for name in $(awk -F: -v lo="$UID_LOW" -v hi="$UID_HIGH" \
'$3 + 0 >= lo && $3 + 0 <= hi { print $1 }' /etc/passwd); do
[ "$name" = root ] && continue
case " $KEEP " in
*" $name "*) continue ;;
esac
log "removing account $name (not in the record)"
userdel -f "$name" >/dev/null 2>&1 || log "userdel $name failed"
groupdel "$name" >/dev/null 2>&1
done
}
replay () {
[ -f "$RECORD" ] || return 0
mkdir -p "$HOMES"
KEEP=""
while IFS=: read -r name hash uid rest; do
[ -n "$name" ] || continue
case "$name" in '#'*) continue ;; esac
if ! valid_name "$name" || ! valid_uid "$uid" || [ -z "$hash" ]; then
log "skipping a malformed record line ($name)"
continue
fi
if [ "$name" = root ]; then
log "ignoring a root line in the record"
continue
fi
ensure_account "$name" "$hash" "$uid" && KEEP="$KEEP $name"
done < "$RECORD"
prune
}
case "$1" in
start|reload|restart|force-reload)
replay
;;
stop)
;;
status)
awk -F: -v lo="$UID_LOW" -v hi="$UID_HIGH" \
'$3 + 0 >= lo && $3 + 0 <= hi { print $1 " (uid " $3 ")" }' /etc/passwd
;;
*)
echo "Usage: $0 {start|stop|reload|restart|status}"
exit 1
;;
esac
exit 0
@@ -0,0 +1,29 @@
SUMMARY = "ForgeFIRM operator accounts: record replay and the root shell warning"
DESCRIPTION = "Replays the account record (/data/forgefirm/users, written \
by forgectrl) into the system account files at boot and on reload, \
removes the local accounts the record does not name, and installs the \
warning an interactive root shell prints."
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
SRC_URI = " \
file://forgefirm-users.init \
file://forgefirm-root.sh \
"
S = "${WORKDIR}"
inherit update-rc.d
INITSCRIPT_NAME = "forgefirm-users"
# 05: rcS has run (mountall mounted /data at S03) and sshd starts at
# S09, so the accounts exist before the first login can arrive.
INITSCRIPT_PARAMS = "start 05 2 3 4 5 ."
# useradd, groupadd, usermod, userdel, groupdel
RDEPENDS:${PN} += "shadow"
do_install() {
install -Dm 0755 ${WORKDIR}/forgefirm-users.init ${D}${sysconfdir}/init.d/forgefirm-users
install -Dm 0644 ${WORKDIR}/forgefirm-root.sh ${D}${sysconfdir}/profile.d/forgefirm-root.sh
}
@@ -18,7 +18,7 @@
DAEMON_BIN=/usr/bin/grblHAL_glowforge DAEMON_BIN=/usr/bin/grblHAL_glowforge
TOKEN_FILE=/data/forgefirm/panel.token TOKEN_FILE=/data/forgefirm/panel.token
CTRL_URL="http://127.0.0.1:${FORGECTRL_PORT:-8080}" CTRL_URL="http://127.0.0.1:${FORGECTRL_PORT:-80}"
super_post() { super_post() {
[ -r "$TOKEN_FILE" ] || return 1 [ -r "$TOKEN_FILE" ] || return 1
@@ -43,3 +43,13 @@ IMAGE_ROOTFS_EXTRA_SPACE = "262144"
# Dev builds identify by build timestamp (matches the artifact name), # Dev builds identify by build timestamp (matches the artifact name),
# tagged so a bench machine is never mistaken for a release. # tagged so a bench machine is never mistaken for a release.
FORGEFIRM_VERSION_STRING = "${DATETIME} (dev)" FORGEFIRM_VERSION_STRING = "${DATETIME} (dev)"
# /etc/forgefirm-dev marks a dev image on the rootfs; its content is the
# version string. The sshd init script starts sshd on a dev image without
# the panel's enable flag (/run/forgefirm/ssh-enabled), and forgectrl
# reads the same marker. A release image has no such file.
write_forgefirm_dev_marker() {
echo "${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/forgefirm-dev
}
write_forgefirm_dev_marker[vardepsexclude] += "DATETIME"
ROOTFS_POSTPROCESS_COMMAND += "write_forgefirm_dev_marker;"
@@ -23,9 +23,10 @@ FORGEFIRM_RELEASE_TRIM ?= "nano"
IMAGE_INSTALL:remove = "python3 ${FORGEFIRM_RELEASE_TRIM}" IMAGE_INSTALL:remove = "python3 ${FORGEFIRM_RELEASE_TRIM}"
# grblhal-glowforge: the grblHAL motion controller (Grbl over TCP:23). # grblhal-glowforge: the grblHAL motion controller (Grbl over TCP:23).
# forgectrl: the ForgeFIRM machine-services daemon (HTTP :8080): controller # forgectrl: the ForgeFIRM machine-services daemon (HTTP :80, HTTPS :443
# supervisor, pulse-device broker, cooling engine, cameras, telemetry, # with a self-signed certificate): controller supervisor, pulse-device
# settings, diagnostics, web control panel, and A/B updates. # broker, cooling engine, cameras, telemetry, settings, diagnostics, web
# control panel, and A/B updates.
# gfhome: one-shot Glowforge web-service homing, invoked by the controller # gfhome: one-shot Glowforge web-service homing, invoked by the controller
# for $H when homing_mode = gfcloud (/data/forgefirm.conf). # for $H when homing_mode = gfcloud (/data/forgefirm.conf).
# gfcloud: full Glowforge web-service controller daemon (the factory cloud # gfcloud: full Glowforge web-service controller daemon (the factory cloud
@@ -44,6 +45,31 @@ IMAGE_INSTALL:remove = "python3 ${FORGEFIRM_RELEASE_TRIM}"
# VIRTUAL-RUNTIME_base-utils-syslog (conf/distro/forgefirm.conf). # VIRTUAL-RUNTIME_base-utils-syslog (conf/distro/forgefirm.conf).
IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome gfcloud v4l-utils fwup ffboot slotmigrate forgefirm-logging" IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome gfcloud v4l-utils fwup ffboot slotmigrate forgefirm-logging"
# forgefirm-users: replays the operator account record
# (/data/forgefirm/users, written by forgectrl) into the system account
# files at boot, before sshd, and on reload; also installs the warning an
# interactive root shell prints. forgefirm-banner: keeps the control
# panel addresses in the serial-console banner (/etc/issue).
# avahi-daemon: mDNS, so the panel answers at https://forgefirm.local/
# and shows up in service browsers. The daemon is installed by name (the
# zeroconf distro feature stays off: it would bring libnss-mdns); the
# build options and the configuration are in conf/distro/forgefirm.conf
# and recipes-connectivity/avahi.
IMAGE_INSTALL:append = " forgefirm-users forgefirm-banner avahi-daemon"
# Root policy. root has no password and logs in at the serial console
# only: that is the recovery path when the network, the panel or an
# account is broken, and the console is behind the case. Over the
# network, sshd refuses root (PermitRootLogin no) and any account without
# a password (PermitEmptyPasswords no), both set by
# recipes-connectivity/openssh, and sshd runs only when the panel turns
# it on. Operator logins are the accounts in the record (forgefirm-users).
# empty-root-password keeps the rootfs postprocess from locking root
# (zap_empty_root_password in rootfs-postcommands.bbclass); it is not
# debug-tweaks, which belongs to the dev image alone and would open SSH.
# scripts/release.sh checks the built rootfs for exactly this state.
IMAGE_FEATURES += "empty-root-password"
# Mesa GLES2/EGL on etnaviv for forgectrl's GPU demosaic (loaded with # Mesa GLES2/EGL on etnaviv for forgectrl's GPU demosaic (loaded with
# dlopen at runtime; forgectrl itself has no build-time GL dependency, # dlopen at runtime; forgectrl itself has no build-time GL dependency,
# and without these packages it falls back to the NEON path). # and without these packages it falls back to the NEON path).
@@ -80,3 +106,30 @@ write_forgefirm_version() {
} }
write_forgefirm_version[vardepsexclude] += "DATETIME" write_forgefirm_version[vardepsexclude] += "DATETIME"
ROOTFS_POSTPROCESS_COMMAND += "write_forgefirm_version;" ROOTFS_POSTPROCESS_COMMAND += "write_forgefirm_version;"
# The license texts ride with the software. The license class writes
# the image's license manifest (every installed package with its
# license) and copies each package's license texts into
# /usr/share/common-licenses, one copy of each generic text and
# symlinks to it per package. That tree costs several megabytes of
# small files on a raw ext4 rootfs, so it is packed into one
# reproducible tar.gz (sorted names, no timestamps, no owners) at
# /usr/share/forgefirm/licenses.tar.gz and the tree is removed. The
# control panel serves the bundle and its manifest (GET /system/licenses,
# GET /system/licenses/manifest). The license class runs first
# (license_create_manifest is prepended to this list); this step is
# appended, so it runs after.
COPY_LIC_MANIFEST = "1"
COPY_LIC_DIRS = "1"
pack_licenses() {
d="${IMAGE_ROOTFS}${datadir}/common-licenses"
[ -d "$d" ] || bbfatal "pack_licenses: $d is missing (COPY_LIC_DIRS off?)"
[ -f "$d/license.manifest" ] || bbfatal "pack_licenses: no license.manifest in $d"
install -d "${IMAGE_ROOTFS}${datadir}/forgefirm"
tar -C "${IMAGE_ROOTFS}${datadir}" --sort=name --mtime=@0 --owner=0 --group=0 \
--numeric-owner -cf - common-licenses | gzip -9 -n \
> "${IMAGE_ROOTFS}${datadir}/forgefirm/licenses.tar.gz"
rm -rf "$d"
}
ROOTFS_POSTPROCESS_COMMAND += "pack_licenses;"
@@ -1,3 +1,8 @@
# forgectrl serves plain HTTP on the LAN: no TLS in the HTTP library, so # forgectrl serves HTTPS on 443 with a self-signed certificate through
# the rootfs carries no GnuTLS stack for it. # this library, so the https option (GnuTLS) is on; curl is the poky
PACKAGECONFIG:remove = "https" # default (it builds the library's own test client). libgcrypt is a
# build-time dependency of the https option in the poky recipe;
# libmicrohttpd links it only for a GnuTLS older than 2.12, so it does
# not reach the rootfs. The GnuTLS build options are in
# conf/distro/forgefirm.conf.
PACKAGECONFIG = "curl https"
+2
View File
@@ -35,6 +35,8 @@ page's takeover does that; from a host, stop them first.
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the density dose curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m4feeds [S] [F1] [F2]` (the density time base across feeds: one out-and-back line pair per feed at the same S under M4 density, one armed run; the operator reads within-line evenness and reversal darkness at both feeds - M4's velocity scaling is what holds dose per mm through the accel), `m4corner [S] [F]` (M4 velocity-scaled power into corners: a corner-heavy vector pattern at 30 % under M4 density, one armed run; the operator confirms every commanded segment marks - the floor makes a dropout unreachable - and the drill asserts the arm report, one discharge window and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <secs> [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. | | `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the density dose curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m4feeds [S] [F1] [F2]` (the density time base across feeds: one out-and-back line pair per feed at the same S under M4 density, one armed run; the operator reads within-line evenness and reversal darkness at both feeds - M4's velocity scaling is what holds dose per mm through the accel), `m4corner [S] [F]` (M4 velocity-scaled power into corners: a corner-heavy vector pattern at 30 % under M4 density, one armed run; the operator confirms every commanded segment marks - the floor makes a dropout unreachable - and the drill asserts the arm report, one discharge window and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <secs> [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
| `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. | | `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. |
| `gfbench.py` | Not a tool: the helper the board/host tools share - `HOST`/`LOCAL` from `GF_HOST`, `board(cmd)` (local `sh -c` or ssh), the factory coolant conversion `degc()`, `data_path()` (`FORGETEST_BENCH_DATA` or next to the tool), forgectrl's HTTP API with the panel token, `setting(key)` (from forgectrl, or from `/data/forgefirm.conf` on the board while forgectrl is stopped). | | `gfbench.py` | Not a tool: the helper the board/host tools share - `HOST`/`LOCAL` from `GF_HOST`, `board(cmd)` (local `sh -c` or ssh), the factory coolant conversion `degc()`, `data_path()` (`FORGETEST_BENCH_DATA` or next to the tool), forgectrl's HTTP API with the panel token, `setting(key)` (from forgectrl, or from `/data/forgefirm.conf` on the board while forgectrl is stopped). |
| `lens_travel.py` | Lens travel drill (runs on the board; stops the controller through forgectrl and restarts it; lens motion only): steps the lens over sysfs the way the focus card's lens home does and counts, per condition and round, the hall's hysteresis band (down off the rising edge until the hall leaves home, back up until it reads home), the rising edge's height above the bottom stop after a drive onto it, and the top stop's height above the edge (a drive up, down until the hall leaves home, back up; the difference). Conditions: the card's half-step drive onto the stop, a short drive, a long one, the factory's full-step home mode; `--ladder` drives a list of half-step descents below the leave-home point and counts each back (exact until the stop, short by an even number after a stall), `--current` picks the run current, the hold current, or hold down / run up, `--settle` and `--cadence` vary the timing; `--park bottom|top|edge-N|edge+N` holds the lens there for a depth-gauge reading with the controller in standby, `--park edge` returns it to the rising edge and restarts the controller. Stall drills on the bench reference machine only. The posture found is put back. |
| `lens_stop_accel.py` | Lens stop detection by the head accelerometer (runs on the board; stops the controller through forgectrl and restarts it; lens motion only; stall drills on the bench reference machine only): reads the LIS2HH12 straight over `/dev/i2c-3` in six-byte bursts at 800 Hz (the iio path waits a sample period per read), steps the lens one half-step at a time from the hall's rising edge toward each stop and past it, and prints per step the peak-to-peak on each axis. A free step rings strongly on every second half-step; at a stop the ring dies, and a rotor slip a few steps later is a burst three times any free ring. `--find N` runs the contact rule N times per stop (a strong-parity step ringing under `--thresh`, or a burst over four times it, calls contact; the lens backs off `--back` and the count home proves no slip). `--save` keeps the per-step sample traces as JSON. |
| `fan_test.py` | Fan/coolant bench (board or host; controller running): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. | | `fan_test.py` | Fan/coolant bench (board or host; controller running): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. |
| `fan_floor_measure.py` | The numbers the airflow gates ship with (board or host): `spinup` opens a run session with M8 from idle and samples the four tachs and the purge current at 1 Hz, reporting per fan the steady speed, the time to 90 percent and the spread over the steady window, plus the purge current at idle and at run duty (the pump is always on; a dead one reads about 1), and candidate floors at 55 percent; `cut` samples only, during a real cut, for the spread under load. Results as JSON in the bench data directory. | | `fan_floor_measure.py` | The numbers the airflow gates ship with (board or host): `spinup` opens a run session with M8 from idle and samples the four tachs and the purge current at 1 Hz, reporting per fan the steady speed, the time to 90 percent and the spread over the steady window, plus the purge current at idle and at run duty (the pump is always on; a dead one reads about 1), and candidate floors at 55 percent; `cut` samples only, during a real cut, for the spread under load. Results as JSON in the bench data directory. |
| `flow_characterize.py` | Coolant flow characterization using the factory temperature curve (board or host; forgectrl and controller stopped): baseline → flow → no-flow → recovery, printing the ΔT bands and their separation. Takes the heater duty as an argument (`flow_characterize.py 30`); aborts if downstream passes 45 °C. | | `flow_characterize.py` | Coolant flow characterization using the factory temperature curve (board or host; forgectrl and controller stopped): baseline → flow → no-flow → recovery, printing the ΔT bands and their separation. Takes the heater duty as an argument (`flow_characterize.py 30`); aborts if downstream passes 45 °C. |
+7 -7
View File
@@ -16,7 +16,7 @@ import sys
import time import time
import urllib.request import urllib.request
from live_fire_drills import Grbl, HOST, PORT from live_fire_drills import Grbl, HOST, PORT, BASE
def raw(): def raw():
@@ -28,11 +28,11 @@ def raw():
def status(): def status():
with urllib.request.urlopen('http://127.0.0.1:8080/status', timeout=2) as r: with urllib.request.urlopen(BASE + '/status', timeout=2) as r:
s = json.load(r) s = json.load(r)
with urllib.request.urlopen('http://127.0.0.1:8080/cool/status', timeout=2) as r: with urllib.request.urlopen(BASE + '/cool/status', timeout=2) as r:
c = json.load(r) c = json.load(r)
with urllib.request.urlopen('http://127.0.0.1:8080/settings', timeout=2) as r: with urllib.request.urlopen(BASE + '/settings', timeout=2) as r:
st = json.load(r) st = json.load(r)
return (s['coolant']['down_c'], s['coolant']['up_c'], c['down_c'], c['up_c'], c['phase'], return (s['coolant']['down_c'], s['coolant']['up_c'], c['down_c'], c['up_c'], c['phase'],
st.get('cool_aa_offset_counts')) st.get('cool_aa_offset_counts'))
@@ -58,7 +58,7 @@ def post_settings(**kv):
tok = f.read().strip() tok = f.read().strip()
except OSError: except OSError:
tok = '' tok = ''
req = urllib.request.Request('http://127.0.0.1:8080/settings', req = urllib.request.Request(BASE + '/settings',
data=urllib.parse.urlencode(kv).encode(), data=urllib.parse.urlencode(kv).encode(),
headers={'X-ForgeFIRM-Token': tok}) headers={'X-ForgeFIRM-Token': tok})
with urllib.request.urlopen(req, timeout=4) as r: with urllib.request.urlopen(req, timeout=4) as r:
@@ -76,7 +76,7 @@ def main():
# A run session starts the flow check, whose heater warms the # A run session starts the flow check, whose heater warms the
# downstream sensor inside this check's dwell: off for this session, # downstream sensor inside this check's dwell: off for this session,
# back afterward. # back afterward.
with urllib.request.urlopen('http://127.0.0.1:8080/settings', timeout=2) as r: with urllib.request.urlopen(BASE + '/settings', timeout=2) as r:
was = json.load(r).get('cool_flow_check_s') was = json.load(r).get('cool_flow_check_s')
if was in (None, ''): if was in (None, ''):
was = '50' # unset reads as empty: the shipped default was = '50' # unset reads as empty: the shipped default
@@ -95,7 +95,7 @@ def main():
post_settings(cool_flow_check_s=str(was)) post_settings(cool_flow_check_s=str(was))
else: else:
post_settings(cool_flow_check_s='50') post_settings(cool_flow_check_s='50')
with urllib.request.urlopen('http://127.0.0.1:8080/settings', timeout=2) as r: with urllib.request.urlopen(BASE + '/settings', timeout=2) as r:
print('flow check restored: cool_flow_check_s = %s' % json.load(r).get('cool_flow_check_s')) print('flow check restored: cool_flow_check_s = %s' % json.load(r).get('cool_flow_check_s'))
time.sleep(25.0) # the session closes, the fans idle time.sleep(25.0) # the session closes, the fans idle
r2, s2 = mean_over(8.0) r2, s2 = mean_over(8.0)
+1
View File
@@ -15,5 +15,6 @@ TC="$FF_BUILD_TOP/tmp/work/cortexa9t2hf-neon-fslc-linux-gnueabi/ulfius/2.7.15"
"$TC/recipe-sysroot-native/usr/bin/arm-fslc-linux-gnueabi/arm-fslc-linux-gnueabi-gcc" \ "$TC/recipe-sysroot-native/usr/bin/arm-fslc-linux-gnueabi/arm-fslc-linux-gnueabi-gcc" \
--sysroot="$TC/recipe-sysroot" \ --sysroot="$TC/recipe-sysroot" \
-mthumb -mfpu=neon -mfloat-abi=hard -mcpu=cortex-a9 \ -mthumb -mfpu=neon -mfloat-abi=hard -mcpu=cortex-a9 \
-D_TIME_BITS=64 -D_FILE_OFFSET_BITS=64 \
-O2 -Wall -Wextra -o "$SP/feeder" "$SP/feeder.c" -O2 -Wall -Wextra -o "$SP/feeder" "$SP/feeder.c"
echo FEEDER-OK echo FEEDER-OK
+10 -5
View File
@@ -1,8 +1,10 @@
#!/bin/bash #!/bin/bash
# Cross-compiles forgectrl (the sibling repo) for the factory board, # Cross-compiles forgectrl (the sibling repo) for the factory board,
# borrowing the Yocto cross toolchain + sysroot from the forgectrl # borrowing the Yocto cross toolchain + sysroot from the forgectrl
# recipe work directory (which carries ulfius and libjpeg). If that # recipe work directory (which carries ulfius, jansson, gnutls,
# path ages out after a clean, regenerate it with: bitbake forgectrl. # libxcrypt and libjpeg). The newest version directory under the
# recipe's work tree is taken. If that path ages out after a clean,
# regenerate it with: bitbake forgectrl.
# #
# Environment (defaults derive from this script's location, assuming # Environment (defaults derive from this script's location, assuming
# the standard multi-repo checkout layout): # the standard multi-repo checkout layout):
@@ -12,8 +14,11 @@ set -e
SP="$(cd "$(dirname "$0")" && pwd)" SP="$(cd "$(dirname "$0")" && pwd)"
FF_SRC_TOP="${FF_SRC_TOP:-$(cd "$SP/../../.." && pwd)}" FF_SRC_TOP="${FF_SRC_TOP:-$(cd "$SP/../../.." && pwd)}"
FF_BUILD_TOP="${FF_BUILD_TOP:-$FF_SRC_TOP/forgefirm/build}" FF_BUILD_TOP="${FF_BUILD_TOP:-$FF_SRC_TOP/forgefirm/build}"
TC="$FF_BUILD_TOP/tmp/work/cortexa9t2hf-neon-fslc-linux-gnueabi/forgectrl/0.1.0" TCROOT="$FF_BUILD_TOP/tmp/work/cortexa9t2hf-neon-fslc-linux-gnueabi/forgectrl"
[ -d "$TC/recipe-sysroot" ] || { echo "toolchain not staged at $TC (run: bitbake forgectrl)"; exit 1; } TC=$(ls -d "$TCROOT"/*/ 2>/dev/null | sort -V | tail -n 1)
TC="${TC%/}"
[ -n "$TC" ] && [ -d "$TC/recipe-sysroot" ] || { echo "toolchain not staged under $TCROOT (run: bitbake forgectrl)"; exit 1; }
echo "toolchain: $TC"
export PATH="$TC/recipe-sysroot-native/usr/bin:$TC/recipe-sysroot-native/usr/bin/arm-fslc-linux-gnueabi:$PATH" export PATH="$TC/recipe-sysroot-native/usr/bin:$TC/recipe-sysroot-native/usr/bin/arm-fslc-linux-gnueabi:$PATH"
LOG=$(mktemp -t fcbuild.XXXXXX) LOG=$(mktemp -t fcbuild.XXXXXX)
cd "$FF_SRC_TOP/forgectrl" cd "$FF_SRC_TOP/forgectrl"
@@ -22,7 +27,7 @@ cmake -B build-arm \
-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=arm \ -DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=arm \
-DCMAKE_C_COMPILER=arm-fslc-linux-gnueabi-gcc \ -DCMAKE_C_COMPILER=arm-fslc-linux-gnueabi-gcc \
-DCMAKE_BUILD_TYPE=None \ -DCMAKE_BUILD_TYPE=None \
"-DCMAKE_C_FLAGS=--sysroot=$TC/recipe-sysroot -mthumb -mfpu=neon -mfloat-abi=hard -mcpu=cortex-a9 -O2 -g" \ "-DCMAKE_C_FLAGS=--sysroot=$TC/recipe-sysroot -mthumb -mfpu=neon -mfloat-abi=hard -mcpu=cortex-a9 -D_TIME_BITS=64 -D_FILE_OFFSET_BITS=64 -O2 -g" \
"-DCMAKE_EXE_LINKER_FLAGS=--sysroot=$TC/recipe-sysroot" \ "-DCMAKE_EXE_LINKER_FLAGS=--sysroot=$TC/recipe-sysroot" \
> "$LOG" 2>&1 > "$LOG" 2>&1
cmake --build build-arm -j8 >> "$LOG" 2>&1 || { tail -30 "$LOG"; exit 1; } cmake --build build-arm -j8 >> "$LOG" 2>&1 || { tail -30 "$LOG"; exit 1; }
+1 -1
View File
@@ -22,7 +22,7 @@ cmake -B build-arm \
-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=arm \ -DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=arm \
-DCMAKE_C_COMPILER=arm-fslc-linux-gnueabi-gcc \ -DCMAKE_C_COMPILER=arm-fslc-linux-gnueabi-gcc \
-DCMAKE_BUILD_TYPE=None \ -DCMAKE_BUILD_TYPE=None \
"-DCMAKE_C_FLAGS=--sysroot=$TC/recipe-sysroot -mthumb -mfpu=neon -mfloat-abi=hard -mcpu=cortex-a9 -O1 -g" \ "-DCMAKE_C_FLAGS=--sysroot=$TC/recipe-sysroot -mthumb -mfpu=neon -mfloat-abi=hard -mcpu=cortex-a9 -D_TIME_BITS=64 -D_FILE_OFFSET_BITS=64 -O1 -g" \
"-DCMAKE_EXE_LINKER_FLAGS=--sysroot=$TC/recipe-sysroot" \ "-DCMAKE_EXE_LINKER_FLAGS=--sysroot=$TC/recipe-sysroot" \
> "$LOG" 2>&1 > "$LOG" 2>&1
cmake --build build-arm -j8 >> "$LOG" 2>&1 || { tail -30 "$LOG"; exit 1; } cmake --build build-arm -j8 >> "$LOG" 2>&1 || { tail -30 "$LOG"; exit 1; }
+2 -2
View File
@@ -10,8 +10,8 @@ P=$(pidof forgectrl)
CTRL=$(pidof grblHAL_glowforge) CTRL=$(pidof grblHAL_glowforge)
echo "forgectrl pid $P, controller pid ${CTRL:-none}" echo "forgectrl pid $P, controller pid ${CTRL:-none}"
# Kick the helpers off in the background. # Kick the helpers off in the background.
( curl -s -X POST -H "X-ForgeFIRM-Token: $TOK" http://127.0.0.1:8080/update/check >/tmp/upd.out 2>&1 ) & ( curl -s -X POST -H "X-ForgeFIRM-Token: $TOK" http://127.0.0.1:${FORGECTRL_PORT:-80}/update/check >/tmp/upd.out 2>&1 ) &
( curl -s -o /tmp/snap.jpg http://127.0.0.1:8080/cam/snapshot ) & ( curl -s -o /tmp/snap.jpg http://127.0.0.1:${FORGECTRL_PORT:-80}/cam/snapshot ) &
hits=0; seen=0; names="" hits=0; seen=0; names=""
i=0 i=0
while [ $i -lt 60 ]; do # ~6 s of scanning at 10 Hz while [ $i -lt 60 ]; do # ~6 s of scanning at 10 Hz
+2 -2
View File
@@ -18,7 +18,7 @@ degc(raw) the factory B-equation coolant conversion
data_path(f) where a tool keeps its data files: FORGETEST_BENCH_DATA data_path(f) where a tool keeps its data files: FORGETEST_BENCH_DATA
when set (the bench page passes <data>/bench/), else next when set (the bench page passes <data>/bench/), else next
to the tool. to the tool.
forgectrl_* the machine-services HTTP API (:8080) with the panel token forgectrl_* the machine-services HTTP API (:80) with the panel token
from GF_TOKEN or, on the board, /data/forgefirm/panel.token. from GF_TOKEN or, on the board, /data/forgefirm/panel.token.
""" """
import json import json
@@ -90,7 +90,7 @@ def data_path(name):
# ------------------------------------------------------------- forgectrl # ------------------------------------------------------------- forgectrl
def forgectrl_base(): def forgectrl_base():
return os.environ.get("FORGECTRL_URL") or "http://%s:8080" % HOST return os.environ.get("FORGECTRL_URL") or "http://%s:%s" % (HOST, os.environ.get("FORGECTRL_PORT") or "80")
def token(): def token():
File diff suppressed because it is too large Load Diff
+110 -9
View File
@@ -79,6 +79,12 @@ over TCP, then checks the dumps against the kernel feeder contract:
the default gamma of 2 than at gamma 1, and the cruise middle the default gamma of 2 than at gamma 1, and the cruise middle
renders the same - the exponent shapes only the velocity-scaled renders the same - the exponent shapes only the velocity-scaled
rolloff, never the programmed level rolloff, never the programmed level
24. a hold verdict is held again after a resume: with the engine's
verdict at its fail tier (hold, fire blocked, no resume) the client
holds the job; a ~ under that verdict, which is what a button press
or a sender does, moves the head for at most one client poll, dark,
before the client holds it again and says so; the clean verdict then
resumes the hold the client took, and the rest of the line cuts lit
The analog sessions select the reference mode through the config; on The analog sessions select the reference mode through the config; on
hardware the controller ignores it (density is the only product model - hardware the controller ignores it (density is the only product model -
@@ -138,6 +144,16 @@ JOB_M4 = [
"M5", "M5",
] ]
# Session Z: the lens is in the stream. The sender references the lens
# (M103, as a commissioning card does), then a 1 mm move up and back at
# the screw's 2.922 half-steps per millimeter: three Z steps with the
# direction bit set, three with it clear.
JOB_Z = [
"M103 Z3 P14 Q20",
"G0 Z4",
"G0 Z3",
]
# Session B: M3 constant power to the end of the stream. The core never # Session B: M3 constant power to the end of the stream. The core never
# issues a laser-off update for M3, so the stream engine itself must # issues a laser-off update for M3, so the stream engine itself must
# terminate the cycle dark (rule 7). # terminate the cycle dark (rule 7).
@@ -372,17 +388,28 @@ def wait_state(sock, log, prefix, timeout=5.0):
fail("controller never reached %s" % prefix) fail("controller never reached %s" % prefix)
# The published verdict is clean unless a session sets this: then it is
# the engine's fail tier (hold, fire blocked, no resume), what an airflow
# fault publishes. A ("verdict", "hold") step sets it, ("verdict",
# "clean") clears it.
VERDICT_HOLD = threading.Event()
def publish_verdicts(path, stop): def publish_verdicts(path, stop):
"""Publish a fresh, clean cooling verdict every 0.5 s (the arm flow """Publish a fresh cooling verdict every 0.5 s (the arm flow refuses
refuses without one; freshness window is 2 s). Same-host monotonic without one; freshness window is 2 s), clean unless VERDICT_HOLD is
clock, atomic rename so the reader never sees a torn file. "armed" set. Same-host monotonic clock, atomic rename so the reader never
is the engine's acknowledgment that it has taken the controller's sees a torn file. "armed" is the engine's acknowledgment that it has
armed window; the arm waits for it, so a stand-in engine that means taken the controller's armed window; the arm waits for it, so a
to let jobs run must assert it.""" stand-in engine that means to let jobs run must assert it."""
while not stop.is_set(): while not stop.is_set():
body = ('{"ts_mono":%.3f,"fire_ok":true,"hold":false,' hold = VERDICT_HOLD.is_set()
'"resume_ok":true,"armed":true,"reason":""}' body = ('{"ts_mono":%.3f,"fire_ok":%s,"hold":%s,'
% time.clock_gettime(time.CLOCK_MONOTONIC)) '"resume_ok":%s,"armed":true,"reason":"%s"}'
% (time.clock_gettime(time.CLOCK_MONOTONIC),
"false" if hold else "true", "true" if hold else "false",
"false" if hold else "true",
"harness: airflow fault" if hold else ""))
tmp = path + ".tmp" tmp = path + ".tmp"
with open(tmp, "w") as f: with open(tmp, "w") as f:
f.write(body) f.write(body)
@@ -412,6 +439,7 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
env["GFHOME_CONF"] = conf_path env["GFHOME_CONF"] = conf_path
stop = threading.Event() stop = threading.Event()
VERDICT_HOLD.clear()
pub = threading.Thread(target=publish_verdicts, args=(verdict, stop), daemon=True) pub = threading.Thread(target=publish_verdicts, args=(verdict, stop), daemon=True)
pub.start() pub.start()
@@ -444,6 +472,11 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
sock.sendall(step[1]) # a realtime character: no ok follows sock.sendall(step[1]) # a realtime character: no ok follows
elif isinstance(step, tuple) and step[0] == "wait_state": elif isinstance(step, tuple) and step[0] == "wait_state":
wait_state(sock, log, step[1]) wait_state(sock, log, step[1])
elif isinstance(step, tuple) and step[0] == "verdict":
if step[1] == "hold":
VERDICT_HOLD.set()
else:
VERDICT_HOLD.clear()
else: else:
send_line(sock, step, log) send_line(sock, step, log)
@@ -466,6 +499,7 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
proc.kill() proc.kill()
stop.set() stop.set()
pub.join(2) pub.join(2)
VERDICT_HOLD.clear()
data = open(dump, "rb").read() data = open(dump, "rb").read()
if not data and arm_required: if not data and arm_required:
@@ -497,6 +531,18 @@ def check_fire_gaps(name, data):
return worst return worst
def check_z_move(name, data):
"""The lens in the stream: a job's Z move steps it, up with the
direction bit set, down with it clear, the count the scale gives."""
ticks = tick_bytes(data)
up = sum(1 for b in ticks if b & 0x20 and b & 0x40)
down = sum(1 for b in ticks if b & 0x20 and not b & 0x40)
if up != 3 or down != 3:
fail("[%s] Z steps up %d, down %d (expected 3 and 3 for 1 mm at "
"2.922 half-steps per mm)" % (name, up, down))
print("PASS [%s]: a 1 mm Z move steps the lens %d up and %d back" % (name, up, down))
def check_termination(name, data): def check_termination(name, data):
"""Rule 7: the stream's final tick byte must carry FIRE clear.""" """Rule 7: the stream's final tick byte must carry FIRE clear."""
ticks = tick_bytes(data) ticks = tick_bytes(data)
@@ -735,6 +781,10 @@ def main():
data = run_session("m4", JOB_M4, conf=ANALOG_CONF) data = run_session("m4", JOB_M4, conf=ANALOG_CONF)
fire_ticks, powers, x_max, tail_steps = check_m4_job(data) fire_ticks, powers, x_max, tail_steps = check_m4_job(data)
check_termination("m4", data) check_termination("m4", data)
# --- session Z: the lens in the stream --------------------------------
zdata = run_session("z", JOB_Z, arm_required=False)
check_z_move("z", zdata)
gap_a = check_fire_gaps("m4", data) gap_a = check_fire_gaps("m4", data)
print("PASS [m4]: %d bytes, %d power bytes, %d fire ticks, powers %s, " print("PASS [m4]: %d bytes, %d power bytes, %d fire ticks, powers %s, "
"X peak %d steps net 0, %d dark return steps, max fire gap %d" "X peak %d steps net 0, %d dark return steps, max fire gap %d"
@@ -1005,6 +1055,57 @@ def main():
"(%d ticks), lit from the first step out (%d fire ticks)" "(%d ticks), lit from the first step out (%d fire ticks)"
% (mode, decel, dlen, accel)) % (mode, decel, dlen, accel))
# --- rule 24: a hold verdict is held again after a resume -----------
# One long line at 50 mm/s. Mid-move the engine's verdict goes to its
# fail tier (hold, fire blocked, no resume: an airflow fault) and the
# client takes the feed hold. A ~ then resumes the job under the
# standing verdict, which is what a button press or a sender does;
# the client must hold it again within its poll, saying so, and the
# stretch it moved in between ships dark. The clean verdict then
# resumes the hold the client took, and the rest of the line cuts lit.
TICK_HZ = 28160
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
("verdict", "hold"), ("wait_state", "Hold:0"), ("sleep", 0.5),
("rt", b"~"), ("sleep", 1.2), ("wait_state", "Hold:0"),
("sleep", 0.5), ("verdict", "clean"), WAIT_IDLE, "M5"]
data = run_session("verdict-rehold", steps, conf=DENSITY_CONF_FLOORED)
text = run_session.text
if "held again" not in text:
fail("[verdict-rehold] the client did not say it held the job again")
if "resuming" not in text:
fail("[verdict-rehold] the client did not resume its own hold once the verdict cleared")
ticks = tick_bytes(data)
step = [1 if t & 0x05 else 0 for t in ticks]
fire = [1 if t & 0x10 else 0 for t in ticks]
first = step.index(1)
last = len(step) - 1 - step[::-1].index(1)
holds, run = [], 0 # the stationary stretches inside the motion
for i in range(first, last + 1):
if step[i]:
if run >= 2000:
holds.append((i - run, i))
run = 0
else:
run += 1
if len(holds) != 2:
fail("[verdict-rehold] expected two holds in the stream, found %d: %s"
% (len(holds), holds))
(_h1s, h1e), (h2s, h2e) = holds
between = sum(fire[h1e:h2s])
if between:
fail("[verdict-rehold] FIRE while resumed under the hold verdict: %d fire ticks "
"between the holds" % between)
if h2s - h1e > TICK_HZ:
fail("[verdict-rehold] the second hold came late: %d ticks (%.2f s) of motion under "
"the verdict" % (h2s - h1e, (h2s - h1e) / float(TICK_HZ)))
lit_after = sum(fire[h2e:last + 1])
if lit_after < 50:
fail("[verdict-rehold] the resume after the clean verdict ran dark (%d fire ticks)"
% lit_after)
print("PASS [verdict-rehold]: held, resumed dark for %d ticks (%.2f s), held again, "
"lit after the clear (%d fire ticks)"
% (h2s - h1e, (h2s - h1e) / float(TICK_HZ), lit_after))
# --- rule 22: a jog never fires, whatever the modal spindle says ---- # --- rule 22: a jog never fires, whatever the modal spindle says ----
# The arm flow runs on the M3 (window open), the modal spindle is on # The arm flow runs on the M3 (window open), the modal spindle is on
# at S1000, and the jogs come from Idle: exactly what a sender's Fire # at S1000, and the jogs come from Idle: exactly what a sender's Fire
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""Lens stop detection by the head accelerometer: a bench drill.
Steps the lens one half-step at a time from the hall's rising edge toward
each stop and a little past it, sampling the head accelerometer through
the step's whole cadence window, and prints per step the peak-to-peak on
each axis. A normal step rings a little; a stall against a stop (the
rotor slipping, the carriage still) should ring differently. If it does,
a shipped lens move could find a stop with a single slipped step instead
of driving onto it.
The accelerometer (an ST LIS2HH12 on i2c-3 at 0x1e, the crash watch's
chip) is read the way the crash watch reads it: straight over the bus in
six-byte bursts, its rate register set to 800 Hz for the run and put
back after. The iio path waits a sample period per read and is far too
slow for this.
Runs on the board with the controller stopped through forgectrl and
restarted after (the same handling as lens_travel.py). Lens motion only;
nothing fires. Stall drills run on the bench reference machine only.
lens_stop_accel.py [--past N] [--window MS] [--save FILE]
"""
import argparse
import fcntl
import json
import os
import struct
import sys
import time
import urllib.request
SYS = "/sys/glowforge/"
TOKEN_FILE = "/data/forgefirm/panel.token"
BASE = "http://127.0.0.1"
STEP_S = 0.180
I2C_DEV = "/dev/i2c-3"
I2C_SLAVE_FORCE = 0x0706
ADDR = 0x1E
CTRL1 = 0x20 # ODR [6:4], BDU, XYZ enables
CTRL1_RUN = 0x6F # 800 Hz, BDU, XYZ on (the crash watch's run value)
OUT_X_L = 0x28
AUTO_INC = 0x80
def rd(attr):
with open(SYS + attr) as f:
return f.read().strip()
def wr(attr, value):
with open(SYS + attr, "w") as f:
f.write(str(value))
def api(method, path):
with open(TOKEN_FILE) as f:
token = f.read().strip()
req = urllib.request.Request(BASE + path, method=method, headers={"X-ForgeFIRM-Token": token})
with urllib.request.urlopen(req, timeout=15) as r:
raw = r.read().decode()
return json.loads(raw) if raw.strip().startswith("{") else raw
def at_home():
return rd("head/hall_sensor") == "0"
class Accel:
"""The chip over the bus: bursts of x, y, z at up to the bus rate."""
def __init__(self):
self.fd = os.open(I2C_DEV, os.O_RDWR)
fcntl.ioctl(self.fd, I2C_SLAVE_FORCE, ADDR)
self.ctrl1_found = self.reg(CTRL1)
def reg(self, r):
os.write(self.fd, bytes([r]))
return os.read(self.fd, 1)[0]
def set_reg(self, r, v):
os.write(self.fd, bytes([r, v]))
def start(self):
self.set_reg(CTRL1, CTRL1_RUN)
time.sleep(0.02)
def restore(self):
self.set_reg(CTRL1, self.ctrl1_found)
def read(self):
os.write(self.fd, bytes([OUT_X_L | AUTO_INC]))
return struct.unpack("<hhh", os.read(self.fd, 6))
def sample(self, seconds, keep=None):
"""Peak-to-peak per axis and the sample count over `seconds`;
the samples appended to `keep` when given."""
lo = [None] * 3
hi = [None] * 3
n = 0
end = time.monotonic() + seconds
while time.monotonic() < end:
v = self.read()
if keep is not None:
keep.append(v)
for i in range(3):
if lo[i] is None or v[i] < lo[i]:
lo[i] = v[i]
if hi[i] is None or v[i] > hi[i]:
hi[i] = v[i]
n += 1
return tuple(hi[i] - lo[i] for i in range(3)), n
def step_sampled(accel, up, window_s, keep=None):
"""One half-step, the accelerometer sampled through the window."""
wr("cnc/z_step", "1" if up else "0")
p2p, n = accel.sample(window_s, keep)
rest = STEP_S - window_s
if rest > 0:
time.sleep(rest)
return p2p, n
def until(home, up, limit):
for n in range(limit):
if at_home() == home:
return n
wr("cnc/z_step", "1" if up else "0")
time.sleep(STEP_S)
return -1
def to_edge():
if at_home():
if until(False, False, 60) < 0:
return False
return until(True, True, 80) >= 0
def fmt(p2p):
return "x %5d y %5d z %5d" % p2p
def run_leg(accel, up, count, window_s, expect, trace):
"""From the edge, `count` single steps `up` or down, each sampled."""
rows = []
label = "above" if up else "below"
print("\n== %s the edge, one half-step at a time (the bench stop is about %d %s)" % (label, expect, label))
for i in range(1, count + 1):
keep = []
p2p, n = step_sampled(accel, up, window_s, keep)
k = i if up else -i
rows.append((i, p2p))
trace.append({"k": k, "p2p": p2p, "samples": keep})
mark = " <- past the expected stop" if i > expect else ""
print(" %+3d: %s (%d samples)%s" % (k, fmt(p2p), n, mark))
return rows
def summarize(rows, expect):
free = [p for i, p in rows if i <= expect - 2]
stall = [p for i, p in rows if i > expect]
if not free or not stall:
return
for i, a in enumerate("xyz"):
fv = sorted(p[i] for p in free)
sv = sorted(p[i] for p in stall)
print(" %s: free steps p2p min %d median %d max %d; past the stop min %d median %d max %d"
% (a, fv[0], fv[len(fv) // 2], fv[-1], sv[0], sv[len(sv) // 2], sv[-1]))
def find_stop(accel, up, window_s, limit, thresh):
"""Step `up` or down from the edge one half-step at a time until the
carriage stops moving. A free step rings strongly on every second
half-step (the parity is learned from the first two), so contact is
called the moment a strong-parity step rings under `thresh`; a burst
over four times `thresh` is a rotor slip, which means the stop was
reached a step or two earlier. Returns (count, slipped), or (-1, 0)."""
sums = []
strong_parity = None
for i in range(1, limit + 1):
p2p, n = step_sampled(accel, up, window_s)
total = sum(p2p)
sums.append(total)
k = i if up else -i
note = ""
if total > 4 * thresh:
note = " <- slip burst"
elif strong_parity is None and i == 2:
strong_parity = 2 if sums[1] > sums[0] else 1
note = " (strong steps are the %s ones)" % ("even" if strong_parity == 2 else "odd")
print(" %+3d: %s sum %6d%s" % (k, fmt(p2p), total, note))
if total > 4 * thresh:
return i, 1
if strong_parity is not None and i % 2 == strong_parity % 2 and total < thresh:
return i, 0
return -1, 0
def find_leg(accel, up, window_s, limit, thresh, back):
label = "top" if up else "bottom"
print("\n== finding the %s stop by the ring (contact = two quiet steps in a row, sum under %d)"
% (label, thresh))
if not to_edge():
print(" could not find the edge first")
return None
i, slipped = find_stop(accel, up, window_s, limit, thresh)
if i < 0:
print(" no contact within %d half-steps" % limit)
return None
for _ in range(back):
wr("cnc/z_step", "0" if up else "1")
time.sleep(STEP_S)
print(" contact called at %+d%s; backed off %d" % (i if up else -i, " by a slip" if slipped else "", back))
# The proof of gentleness: the count back to the edge equals the
# steps taken only if nothing slipped.
if up:
c = until(False, False, 80)
c2 = until(True, True, 60)
print(" back down to leave home: %d, up to the edge: %d (expected %d and the band)"
% (c, c2, i - back))
else:
c = until(True, True, 80)
print(" back up to the edge: %d (expected %d: no slip)" % (c, i - back))
return i
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--past", type=int, default=6, help="half-steps past each expected stop")
ap.add_argument("--window", type=int, default=170, help="ms sampled after each step (of the 180 cadence)")
ap.add_argument("--below", type=int, default=18, help="the bench bottom stop, half-steps below the edge")
ap.add_argument("--above", type=int, default=20, help="the bench top stop, half-steps above the edge")
ap.add_argument("--save", default=None, help="write the per-step sample traces to this JSON file")
ap.add_argument("--find", type=int, default=0,
help="instead of the legs: find each stop by the ring this many times")
ap.add_argument("--thresh", type=int, default=8000, help="the quiet-step threshold on the summed p2p")
ap.add_argument("--back", type=int, default=2, help="half-steps to back off after contact")
args = ap.parse_args()
window_s = min(args.window, 180) / 1000.0
dark = api("GET", "/wiz/dark")
if dark.get("running"):
sys.exit("a wizard is running: %s" % dark.get("id"))
mode = api("GET", "/mode")
print("mode before:", mode)
accel = Accel()
print("accelerometer: %s at 0x%02x, CTRL1 found 0x%02x" % (I2C_DEV, ADDR, accel.ctrl1_found))
found = {a: rd(a) for a in ("cnc/motor_lock", "head/z_current", "head/z_mode", "head/z_enable")}
stopped = False
trace = []
try:
if mode.get("controller") == "running":
print("stopping the controller:", api("POST", "/controller/stop"))
stopped = True
time.sleep(2)
accel.start()
p2p, n = accel.sample(2.0)
print("at rest, 2 s: %s (%d samples, %.0f Hz)" % (fmt(p2p), n, n / 2.0))
wr("cnc/motor_lock", "0")
wr("head/z_current", "0")
wr("head/z_mode", "1")
wr("head/z_enable", "0")
time.sleep(0.3)
p2p, n = accel.sample(1.0)
print("energized, at rest, 1 s: %s" % fmt(p2p))
if not to_edge():
sys.exit("could not find the hall edge")
if args.find:
for r in range(args.find):
print("### find round %d" % (r + 1))
find_leg(accel, False, window_s, 30, args.thresh, args.back)
find_leg(accel, True, window_s, 32, args.thresh, args.back)
to_edge()
print(" back on the edge")
return
below = run_leg(accel, False, args.below + args.past, window_s, args.below, trace)
summarize(below, args.below)
back = until(True, True, 80)
print(" back up to the edge: %d half-steps" % back)
above = run_leg(accel, True, args.above + args.past, window_s, args.above, trace)
summarize(above, args.above)
to_edge()
print(" back on the edge")
finally:
try:
accel.restore()
except OSError as e:
print("restore of the accelerometer rate failed: %s" % e)
for a, v in found.items():
try:
wr(a, v)
except OSError as e:
print("restore %s=%s failed: %s" % (a, v, e))
print("posture restored:", {a: rd(a) for a in found}, "hall:", rd("head/hall_sensor"),
"CTRL1 0x%02x" % accel.reg(CTRL1))
if stopped:
print("starting the controller:", api("POST", "/controller/start"))
time.sleep(4)
print("mode after:", api("GET", "/mode"))
if args.save:
with open(args.save, "w") as f:
json.dump(trace, f)
print("traces saved to", args.save)
if __name__ == "__main__":
main()
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""Lens travel drill: where the carriage's stops sit against the hall edge.
Runs on the board with the controller stopped (through forgectrl, so the
supervisor holds it), steps the lens over sysfs the way the focus card's
lens home does, and counts, under each condition and for several rounds:
band the hall's hysteresis: from the rising edge, the half-steps
down until the hall leaves home, and back up until it reads
home again
below the half-steps from the bottom stop up to the hall edge
(down off the edge, a drive of N steps onto the stop, the stall
takes the rest, then up until the hall reads home)
above the half-steps from the hall edge up to the top stop (a drive
of 36 half-steps up from the edge, down until the hall leaves
home, then up until it reads home again; the top sits the
difference above the rising edge)
Conditions: the focus card's (half-step, run current, the whole travel
driven onto the stop), a short drive that may not reach the stop, a long
drive well past it, and the factory's lens home mode (full steps at the
run current; the counts are doubled to half-steps). The current is the
run current throughout: the lens does not rise at the hold current.
Lens motion only; nothing fires. The posture found is put back (motor
lock, current, mode, enable) and the controller restarted.
lens_travel.py [--rounds N] [--only wizard|short|long|full] [--no-above]
"""
import argparse
import json
import sys
import time
import urllib.request
SYS = "/sys/glowforge/"
TOKEN_FILE = "/data/forgefirm/panel.token"
BASE = "http://127.0.0.1"
STEP_S = 0.180 # the cadence of every lens reference (the service's and the card's)
TRAVEL_HALF = 36 # the carriage's travel in half-steps of its screw
CONDITIONS = [
# key, label, z_mode (1 half, 0 full), the drive onto the stop in that mode's steps
("wizard", "half-step, run current, drive 36 half-steps onto the stop (the focus card)", 1, 36),
("short", "half-step, run current, drive 16 half-steps (may not reach the stop)", 1, 16),
("long", "half-step, run current, drive 60 half-steps (well past the stop)", 1, 60),
("full", "full-step, run current, drive 18 full steps (the factory's lens home mode)", 0, 18),
]
def rd(attr):
with open(SYS + attr) as f:
return f.read().strip()
def wr(attr, value):
with open(SYS + attr, "w") as f:
f.write(str(value))
def api(method, path):
with open(TOKEN_FILE) as f:
token = f.read().strip()
req = urllib.request.Request(BASE + path, method=method, headers={"X-ForgeFIRM-Token": token})
with urllib.request.urlopen(req, timeout=15) as r:
raw = r.read().decode()
return json.loads(raw) if raw.strip().startswith("{") else raw
def at_home():
return rd("head/hall_sensor") == "0"
def step(up):
wr("cnc/z_step", "1" if up else "0")
time.sleep(STEP_S)
SETTLE_S = 0.0 # extra rest before every hall read (--settle)
def until(home, up, limit):
"""Step `up` until the hall reads `home`; the steps taken, or -1."""
for n in range(limit):
if SETTLE_S:
time.sleep(SETTLE_S)
if at_home() == home:
return n
step(up)
return -1
CURRENT = "high" # high | low | low-down (--current)
def count_below(drive):
"""Down off the edge, `drive` steps onto the stop, up to the edge.
Returns (count, the steps it took to leave home, error)."""
if CURRENT == "low-down":
wr("head/z_current", "1")
left = until(False, False, 60)
if left < 0:
return None, left, "the hall never left home going down"
for _ in range(drive):
step(False)
if CURRENT == "low-down":
wr("head/z_current", "0")
time.sleep(0.1)
c = until(True, True, 80)
return (c if c >= 0 else None), left, ("" if c >= 0 else "the hall never read home going up")
def to_edge():
"""Put the lens on the rising edge: below it if needed, then up."""
if at_home():
if until(False, False, 60) < 0:
return False
return until(True, True, 80) >= 0
def count_band():
"""From the rising edge: down until the hall leaves home, then up
until it reads home again."""
if not to_edge():
return None, None, "could not find the edge"
down = until(False, False, 60)
if down < 0:
return None, None, "the hall never left home going down"
up = until(True, True, 60)
return down, (up if up >= 0 else None), ("" if up >= 0 else "the hall never read home going up")
def count_above(drive):
"""From the edge, `drive` steps up onto the top stop, down until the
hall leaves home (a), up until it reads home again (b): the top is
a - b above the rising edge. The lens ends on the edge."""
if not to_edge():
return None, None, "could not find the edge first"
for _ in range(drive):
step(True)
a = until(False, False, 80)
if a < 0:
return None, None, "the hall never left home coming down"
b = until(True, True, 60)
if b < 0:
return a, None, "the hall never read home going back up"
return a, b, ""
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--rounds", type=int, default=3)
ap.add_argument("--only", choices=[c[0] for c in CONDITIONS])
ap.add_argument("--no-above", action="store_true", help="skip the top-stop count")
ap.add_argument("--settle", type=int, default=0, help="extra ms of rest before every hall read")
ap.add_argument("--cadence", type=int, default=180, help="ms per step (180 is every reference's)")
ap.add_argument("--ladder", default=None,
help="instead of the conditions: comma-separated half-step drives below the "
"leave-home point, each counted back up; half-step mode, run current")
ap.add_argument("--full", action="store_true", help="the ladder in full-step mode")
ap.add_argument("--current", choices=["high", "low", "low-down"], default="high",
help="the drive current: the run current, the hold current, or the hold "
"current for the drive onto the stop and the run current for the count")
ap.add_argument("--park",
help="park the lens for a depth-gauge measurement: bottom, top, edge-N or "
"edge+N (N half-steps from the rising edge, stall-free; the controller "
"stays stopped), or edge (back on the hall edge; the controller restarts)")
args = ap.parse_args()
global SETTLE_S, STEP_S, CURRENT
SETTLE_S = args.settle / 1000.0
STEP_S = args.cadence / 1000.0
CURRENT = args.current
print("step cadence %d ms, extra settle before each hall read %d ms, current %s"
% (STEP_S * 1000, args.settle, CURRENT))
dark = api("GET", "/wiz/dark")
if dark.get("running"):
sys.exit("a wizard is running: %s" % dark.get("id"))
mode = api("GET", "/mode")
print("mode before:", mode)
found = {a: rd(a) for a in ("cnc/motor_lock", "head/z_current", "head/z_mode", "head/z_enable")}
print("posture found:", found, "hall:", rd("head/hall_sensor"))
stopped = False
results = []
try:
if mode.get("controller") == "running":
print("stopping the controller:", api("POST", "/controller/stop"))
stopped = True
time.sleep(2)
wr("cnc/motor_lock", "0")
wr("head/z_current", "1" if CURRENT == "low" else "0")
wr("head/z_enable", "0")
if args.park:
wr("head/z_mode", "1")
time.sleep(0.2)
if args.park == "bottom":
left = until(False, False, 60)
for _ in range(24):
step(False)
print("parked on the bottom stop (left home after %d, then 24 half-steps down)" % left)
elif args.park == "top":
to_edge()
for _ in range(26):
step(True)
print("parked on the top stop (26 half-steps up from the rising edge)")
elif args.park.startswith("edge-") or args.park.startswith("edge+"):
n = int(args.park[5:])
up = args.park[4] == "+"
to_edge()
for _ in range(n):
step(up)
print("parked %d half-steps %s the rising edge, stall-free" % (n, "above" if up else "below"))
else:
wr("head/z_current", "0")
to_edge()
print("parked on the hall's rising edge")
stopped = True # the controller restarts whatever state it was found in
if args.park != "edge":
# the hold posture keeps the lens where it is; the controller
# stays stopped so nothing moves the head during the measurement
for a, v in found.items():
wr(a, v)
print("posture:", {a: rd(a) for a in found}, "hall:", rd("head/hall_sensor"))
print("the controller stays stopped: run --park edge to return and restart it")
stopped = False
found = {}
return
mult = 2 if args.full else 1
wr("head/z_mode", 0 if args.full else 1)
time.sleep(0.2)
print("\n== ladder of drives below the leave-home point (%s-step mode)"
% ("full" if args.full else "half"))
for r in range(args.rounds):
row = []
for n in [int(x) for x in args.ladder.split(",")]:
c, left, err = count_below(n // mult)
row.append((n, None if c is None else c * mult, left * mult))
print(" round %d: " % (r + 1) + " ".join("drive %d -> up %s (left %d)" % t for t in row))
args.only = "none"
for key, label, zmode, drive in CONDITIONS:
if args.only and key != args.only:
continue
wr("head/z_mode", zmode)
time.sleep(0.2)
mult = 1 if zmode == 1 else 2
unit = "half-steps" if mult == 1 else "full steps"
print("\n== %s" % label)
bands = []
for r in range(args.rounds):
d, u, err = count_band()
if d is None or u is None:
print(" band %d: FAILED: %s (down %s)" % (r + 1, err, d))
continue
bands.append((d * mult, u * mult))
print(" band %d: home for %d half-steps below the edge going down, home again %d "
"half-steps up" % (r + 1, d * mult, u * mult))
below = []
for r in range(args.rounds):
t0 = time.time()
c, left, err = count_below(drive)
if c is None:
print(" round %d: FAILED: %s" % (r + 1, err))
below.append(None)
continue
below.append(c * mult)
print(" round %d: the edge %d half-steps above the bottom stop (%d %s; left home "
"after %d; %.1f s)" % (r + 1, c * mult, c, unit, left * mult, time.time() - t0))
above = None
if not args.no_above:
for r in range(2):
t0 = time.time()
a, b, err = count_above(TRAVEL_HALF // mult)
if a is None or b is None:
print(" top %d: FAILED: %s (a=%s b=%s)" % (r + 1, err, a, b))
continue
above = (a - b) * mult
print(" top %d: down %d half-steps to leave home, %d back up to the edge: the top "
"stop %d half-steps above the rising edge (%.1f s)"
% (r + 1, a * mult, b * mult, above, time.time() - t0))
results.append((key, bands, below, above))
wr("head/z_mode", "1")
wr("head/z_current", "0")
# leave the lens on the edge, as every reference does
to_edge()
finally:
for a, v in found.items():
try:
wr(a, v)
except OSError as e:
print("restore %s=%s failed: %s" % (a, v, e))
print("\nposture restored:", {a: rd(a) for a in found}, "hall:", rd("head/hall_sensor"))
if stopped:
print("starting the controller:", api("POST", "/controller/start"))
time.sleep(4)
print("mode after:", api("GET", "/mode"))
print("\n%-8s %-22s %-22s %-6s %s" % ("cond", "band down/up", "below (half-steps)", "above", "total"))
for key, bands, below, above in results:
ok = [b for b in below if b is not None]
tot = (ok[-1] + above) if ok and above is not None else None
print("%-8s %-22s %-22s %-6s %s" % (key, bands, below, above, tot))
if __name__ == "__main__":
main()
+4 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Live-fire bench drills - Phases 4, 5, 6. Runs on the board (the bench """Live-fire bench drills - Phases 4, 5, 6. Runs on the board (the bench
page) or from a LAN host, against grblHAL over TCP (port 23) and page) or from a LAN host, against grblHAL over TCP (port 23) and
forgectrl over HTTP (:8080); the machine is GF_HOST, default 127.0.0.1. forgectrl over HTTP (:80, FORGECTRL_PORT overrides); the machine is GF_HOST, default 127.0.0.1.
LIVE LASER: the operator must be armed with eye protection, a fire LIVE LASER: the operator must be armed with eye protection, a fire
watch, an extinguisher, and the exhaust running. Every drill waits for watch, an extinguisher, and the exhaust running. Every drill waits for
the operator to press the physical arm button before the machine fires; the operator to press the physical arm button before the machine fires;
@@ -184,7 +184,8 @@ import urllib.request
HOST = os.environ.get('GF_HOST') or '127.0.0.1' HOST = os.environ.get('GF_HOST') or '127.0.0.1'
PORT = 23 PORT = 23
BASE = 'http://%s:8080' % HOST CTRL_PORT = int(os.environ.get('FORGECTRL_PORT') or 80)
BASE = 'http://%s:%d' % (HOST, CTRL_PORT)
def panel_token(): def panel_token():
@@ -3021,7 +3022,7 @@ def post_ctrl(action):
# http.client preserves the header-name case exactly as given. # http.client preserves the header-name case exactly as given.
import http.client import http.client
tok = panel_token() tok = panel_token()
c = http.client.HTTPConnection(HOST, 8080, timeout=8) c = http.client.HTTPConnection(HOST, CTRL_PORT, timeout=8)
c.putrequest('POST', '/controller/' + action) c.putrequest('POST', '/controller/' + action)
c.putheader('X-ForgeFIRM-Token', tok) c.putheader('X-ForgeFIRM-Token', tok)
c.putheader('Content-Length', '0') c.putheader('Content-Length', '0')
+5 -2
View File
@@ -32,6 +32,9 @@ import sys
import threading import threading
import time import time
# forgectrl on the board (FORGECTRL_PORT overrides the port)
BASE = 'http://127.0.0.1:%s' % (os.environ.get('FORGECTRL_PORT') or '80')
SYSFS = '/sys/glowforge/' SYSFS = '/sys/glowforge/'
LEDS = '/sys/class/leds/' LEDS = '/sys/class/leds/'
HZ = 25 HZ = 25
@@ -225,9 +228,9 @@ def run_armed():
def status(): def status():
try: try:
with urllib.request.urlopen('http://127.0.0.1:8080/status', timeout=2) as r: with urllib.request.urlopen(BASE + '/status', timeout=2) as r:
s = _json.load(r) s = _json.load(r)
with urllib.request.urlopen('http://127.0.0.1:8080/cool/status', timeout=2) as r: with urllib.request.urlopen(BASE + '/cool/status', timeout=2) as r:
c = _json.load(r) c = _json.load(r)
return {'t': time.time(), 'armed': c.get('armed'), 'phase': c.get('phase'), return {'t': time.time(), 'armed': c.get('armed'), 'phase': c.get('phase'),
'hv_enable': (s.get('switches') or {}).get('hv_enable'), 'hv_enable': (s.get('switches') or {}).get('hv_enable'),
+21 -10
View File
@@ -180,16 +180,27 @@ else
echo "acceptance gate OK ($ART)" echo "acceptance gate OK ($ART)"
fi fi
# Back-door gate: the release image must not ship a passwordless root. A # Root policy gate. The release image ships root WITHOUT a password (the
# debug-tweaks image sets root's password field empty (root::...); a # serial console is the recovery path) and sshd refuses root and empty
# hardened image leaves it locked (root:*: / root:!:) or hashed. Read the # passwords: PermitRootLogin no and PermitEmptyPasswords no must be
# actual built shadow file - this catches the flag however it slipped in # active (uncommented) in the built sshd_config, and root's shadow field
# (recipe, local.conf, an inherited class). # must be empty. Read the built files, not the recipes: this catches a
ROOT_PW=$(debugfs -R "cat /etc/shadow" "$EXT4" 2>/dev/null \ # drift however it got in (recipe, local.conf, an inherited class).
| awk -F: '$1=="root"{print $2; exit}') SSHD_CONFIG=$(debugfs -R "cat /etc/ssh/sshd_config" "$EXT4" 2>/dev/null)
[ -n "$ROOT_PW" ] \ [ -n "$SSHD_CONFIG" ] \
|| die "release rootfs has a passwordless root (debug-tweaks leaked into forgefirm-image?)" || die "release rootfs carries no /etc/ssh/sshd_config"
echo "root login gate OK (root password field is not empty)" printf '%s\n' "$SSHD_CONFIG" | grep -Eq '^PermitRootLogin[[:space:]]+no[[:space:]]*$' \
|| die "release sshd_config has no active 'PermitRootLogin no' (recipes-connectivity/openssh drift?)"
printf '%s\n' "$SSHD_CONFIG" | grep -Eq '^PermitEmptyPasswords[[:space:]]+no[[:space:]]*$' \
|| die "release sshd_config has no active 'PermitEmptyPasswords no' (recipes-connectivity/openssh drift?)"
ROOT_SHADOW=$(debugfs -R "cat /etc/shadow" "$EXT4" 2>/dev/null \
| awk -F: '$1=="root"{print; exit}')
[ -n "$ROOT_SHADOW" ] \
|| die "release rootfs has no root entry in /etc/shadow"
ROOT_PW=$(printf '%s\n' "$ROOT_SHADOW" | awk -F: '{print $2}')
[ -z "$ROOT_PW" ] \
|| die "release rootfs has a non-empty root password field: the policy is an empty field (empty-root-password in forgefirm-image.bb); a build drift"
echo "root policy gate OK (root field empty; sshd refuses root and empty passwords)"
# Config-level guard: debug-tweaks must not sit in the shared kas config, # Config-level guard: debug-tweaks must not sit in the shared kas config,
# where it would apply to every target including the release image. # where it would apply to every target including the release image.