mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-28 01:01:12 -07:00
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:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""The setup's checks in the catalog: registered as automatic tests with
|
||||||
|
no precheck, the takeover ones marked so, every covers map naming the
|
||||||
|
check runner; and the check driver itself against the fake daemon: a
|
||||||
|
start, a prompt answered, the result taken, the record read at version
|
||||||
|
1, and the settings a check may write put back as found."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
import helpers # noqa: E402
|
||||||
|
from forgetest import catalog # noqa: E402
|
||||||
|
from forgetest.runner import Context, Run # noqa: E402
|
||||||
|
|
||||||
|
IDS = ["commission.check-switches", "commission.check-sensors", "commission.check-airflow",
|
||||||
|
"commission.check-cameras", "commission.check-motion", "commission.check-flow-verify"]
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.reg = catalog.load_suite()
|
||||||
|
|
||||||
|
def test_every_check_runs_unattended_with_the_fixture(self):
|
||||||
|
# The lid and button checks name their machine actions, which the
|
||||||
|
# bench fixture performs; the rest need no hands at all.
|
||||||
|
for tid in IDS:
|
||||||
|
self.assertIn(tid, self.reg)
|
||||||
|
for tid in ("commission.check-switches", "commission.check-cameras"):
|
||||||
|
self.assertEqual(self.reg[tid].kind, "operator", tid)
|
||||||
|
self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid)
|
||||||
|
for tid in ("commission.check-sensors", "commission.check-airflow", "commission.check-motion",
|
||||||
|
"commission.check-flow-verify"):
|
||||||
|
self.assertEqual(self.reg[tid].kind, "auto", tid)
|
||||||
|
|
||||||
|
def test_no_check_asks_the_operator_to_prepare_anything(self):
|
||||||
|
for tid in IDS:
|
||||||
|
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
|
||||||
|
|
||||||
|
def test_the_checks_that_take_the_machine_say_so(self):
|
||||||
|
for tid in ("commission.check-airflow", "commission.check-motion", "commission.check-flow-verify"):
|
||||||
|
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||||
|
|
||||||
|
def test_every_check_covers_the_runner_and_the_page(self):
|
||||||
|
for tid in IDS:
|
||||||
|
covers = set(self.reg[tid].covers)
|
||||||
|
self.assertIn(("forgectrl", "src/wizdark.*"), covers, tid)
|
||||||
|
self.assertIn(("forgectrl", "src/ui/wizard.*"), covers, tid)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorsCheckTests(unittest.TestCase):
|
||||||
|
"""The check driver against a scripted daemon."""
|
||||||
|
|
||||||
|
RESULT = {"coolant_down_c": 22.4, "coolant_up_c": 21.9, "chassis_c": 28.0, "soc_c": 45.2,
|
||||||
|
"lid_ir_max": [110, 120, 105, 118], "accel_events": 0, "laser_pgood": 1,
|
||||||
|
"hv_current_max": 0, "exhaust_rpm_idle": 3400, "intake_rpm_idle": 2200}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.fake = helpers.FakeForgectrl().start()
|
||||||
|
self.fake.state["settings"].update({"cool_temp_offset_c": "0.5"})
|
||||||
|
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
|
||||||
|
"log": [], "prompt": None, "result": None, "error": "",
|
||||||
|
"shots": {"lid": False, "head": False}}
|
||||||
|
self.versions = {}
|
||||||
|
self.answers = []
|
||||||
|
|
||||||
|
def on_get(path, q):
|
||||||
|
if path == "/wiz/dark":
|
||||||
|
return 200, dict(self.dark)
|
||||||
|
if path == "/wiz":
|
||||||
|
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def on_post(path, form):
|
||||||
|
s = self.fake.state["settings"]
|
||||||
|
if path == "/wiz/sensors/start":
|
||||||
|
self.dark.update({"id": "sensors", "running": True, "phase": "sampling",
|
||||||
|
"prompt": {"seq": 7, "id": "room-temp", "kind": "number",
|
||||||
|
"text": "Optional: the room temperature.",
|
||||||
|
"options": ["Set", "Skip"]}})
|
||||||
|
return 200, {"started": True, "id": "sensors"}
|
||||||
|
if path == "/wiz/sensors/answer":
|
||||||
|
self.answers.append(dict(form))
|
||||||
|
if form.get("seq") != "7":
|
||||||
|
return 409, {"error": "no such prompt is open"}
|
||||||
|
if form.get("value") == "Skip":
|
||||||
|
self.dark.update({"running": False, "prompt": None, "progress": 100,
|
||||||
|
"result": dict(self.RESULT)})
|
||||||
|
self.versions["sensors"] = 1
|
||||||
|
return 200, {"ok": True}
|
||||||
|
if path == "/settings":
|
||||||
|
for k, v in form.items():
|
||||||
|
if v == "":
|
||||||
|
s.pop(k, None)
|
||||||
|
else:
|
||||||
|
s[k] = v
|
||||||
|
return 200, dict(s)
|
||||||
|
return None
|
||||||
|
self.fake.on_get = on_get
|
||||||
|
self.fake.on_post = on_post
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.fake.stop()
|
||||||
|
|
||||||
|
def test_start_prompt_answer_result_and_restore(self):
|
||||||
|
t = catalog.load_suite()["commission.check-sensors"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
self.assertIn(("/wiz/sensors/start", {}), [(p, f) for p, f in self.fake.posts])
|
||||||
|
self.assertEqual(self.answers, [{"seq": "7", "value": "Skip"}])
|
||||||
|
self.assertEqual(run.evidence["result"]["laser_pgood"], 1)
|
||||||
|
self.assertEqual(run.evidence["settings_before"], {"cool_temp_offset_c": "0.5"})
|
||||||
|
self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""The setup's sheet in the catalog: one live test on the button with a
|
||||||
|
piece of wood at hand, its covers map naming the live runner, the
|
||||||
|
renderer, the sender, the page, and the driver's laser module; and the
|
||||||
|
whole run against a scripted daemon: the placement's jogs and origin,
|
||||||
|
then each card's preview and program read, its start, the arm prompt and
|
||||||
|
the pick answered, the witnesses judged, the record read at version 1,
|
||||||
|
every card's evidence filed under its own key, and the settings the cards
|
||||||
|
wrote put back as found at the end."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
import helpers # noqa: E402
|
||||||
|
from forgetest import catalog # noqa: E402
|
||||||
|
from forgetest.runner import Context, Run # noqa: E402
|
||||||
|
from forgetest.suite import commission_sheet # noqa: E402
|
||||||
|
|
||||||
|
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
|
||||||
|
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
|
||||||
|
|
||||||
|
# Each wizard as the daemon runs it: its prompts in order (id, kind,
|
||||||
|
# options) and the result it ends with. A "jog" prompt repeats until the
|
||||||
|
# origin is set.
|
||||||
|
SCRIPT = {
|
||||||
|
"sheet.place": ([("place", "jog", ["X-10", "X+10", "Set origin"]),
|
||||||
|
("sheet-kind", "choice", ["Full sheet", "One card"]),
|
||||||
|
("thickness", "number", ["Set", "Skip"])],
|
||||||
|
{"origin_x": 100, "origin_y": 200, "origin_z": 0, "steps_per_mm": {"x": 40.0, "y": 40.0},
|
||||||
|
"z_referenced": True, "thickness_mm": 3.2, "alone": False}),
|
||||||
|
"sheet.frame": ([("frame-arm", "continue", ["Continue"]), ("frame-ok", "choice", ["Yes", "No", "Again"])],
|
||||||
|
{"mark_s": 400, "mark_feed": 3000, "emission": EMISSION}),
|
||||||
|
"laser.focus": ([("focus-arm", "continue", ["Continue"]),
|
||||||
|
("focus-pick", "multichoice", [str(i) for i in range(1, 13)]),
|
||||||
|
("thickness", "number", ["Keep", "Set"])],
|
||||||
|
{"pick": 11, "thickness_mm": 3.2, "pick_half_steps": -11, "edge_z_mm": 6.96,
|
||||||
|
"steps_per_mm": 2.922, "max_height_mm": 13.81,
|
||||||
|
"focus_range_mm": {"min": 2.17, "max": 13.81},
|
||||||
|
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
|
||||||
|
"contact_above": 22, "why": ""}, "emission": EMISSION}),
|
||||||
|
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
|
||||||
|
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
|
||||||
|
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
|
||||||
|
"laser.dose-curve": ([("dose-arm", "continue", ["Continue"])],
|
||||||
|
{"points": [[10, 0.44], [100, 100]], "curve": "10:0.44,100:100", "emission": EMISSION}),
|
||||||
|
"laser.corner": ([("corner-arm", "continue", ["Continue"]),
|
||||||
|
("corner-pick", "choice", ["1.00", "1.25", "1.50", "1.75", "2.00"])],
|
||||||
|
{"gamma": 1.5, "emission": EMISSION}),
|
||||||
|
"cooling.flow-load": ([("load-arm", "continue", ["Continue"])],
|
||||||
|
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
|
||||||
|
"k_cw": 2.78e-5, "emission": EMISSION}),
|
||||||
|
}
|
||||||
|
# What each card writes when it ends.
|
||||||
|
WRITES = {
|
||||||
|
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14",
|
||||||
|
"lens_stop_above_steps": "20"},
|
||||||
|
"laser.floor": {"laser_floor_density": "10"},
|
||||||
|
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
|
||||||
|
"laser.corner": {"laser_corner_gamma": "1.5"},
|
||||||
|
"cooling.flow-load": {"cool_laser_heat_density": "2.14e-5", "cool_laser_heat_cw": "2.78e-5"},
|
||||||
|
}
|
||||||
|
FOUND = {"lens_hall_edge_z_mm": "3.35", "lens_stop_below_steps": "10", "lens_stop_above_steps": "12",
|
||||||
|
"laser_floor_density": "12",
|
||||||
|
"laser_dose_curve": "off", "laser_corner_gamma": "2", "cool_laser_heat_density": "1e-5",
|
||||||
|
"cool_laser_heat_cw": "1e-5"}
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.t = catalog.load_suite()["commission.sheet"]
|
||||||
|
|
||||||
|
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
|
||||||
|
t = self.t
|
||||||
|
self.assertEqual(t.kind, "live")
|
||||||
|
self.assertEqual(t.mode, "grbl")
|
||||||
|
self.assertEqual(t.hardware, "takeover")
|
||||||
|
self.assertIn("button", t.actions)
|
||||||
|
self.assertTrue(t.hands)
|
||||||
|
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
|
||||||
|
self.assertIsNone(getattr(t, "precheck", None))
|
||||||
|
for want in ("commission.check-motion", "laser.emission-witness", "cooling.flow-verify"):
|
||||||
|
self.assertIn(want, t.requires)
|
||||||
|
|
||||||
|
def test_no_other_sheet_test_remains(self):
|
||||||
|
ids = [i for i in catalog.load_suite() if i.startswith("commission.sheet")]
|
||||||
|
self.assertEqual(ids, ["commission.sheet"])
|
||||||
|
|
||||||
|
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
|
||||||
|
covers = set(self.t.covers)
|
||||||
|
for want in (("forgectrl", "src/wizlive.*"), ("forgectrl", "src/sheet.*"), ("forgectrl", "src/jobstream.*"),
|
||||||
|
("forgectrl", "src/curverec.*"), ("forgectrl", "src/font_hershey.*"),
|
||||||
|
("forgectrl", "src/ui/wizard.*"), ("forgectrl", "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()
|
||||||
@@ -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()
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
+1076
-1013
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user