48 Commits
Author SHA1 Message Date
ScottW514 7a9f655916 Release v0.0.5
FORGEFIRM_RELEASE = 0.0.5 and the acceptance artifact that authorizes it:
campaign c-20260915231006 on the dev image 20260915225440 (grbl pin
0.1.17, forgefirm 52a9138), authorized, all required tests passed on the
bench reference.
2026-09-15 20:07:22 -04:00
ScottW514 52a91385cd Pin grblHAL-glowforge 0.1.17
The clean-parser-on-connect fix (ecebe9c): a soft-limit-rejected jog
held subsequent g-code until a blank line, and the hold outlived the
connection - a new sender got error:15 on its first g-code line.
2026-09-15 18:52:25 -04:00
ScottW514 b17778f538 forgetest: verdict-cut holds the daemon under the report dead-man
laser.verdict-cut freezes the daemon so the verdict the controller
caches expires and the driver's pause tier holds the job, then resumes
and proves the job ran dark through the hold with the laser latch never
locked. The freeze was 3.5 s. The engine's report dead-man locks the
laser latch when the controller's report is older than 5.0 s while
armed, and the last report is up to 1 s old when the freeze starts, so
a 3.5 s freeze left only a half second of margin - a dead-man latch
lock reads exactly like the failure the test refuses.

The freeze is now 3.0 s: still past the 2.0 s the cached verdict takes
to expire (so the pause tier holds and the hold is seen), a full second
under the dead-man. No behavior changed, only the freeze the test
holds.
2026-09-15 18:51:59 -04:00
ScottW514 d5551d7e8a Pin grblHAL-glowforge 0.1.16
The fresh-controller button-lamp reset (a87837a): a controller ended
mid-arm by a fail tier left the button ring glowing 'press to arm', and
the respawned controller did not clear it.
2026-09-15 17:36:10 -04:00
ScottW514 41417f820b forgetest: clear a latched controller error, bound the move-start check, and follow the fail-tier restart
Three acceptance tests broke on the WI-4/WI-5 firmware, all from a
behavior a change moved and no test tracked.

motion: soft limits are armed after a home (the driver's, since the bed
has no switches), so a jog past the bed is refused with error:15 - and
grblHAL then answers error:15 to every following G-code line, across a
fresh connection, until a blank line acknowledges it. A prior test's or
the baseline hand-back's rejected jog left that latch, and the next
test's first move failed with a stale error unrelated to the move.
clean_slate now clears it with a blank line before the first move. The
four "send G1, sleep, check Run once" sites are replaced by start_move,
which waits for Run on a bound and, when it does not come, names the
reply, the state, the drained messages and a forgectrl snapshot - the
capture that turned "the move did not start" into "answered error:15".

cooling.fire-watch-tiers: the FIRE fail tier now stops the controller
through the supervisor and starts it again, so leg 2's Grbl connection
dies mid-leg. It now records the pid, reads FIRE from the engine and
the latch from sysfs, waits out the restart, proves the supervisor
logged the fail-tier stop, and opens a fresh session for the legs that
follow; src/super.* added to its covers.

Proof: full forgetest host suite green; coverage lint clean (90 tests);
bench reference on the dev image, unattended queue: motion.* and
cooling.* pass, cooling.fire-watch-tiers and cooling.fail-tier-stop
pass with the button-lamp driver fix (grblHAL-glowforge).
2026-09-15 17:35:25 -04:00
ScottW514 db6015dc81 forgetest: cloud.mode-switch opens the lid behind the controller's start, ahead of the hunt
The supervisor holds every controller spawn until the enclosure is
closed (forgectrl 0.1.25), and the test opened the lid before it asked
for the cloud controller: POST /mode answered "waiting, the lid is
open" and the controller never came up. The round trip now switches
with the lid closed, polls /mode five times a second, and opens the
lid the moment the controller is running. The client requests its
connect-time hunt a few seconds after its start, right behind its
session, so the hunt still finds the lid open. The order is recorded
and judged: the hunt's request line must not be in the client's log
when the lid reads open (hunt_before_lid_open), and the test refuses
to start with the lid open. The catalog text tells the operator to
open the lid at once, with a hand ready on it.

Proof. Host: test_cloud_suite drives the round trip with the hunt
landing only once the lid reads open, as on the bench, plus the lost
race (the hunt requested before the lid opened fails the test with
"before the lid was open") and the start with the lid open refused;
8 mode-switch cases green. Bench reference (dev image 20260915001814,
forgectrl 0.1.25): cloud.mode-switch PASS in 118 s, the lid open 4 s
before the client requested its hunt, no refusal before the hunt's
end, the lens homed, the exhaust row unjudged, 5 service motions
after the lid closed, $H under gfhome homed in 48.4 s with 9 motion
windows. No catalog consequence beyond the test itself: its covers
map is unchanged.
2026-09-15 15:41:35 -04:00
ScottW514 0c87927388 Pin grblHAL-glowforge 0.1.15
The driver at its AGENTS.md and header-comment revision (9bfaa98): no
code change since 0.1.14; the pinned source now matches the head of the
repository for the release.
2026-09-14 20:15:43 -04:00
ScottW514 ea8a94a7d1 AGENTS.md: unreadable is fail-closed, and the drills that are catalog tests
The project-wide safety rule joins every repository: a failed read of
a safety input, a state file, or a verdict is never treated as still
running or keep waiting without a bound. The acceptance-coverage rules
add that a silence-while-armed injection and a crash during $H are
catalog drills, not one-off bench scripts.
2026-09-14 20:08:14 -04:00
ScottW514 79c03641bd Lock meta-openglow at the python3-gfhardware pin
The BSP layer at dd8f74c: python3-gfhardware pinned on the cloud
client's latch and feed changes (5ca279a), the same revision the
forgefirm-app recipes pin, and the layer's line endings normalized.
2026-09-14 20:04:58 -04:00
ScottW514 ea6ac54ab3 Pin forgefirm-app 0.1.29
The cloud client: the laser latch unlocks at the run and nowhere
earlier, the warm-up is supervised, a live feed must land and finish,
fire needs a power byte first, the homing runner always stops
(python3-gfhardware 5ca279a; the pin also carries the "setup" naming
in the comments and the LF normalization). Bench-proven with
cloud.dark-print, cloud.verdict-refuse and motion.deadman on the bench
reference.
2026-09-14 19:45:44 -04:00
ScottW514 f080d5d9cb Catalog: the cloud client's latch at the run, and a hold that never clears
The cloud client changes (python3-gfhardware: the latch unlocks at the
run and nowhere earlier, the warm-up is supervised, a live feed must
land and finish, fire needs a power byte first, the homing runner
always stops). This commit carries the catalog tests that hold the
part the bench can see.

forgetest/forgetest/suite/cloud.py:
- cloud.dark-print (new, kind operator, one press, dark by
  construction) replaces cloud.verdict-hold. The print arms on the
  press, waits for the engine's acknowledgment and runs: the laser
  latch is locked at the button and through the wait, unlocked only
  for the run (immediately before it starts), locked again when the
  job ends, and the print completes. The engine's own warm-up release
  stays proven by cooling.floor-and-warm-up.
- cloud.verdict-refuse (new, kind operator, one press, dark): the
  start gate far above the coolant and cloud_hold_max_s at its minimum
  keep the armed print under the warm-up past the bound. The client
  waits with the latch locked (sampled every two seconds), cancels at
  the bound with its own log line, never runs, closes the armed window,
  and the print ends ':cancelled'. The settings are restored.
- cloud.verdict-hold is retired: its release rode the loop heater at
  the flow-check duty against a gate one degree above the coolant,
  inside the upstream reading's noise band, and its run hovered for
  minutes with the engine's "warm-up stalled" line in the log. The two
  tests above prove the client's contract without the thermal race.
- cloud.oversize-stream already reads cnc/streaming back at both ends
  of the run, which is the readback the client now insists on. A
  forced streaming write failure has no seam on the board (the write
  goes to sysfs as root) and stays a host test.

tests/test_cloud_suite.py follows: excerpts and failure cases for the
two new tests in place of the retired one's.

Proof. Host: the forgetest unit tests. Bench reference:
cloud.dark-print passed (locked at the button, unlocked in the run,
locked after, ':completed'), cloud.verdict-refuse passed (held 60 s,
31 latch samples all locked, ':cancelled', settings restored);
motion.deadman's kill during $H ended the runner on SIGTERM alone; and
the retired cloud.verdict-hold passed once more on the new client
before it went (the latch locked through an 8 minute warm-up hold, the
release ran the print to completion).
2026-09-14 19:45:20 -04:00
ScottW514 d465625a98 Pin forgectrl 0.1.25
The supervisor: controller death as a signal, the homing runner and
the kernel before a respawn, the enclosure check before every spawn,
the engine's fail tiers ending the controller (forgectrl da3eddc; the
pin also carries the "setup" naming of the first run and the LF
normalization, whose advisory wording change asks every machine to
accept the advisories again). Bench-proven with motion.deadman,
motion.respawn-gate and cooling.fail-tier-stop on the bench reference.
2026-09-14 18:44:02 -04:00
ScottW514 7089a25721 Catalog tests for the supervisor: a kill during $H, a respawn behind the lid, the fail-tier stop, the 300 ms relock
The daemon's supervisor and engine change (forgectrl: controller death
as a signal, the homing runner and the kernel before a respawn, the
enclosure check before every spawn, the fail tiers ending the
controller). This commit carries the catalog tests that hold them.

forgetest/forgetest/suite/motion.py:
- motion.deadman gains a last phase: SIGKILL of the controller during
  $H (the web-service homing, run only with cloud mode enabled, the
  homing mode set and put back by the test). The homing runner must be
  gone before the respawn, the kernel idle when the new controller
  starts, no halt needed, and the pulse device held by the daemon and
  one controller. The head ends wherever the homing was.
- motion.respawn-gate (kind operator, the lid through the fixture where
  one is wired): the controller killed with the lid open. The
  supervisor safes (latch locked), reports waiting with why naming the
  lid, starts nothing while the lid stays open, and comes back verified
  when it closes, without a second motion probe.

forgetest/forgetest/suite/laser.py:
- laser.armed-kill reads the latch and the kernel state from sysfs
  every few milliseconds after the SIGKILL: the latch must lock within
  300 ms of the kill and the kernel leave running within a second (a
  death is a signal to the supervisor, not a poll). The emission bound
  stays at 2.5 s: the witness counts a window.

forgetest/forgetest/suite/cooling.py:
- cooling.fail-tier-stop (kind operator, one press, no emission): the
  crash watch's thresholds at their lowest make the head's own move
  trip the abort generator inside an armed, dark (S0) job. The engine
  logs the crash signal, the supervisor logs the stop and starts a new
  controller, the latch is locked, the kernel idle, no emission, the
  thresholds put back. The watch exists only inside the armed window,
  so the press is the arm, not a fire.

Proof. Host: the forgetest unit tests. Bench reference: motion.deadman
passed with the new phase (the runner gone in 0.9 s, the kernel idle
at the respawn, holders forgectrl and grblHAL_glowforge),
motion.respawn-gate passed (waiting 1.3 s after the kill with "the lid
is open", running and verified the moment the lid closed, no probe
line), cooling.fail-tier-stop passed (the signal at +0.27 s, the
controller pid 21823 to 22273, latch locked, emission 0).
2026-09-14 18:44:02 -04:00
ScottW514 d65a7aa6aa Pin grblHAL-glowforge 0.1.14
Grbl protocol: the welcome banner on every connect, a one-second TX
stall bound, a 2 KiB TX ring (grblHAL-glowforge e507e13). Bench-proven
on the bench reference: a LightBurn connect shows the banner and the
machine settings read completes without a disconnect.
2026-09-14 17:52:18 -04:00
ScottW514 70e945a949 Pin grblHAL-glowforge 0.1.13
Stream and motion robustness: the shipper writes outside the lock, a
clamp inside an armed window faults, the X/Y soft limits follow the
home, the machine's settings are pinned, the homing keys are clamped
(grblHAL-glowforge 511ff25). Bench-proven with motion.soft-limits and
motion.deadman on the bench reference.
2026-09-14 17:28:48 -04:00
ScottW514 2f2a4160af Stream and motion robustness: harness rules, catalog tests, the hand-back's counter scale
The GRBL driver's stream engine and its motion envelope change
(grblHAL-glowforge: the shipper writes outside the lock, a clamp inside
an armed window faults, the X/Y soft limits follow the home, the
machine's settings are pinned, the homing keys are clamped). This commit
carries the host rules and the catalog tests that hold them; the driver
commit follows, because its CI fetches these harnesses unpinned.

scripts/bench/laser_stream_test.py:
- Rule 28: a 300 ms producer stall while armed faults the stream with
  ALARM:17, the kernel sees no step burst (at most the planned steps per
  100 ticks), the stream ends dark, the latch sideband ends on the lock.
  The same stall unarmed is a warning: the move completes with every
  step, the clamp visible as the burst the kernel counts.
- Rule 29: a 300 ms stall of the sink's write leaves the producer on
  pace: no clamp, every step, lit through, dark at the end.
- The stand-in engine takes GFSINK_STALL_MS and GFSINK_WRITE_STALL_MS,
  null-sink only.

scripts/bench/z_envelope_test.py:
- Rule 10: homed (a gfcloud home), a program move past X max, Y max or
  the near edge alarms with ALARM:2 before any motion, a jog past the bed
  is refused with error 15, a move inside the bed runs, and a $20 write
  keeps the limits. The core repeats the last error for the line after a
  refused jog until an empty line clears it, so the rule sends one.

forgetest/forgetest/suite/motion.py:
- motion.soft-limits (kind auto, no emission): homes through the cloud
  suite's gfhome homing when the machine is not homed, then the three
  refusals (ALARM:2, the kernel counters still), the refused jog, the
  inside move, and the return to the corner read at rest.
- motion.deadman phase 2b: a 100 ms SIGSTOP mid-move, inside the
  kernel's queue: no underrun, the controller's log warns of the clamped
  late events, the move completes with every step (read at rest), the
  latch stays locked. The armed clamp is proven on the host (rule 28).

forgetest/forgetest/suite/cloud.py:
- gfhome_homing drains the driver's answer to $H once the session ends:
  it sits behind the status reports and passed for the reply to the
  caller's next command (a setting read as None).

forgetest/forgetest/baseline.py:
- The hand-back reads the position counters at the kernel's own
  microstep mode (cnc/x_mode, read before the sysfs restore puts the
  settings' mode back). At the x8 constant, an x32 machine's 30 mm read
  as 120 mm, beyond the return bound, and the displaced head was left in
  place. The dead band scales the same way. tests/test_baseline.py holds
  both.

Proof. Host: stream rules 1 to 29 and z_envelope rules 1 to 10 against
the null-sink driver, the forgetest unit tests. Bench reference:
motion.soft-limits passed (X 495 and Y 279 refused with ALARM:2 and
0.000 mm of kernel motion, X-1 the same, the jog error:15, the inside
move ran, back at the corner 0.0/0.0 mm); motion.deadman passed with
the new phase (92 late events clamped, max behind 92.3 ms, no underrun,
30.0 mm counted, latch locked) and the hand-back jogged the head back
under the x32 scale.
2026-09-14 17:28:28 -04:00
ScottW514 ea458850f0 Pin grblHAL-glowforge 0.1.12
The emission gates: the per-tick fire gate, the latch with an owner,
the cooling verdict's two tiers, and the arm-flow gates
(grblHAL-glowforge 16d2b9e). Bench-proven with laser.verdict-cut and
laser.armed-kill on the bench reference.
2026-09-14 16:08:17 -04:00
ScottW514 e9d5f3c842 Laser harness rules and catalog tests for the emission gates
The GRBL driver's emission gates change (grblHAL-glowforge: the per-tick
fire gate, the latch with an owner, the cooling verdict's two tiers, and
the arm-flow gates). This commit carries the host rules and the catalog
tests that hold them; the driver commit follows, because its CI fetches
these harnesses unpinned.

scripts/bench/laser_stream_test.py:
- Rule 24 is the verdict's pause tier: the client holds the job under the
  open window, the first deceleration runs lit to the stop (the dark lead
  before the stop is at most 1000 ticks: the producer's lead plus one
  shipper period), a resume under the standing verdict moves dark and is
  held again, the clean verdict resumes lit with no press, and the latch
  sideband carries only the arm's unlock and the program end's lock.
- Rule 25 is the fail tier: AIRFLOW mid-cut ends the job with ALARM:3,
  the stream ends dark well short of the line, the sideband ends on the
  lock, and a resume under the clean verdict that follows resumes nothing.
- Rule 26: a sender change mid-M3 holds the job with the deceleration
  dark: the gate follows the window on every tick.
- Rule 27: a verdict that goes stale holds the job at the cache's own
  expiry, lit to the stop, never a poll later; the engine's return
  resumes lit.
- The stand-in engine publishes the verdict name and has a stale mode;
  the session steps gain expect_text, reconnect and a wait_state timeout;
  every session reads the latch sideband (GFSINK_LATCH_LOG).

scripts/bench/laser_lifecycle_test.py:
- Rules 11 to 15: the pause tier resumes with no press and no prompt, the
  fail tier ends the job and nothing resumes it, a sender change during a
  re-arm cancels it, a jog does not hold the window open, and a press
  counts only after the button has been seen up. The stand-in engine
  takes a live verdict dict. start_armed_move waits for a fresh prompt
  and a fresh armed message: a press that lands before the wait has
  begun is not consent, and the old stale match let one land early.

forgetest/forgetest/suite/laser.py:
- laser.verdict-cut (kind live, one press): a 40 mm M3 line; the test
  pauses the daemon for 3.5 s so the verdict goes stale (the settings
  route is idle-gated and the engine reloads its gates at a session
  start, so no setting can trip a pause mid-cut; the crash tiers need a
  physical knock). The controller must hold with the SoC latch and the
  hardware button latch both clear in every sample, emission must read 0
  before the resume, and the clean verdict must resume the cut lit with
  no press and no prompt; M2 disarms as usual.
- laser.armed-kill asserts that the respawned controller comes up with
  the latch still locked.

Proof: both harnesses pass against the driver change on the host, and
the forgetest unit tests pass. On the bench reference, laser.verdict-cut
passed with no gap in the cut (held at +1.71 s after the pause began,
both latches clear in every sample, emission 0 at +2.06 s after the
hold, resumed lit at +4.0 s with the beam detector 678 counts over idle,
kernel drift 0.0 mm, disarmed 0.1 s after Idle) and laser.armed-kill
passed (emission 0 at +1.8 s after the supervisor's stop and +2.1 s
after the SIGKILL, latch locked, respawned with the latch locked, button
dark). Two earlier verdict-cut runs shaped the driver: a pause that
locked the latch resumed dark, because a lock sets the hardware button
latch, and a hold taken a poll after the gate closed left a several-mm
gap.

Catalog: one test added and one extended; both cover src/** of the
driver through the existing laser covers.
2026-09-14 16:07:55 -04:00
ScottW514 7c0412075c Normalize line endings to LF
.gitattributes sets text=auto with eol=lf, so every text file is
stored and checked out with LF, and a patch keeps its bytes. The files
that carried CRLF from a Windows editor are renormalized. No content
changes.
2026-09-14 10:39:08 -04:00
ScottW514 d8b8adaef4 forgetest: the setup suite, and the sensors check asks nothing
The commission.* tests are setup.* (subsystem "setup"): suite/setup.py,
setup_dark.py, setup_sheet.py, and the host tests test_setup_*.py. The
coverage maps name src/setup.* in place of src/commission.*, the record
is setup.json, and the bench seed in forgetest.init creates
/run/forgefirm/setup-override.

setup.check-sensors follows the check as it is now: no question is
asked and no setting is written. The test reads the settings before
and after and fails on any change, and fails at once if the check
opens a prompt. The host test drives the fake daemon with no prompt
and proves both outcomes.

Proof: test_setup_dark.py, test_setup_sheet.py, and
test_setup_suite.py pass (46 tests). The coverage lint names
src/commission.c and .h as uncovered until the forgectrl pin moves to
the revision that carries the rename.
2026-09-14 10:37:55 -04:00
ScottW514 c0d4302d73 ForgeFIRM v0.0.4
FORGEFIRM_RELEASE 0.0.4. The acceptance record that authorizes this
build: campaign c-20260912182427-e1ee on dev image 20260912180956
(manifest 8bb5b6f3), 85 tests, 85 satisfied, 0 required, exported
2026-09-12T19:21:35Z. The release notes in releases/v0.0.4/notes.md
name this the first public beta; the pipeline takes a release's
notes.md, when it carries one, in place of GitHub's generated notes.
2026-09-12 15:22:33 -04:00
ScottW514 884d014d2c Pin forgectrl 0.1.24 (verified fan writes) and cover the lost-duty put-back
forgectrl 0.1.24 = 2380e07: every fan duty write is read back and
retried, a duty a device lost is put back by the tick, and the airflow
fault names the duty commanded and the duty in force. The corner card
of the sheet on the bench reference was held as a slow air-assist fan
after a run-duty write the head never took; nothing checked.

Acceptance: cooling.fan-duty-readback (auto, grbl) opens an M8 session,
reads every fan's run duty back, writes the head's air-assist register
back to the idle duty and the exhaust PWM to zero behind the engine's
back, and holds both to their run duties again within a few ticks, each
loss named in the log, the session OK to its end, the idle duties after
M9. Covers the head driver too. On the bench reference with forgectrl
0.1.24 hot-deployed it PASSED: both duties back after 0.5 s.
2026-09-12 14:08:34 -04:00
ScottW514 a9f0ad0d51 Take forgectrl 0.1.23's theme.css into the forgetest page
The shared theme gained the release dialog's styles in forgectrl; the
copy the acceptance page uses follows it, as check-ui-vendor requires.
2026-09-12 12:58:27 -04:00
ScottW514 2dde10cf43 Pin forgectrl 0.1.23 (the mirror test reads the release reply's packed keys)
forgectrl 0.1.23 = cb8c140: the dev-server mirror test holds the mock's
release replies to the keys reply_release packs; the daemon is the one
0.1.22 carried. CI is green on this revision.
2026-09-12 12:48:40 -04:00
ScottW514 f41464cc58 Pin forgectrl 0.1.22 (the releases-API check and the release dialog) and cover its routes
forgectrl 0.1.22 = 0235a88: the release check reads the GitHub releases
API and never requests the firmware file's URL, the daemon checks daily,
and the panel raises a per-release dismissable alert and runs the install
from one dialog.

Acceptance: update.release-check (auto) exercises GET /update/release,
POST /update/check (a machine with no route to the API answers 502, which
the drill records and steps over), the v<semver> shape and `new` of a
published release, and the dismissal round trip, and puts the dismissal
back. forgectrl.auth's unauthenticated-write list gains /update/check and
/update/dismiss. Coverage lint: 84 tests, 0 uncovered paths; the
forgetest unit tests pass (373). On the bench reference (dev image
20260911203113 with forgectrl 0.1.22 hot-deployed) every check of both
tests passed; the runs were marked FAIL only by the hand-back baseline,
because the controller is gated until the changed privacy advisory is
accepted again.
2026-09-12 12:41:56 -04:00
ScottW514 20c6256527 forgectrl 0.1.21: the release check reads the tag from the first hop
Pin forgectrl at 92cead6 (0.1.21). The published-release check reads
the release tag from the first redirect hop instead of the end of the
chain, where the asset store's URL carries none; every image through
v0.0.3 reported "release server error (HTTP 200)" against a published
release. The forgectrl commit carries the proof (relcheck_test in its
CI). The update.slots-and-signature covers map names the new
src/relcheck.c and src/relcheck.h so the coverage lint stays whole. A
catalog test of the check against a published release is held for a
later change.
2026-09-11 16:29:34 -04:00
ScottW514 b7a595ced7 Release v0.0.3: the version and the acceptance artifact for this tree
FORGEFIRM_RELEASE takes 0.0.3. The file sits outside the layer content
hash, so the bump changes the version and invalidates no acceptance
result.

releases/v0.0.3 carries the artifact the bench exported for this image:
campaign c-20260911182256-078f on 20260911172215 (dev), manifest identity
fb24c3f4508ae54592f8d0b98ed0b2a07a933274980d8c1e4e5a967618ef9bd8, 83
tests, 83 satisfied (57 inherited), 0 required, release authorized. The
release gate recomputes every catalog test's domain fingerprint from the
manifest inside the release rootfs and signs only when the recorded
results agree.

The release carries, since v0.0.2: the lens frame in one place, so every
Z a commissioning card sends comes from the settings the controller
opens its Z limit from (the tail at the head's reference, the focus
window written before the card's controller starts, every program judged
against the reach before its first line goes out); the commissioning
sheet acceptance test run as a fresh machine's; and the CPU percent on
/status that holds across a status read inside the same scheduler tick
as another reader's. Components: forgectrl 0.1.20, grblhal-glowforge
0.1.11, forgefirm-app 0.1.28+git, kernel-module 0.0.5, meta-openglow
ced2af2.
2026-09-11 14:55:25 -04:00
ScottW514 5c93e49c8c Pin forgectrl 0.1.20: the CPU percent holds across a same-tick status read
forgectrl 864b8da repeats the last CPU percent when a /status read finds
the /proc/stat counters unmoved, the race that failed
forgectrl.panel-serves on the release candidate when the test's second
read shared a scheduler tick with a panel poll. Every test covering
forgectrl re-runs.
2026-09-11 13:19:45 -04:00
ScottW514 650483c085 Pin forgectrl 0.1.19: the lens frame with a workflow file that parses
forgectrl 3e54612 renames the lens_test step in build.yml, whose name
held a colon that YAML read as a mapping, so CI on f8ddb17 ran nothing.
The source is f8ddb17's; the pin moves so the release names a commit
with a green build.
2026-09-11 12:55:02 -04:00
ScottW514 7b8f72b632 Run the commissioning sheet as a fresh machine, and pin forgectrl 0.1.18 (the lens frame)
forgectrl f8ddb17 puts the lens frame in one place and writes the focus
window before the card's controller starts, after two commissioning
cards ended in ALARM:2 on a Z the wizard sent from one source while the
controller's Z limit stood on another. The acceptance run had passed
only because the bench's settings already held the stops from an
earlier focus run, so the run is now a fresh machine's.

commission.sheet clears the three lens settings inside its Restore
before the cards, checks that the frame runs in the fallback window,
and, after the focus card, that the settings hold the window the ladder
ran in (the stops found or the fallback), that every ladder height lies
in that window's reach, and that the program served now agrees with
/status. Every served program's Z is checked against the reach /status
reports before the card starts, so a stray Z fails the test with nothing
burned. The test covers src/lens.*. The host mock in
tests/test_commission_sheet.py mirrors the daemon (the /status lens block
from the settings, the ladder served from the settings, the window
written at the focus start); two regression tests reproduce the defects:
a focus result naming a window the settings do not hold, and a served
program with a Z beyond the reach. 10/10 green.

scripts/bench/z_envelope_test.py, the grblHAL CI harness, adds the
referenced-lens cases: with forgectrl's marker and the shared settings,
the fallback window and a 14/20 window run to the ends of their reach
and two half-steps past either end alarms, and a count of 41 falls back
on its side alone. Passed on the null-sink build. The bench page's
description of the harness follows.

The forgectrl pin moves to f8ddb17 (0.1.18); every test covering
forgectrl re-runs.
2026-09-11 12:44:43 -04:00
ScottW514 e8d4560ff0 Release v0.0.2: the version and the acceptance artifact for this tree
FORGEFIRM_RELEASE takes 0.0.2. The file sits outside the layer content
hash, so the bump changes the version and invalidates no acceptance
result.

releases/v0.0.2 carries the artifact the bench exported for this image:
campaign c-20260910181641-11a2 on 20260910181308 (dev), manifest identity
a31c820d26d09155d2bf7c629389c5dba5e55d2b52dbbc0ccab5440b187b3b3c, 83
tests, 83 satisfied (57 inherited), 0 required, release authorized. The
release gate recomputes every catalog test's domain fingerprint from the
manifest inside the release rootfs and signs only when the recorded
results agree.

The release carries, since v0.0.1: every machine named after its own MAC
with mDNS dropped, the fan run posture a diagnostic measures, the
hand-back rules the bench now enforces, the flow-load tail ending at the
coolant peak, and the stream flag a dead session leaves behind cleared by
the session that takes the device. Components: forgectrl 0.1.17,
grblhal-glowforge 0.1.11, forgefirm-app 0.1.28+git, kernel-module
0.0.5, meta-openglow ced2af2.
2026-09-10 14:34:33 -04:00
ScottW514 00cc009871 Pin the stream-flag fix in the controller
grblHAL-glowforge 0.1.11 (1f2ea9c): a controller taking the pulse device
clears a dead session's cnc/streaming flag, the way it already acks that
session's stale underrun. laser.armed-kill kills the controller mid-fire,
and the machine came back with the kernel still believing the killed
session was feeding it.
2026-09-10 14:11:08 -04:00
ScottW514 c47f7d9a86 Put the head back after the hold in the disarm test
laser.disarm-in-hold streams a 40 mm move, feed-holds it two seconds in,
waits out the disarm grace, and then resets out of the hold. The recovery
put the laser and the controller back and left the head where the hold
had caught it: 11.34 mm along on the bench reference, 605 counts, which
the hand-back jogged out and reported.

The head goes back now, by the distance it actually travelled rather than
the distance the move asked for: the hold catches it at a slightly
different point every run - ten millimetres nominal at F300 for two
seconds, 11.34 measured - so the kernel counters before the move and
after the reset are what the return jog is built from. Where it stopped
and where it ended go into the evidence.

This is not the fault the M5 rapid job had. That job's moves netted plus
twenty by construction; this one stops part-way on purpose, and the
recovery simply never returned it.

laser.armed-kill has the same shape, a kill mid-fire that stops the head
where it stops, and is left alone until a run says whether it needs the
same treatment.
2026-09-10 13:58:23 -04:00
ScottW514 c217e24905 Put the head back at the end of the M5 rapid job
laser.m5-rapid-dark cut 20 mm out and then ran the two rapids the test is
about, one back and one out again. The three net to plus 20 mm, so the
job ended with the head 20 mm from where it started, every run: 1067
counts at 53.333 per mm, which the hand-back jogged out and reported.
That one is dirt, and the position dead band was right to leave it alone
- it is 20 mm, not the step a return rounds to.

A third rapid, back 20 mm with a short dwell, ends the job where it began.
It sits inside the sampling window and after the M5, so it is one more
rapid that must be dark, which is what the test already asks of the other
two: the assertion is wider, not narrower. The operator's clearance step
is unchanged, because the head still needs its 20 mm of +X.

Every job list in the suite that moves in G91 now nets zero on X
(cooling.py, laser.py twice).
2026-09-10 13:46:34 -04:00
ScottW514 b94c990bbc Leave the lens to the cloud client in cloud mode
cloud.verdict-hold printed to completion and then failed its hand-back on
head/z_mode=0. That job's header carried ZSmd 0, gfhardware's
set_mode_from_puls turns that into z_mode 0, and z_mode 0 is full step
(lenshome.c): the cloud client had set the lens the way the pulse file it
was playing asked. Nothing was left behind - the machine did what the job
said.

The baseline already skips nine attributes in cloud mode, for the reason
written above the list: the cloud client sets its own values for them
from every pulse header, and forcing the GRBL values under it would be
the baseline configuring another controller's machine. head/z_current and
head/z_mode are the same thing and were not on the list. They are now, in
cloud mode only; in GRBL mode the pair is still checked against 1 and 1,
which is the half of this an exemption could quietly break, so a test
holds it.

Host-proven: 89 baseline unit tests.
2026-09-10 13:08:33 -04:00
ScottW514 8d8ca2a8c6 Judge a hand-back on what a run left, not on the machine still working
A leftover is dirt a run left behind. Four of the checks were reporting
something else: the machine part-way through work of its own, which it
finishes on its own a few seconds later. Each one failed a run that had
done nothing wrong.

laser.arm-wait-lid found it. The test passed every check it makes and
failed on cool=smoke/armed=False/hold=False. The smoke clear is the
engine's post-job work - run duty for smoke_s after an armed session,
timed, ending by itself (cool.c, Cool_Smoke) - and the machine was idle
fifteen seconds later without being asked. Armed and hold were both
false: nothing had been left.

The four, and what each was catching:

  cool         a phase alone (the smoke clear, a cooldown). Armed or
               holding stays a leftover: the run left a job alive and the
               engine is keeping the fans up for it.
  controller   the supervisor's own start. A takeover ends by starting
               forgectrl again, and the respawn runs the liveness probe
               and the lens reference before it reports running and
               verified. The run had put it back.
  state        the ring draining to the end of a job. An underrun stays a
               leftover and is still acknowledged with cnc/stop: that one
               is the run's.
  leds         read_led read brightness, write_led writes target, and the
               smooth trigger fades brightness toward target. An LED the
               machine had already released still read lit mid-fade, and
               the restore called itself done before the fade had moved.
               Judged on target now: a run that left the button lit left
               a target standing.

The waiting, the restoring and the reporting of anything a run actually
left are unchanged. What changed is which readings count as dirt.

Host-proven: 87 baseline unit tests, two of them new - a fading button
LED is not a leftover, a lit one is.
2026-09-10 12:51:05 -04:00
ScottW514 4fbf14195b Wait for the purge fan's draw, do not read it in the same second
The airflow check reads the purge fan's off current with the fan off, and
the stand-down that follows commands it back on. The guard that proves
the machine was handed back whole read the draw immediately, so what it
got back was the off current the check had just measured: on the bench
reference, 74 against a 300 floor, with the fan drawing 631 a moment
later. The check failed for having worked.

The current follows the command; it does not arrive with it. The guard
now waits for the draw to reach the floor, up to PURGE_SPINUP_S, the way
the controller below it is already waited for, and logs how long it took.
It is no weaker: a fan that never reaches its floor still fails the
check, and the message now says how long it was given.

Pin: forgectrl 0.1.17 (a9e7073, the air assist and the purge in the
diagnostic run posture - the reason that check measured an idle fan and
wrote a floor from it).
2026-09-10 11:59:47 -04:00
ScottW514 d3fe1d90b9 Hand the machine back, do not describe what is wrong with it
The hand-back is the promise that a run leaves the machine where it found
it. Two of its checks reported instead of restoring, and the machine sat
in the state the run left it in.

The cooling engine: a run that ends without ending its job leaves one
alive behind it - the engine armed and holding for a job that is never
coming back, the fans at run duty. The check waited two minutes for that
to resolve itself, which it cannot, and wrote "failed: still
run/armed=True/hold=True". On the bench reference the fans then ran for
an hour.

The pulse ring: bytes the last job never played sit there, and the next
run replays them before its own. The check refused the return jog and
wrote "clear the ring (controller restart) before moving" - the
instruction, to a log, instead of the action.

Both end the same way: stop the controller and let the supervisor bring
it back. The job goes, the arm and the hold go with it, and the ring is
empty on the way in. stand_down() does that and proves it settled;
the cooling check calls it when the engine will not idle on its own, and
the return jog calls it when the ring has residue, refusing only if bytes
survive a restart. A leftover still reports what it found - the record of
what the run did is the point - but it reports having fixed it.

Host-proven: 62 baseline unit tests, including one that the hand-back
calls the stand-down for ring residue rather than describing it.
2026-09-10 11:34:27 -04:00
ScottW514 594b6990fd Stop a finished run's clock
Run.snapshot() computed elapsed_s from the current time on every call,
whether the run had ended or not. The page shows the last run until the
next one starts, and it polls, so a finished run's figure went on
counting: the result badge said FAIL beside a number still climbing, and
the run read as still going. On the bench reference a test that ended
after 5082 s was showing 5242 s and rising.

The figure a finished run should carry was already recorded next to it:
finished["duration_s"], fixed when the result was written. snapshot()
now returns that once the run has ended, and the live count only while
it is running.

Host-proven: two unit tests - a finished run's clock reads its duration
and does not move across a poll, a running one's still climbs.
2026-09-10 10:43:46 -04:00
ScottW514 57ba3454b4 Clear a setting the only way the daemon accepts, everywhere it is cleared
An empty form field never reaches the request, so POST /settings with an
empty value in the body carries no key at all and the daemon answers 400,
"no known setting in request". Only the query-string form gets an empty
value through. Measured on the bench reference:

    POST /settings -d "lid_policy="   -> 400, the value unchanged
    POST /settings?lid_policy=        -> 200, the key cleared

Three places in the suite already knew this and say so in a comment; two
did not.

motion.lid-policy-hold restored with the form body, so on a machine whose
lid_policy has never been set the restore was refused and the test failed
its own check. It now uses the query-string form for the empty value, as
the others do. This is the same test as the commit before: that one
stopped it from writing the word "cancel" where the machine had nothing,
and this one lets it write the nothing.

cloud.mode-switch had the older shape of the same fault: it restored
homing_mode to the literal "none" where the machine had it unset. On the
bench reference the restore branch never ran, because that machine homes
through the cloud, so it has not fired yet; on a machine with homing_mode
unset it would have. It now restores exactly what it found.

The rest of the suite was audited for this and is clean: camera.key-read,
cooling.critical-tier, cooling.tec-drive, cooling.crash-watch-plumbing,
cooling.fire-watch-tiers, cooling.floor-and-warm-up, cloud.verdict-hold,
forgectrl.settings, logs.levels and motion.xy-microsteps all either guard
the empty value or go through the shared Restore helper, which has always
done it correctly. commission.cloud-header-capture uses that helper too.
2026-09-10 10:26:25 -04:00
ScottW514 d58ee39086 Hand lid_policy back unset where the machine had it unset
motion.lid-policy-hold read the setting as

    was = (fc.settings() or {}).get("lid_policy") or "cancel"

and wrote `was` back at the end. On a machine that has never set the
policy the setting reads as the empty string and behaves as cancel, so
the `or` turned "unset" into the word and the test handed the machine
back carrying a setting it did not arrive with. The hand-back reported
it, restored it, and failed the test.

The value is now captured exactly, empty included, and restored as
captured; the default belongs to reading the value, never to writing it
back. The read-back check gets the same default, so it no longer compares
None with the empty string. lid_policy_in_force carries the effective
policy into the evidence, which is what the old expression was reaching
for.

Found on the bench reference, on the run after the position dead band let
motion.lid-cancel-home through. The four other places in the suite that
read a setting with `or` are safe: two default to the empty string, which
is what unset is, and two feed a check rather than a restore. The shared
Restore helper captures raw values and writes the empty string back for
unset, as this now does.

Only this test's earlier passes are invalidated: the change is inside its
own body, and the per-test source hash of the other eleven motion tests
is unchanged (checked against the file before the edit).
2026-09-10 10:13:16 -04:00
ScottW514 b2c42f0cc3 Do not fail a hand-back on the step the counters round to
The post-run pass compared the kernel position counters exactly, so a
test that put the head back within a hundredth of a millimeter failed
whenever that distance did not round to the same step.

motion.lid-cancel-home found it on the bench reference. The test cancels
a job with the lid twice and the controller returns the head to the job
start each time, landing 0.038 and 0.037 mm out - the same figures as the
run before it, which passed. This time the two returns left four steps on
X, 0.075 mm at 53.333 steps per mm, and the hand-back called it a
leftover. Twenty-three runs of that test, all passing, on returns of the
same accuracy: whether the residue rounds to zero is chance, not a
property of the machine or of the test.

A difference inside POSITION_DEADBAND_MM (0.1 mm, five steps at x8) on X
and Y is now the quantization rather than a leftover. The head is still
put back, so nothing accumulates over a campaign - only the failure goes.
Z stays exact: the return never moves the lens, so a Z difference is
still a leftover and still unrestorable.

Host-proven: four new unit tests on the boundary (four steps and a moved
Z on either side of it, and an unreadable reading), and the forgetest
suite.
2026-09-10 09:53:27 -04:00
ScottW514 1dd6ad608b Press the button when the machine asks, not when its LED is on
The sheet handed the bench actuator every press after the operator's
presence press, and the actuator pressed every time - into nothing. On
the bench reference all six cards were pressed before the card asked:
the frame by 8 s, the focus by 33, the floor by 10, the dose curve by 8,
the corner by 9, and the flow-load card by 69. The operator then pressed
all six himself, which is the opposite of what the ready gate promises.

arm_press() waits for hw.button_lit(), which is true when any button LED
is on, and burn() started that wait before run_check had even started the
wizard. The button is lit through parts of a card that are not the arm -
the lens reference, the program on its way to the controller - so the
wait ended at once, the press landed before the job waited for it, and
the thread was gone by the time the real cue came. forgectrl uses the
same predicate but only inside the job's own sample callback, with the
tube still dark, where a lit button does mean the arm.

The machine already says when it wants the press: a live check opens a
`press` wait prompt at that moment, and run_check sees every prompt. It
now presses there, through a new Ctx.press_now() - no LED read, no
waiting thread, no timing guess. That retires the per-card lit-timeout
column of CARDS, which existed only to give the flow-load card's coolant
settle enough room for a wait that was reading the wrong thing.

The four grbl-driven arm_press() callers in laser.py and cooling.py are
left as they are: they call it with the machine idle and its LEDs dark,
so the level read is the edge they mean. The same shape would bite them
if that ever stopped being true.

Pin: forgectrl 0.1.15 (6e69479, the flow-load tail ending at the coolant
peak and the way out of a finished setup page).

Host-proven: 357 forgetest unit tests, including two new ones - the
actuator presses on the prompt with button_lit stubbed false throughout,
so the LED is provably not consulted, and the press falls to the operator
without a takeover.
2026-09-10 08:41:53 -04:00
ScottW514 f0c40e7d4f Name every machine after its own MAC address, and drop mDNS
One name for every machine was wrong: an operator with two of them on a
network had one forgefirm.local, and mDNS does not work on many networks
at all. The machine now calls itself forgefirm-<xxxx>, from the last four
hex digits of its WiFi MAC address, and sends that name with its DHCP
request, so a network with dynamic DNS publishes it and a router lists
the machine by name. The name is the same at every boot, two machines
take different names, and no serial number leaves the machine.

forgefirm-hostname (new): reads the wlan0 MAC address (eth0 on a machine
with no WiFi) at S38 in rcS, after udev has probed the network drivers
and before poky's hostname.sh reads the file and before the network
starts. The rootfs is read-only, so the name is written through a
bind-mounted copy under /run/forgefirm. A bounded wait covers a slow
probe. hostname:pn-base-files is "forgefirm": the name before S38, and
the fallback when no MAC address can be read.

avahi is deleted - the bbappend, the daemon configuration, the service
file, the image install and the distro block. The address is the way in
that works on every network, and the DHCP name covers the rest.

forgefirm-banner: the marker lines are gone. "# ForgeFIRM addresses" and
"# end" delimited the address block inside /etc/issue, and getty prints
every line of that file, so both markers were on the console. The script
now keeps the image's own text in a second copy under /run/forgefirm,
captured once per boot before the first write, and renders the whole
banner from it. The block is the addresses alone: no mDNS name.

forgefirm-image.bb: the ForgeFIRM mark, under the OpenGlow one the base
image carries, with the version on the mark's own last line,
right-justified to the mark's last column. The mark is written once and
rendered per reader, because /etc/issue is parsed by busybox getty (a
backslash or a percent sign starts an escape, so the art goes in with
every backslash doubled) while /etc/motd is written out as it is. Widths
are measured in columns, not bytes: the color sequences take no room on
the screen. /etc/issue.net stays unused - the machine tells a client that
has not logged in nothing.

Acceptance: commission.mdns-announce is replaced by
commission.machine-name, which checks the name against the MAC address,
the bind-mounted /etc/hostname, the DHCP client's hostname option, the
banner's addresses, and that no mDNS responder is on the image; it covers
nothing by design, like the test it replaces. forgectrl.auth gains the
own-name Host check and its refusal with a domain on it. image.health
checks the /etc/hostname mount and the version on the mark's last line in
both files. commission.ssh-until-reboot asserts there is no
pre-authentication banner. commission_dark's lens coverage widens to
src/lenshome.* so src/lenshome.h is covered; the lint is clean at 83
tests.

Pins: forgectrl 0.1.14 (9e5330f, the hostname certificate and the Host
rule), meta-openglow ced2af2 (the DHCP hostname option and the motd mark)
in the kas lock.

Proven on the bench reference, hot-deployed and rebooted (image
20260910000208 dev): hostname forgefirm-b00a from MAC 2c:6b:7d:0d:b0:0a,
live and in the bind-mounted file; the DHCP client running with
-x hostname:forgefirm-b00a; the console banner and the motd carrying both
marks with the version aligned to the mark's last column, no marker line
and no .local name; forgectrl regenerating its certificate for the new
name. Host tests: 357 forgetest unit tests, forgectrl clean under
-Werror, tls_test and sanitize_test.
2026-09-10 07:18:27 -04:00
ScottW514 d858a23f45 Carry the panel stylesheet into forgetest
The acceptance page shares theme.css with the panel byte for byte, and forgectrl's copy gained the setup header's Download logs button. CI caught the drift, which is what that check is for; the pinned revision decides, so the copy follows.
2026-09-09 19:53:09 -04:00
ScottW514 c7b80ab2e8 A test that does not hand the machine back fails
The baseline has always examined the machine after every run and recorded
what the run left behind. It did nothing else with it: the leftovers went
to the log and the evidence, and the test still reported PASS. So a check
could measure correctly, walk away with the machine in a state nobody
chose, and be recorded green.

That is how the purge fan came to be left off by the airflow check. The
leftover was not even watched, but had it been, it would have been noted
and the test would have passed anyway, and an operator would still have
met the airflow hold at their first fire.

A post-run leftover now fails the run. One the baseline put back fails it
too: the restore is the bench cleaning up after a defect, not the defect's
absence. The message names what was left.

The baseline watches the head as well as the motion side now: purge air
on, which is how the machine idles, and the lens motor at its hold current
in half step, which the lens checks and the sheet cards take and must hand
back. The airflow check proves the machine is whole rather than merely
measured: afterward the purge fan must read commanded-on and must draw
above the floor the check itself just wrote.

The pin takes forgectrl 0.1.13 (a2d73ef), which restores the idle posture
after a diagnostic, fixes the lens session's takeover flag, and gives the
setup a Download logs button, since the panel's Logs tab is unreachable
until the setup is complete.

Expect this to find things. A test that has been handing the machine back
imperfectly has been passing until now, and the first campaign under the
rule is where that shows.
2026-09-09 19:44:24 -04:00
ScottW514 468e92655a Teach the manifest guard test about the version file
The guard keeps scripts/manifest-from-tree.py and forgefirm-image-manifest.bbclass computing the same identity, so it pins the skip list by value and the new suffix failed it. It now expects the version file in both places, and a second case proves the point the change exists for: setting the release number leaves the layer hash where it was. My miss: I proved the identity by hand and pushed without running the unit suite first.
2026-09-09 18:28:47 -04:00
ScottW514 d898b5659d Give the release version its own file, outside the layer content hash
Setting the release number was a platform change. FORGEFIRM_RELEASE sat
in forgefirm-image.bb, the recipe hashes as content of meta-forgefirm,
and a change to the content of a layer invalidates every acceptance
result. So a version bump threw away the campaign that was meant to
authorize that very release, and the number therefore had to be decided
before the image the campaign ran on. Nothing said so: the release-flow
page went straight from the kas configuration to the artifact and the
pipeline, while the gate quietly required the recipe value, the rootfs
stamp, the archive's meta-version and the tag to agree. v0.0.1 was cut
on a tree whose number happened to be right; the next one would have
cost a second campaign to discover the rule.

The number moves to forgefirm-release.inc, which carries it and nothing
else, and the manifest leaves that file out of the layer content hash
exactly as it leaves out the component pin files
(FORGEFIRM_MANIFEST_VERSION_SUFFIX, and the same list in
scripts/manifest-from-tree.py, which computes the identity on a
workstation and must agree byte for byte). release.sh reads the number
from the new file.

The version is metadata, not platform content, and this only makes the
manifest say what it already meant: the version string was already
outside the identity hash, and it was the file carrying it that defeated
that. Nothing is weakened. release.sh still requires the number to equal
the rootfs stamp, the .fw meta-version and the release tag, and
image.health still compares the stamp on the running machine with the
manifest's.

Proven: the tree manifest is byte-identical across a bump from 0.0.1 to
0.0.2 (identity a64e51b8e5ecca0af683d4f0 either way, the meta-forgefirm
layer hash unchanged), where before the two differed. bitbake resolves
FORGEFIRM_RELEASE=0.0.1 and FORGEFIRM_VERSION_STRING=v0.0.1 for the
release image through the new require, and the dev image still overrides
the string with its build timestamp.
2026-09-09 18:12:03 -04:00
58 changed files with 92317 additions and 1897 deletions
+4
View File
@@ -0,0 +1,4 @@
# Every text file is stored and checked out with LF; a patch keeps its bytes.
* text=auto eol=lf
*.patch -text
*.diff -text
+5
View File
@@ -45,6 +45,8 @@ contributor is welcome to read it too: it is the same set of rules.
chain of others.
- Never exercise a gate or a limit through the `GFCOOL_*` environment
overrides in a test. Use the settings API, and restore it in the teardown.
- A silence-while-armed injection and a crash during `$H` are catalog
drills, not one-off bench scripts.
### CI couplings that set the push order
@@ -186,6 +188,9 @@ does.
test with the fix, in the same commit, never after.
- Position counters, homing anchors, and a homed flag are not proof of
physical motion. The head accelerometer is, and so are the operator's eyes.
- Unreadable is fail-closed. A failed read of a safety input, a state file,
or a verdict is never treated as "still running" or "keep waiting" without
a bound.
### Proof before done
+2 -2
View File
@@ -22,13 +22,13 @@ case "$1" in
start)
echo "Starting forgetest"
mkdir -p "$DATA"
# The bench seed: the commissioning override lifts the wizard part of
# The bench seed: the setup override lifts the wizard part of
# the controller gate until the next reboot (/run is tmpfs), so a
# 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
: > /run/forgefirm/setup-override
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
;;
+163 -20
View File
@@ -65,6 +65,21 @@ FIXED_SYSFS = [
("head/white_led", "0"),
("thermal/heater_pwm", "0"),
("thermal/tec_on", "0"),
# Purge air runs continuously, as on the factory machine: the engine
# turns it on at start and only a listening (the quiet hold) switches
# it off, which puts it back on release. A check that switches it off
# to measure it and forgets leaves the machine one job away from an
# airflow hold mid-cut, judged against the floor that same check just
# wrote, and nothing notices until the daemon restarts: the engine's
# idle phase never re-applies its own duties. The airflow check did
# exactly that, and it reached an operator's first fire.
("head/purge_air", "1"),
# The lens motor at rest: hold current, half step. The lens checks and
# the sheet cards take it to the run current in full or half step and
# must hand it back, or the motor sits hot and the next reference
# starts from a state nobody chose.
("head/z_current", "1"),
("head/z_mode", "1"),
]
# The subset the GRBL controller writes at its start: checked and restored
@@ -72,9 +87,14 @@ FIXED_SYSFS = [
# every pulse header (step_freq 10 kHz, the run currents), runs at the
# service's own x8 with the module's ramp whatever xy_microsteps says, and
# hands the hold currents back at idle; forcing the GRBL values under it
# would be the baseline configuring another controller's machine.
# would be the baseline configuring another controller's machine. The Z
# pair belongs with them: the cloud client takes the lens microstep mode
# from the pulse header it is playing (ZSmd, gfhardware z_axis
# set_mode_from_puls) and the current with it, so a job whose header asks
# for full step leaves z_mode 0 behind by doing what it was told.
GRBL_CONTROLLER_SYSFS = ("cnc/motor_lock", "cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
"cnc/step_freq", "cnc/ramp_rate", "pic/x_step_current", "pic/y_step_current")
"cnc/step_freq", "cnc/ramp_rate", "pic/x_step_current", "pic/y_step_current",
"head/z_current", "head/z_mode")
# Settings the baseline never hands back as bare settings: controller_mode
# is the persisted mirror of the live mode (the mode item restores it
@@ -118,6 +138,13 @@ GRBL_PORT_S = 30 # the Grbl port after the supervisor reports
XY_STEPS_PER_MM = 53.333 # boards/glowforge.h (x8 microstepping)
RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most this far
# The counters count steps; the controller's own returns land within a
# tenth of a millimeter of where they started, not on the step. A
# difference under this is that quantization, not a leftover: the head is
# still put back, so nothing accumulates over a campaign, but the test
# that made it does not fail for it. X and Y only - Z is the lens, which
# the return never moves, so any Z difference is still a leftover.
POSITION_DEADBAND_MM = 0.1
# The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset =
# 8). The GRBL controller reads it at its start and derives its scale,
@@ -160,11 +187,18 @@ def leds_root():
return r if r.endswith("/") else r + "/"
def read_led(name):
def read_led(name, attr="target"):
"""A button LED's commanded level. The trigger fades brightness toward
target, so brightness is where the fade has reached and target is what
the machine was told: a run that left the button lit left a target, and
a run that ended a moment ago may still be fading from one. Falls back
to brightness where no target exists."""
for a in (attr, "brightness"):
try:
with open(leds_root() + name + "/brightness") as f:
with open(leds_root() + name + "/" + a) as f:
return f.read().strip()
except OSError:
continue
return None
@@ -183,6 +217,27 @@ def read_position():
return None
def counter_steps_per_mm():
"""The scale of the X/Y position counters: the kernel counts the
microsteps of the mode it runs (cnc/x_mode), so x16 and x32 count two
and four times the x8 steps for the same millimeter. The x8 scale when
the attribute is unreadable."""
return XY_STEPS_PER_MM_OF.get(hw.sysfs_int("cnc/x_mode"), XY_STEPS_PER_MM)
def position_quantized(was, now, spm=XY_STEPS_PER_MM):
"""True when two step-counter readings differ by no more than the
dead band on X and Y and not at all on Z: the step quantization of a
move that landed where it meant to, rather than a leftover. A cancel
that returns the head to the job start lands within a few hundredths
of a millimeter, which is a step or four, and whether that rounds to
the same integer is chance. spm is the counters' scale (the mode's)."""
if was is None or now is None or now[2] != was[2]:
return False
return (abs(now[0] - was[0]) / spm <= POSITION_DEADBAND_MM and
abs(now[1] - was[1]) / spm <= POSITION_DEADBAND_MM)
def read_ring_residue():
"""Unplayed bytes queued in the kernel pulse ring: total written minus
processed (the cnc/position byte counters), or None when unreadable.
@@ -274,7 +329,7 @@ class Baseline:
def wait_settled(self, timeout=SETTLE_S, unreachable_s=10):
"""Block until forgectrl reports a settled supervisor: motion
verified (the probe passed), motion-fault (the ladder exhausted),
standby (the manual stop lever), or gated (the commissioning gate
standby (the manual stop lever), or gated (the setup gate
is closed: no controller spawns until it opens). Gives up after
unreachable_s without an answer. Returns the last /mode body (None
if unreachable)."""
@@ -403,6 +458,9 @@ class Baseline:
preserved state to hand back (post) - None compares nothing."""
left = []
self.mode = None
# the counters' scale is the mode the run counted under, read
# before the kernel side puts the settings' mode back
self._counter_spm = counter_steps_per_mm()
self._forgectrl_side(left, captured)
self._kernel_side(left)
self._lamp_side(left)
@@ -421,6 +479,37 @@ class Baseline:
time.sleep(1.0)
return None
def stand_down(self, why):
"""End whatever the machine is still doing, and prove it ended.
A run that dies mid-job leaves the job alive behind it: the
cooling engine armed and holding for a job nothing will finish,
the fans at run duty, and the rest of the program still queued in
the pulse ring. Waiting does not end any of that - the engine is
holding correctly, for a job that is never coming back.
Stopping the controller ends it. The supervisor brings the
controller back, the arm and the hold go with the job, and the
ring is empty on the way in. This is what the ring-residue
message has always told an operator to do; the hand-back does it
instead of saying it.
Returns the /mode body the machine settled on, or None."""
self.log("baseline: standing the machine down (%s): stopping the controller" % why)
st, _b = self.fc_post("/controller/stop")
if st != 200:
self.log("baseline: stand-down: /controller/stop -> %s" % st)
deadline = time.time() + 30
while time.time() < deadline and not self.abort():
body = self.fc_get("/mode")[1] or {}
if body.get("controller") != "running":
break
time.sleep(1.0)
st, _b = self.fc_post("/controller/start")
if st != 200:
self.log("baseline: stand-down: /controller/start -> %s" % st)
return self.wait_settled()
def cloud_mode(self):
return self.mode == "cloud"
@@ -479,7 +568,14 @@ class Baseline:
before = (mode.get("controller"), mode.get("motion"))
mode = self.wait_settled() or mode
if mode.get("controller") == "running" and mode.get("motion") == "verified":
left.append(Leftover("controller", "%s/%s" % before, "running/verified", "waited"))
# The supervisor's own work: a takeover ends by starting
# forgectrl again, and the respawn runs the liveness probe
# and the lens reference before the controller is up and
# motion is verified. Waiting for that is right; calling
# it a leftover is not, because the run did put it back -
# the machine was still doing what it was asked.
self.log("controller: %s/%s settled to running/verified on its own; "
"the supervisor's own start, not a leftover" % before)
else:
left.append(Leftover("controller", "%s/%s" % before, "running/verified",
"failed: %s/%s" % (mode.get("controller"), mode.get("motion"))))
@@ -487,12 +583,21 @@ class Baseline:
st, s = self.fc_get("/status")
if st == 200 and isinstance(s, dict):
if s.get("state") != "idle":
if s.get("state") == "underrun":
# An underrun is the run's: the engine ran out of bytes and
# the fault is acknowledged here. Any other state is the
# machine finishing what it was given - the ring draining
# to the end of a job - and it reaches idle on its own.
underrun = s.get("state") == "underrun"
if underrun:
try:
hw.sysfs_write("cnc/stop", "1") # ack
except OSError:
pass
w = self._wait("idle", lambda: (self.fc_get("/status")[1] or {}).get("state") == "idle", IDLE_S)
if w is not None and not underrun:
self.log("state: %s reached idle on its own after %.0f s; the machine "
"finishing, not a leftover" % (s.get("state"), w))
else:
left.append(Leftover("state", s.get("state"), "idle",
"waited" if w is not None else "failed: not idle"))
if s.get("laser_locked") is False:
@@ -507,11 +612,31 @@ class Baseline:
if st == 200 and isinstance(c, dict):
if c.get("phase") != "idle" or c.get("armed") or c.get("hold"):
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
w = self._wait("cool idle", lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed")
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {}),
COOL_IDLE_S)
idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
# Armed, or holding, is the run's doing. A phase alone is
# not: the engine clears smoke at run duty after an armed
# session and cools down after a hot one, both timed and
# both ending on their own. Waiting for that is right;
# calling it a leftover is not, because nothing was left -
# the machine was still finishing.
dirt = bool(c.get("armed") or c.get("hold"))
w = self._wait("cool idle", idle, COOL_IDLE_S)
if w is not None and not dirt:
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
"work, not a leftover" % (found, w))
else:
# The engine holds for a job. A run that ended without
# ending its job leaves one alive, and no amount of
# waiting ends it: stand the machine down and let the
# supervisor bring the controller back clean.
act = "waited"
if w is None:
self.stand_down("the cooling engine is still %s" % found)
w = self._wait("cool idle", idle, COOL_IDLE_S)
act = "restored (stood the machine down)"
left.append(Leftover("cool", found, "idle/unarmed/no hold",
"waited" if w is not None else "failed: still %s" % found))
act if w is not None else "failed: still %s" % found))
def _lamp_side(self, left):
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
@@ -575,14 +700,18 @@ class Baseline:
except OSError as e:
act = "failed: %s" % e
left.append(Leftover("leds/" + name, got, "0", act))
elif (read_led(name, "brightness") or "0") != "0":
self.log("leds/%s: target 0, brightness %s; the fade from a level the machine "
"itself ended, not a leftover" % (name, read_led(name, "brightness")))
def _return_head(self, was, now):
def _return_head(self, was, now, spm=XY_STEPS_PER_MM):
"""Jog the head back along its own path by the kernel-measured X/Y
delta (Z is never touched), through the GRBL controller. Bounded:
beyond RETURN_MAX_MM per axis, or without a running GRBL
controller, the counters are reported and left."""
dx = (now[0] - was[0]) / XY_STEPS_PER_MM
dy = (now[1] - was[1]) / XY_STEPS_PER_MM
delta (Z is never touched), through the GRBL controller. spm is
the counters' scale (the mode's). Bounded: beyond RETURN_MAX_MM
per axis, or without a running GRBL controller, the counters are
reported and left."""
dx = (now[0] - was[0]) / spm
dy = (now[1] - was[1]) / spm
if abs(dx) < 0.02 and abs(dy) < 0.02:
return "unrestorable (Z only)" if now[2] != was[2] else "restored"
if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM:
@@ -590,10 +719,18 @@ class Baseline:
# Never jog on top of stale bytes: a run started now would replay
# whatever the ring still holds before the jog, in a direction and
# for a distance nobody asked for. Report and leave the head.
# Stale bytes in the ring are the last job's, and a jog now would
# replay them before its own. The ring empties when the
# controller restarts, so the hand-back restarts it rather than
# leaving the head where it is and the bytes where they are.
residue = read_ring_residue()
if residue:
return ("unrestorable: %d unplayed bytes queued in the kernel ring - a jog would "
"replay them; clear the ring (controller restart) before moving" % residue)
self.log("baseline: %d unplayed bytes in the kernel ring before the return" % residue)
self.stand_down("%d unplayed bytes in the kernel ring" % residue)
residue = read_ring_residue()
if residue:
return ("unrestorable: %d unplayed bytes still queued in the kernel ring after a "
"controller restart - a jog would replay them" % residue)
# a controller may be inside a respawn backoff (seconds): wait for it
mode = None
deadline = time.time() + 30
@@ -662,7 +799,13 @@ class Baseline:
was = captured.get("position")
now = read_position()
if was is not None and now is not None and now != was and not self.cloud_mode():
left.append(Leftover("position", now, was, self._return_head(was, now)))
spm = self._counter_spm
act = self._return_head(was, now, spm)
if position_quantized(was, now, spm):
self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, "
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
else:
left.append(Leftover("position", now, was, act))
was = captured.get("settings")
if was:
st, body = self.fc_get("/settings")
+5 -2
View File
@@ -247,8 +247,11 @@ TOOLS = [
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the "
"lens stands, and neither a $20 nor a $132 write frees it. A CI harness (the grblHAL repo): needs "
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
"lens stands, and neither a $20 nor a $132 write frees it. A referenced lens (forgectrl's marker "
"and the lens settings) opens it to the window the settings hold, the fallback or the focus "
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
"bench-page tool."},
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
+60 -2
View File
@@ -164,7 +164,12 @@ class Run:
notice = dict(self.notice) if self.notice else None
return {
"kind": self.kind, "id": self.id, "title": self.title,
"started": self.started_ts, "elapsed_s": int(time.time() - self.started),
# A finished run's clock stops: the page shows the last run
# until the next one starts, and a live figure there counts
# the time since, not the time it took.
"started": self.started_ts,
"elapsed_s": (self.finished["duration_s"] if self.finished
else int(time.time() - self.started)),
"log": lines, "dropped": self.dropped, "prompt": prompt, "notice": notice,
"finished": self.finished, "aborting": self.aborted.is_set(),
}
@@ -424,6 +429,45 @@ class Context:
self.log("ACT %s %s: done after %.1f s", channel, state, dt)
return dt
def press_now(self, text="The button is lit white. Press it now: the laser fires after your press."):
"""The arm press, at the moment the machine says it is waiting.
A check that runs a job opens a `press` prompt of its own when
it sees the button lit and the tube still dark, which is the
machine's own word that the press it wants is this one. Pressing
on that is exact; pressing on a button LED read from here is
not, because the button is lit through parts of a check that are
not the arm (the lens reference, the program on its way to the
controller), and a press then lands before the job waits for it
and is lost.
The fixture presses where the bench took this test's presses (or
opted in standing); otherwise the notice goes up for a person,
who is looking at the same prompt. Returns True when the fixture
pressed."""
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
self.evidence.setdefault("actions", []).append(rec)
opted_in = fixture is not None and fixture.covers("button") and \
(fixture.arm_press or self.run.fixture_takeover)
if not opted_in:
if self.run.fixture_takeover:
rec["fixture_lost"] = True
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
"gone - asking the operator for the arm press")
self.notice(text)
return False
try:
fixture.act("button", "press")
except _fixture.FixtureError as e:
rec["fixture_error"] = str(e)
self.log("ARM press: fixture failed (%s) - asking the operator", e)
self.notice(text)
return False
rec["by"] = "fixture"
self.log("ARM press by the fixture, at the machine's own press prompt")
return True
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
@@ -994,7 +1038,21 @@ class Runner:
run.log(traceback.format_exc().rstrip())
self.fixture_release(run)
try:
self._baseline_post(run, captured)
left = self._baseline_post(run, captured)
# A test hands the machine back as it found it. Anything left
# behind was recorded here and nothing more, so a check that
# measured correctly and walked away with the machine in a
# state nobody chose still passed: that is how the airflow
# check came to leave the purge fan off, and an operator met
# it at their first fire instead of the bench meeting it here.
# The pass is now conditional on the machine being whole. A
# leftover the baseline put back still fails: the restore is
# the bench cleaning up after a defect, not the defect's
# absence.
if left and result == _campaign.PASS:
result = _campaign.FAIL
message = ("the machine was not handed back as found: %s"
% "; ".join(str(x) for x in left))
except Exception as e: # noqa: BLE001 - never lose the result over the cleanup
run.log("baseline: post pass errored: %s: %s" % (type(e).__name__, e))
duration = int(time.time() - run.started)
+3 -3
View File
@@ -4,9 +4,9 @@ order. Each module registers its tests with @catalog.test."""
from . import image # noqa: F401,E402
from . import kernel # noqa: F401,E402
from . import forgectrl # noqa: F401,E402
from . import commission # noqa: F401,E402
from . import commission_dark # noqa: F401,E402
from . import commission_sheet # noqa: F401,E402
from . import setup # noqa: F401,E402
from . import setup_dark # noqa: F401,E402
from . import setup_sheet # noqa: F401,E402
from . import logs # noqa: F401,E402
from . import motion # noqa: F401,E402
from . import cooling # noqa: F401,E402
+1 -1
View File
@@ -396,7 +396,7 @@ def lid_privacy(ctx):
"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 .setup import request
from .forgectrl import lan_ip
fc = ctx.forgectrl
ev = ctx.evidence
+154 -59
View File
@@ -87,6 +87,9 @@ FORGECTRL_LOG = "/data/log/forgefirm/forgectrl/forgectrl.log"
LIMITS_MARK = "job limits from the header: "
EFFECTIVE_MARK = "effective limits: coolant ceiling "
SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
# The service's connect-time hunt, as the client logs its request: a few
# seconds after the controller starts, right behind the session.
HUNT_REQUEST = "service action request: hunt"
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
@@ -145,7 +148,7 @@ def return_head(ctx, feed=2400):
"head not back at the start after the return jog: %s", pos)
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90, poll=1.0):
t0 = time.time()
last = None
while time.time() - t0 < timeout:
@@ -157,7 +160,7 @@ def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
return m
if m.get("controller") == "motion-fault":
break
time.sleep(1)
time.sleep(poll)
return last
@@ -227,6 +230,11 @@ def gfhome_homing(ctx, ev, g):
# homed: the counters are re-anchored at the corner, where the head stays
ctx.counters_rezeroed()
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing")
# The driver answers $H when the session ends; that ok (and the
# session's messages) sit in the buffer behind the status reports and
# would pass for the reply to the caller's next command.
ack = " | ".join(ln.strip() for ln in g.drain().splitlines() if ln.strip())
ctx.log("$H acknowledged: %s", ack[:160] or "(nothing buffered)")
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt "
@@ -235,17 +243,22 @@ def gfhome_homing(ctx, ev, g):
covers=_HOMING_PATH + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"),
("grblhal-glowforge", "src/**")],
requires=["forgectrl.auth", "motion.pacing"], actions=["lid"],
steps=["Bed clear; cloud credentials configured; the machine on the network. The test "
"turns cloud mode and the gfcloud homing on itself when they are off, and puts "
"the settings back at the end.",
"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 "
"their own, and the head ends parked at the home corner."],
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. "
"The test turns cloud mode and the gfcloud homing on itself when they are off, and "
"puts the settings back at the end.",
"Open the lid the moment you are told, with a hand ready on it: the cloud client's "
"first hunt begins a few seconds after its controller starts and must find the lid "
"open. Leave it open through the connect and the hunt; close it when told. Nothing "
"else: the switch back and the $H homing run on their own, and the head ends parked "
"at the home corner."],
description="One round trip with the two service-driven motions on it. POST /mode switches "
"to the cloud controller: gfcloud comes up under supervision, authenticates and "
"to the cloud controller with the lid closed (no controller starts with the "
"enclosure open): gfcloud comes up under supervision, authenticates and "
"establishes its service session (its own log lines are the evidence; the "
"connect-time firmware probe is recorded when configured). Its connect-time hunt "
"runs with the lid OPEN, as the factory's does - it completes, nothing before its "
"connect-time firmware probe is recorded when configured). The lid is opened the "
"moment the controller is up, before the client requests its connect-time hunt "
"(a hunt requested before the lid was open fails the test), so the hunt runs "
"with the lid OPEN, as the factory's does - it completes, nothing before its "
"end is refused for the lid, the lens homes - and with the extraction fans off it "
"is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row "
"reads unjudged); the camera service survives the switch. The lid closed, the "
@@ -270,17 +283,32 @@ def mode_switch(ctx):
except OSError:
pass
lamp0 = hw.sysfs_read("pic/lid_led")
ctx.check(ctx.switch("lid") is True, "close the lid first: no controller starts with it open")
ctx.act("lid", "open", text="Leave it open: the cloud client connects and hunts with the lid open.")
# The switch is made with the enclosure closed (the supervisor holds
# every spawn until it is). The lid opens the moment the controller is
# up: the client requests its connect-time hunt a few seconds after
# its start, right behind its session, and the hunt must find the lid
# open. The poll is tight so the window is not spent waiting.
log_offset = log_size(GFCLOUD_LOG)
st, body = fc.post("/mode", data={"controller": "cloud"})
ctx.log("POST /mode controller=cloud -> %s %s", st, body)
ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body)
m = wait_mode(ctx, fc, "cloud", timeout=90)
m = wait_mode(ctx, fc, "cloud", timeout=90, poll=0.2)
ev["mode_cloud"] = m
ctx.log("mode after switch: %s", m)
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
"cloud controller did not come up: %s", m)
ctx.act("lid", "open", text="Now, at once: the cloud client's first hunt begins a few seconds "
"after its controller starts and must find the lid open. Leave it open through the "
"connect and the hunt.")
# The order is the proof: the hunt's request is not in the log yet
# when the lid reads open.
ev["hunt_before_lid_open"] = log_has(log_offset, HUNT_REQUEST)
ctx.check(not ev["hunt_before_lid_open"],
"the client requested its hunt before the lid was open (the hunt follows the "
"controller's start by a few seconds): the lid opened too late, so a hunt with the "
"lid open is not proven")
# the client's own session lines are the evidence of a live cloud session
session = wait_session(ctx, log_offset)
probe = None
@@ -372,7 +400,13 @@ def mode_switch(ctx):
gfhome_homing(ctx, ev, g)
finally:
if ev["homing_mode"] != "gfcloud":
st, body = fc.post("/settings", data={"homing_mode": ev["homing_mode"] or "none"})
# Back to exactly what the machine had: unset is the empty
# string, and clearing a key needs the query-string form.
# "none" is a value, and writing it where the machine had
# nothing is a leftover the hand-back reports.
st, body = (fc.post("/settings", params={"homing_mode": ""})
if not ev["homing_mode"]
else fc.post("/settings", data={"homing_mode": ev["homing_mode"]}))
ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st)
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"])
@@ -1289,42 +1323,96 @@ def lid_during_button_wait_body(ctx, ev, off, job, offset):
# ---------------------------------------------------------------- the cooling verdict
HOLD_KEYS = ("cool_temp_min", "cool_temp_start")
WARMUP_ABOVE_C = 1.0 # the start gate this far above the loop: a few heater minutes
WARMUP_RELEASE_S = 720 # the flow heater warms the bulk 0.4 to 0.8 C a minute
REFUSE_KEYS = HOLD_KEYS + ("cloud_hold_max_s",)
HOLD_REFUSE_S = 60 # the shortest bound the client takes
HOLD_REFUSE_LINE = "cooling hold held 60 s - relocking the laser"
WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP"
WARMUP_RELEASE_LINE = "cooling verdict clean after WARMUP; starting the run"
@test("cloud.verdict-hold", title="A cloud print armed under the warm-up gate waits for the engine",
subsystem="cloud", kind="operator", est_min=14,
@test("cloud.dark-print", title="A dark cloud print: the latch locked until the run, unlocked "
"for it, locked after it",
subsystem="cloud", kind="operator", est_min=3,
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
steps=[OFFLINE_STEP,
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate just above "
"the coolant and restores it. Press the button when it lights: the print then waits "
"for the warm-up (a few minutes with the loop heater on), runs dark, and finishes."],
description="The cooling-engine contract in cloud mode, on the engine itself: the start gate "
"set just above the coolant makes the armed session open under the warm-up "
"(WARMUP: fire blocked, hold), and the client waits it out after the button "
"instead of canceling - nothing runs, the latch stays unlocked for the armed "
"window - until the engine releases into run, and the release starts the print, "
"which completes. The hold mid-run (the same laser-off pause as the button's, "
"resumed on the engine's resume_ok) and the bound on a hold are host-tested: the "
"gates apply at session open, so no setting can produce a hold mid-run here.")
def verdict_hold(ctx):
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
"when it lights: the print waits for the engine, runs, and finishes."],
description="The cooling-engine contract in cloud mode at its ordinary end, on the engine "
"itself: the print arms on the press, waits for the engine's acknowledgment (the "
"fans at their floors), and runs. The laser latch is locked at the button and "
"through the wait, unlocks only for the run (immediately before it starts), and "
"is locked again when the job ends; the print completes. A hold the engine never "
"clears is cloud.verdict-refuse; the engine's own warm-up release is "
"cooling.floor-and-warm-up.")
def dark_print(ctx):
ev = ctx.evidence
fc = ctx.forgectrl
offset = enter_offline(ctx)
job = offline_job(ctx, "dark.puls", seconds=30)
off = Offline().__enter__()
try:
dark_print_body(ctx, ev, off, job, offset, fc)
finally:
off.__exit__(None, None, None)
offline_cleanup(ctx)
def dark_print_body(ctx, ev, off, job, offset, fc):
off.print_ready(9005, job)
got = wait_log(ctx, offset, ["waiting for button"], 120)
ctx.check(got["waiting for button"], "the print never reached the button wait")
ev["latch_locked_at_button"] = latch_locked()
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
ctx.act("button", "press", text="The button is lit: press it. The print runs dark and finishes.",
until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=PRESS_TIMEOUT_S)
run = wait_print_running(ctx, offset, 60)
ctx.check(run is not None, "the print did not start after the press")
ctx.sleep(1.0)
ev["latch_locked_in_run"] = latch_locked()
ctx.check(ev["latch_locked_in_run"] is False, "the latch is locked during the run")
fin = wait_action_finished(ctx, offset, "print", 180)
ev["print finished"] = message(fin)
ctx.check(fin and COMPLETED in fin, "the print did not complete: %s", message(fin) or "no finish line")
st, cs = fc.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
ctx.check(not ev["armed_after"], "armed window still open after the print")
ev["latch_locked_after"] = latch_locked()
ctx.check(ev["latch_locked_after"], "the latch is unlocked after the print")
ev["events"] = offline_events(off)
ctx.log("PASS: locked at the button, unlocked for the run, locked after it; the print completed")
@test("cloud.verdict-refuse", title="A cloud print held by the engine past the bound is canceled with "
"the latch never unlocked",
subsystem="cloud", kind="operator", est_min=4,
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
("forgectrl", "src/cool.*")],
requires=["cloud.dark-print"], actions=["button"],
steps=[OFFLINE_STEP,
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate well above "
"the coolant and the hold bound to its minimum, and restores both. Press the button "
"when it lights: nothing runs; a minute later the print cancels by itself."],
description="The other end of the cooling-engine contract before a run: a verdict that "
"refuses fire and never clears. The start gate far above the coolant keeps the "
"armed session under the warm-up for longer than the hold bound (cloud_hold_max_s "
"at its minimum, 60 s). The client waits with the laser latch locked the whole "
"time - it is never unlocked before the verdict passes, so there is nothing to "
"relock - then cancels the print at the bound: no run, the armed window closed, "
"the print ':cancelled'. Dark by construction.")
def verdict_refuse(ctx):
ev = ctx.evidence
fc = ctx.forgectrl
before = fc.settings()
orig = {k: before.get(k, "") for k in HOLD_KEYS}
orig = {k: before.get(k, "") for k in REFUSE_KEYS}
ev["orig"] = orig
up = (fc.status().get("coolant") or {}).get("up_c")
ctx.check(up is not None and 10.0 <= up <= 30.0, "coolant outside the test's range (up_c %s)", up)
offset = enter_offline(ctx)
job = offline_job(ctx, "hold.puls", seconds=30)
job = offline_job(ctx, "refuse.puls", seconds=30)
off = Offline().__enter__()
try:
verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig)
verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig)
finally:
st, body = fc.post("/settings", params=orig)
ctx.log("restore: POST /settings %s -> %s", orig, st)
@@ -1332,46 +1420,53 @@ def verdict_hold(ctx):
offline_cleanup(ctx)
def verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig):
gate = round(up + WARMUP_ABOVE_C, 1)
def verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig):
gate = round(up + 8.0, 1) # eight degrees: the heater cannot get there in a minute
st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
"cool_temp_min": orig["cool_temp_min"]})
ctx.check(st == 200, "POST /settings cool_temp_start=%s -> %s %s", gate, st, body)
"cool_temp_min": orig["cool_temp_min"],
"cloud_hold_max_s": str(HOLD_REFUSE_S)})
ctx.check(st == 200, "POST /settings cool_temp_start=%s cloud_hold_max_s=%s -> %s %s",
gate, HOLD_REFUSE_S, st, body)
ev["gate"] = gate
off.print_ready(9005, job)
off.print_ready(9006, job)
got = wait_log(ctx, offset, ["waiting for button"], 120)
ctx.check(got["waiting for button"], "the print never reached the button wait")
ctx.act("button", "press", text="The button is lit: press it. The print then waits for the warm-up.",
ev["latch_locked_at_button"] = latch_locked()
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
ctx.act("button", "press", text="The button is lit: press it. Nothing runs; the print cancels "
"itself a minute later.",
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30)
ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up")
st, c = fc.get("/cool/status")
ev["warmup"] = c
ctx.check(isinstance(c, dict) and c.get("verdict") == "WARMUP" and c.get("hold") is True
and c.get("fire_ok") is False,
"the engine is not holding the armed session on the warm-up: %s", c)
ctx.check(wait_print_running(ctx, offset, 2) is None, "the run started under the warm-up hold")
ctx.notice("Warm-up: the loop heater brings the coolant to the gate; a few minutes. Do nothing.")
try:
t0 = time.time()
run = wait_print_running(ctx, offset, WARMUP_RELEASE_S)
samples = []
ctx.notice("The engine holds the print; the client gives up at the bound. Do nothing.")
try:
while time.time() - t0 < HOLD_REFUSE_S + 30:
ctx.checkpoint()
samples.append(latch_locked())
if log_has(offset, HOLD_REFUSE_LINE):
break
ctx.sleep(2)
finally:
ctx.clear_notice()
ev["release_s"] = round(time.time() - t0)
got = wait_log(ctx, offset, [WARMUP_RELEASE_LINE], 5)
ev["log"] = {WARMUP_WAIT_LINE: True, WARMUP_RELEASE_LINE: bool(got[WARMUP_RELEASE_LINE])}
ctx.check(got[WARMUP_RELEASE_LINE], "the warm-up did not release into the run within %d s",
WARMUP_RELEASE_S)
ctx.check(run is not None, "the print did not start after the warm-up")
fin = wait_action_finished(ctx, offset, "print", 180)
ev["hold_s"] = round(time.time() - t0)
ev["latch_samples"] = {"n": len(samples), "unlocked": samples.count(False)}
ctx.check(log_has(offset, HOLD_REFUSE_LINE), "the client did not cancel at the hold bound (%d s)",
ev["hold_s"])
ctx.check(all(samples), "the latch unlocked during the hold (%d of %d samples)",
samples.count(False), len(samples))
ctx.check(wait_print_running(ctx, offset, 1) is None, "the run started under the refused verdict")
fin = wait_action_finished(ctx, offset, "print", 120)
ev["print finished"] = message(fin)
ctx.check(fin and COMPLETED in fin, "the print did not complete after the warm-up: %s",
ctx.check(fin and CANCELLED in fin, "the held print did not end ':cancelled': %s",
message(fin) or "no finish line")
st, cs = fc.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
ctx.check(not ev["armed_after"], "armed window still open after the print")
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
ctx.check(latch_locked(), "the latch is unlocked after the cancel")
ev["events"] = offline_events(off)
ctx.log("PASS: the armed print waited %s s on the warm-up, then ran and completed", ev["release_s"])
ctx.log("PASS: held %s s with the latch locked throughout, then canceled at the bound", ev["hold_s"])
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
+184 -8
View File
@@ -746,7 +746,7 @@ FIRE_WAIT_S = 25 # settings re-read at run start, two-tick breach, 1
@test("cooling.fire-watch-tiers", title="The lid-IR fire watch pauses at its alert and fails at its critical",
subsystem="cooling", kind="auto", mode="grbl", est_min=6,
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"],
covers=_COOL_COVERS + [("forgectrl", "src/super.*")], requires=["kernel.latch-locked-idle"],
steps=["Machine idle, lid closed, lid lamp at its resting level. The test moves the flame "
"thresholds under the lamp's own reading and restores them; three M8/M9 sessions, "
"no fire."],
@@ -759,6 +759,7 @@ FIRE_WAIT_S = 25 # settings re-read at run start, two-tick breach, 1
"read as the four flame gates off and the watch reads watch. Restored, the "
"watch reads armed at OK.")
def fire_watch_tiers(ctx):
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
@@ -810,7 +811,16 @@ def fire_watch_tiers(ctx):
ctx.check(c.get("verdict") == "OK", "the alert survived into a fresh session: %s", c)
# Leg 2: the fail tier. The q1 critical under the lamp reading
# stops the session and latches FIRE until it ends.
# latches FIRE and locks the laser; the fail tier then stops
# the controller through the supervisor and starts it again,
# so no run start can relight what was locked and the sender
# sees the job end. The Grbl socket dies with the restart -
# FIRE is read from the engine, the latch from sysfs, the stop
# from the supervisor's log, and a fresh session is opened for
# the legs that follow.
st, m0 = fc.get("/mode")
pid0 = m0.get("pid") if isinstance(m0, dict) else None
off = _log_offset(FORGECTRL_LOG)
_set_gates(ctx, fc, {"cool_fire_q1_critical": str(max(2, q1 - 5)),
"cool_fire_q1_alert": str(max(1, q1 - 10))})
grbl.command("M8")
@@ -821,19 +831,38 @@ def fire_watch_tiers(ctx):
c = _cool(fc)
if c.get("verdict") == "FIRE":
break
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["critical"] = c
ctx.log("critical leg: verdict %s fire_watch %s", c.get("verdict"), c.get("fire_watch"))
ev["interlock_circuit"] = ilk
ctx.log("critical leg: verdict %s fire_watch %s interlock_circuit %s",
c.get("verdict"), c.get("fire_watch"), ilk)
ctx.check(c.get("verdict") == "FIRE", "q1 critical under the lamp did not latch FIRE: %s", c)
ctx.check(c.get("fire_watch") == "ALARM", "fire_watch %r during FIRE, expected ALARM", c.get("fire_watch"))
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["interlock_circuit"] = ilk
ctx.check(ilk is not None and (ilk & 8), "the laser latch is not locked under FIRE (interlock_circuit=%s)", ilk)
ctx.check(_tail_wait(ctx, fc, "LID IR FIRE SIGNAL (quartiles "),
"the FIRE line is missing from the forgectrl log")
# the fail tier stops the controller and starts it again; the
# Grbl socket dies with it. Wait out the restart, prove the
# supervisor logged the stop, restore the thresholds, and open
# a fresh session on the new controller.
grbl.close()
m1 = None
t0 = time.time()
while time.time() - t0 < 60:
st, m1 = fc.get("/mode")
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
break
ctx.sleep(0.5)
ev["restart"] = {"pid_before": pid0, "mode_after": m1}
ctx.log("after FIRE: %s", ev["restart"])
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid0,
"the controller was not stopped and started again after FIRE: %s", m1)
ctx.check(_log_lines(FORGECTRL_LOG, off, "lid IR fire signal - the controller is stopped"),
"the supervisor did not log the fail-tier stop after FIRE")
_set_gates(ctx, fc, {"cool_fire_q1_critical": orig["cool_fire_q1_critical"],
"cool_fire_q1_alert": orig["cool_fire_q1_alert"]})
grbl.command("M9")
_session_ended(ctx, fc, "critical leg")
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the FIRE restart")
grbl.connect() # a fresh session on the restarted controller
# Leg 3: all four at zero: the watch off, said so, session OK.
_set_gates(ctx, fc, {k: "0" for k in FIRE_KEYS})
@@ -891,7 +920,7 @@ CRASH_GATES = ["crash_abort", "crash_x_alert", "crash_y_alert"]
"read as the three crash gates off by value (gates_off names them) with the "
"session OK; a threshold past the register range is refused; restored, the "
"gates read ok. The tiers themselves (BUMP, CRASH) trip only on a physical "
"knock inside an armed window and are commissioned at the bench, not here.")
"knock inside an armed window and are proven at the bench, not here.")
def crash_watch_plumbing(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
@@ -1329,3 +1358,150 @@ def flow_under_load(ctx):
ctx.check(dt is not None, "the window did not close after M2")
ctx.log("PASS: lit through the window, raw rise %.1f C, laser %.1f C off, judged %.1f C against %.1f "
"(dT %.1f), window closed %.1f s after M2", raw, share, rise, float(limit), float(dtc), dt)
@test("cooling.fan-duty-readback", title="A fan duty a device lost is read back and put back inside a tick",
subsystem="cooling", kind="auto", mode="grbl", est_min=2,
covers=_COOL_COVERS + [("kernel-module-glowforge", "src/head*")],
requires=["kernel.latch-locked-idle"],
description="M8 opens a run session and every fan reads its run duty back from its device. "
"The head's air-assist register is then written back to the idle duty behind the "
"engine's back (what a head reset or a dropped transaction leaves) and the exhaust "
"PWM to zero: within a few ticks each reads its run duty again, the log names each "
"loss and its put-back, and the session stays OK with no airflow fault (a fan put "
"back gets its spin-up grace). Then M9 and the idle duties.")
def fan_duty_readback(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
run = {"head/air_assist_pwm": 1023, "thermal/exhaust_pwm": 65535, "thermal/intake_pwm": 43278}
with ctx.grbl() as g:
c = _hold_session(ctx, g, fc, lambda c: c.get("phase") == "run", "readback", 20)
try:
ctx.check(c.get("phase") == "run", "no run session opened: %s", c)
ctx.sleep(2)
before = {k: hw.sysfs_int(k) for k in run}
ctx.log("duties at run: %s", before)
ev["run_duties"] = before
ctx.check(all(before.get(k) == v for k, v in run.items()),
"not every fan reads its run duty: %s", before)
hw.sysfs_write("head/air_assist_pwm", 204)
hw.sysfs_write("thermal/exhaust_pwm", 0)
lost = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
ctx.log("written behind the engine's back: %s", lost)
back = {}
t0 = time.time()
while time.time() - t0 < 6:
ctx.sleep(0.5)
back = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
if back.get("head/air_assist_pwm") == 1023 and back.get("thermal/exhaust_pwm") == 65535:
break
took = round(time.time() - t0, 1)
ev["put_back"] = {"duties": back, "after_s": took}
ctx.log("put back after %s s: %s", took, back)
ctx.check(back.get("head/air_assist_pwm") == 1023, "the air-assist duty was not put back: %s", back)
ctx.check(back.get("thermal/exhaust_pwm") == 65535, "the exhaust duty was not put back: %s", back)
ctx.sleep(2)
ctx.check(_tail_has(fc, "head/air_assist_pwm read 204 with 1023 commanded: put back"),
"the air-assist loss is not named in the log")
ctx.check(_tail_has(fc, "thermal/exhaust_pwm read 0 with 65535 commanded: put back"),
"the exhaust loss is not named in the log")
for _ in range(8):
ctx.sleep(1)
c = _cool(fc)
if c.get("verdict") != "OK":
break
ev["verdict_after"] = {"verdict": c.get("verdict"), "hold": c.get("hold"), "reason": c.get("reason")}
ctx.check(c.get("verdict") == "OK" and not c.get("hold"),
"the session did not stay OK after the put-back: %s %s", c.get("verdict"), c.get("reason"))
finally:
g.command("M9")
_session_ended(ctx, fc, "readback")
idle = {k: hw.sysfs_int(k) for k in run}
ev["idle_duties"] = idle
ctx.log("duties at idle: %s", idle)
@test("cooling.fail-tier-stop", title="A fail-tier verdict ends the controller: CRASH stops motion, "
"locks the laser, and the supervisor restarts the controller",
subsystem="cooling", kind="operator", mode="grbl", est_min=4,
covers=_COOL_COVERS + [("forgectrl", "src/super.*")],
requires=["cooling.crash-watch-plumbing", "motion.deadman"], actions=["button"],
steps=["Bed clear, lid closed, 40 mm of free +X travel. Nothing fires: the job is dark (S0).",
"Press the physical button when it lights white, once. The head moves and stops on "
"its own; the controller restarts."],
description="The engine's fail tiers (a lid IR fire signal, a head crash signal) do not "
"leave the job to the controller: after the kernel writes (motion stopped, the "
"latch locked) the engine ends the controller through the supervisor, which "
"starts it again, so no run start can relight what was locked and the sender "
"sees the job end. Provable without emission or a physical crash: the crash "
"watch's thresholds at their lowest (1) make the head's own move trip the abort "
"generator inside an armed, dark (S0) job. Expected: the engine logs the crash "
"signal, the supervisor logs the stop and starts a new controller (new pid), the "
"latch is locked, the kernel idle, no emission, and the thresholds are put back.")
def fail_tier_stop(ctx):
from .laser import prepare, stream, sample
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
orig = {k: before.get(k, "") for k in CRASH_KEYS}
ev["orig"] = orig
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
st, m0 = fc.get("/mode")
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
pid0 = m0.get("pid")
_set_gates(ctx, fc, {k: "1" for k in CRASH_KEYS})
off = _log_offset(FORGECTRL_LOG)
t_arm = None
try:
try:
with ctx.grbl() as g:
prepare(ctx, g)
ctx.ready("DARK JOB (S0, nothing fires). Bed clear with 40 mm of free +X travel, lid closed. "
"The head moves 20 mm and the controller restarts on its own.")
stream(g, ["G91", "G21", "M3 S0", "G4 P1", "G1 X20 F1200", "G1 X-20 F1200", "M5", "G90", "M2"])
ctx.arm_press()
t_arm = ctx.wait_for(lambda: bool((sample(ctx) or {}).get("armed")), 240)
ctx.clear_notice()
ctx.check(t_arm is not None, "the window never opened (arm refused, or no press)")
t0 = time.time()
tripped = ctx.wait_for(lambda: _cool(fc).get("verdict") == "CRASH"
or (fc.get("/mode")[1] or {}).get("pid") not in (None, pid0), 30)
ctx.log("the crash tier tripped after %s s", tripped)
except (hw.HwError, OSError) as e:
ctx.log("the Grbl connection ended with the restart: %s", e)
t_kill = time.time()
m1 = None
while time.time() - t_kill < 60:
st, m1 = fc.get("/mode")
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
break
ctx.sleep(0.5)
# the daemon's lines reach the log file through syslog, a moment
# after the events they name
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL"))
and bool(_log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")), 15)
engine = _log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL")
super_ = _log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")
smp = sample(ctx)
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["result"] = {"mode_after": m1, "engine_lines": [ln[-160:] for ln in engine[-2:]],
"super_lines": [ln[-160:] for ln in super_[-2:]],
"emission": smp and smp["emission"], "kernel": smp and smp["kstate"],
"latch_locked": ilk is not None and bool(ilk & (1 << 3))}
ctx.log("after the crash tier: %s", ev["result"])
ctx.check(engine, "the engine did not log the crash signal")
ctx.check(super_, "the supervisor did not log the fail-tier stop")
ctx.check(m1 and m1.get("pid") != pid0, "the controller was not stopped and started again: %s", m1)
ctx.check(ev["result"]["latch_locked"], "latch not locked after the crash tier")
ctx.check(not (smp and smp["emission"]), "emission during a dark job: %s", smp and smp["emission"])
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the restart")
finally:
fc.wait_idle(30, abort=ctx.aborted)
st, body = fc.post("/settings", params=orig)
ctx.log("restore thresholds: POST /settings %s -> %s", orig, st)
after = fc.settings()
ctx.check(all(after.get(k, "") == orig[k] for k in CRASH_KEYS),
"thresholds not restored: %s", {k: after.get(k) for k in CRASH_KEYS})
ctx.log("PASS: the crash tier stopped the job, locked the latch, and the supervisor restarted "
"the controller (pid %s -> %s)", pid0, m1 and m1.get("pid"))
+16 -3
View File
@@ -35,8 +35,9 @@ def lan_ip():
covers=_COVERS_AUTH,
description="Every state-changing endpoint refuses an unauthenticated write (the factory "
"return, the SSH switch and the wizard's own routes included); a non-literal "
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused; the "
"cooling report channel accepts the loopback peer and refuses a non-loopback "
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused, while "
"the machine's own hostname passes and that name with a domain on it does not; "
"the cooling report channel accepts the loopback peer and refuses a non-loopback "
"one (over HTTP the write is sent to HTTPS first, 302; over HTTPS the route "
"answers 403 loopback only); the fuse view is two-factor "
"(token and the physical button) and refused without either; "
@@ -55,6 +56,7 @@ def auth(ctx):
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
("/diag/flow-verify", None), ("/diag/abort", None),
("/update/apply", None), ("/boot", {"target": "a"}),
("/update/check", None), ("/update/dismiss", {"version": "v0.0.0"}),
("/system/reboot", None), ("/restore/factory", None),
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
("/wiz/advisories/accept", None), ("/wiz/account", None),
@@ -96,6 +98,17 @@ def auth(ctx):
ev["host_name"] = st
ctx.log("GET /status Host=evil.example.net -> %s", st)
ctx.check(st == 403, "a DNS-name Host was accepted (%s)", st)
# the machine's own name passes; the same name with a domain on it
# does not, because anyone can register one
own = socket.gethostname()
st, body = fc.get("/status", headers={"Host": own})
ev["host_own_name"] = st
ctx.log("GET /status Host=%s -> %s", own, st)
ctx.check(st == 200, "the machine's own hostname was refused as a Host (%s)", st)
st, body = fc.get("/status", headers={"Host": own + ".example.net"})
ev["host_own_name_domain"] = st
ctx.log("GET /status Host=%s.example.net -> %s", own, st)
ctx.check(st == 403, "a domain name built on the machine's name was accepted (%s)", st)
st, body = fc.get("/status", headers={"Origin": "http://evil.example.net"})
ev["origin_name"] = st
ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
@@ -139,7 +152,7 @@ def auth(ctx):
# 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
from .setup import request, decode
ip = lan_ip()
ev["lan_ip"] = ip
ctx.check(ip, "cannot determine the board's LAN address")
+43 -4
View File
@@ -28,6 +28,12 @@ def _wdog1_wcr():
return None
def _plain(line):
"""A line without its ANSI color sequences: what a terminal shows,
counted in columns rather than in bytes."""
return re.sub(r"\x1b\[[0-9;]*m", "", line)
def _read(path, default=None):
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
@@ -104,7 +110,9 @@ def fds_of(pid):
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
("linux-fslc", "**")],
description="The image that is running is the image the manifest describes, with the "
"kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
"version stamped in /etc/forgefirm-version and under the machine mark in the "
"console banner and the motd, "
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
"the daemon ownership, "
"the init ordering, the file modes the release depends on, and the mounts: the "
"rootfs read-only, /data writable, the account files and the banner rendered "
@@ -113,11 +121,39 @@ def image_health(ctx):
ev = ctx.evidence
manifest = ctx.runner.manifest
# 1. version stamp
# 1. version stamp: the machine-readable file, and the two a person
# reads - the console banner (/etc/issue, which forgefirm-banner
# renders from the image's own text) and the motd a login prints.
# Each carries the machine mark with the version on the mark's own
# last line, right-justified to the mark's last column.
ver = (_read("/etc/forgefirm-version", "") or "").strip()
ev["forgefirm_version"] = ver
ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version)
ctx.check(ver == manifest.version, "/etc/forgefirm-version %r != manifest %r", ver, manifest.version)
for path in ("/etc/issue", "/etc/motd"):
text = _read(path, "") or ""
ev[path] = text
lines = text.splitlines()
at = [i for i, line in enumerate(lines) if line.rstrip().endswith(ver)]
ctx.check(at, "%s carries no line ending in the version %r", path, ver)
if not at:
continue
i = at[0]
ctx.check(lines[i].rstrip() == lines[i], "%s pads the version line: %r", path, lines[i])
ctx.check(_plain(lines[i]).strip() != ver,
"%s puts the version on a line of its own, not on the mark's last line", path)
# Right-justified to the mark's last column: the stamped line is
# exactly as wide as the widest line above it. Only the motd is
# measured, because /etc/issue holds the mark with its
# backslashes doubled for the getty that reads it, so its bytes
# are wider than the columns a person sees.
if path == "/etc/motd":
above = [len(_plain(line)) for line in lines[:i] if line.strip()]
width = max(above) if above else 0
ev["motd_columns"] = [len(_plain(lines[i])), width]
ctx.check(len(_plain(lines[i])) == width,
"/etc/motd stamped line is %d columns, the mark above is %d",
len(_plain(lines[i])), width)
# 2. kernel options
cfg = kernel_config()
@@ -251,7 +287,8 @@ def image_health(ctx):
# 8. the mounts: the rootfs read-only, /data the writable partition,
# the state a read-only rootfs hands off (the read-only-rootfs image
# feature, forgefirm-users, forgefirm-banner, the sshd host keys). The
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
# sshd host keys). The
# dev image alone mounts the factory slots under /factory.
mounts = {}
for line in (_read("/proc/mounts", "") or "").splitlines():
@@ -262,7 +299,8 @@ def image_health(ctx):
def mount_opts(path):
return (mounts.get(path) or {}).get("opts") or []
ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd", "/etc/issue") if p in mounts}
ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd",
"/etc/hostname", "/etc/issue") if p in mounts}
ctx.log("/ mounted %s; /data %s; /var/lib %s", ",".join(mount_opts("/")) or "(absent)",
",".join(mount_opts("/data")) or "(absent)", ",".join(mount_opts("/var/lib")) or "(absent)")
ctx.check("ro" in mount_opts("/"), "the rootfs is not mounted read-only: %s", mounts.get("/"))
@@ -292,6 +330,7 @@ def image_health(ctx):
ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f)
for n in names:
ctx.check(n in passwd_names, "record account %r is missing from /etc/passwd", n)
ctx.check("/etc/hostname" in mounts, "/etc/hostname is not the bind-mounted name copy")
ctx.check("/etc/issue" in mounts, "/etc/issue is not the bind-mounted banner copy")
if hw.pidof("sshd"):
key = "/data/forgefirm/ssh/ssh_host_ed25519_key"
+232 -3
View File
@@ -252,6 +252,26 @@ def kill_trail(ctx, t0, seconds=5.0):
return trail
def kill_fast_trail(t0, seconds=1.5):
"""The latch and the kernel state read from sysfs every few
milliseconds after a kill: the supervisor's own reaction, which the
HTTP samples of kill_trail cannot resolve. A controller death is a
signal to the supervisor, not a poll, so the relock lands within
milliseconds; the kernel leaves running once its stop ramp is done."""
locked_at = stopped_at = None
end = t0 + seconds
while time.time() < end and (locked_at is None or stopped_at is None):
now = time.time() - t0
ilk = hw.sysfs_int("cnc/interlock_circuit")
if locked_at is None and ilk is not None and ilk & IL_LASER_LATCH:
locked_at = round(now, 3)
st = hw.sysfs_read("cnc/state")
if stopped_at is None and st is not None and st != "running":
stopped_at = round(now, 3)
time.sleep(0.005)
return {"latch_locked_at_s": locked_at, "kernel_stopped_at_s": stopped_at}
def judge_kill(ctx, trail, what):
"""(first zero, tail stayed zero, kernel stopped running) from a trail."""
for t in trail:
@@ -566,11 +586,19 @@ def m5_rapid_dark(ctx):
ctx.check(base, "forgectrl /status or /cool/status unavailable")
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
ctx.ready(ARM_CUE % "20 mm +X")
# +20 out on the cut, then the two rapids the test is about, then
# a third that puts the head back where the job found it: the
# first two net to +20 and a job that walks the head 20 mm and
# leaves it there is dirt the hand-back has to jog out. The
# return is inside the sampling window and after the M5, so it is
# one more rapid that must be dark, which is what the test asks
# of the other two.
job = ["G91", "G21", "M3", "S400",
"G1 X20 F600",
"M5", "G4 P2.5",
"G0 X-20", "G4 P2.5",
"G0 X20", "G4 P2.5",
"G0 X-20", "G4 P0.5",
"G90", "M2"]
ctx.arm_press()
try:
@@ -632,6 +660,7 @@ def disarm_in_hold(ctx):
ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
k0 = kernel_start(ctx)
ctx.ready(ARM_CUE % "40 mm +X")
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
ctx.log("armed; waiting for motion to start (arm + your button press)...")
@@ -674,6 +703,20 @@ def disarm_in_hold(ctx):
ctx.sleep(1)
if "Alarm" in g.status_report()["state"]:
g.command("$X")
# The hold parks the head part-way through the move and the reset
# abandons the rest of it, so the job ends wherever the feed hold
# caught it - about 11 mm in, on the bench reference. That is the
# test's own doing and it goes back: the kernel counters say how
# far the head actually went, which is what a jog has to undo.
wait_idle(ctx, g, 15)
dx, dy = kernel_xy_mm(ctx)
back_x, back_y = k0[0] - dx, k0[1] - dy
ev["left_at_mm"] = [round(dx - k0[0], 3), round(dy - k0[1], 3)]
if abs(back_x) >= 0.02 or abs(back_y) >= 0.02:
ctx.log("returning the head: %+.3f/%+.3f mm", back_x, back_y)
g.command("$J=G91X%.3fY%.3fF1200" % (back_x, back_y))
wait_idle(ctx, g, 30)
ev["returned_mm"] = list(kernel_xy_mm(ctx))
ctx.check(disarmed_at is not None, "still armed after 120 s in Hold")
ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold)
check_button_dark(ctx, ev)
@@ -692,7 +735,9 @@ def disarm_in_hold(ctx):
"SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, "
"and the restart is a separate operator-judged step. Unexpected: mid-burn SIGKILL "
"of the controller - the supervisor's exit safing must end the fire tail inside "
"the ring's in-flight window, leave the latch locked, and respawn the controller.")
"the ring's in-flight window, leave the latch locked, and respawn the controller. "
"The death is a signal to the supervisor, not a poll: the latch relocks within "
"300 ms of the kill and the kernel is out of running within a second.")
def armed_kill(ctx):
ev = ctx.evidence
fc = ctx.forgectrl
@@ -746,13 +791,20 @@ def armed_kill(ctx):
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
t_kill = time.time()
_os.kill(pid, _signal.SIGKILL)
fast = kill_fast_trail(t_kill)
trail = kill_trail(ctx, t_kill)
ctx.log("after the kill (sysfs): %s", fast)
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "kill")
ilk = hw.sysfs_int("cnc/interlock_circuit")
locked = ilk is not None and bool(ilk & (1 << 3))
ev["sigkill"] = {"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero,
"kernel_not_running": not_running, "latch_locked": locked, "trail": trail}
"kernel_not_running": not_running, "latch_locked": locked, "trail": trail,
"fast": fast}
ctx.log("latch locked after the kill: %s", locked)
ctx.check(fast["latch_locked_at_s"] is not None and fast["latch_locked_at_s"] < 0.3,
"the latch was not locked within 300 ms of the kill: %s", fast)
ctx.check(fast["kernel_stopped_at_s"] is not None and fast["kernel_stopped_at_s"] < 1.0,
"the kernel was still running 1 s after the kill: %s", fast)
ctx.check(zero_at is not None and zero_at < 2.5,
"emission did not drop within 2.5 s of the kill (first 0 at %s)", zero_at)
ctx.check(tail_zero, "emission returned after the kill")
@@ -769,10 +821,187 @@ def armed_kill(ctx):
ctx.log("/mode after the kill: %s", m1)
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
"supervisor did not respawn the controller: %s", m1)
# The respawned controller starts locked and stays locked: a latch it
# did not unlock is never relit by a run start, and no arm has asked.
ctx.check(wait_grbl_port(ctx), "the respawned controller never accepted a Grbl connection")
ctx.sleep(2)
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["latch_locked_after_respawn"] = ilk is not None and bool(ilk & IL_LASER_LATCH)
ctx.check(ev["latch_locked_after_respawn"],
"the respawned controller unlocked the latch with no arm (interlock_circuit=%s)", ilk)
ev["beam_at_fire_kill"] = smp.get("beam")
check_button_dark(ctx, ev)
ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller "
"respawned, button dark", ev["expected"]["zero_at_s"], zero_at)
"respawned with the latch still locked, button dark", ev["expected"]["zero_at_s"], zero_at)
@test("laser.verdict-cut", title="A blocked verdict mid-cut locks the latch and holds; the clean "
"verdict resumes with no press",
subsystem="laser", kind="live", mode="grbl", est_min=4,
covers=_LASER_COVERS,
requires=["laser.emission-witness"], actions=["button"],
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
"Press the physical button when it lights white (the arm). Nothing else: the test "
"takes the cooling verdict away itself and gives it back."],
description="A 40 mm line at constant power (M3 S400/F300, 8 s). About 1.5 s in, the test "
"pauses the machine-services daemon for 3.5 s, so the cooling verdict the "
"controller reads goes stale: the controller's pause tier. The controller must "
"hold the job (grbl Hold) under the still-open armed window with the stream "
"masked dark, so the kernel's LASER_ON sample count reads 0 before the cut "
"resumes, and it must not write the latch: a lock sets the hardware button "
"latch, which only a press clears, so both bit 3 (the SoC lock) and bit 2 "
"(the button latch) of interlock_circuit stay clear through the whole run. "
"When the daemon returns (the verdict fresh, clean, resume_ok), the controller "
"must resume the cut lit from where it stopped, with no button press and no "
"prompt; the M2 program end then disarms as usual. The daemon's own dead-man "
"(5 s of silence) is not reached: the controller keeps reporting through the "
"pause. The beam detector witnesses the cut on both sides of the hold.")
def verdict_cut(ctx):
import os as _os
import signal as _signal
ev = ctx.evidence
fc = ctx.forgectrl
# The daemon is frozen long enough for the verdict the controller
# caches to expire (VERDICT_MAX_AGE 2.0 s), so the driver's pause tier
# holds the job - but not so long that the engine's report dead-man
# trips on resume. That dead-man locks the laser latch when the
# controller's report is older than REPORT_TIMEOUT_S (5.0 s) while
# armed, which is exactly the "latch stayed locked" this test refuses.
# The last report is up to 1 s old when the freeze starts, so the
# freeze must stay under 4.0 s; 3.0 s clears the stale floor with room
# to see the hold and stays a full second under the dead-man.
PAUSE_S = 3.0
pids = hw.pidof("forgectrl")
ctx.check(pids, "no forgectrl process found to pause")
ev["daemon_pids"] = pids
state_path = "/run/forgefirm/grbl.state"
def grbl_state_file():
try:
with open(state_path) as f:
return f.read()
except OSError:
return ""
def resume_daemon():
for p in pids:
try:
_os.kill(p, _signal.SIGCONT)
except OSError:
pass
trail = []
text = ""
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
k0 = kernel_start(ctx)
base = sample(ctx)
ctx.check(base, "forgectrl /status or /cool/status unavailable")
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
ctx.ready(ARM_CUE % "40 mm +X")
job = ["G91", "G21", "M3", "S400", "G1 X40 F300", "M5", "G0 X-40", "G90", "M2"]
smp = arm_and_fire(ctx, g, room="40 mm +X", job=job)
beams = [(smp.get("beam"), smp.get("beam_d"))]
ctx.log("emission live (%s); pausing the daemon in 1 s", smp["emission"])
ctx.sleep(1.0)
g.drain()
t0 = time.time()
try:
for p in pids:
_os.kill(p, _signal.SIGSTOP)
ctx.log("daemon paused (SIGSTOP %s) for %.1f s: the verdict goes stale", pids, PAUSE_S)
# No HTTP while the daemon is paused: the controller's socket,
# sysfs and the controller's state file are the witnesses.
while time.time() - t0 < PAUSE_S:
st = g.status_report()["state"]
text += g.drain()
il = hw.sysfs_int("cnc/interlock_circuit")
trail.append({"t": round(time.time() - t0, 2), "gstate": st, "il": il,
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
"armed": '"armed":true' in grbl_state_file()})
time.sleep(0.12)
finally:
resume_daemon()
ctx.log("daemon resumed (SIGCONT) at +%.2f s", time.time() - t0)
# Through the resume and to the end of the cut, sampling the same
# witnesses (the daemon's own once it answers again).
resumed_at = None
end = time.time() + 30
while time.time() < end:
ctx.checkpoint()
st = g.status_report()["state"]
text += g.drain()
il = hw.sysfs_int("cnc/interlock_circuit")
row = {"t": round(time.time() - t0, 2), "gstate": st, "il": il,
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
"armed": '"armed":true' in grbl_state_file()}
if time.time() - t0 > PAUSE_S + 1.0:
s = sample(ctx)
if s:
row["beam"] = s.get("beam")
beams.append((s.get("beam"), s.get("beam_d")))
trail.append(row)
if resumed_at is None and st.startswith("Run"):
resumed_at = time.time() - t0
if st.startswith("Idle") and resumed_at is not None and time.time() - t0 > resumed_at + 3.0:
break
time.sleep(0.12)
for r in trail:
ctx.log(" %s", r)
ev["trail"] = trail
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
ctx.log("controller: %s", ev["messages"])
dt = wait_disarm(ctx, 75)
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the job")
check_kernel_returned(ctx, ev, k0)
def locked(r):
return r["il"] is not None and bool(r["il"] & IL_LASER_LATCH)
def button_latched(r):
return r["il"] is not None and bool(r["il"] & IL_BUTTON_LATCH)
held = [r for r in trail if r["gstate"].startswith("Hold")]
ev.update({"held_at_s": held[0]["t"] if held else None,
"resumed_at_s": round(resumed_at, 2) if resumed_at else None,
"latch_locked_samples": sum(1 for r in trail if locked(r)),
"button_latch_set_samples": sum(1 for r in trail if button_latched(r))})
ctx.check(held and held[0]["t"] < PAUSE_S, "the stale verdict did not hold the job during the "
"pause (first Hold at %s)", ev["held_at_s"])
ctx.check("fire masked, job held" in text, "the controller did not report the pause tier")
ctx.check(ev["latch_locked_samples"] == 0,
"the pause tier wrote the latch (locked in %d samples): a lock sets the hardware "
"button latch and the resume would need a press", ev["latch_locked_samples"])
ctx.check(ev["button_latch_set_samples"] == 0,
"the hardware button latch read SET in %d samples: the resume needed a press",
ev["button_latch_set_samples"])
ctx.check(all(r["armed"] for r in trail if r["t"] < (resumed_at or PAUSE_S + 5)),
"the armed window closed during the pause")
ctx.check("press the button" not in text, "the resume asked for a button press")
ctx.check(DISARMED_MSG not in text.split("resuming")[0], "the pause tier disarmed the job")
# Dark from the hold until the resume: the gate masked the stream.
span = [r for r in trail if held and r["t"] >= held[0]["t"]
and (resumed_at is None or r["t"] < resumed_at)]
zero = next((r for r in span if r["emission"] == 0), None)
ev["emission_zero_after_hold_s"] = round(zero["t"] - held[0]["t"], 2) if zero else None
ctx.check(zero is not None and zero["t"] - held[0]["t"] <= 2.5,
"emission did not read 0 within 2.5 s of the hold (before the resume)")
ctx.check(resumed_at is not None, "the clean verdict did not resume the cut")
ctx.check("resuming" in text, "the controller did not report the resume")
after = [r for r in trail if resumed_at and r["t"] >= resumed_at]
ctx.check(any(r["emission"] for r in after), "the resumed cut ran dark (no emission after the resume)")
ctx.check("ALARM" not in text, "an alarm was raised on the pause tier")
ctx.check(dt is not None and dt < 10.0, "the M2 job did not disarm promptly at Idle (%s s)", dt)
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base)
judge_beam(ctx, ev["beam"], "the cut")
check_button_dark(ctx, ev)
ctx.log("PASS: held at +%s s with the latch untouched, emission 0 %s s after the hold, resumed "
"lit at +%s s with no press, disarmed %.1f s after Idle", ev["held_at_s"],
ev["emission_zero_after_hold_s"], ev["resumed_at_s"], dt)
DISARMED_MSG = "laser disarmed - latch locked"
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
+352 -24
View File
@@ -136,9 +136,68 @@ def clean_slate(ctx, g):
ctx.log("unlock: %s", g.command("$X"))
st = g.status_report()
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
clear_error(ctx, g)
return st
def clear_error(ctx, g):
"""Clear a latched line error before the test's first move.
Soft limits are armed after a home (the driver's, since the bed has
no switches), so a jog past the bed is refused with error:15 - and
grblHAL then answers error:15 to every following G-code line, across
a fresh connection, until a blank line acknowledges it ($ commands
and the ? report are unaffected, so a sender that queries on connect
self-clears; a bare G-code stream does not). A prior test's or the
baseline hand-back's rejected jog would otherwise make this test's
first move fail with a stale error unrelated to the move. A blank
line clears it and returns ok. Harmless and idempotent at Idle."""
r = g.command("")
ctx.check(r and r[-1] == "ok" and not any(x.startswith("error") for x in r),
"the controller answered %s to a blank line at the start - not a clean parser", r)
MOVE_START_S = 2.0 # Run is there within 100 ms; a loaded board gets this long
def start_move(ctx, g, line):
"""Start a travel job with `line` and see the controller in Run. The
wait is bounded, not a fixed sleep. When Run never comes, the failure
names what happened instead: the reply (a refused block answers
error), the state the controller sits in (Hold, Door, Alarm, or an
Idle that never took the block), every line it said meanwhile, and
the machine as forgectrl sees it - the evidence a bare "did not
start" leaves out. Recorded in the evidence either way."""
reply = g.command(line, timeout=1.0)
end = time.time() + MOVE_START_S
t0 = time.time()
said = ""
st = None
while time.time() < end:
ctx.checkpoint()
st = g.status_report()
if st["state"].startswith("Run"):
break
said += g.drain()
time.sleep(0.1)
rec = {"line": line, "reply": reply, "state": st["state"] if st else None,
"after_s": round(time.time() - t0, 2)}
ctx.evidence.setdefault("move_start", []).append(rec)
if st is not None and st["state"].startswith("Run"):
return st
msgs = [ln.strip() for ln in said.splitlines() if ln.strip()]
rec["said"] = msgs
s = ctx.forgectrl.status() or {}
gr = ((s.get("grbl") or {}).get("report") or {})
rec["forgectrl"] = {"state": s.get("state"), "switches": s.get("switches"),
"laser_locked": s.get("laser_locked"), "grbl": gr.get("state"),
"alarm": gr.get("alarm"), "sender": (gr.get("sender") or {}).get("connected")}
ctx.check(False, "the move did not start: %s answered %s; controller %s %.1f s later, said %s; "
"forgectrl sees kernel %s, grbl %s (alarm %s), switches %s, laser locked %s",
line, reply, st["state"] if st else "no report", rec["after_s"], msgs or "nothing",
s.get("state"), gr.get("state"), gr.get("alarm"), s.get("switches"), s.get("laser_locked"))
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
subsystem="motion", kind="auto", mode="grbl", est_min=1,
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
@@ -753,21 +812,30 @@ def _return_x(ctx, delta_mm):
machine_idle(ctx)
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move",
subsystem="motion", kind="auto", mode="grbl", est_min=4,
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move, "
"a kill during the web-service homing",
subsystem="motion", kind="auto", mode="grbl", est_min=6,
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
requires=["motion.cancel-abort", "kernel.k1-k2"],
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail."],
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail. "
"With cloud mode enabled the test also homes through the web service and kills the "
"controller mid-homing (about a minute); the head ends wherever the homing was."],
description="SIGKILL of the controller mid-move: the supervisor reaps it, safes (cnc/stop, "
"latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a "
"hang) mid-move: the ring drains into a kernel underrun (fast halt, latch "
"locked); the process resumed from the hang recovers on a soft reset and an unlock "
"(the alarm is critical, so $X alone is refused; the reset acknowledges the "
"fault to the stream and clears the stale ring) and moves again without a "
"restart. forgectrl "
"restart mid-move: the busy controller finishes the move unmanaged and the new "
"daemon retakes supervision at idle. After each drill the head is jogged back "
"by the kernel-measured distance.")
"restart. A short SIGSTOP (100 ms, inside the kernel's 200 ms queue) mid-move: "
"no underrun, but the step producer comes back later than its lead and its "
"late events clamp; unarmed that is a warning in the controller's log and the "
"move completes with every step (the armed case faults, proven on the host). "
"forgectrl restart mid-move: the busy controller finishes the move unmanaged "
"and the new daemon retakes supervision at idle. After each drill the head is "
"jogged back by the kernel-measured distance. SIGKILL during $H (the web-service "
"homing, with cloud mode enabled): the homing runner the controller left behind "
"is ended before the respawn, the kernel is idle when the new controller starts, "
"and the pulse device has one controller on it.")
def deadman(ctx):
import os as _os
import signal as _signal
@@ -897,6 +965,47 @@ def deadman(ctx):
x1 = _kernel_x_mm(ctx)
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
# ---- 2b. a short hang (inside the kernel queue): late events clamp, a warning, the move completes
m2 = wait_running(10)
pid2 = m2["pid"]
x0 = _kernel_x_mm(ctx)
underruns0 = hw.sysfs_int("cnc/underruns", 0)
log0 = _log_offset(GRBLHAL_LOG)
with ctx.grbl() as g:
clean_slate(ctx, g)
g.command("G91")
g.command("G1X30F300", timeout=0.5) # ~6 s of motion
ctx.sleep(1.0)
_os.kill(pid2, _signal.SIGSTOP)
time.sleep(0.1)
_os.kill(pid2, _signal.SIGCONT)
ctx.log("SIGSTOP 100 ms sent to controller pid %d mid-move", pid2)
peak, states, st = wait_idle(ctx, g, 30)
g.command("G90")
machine_idle(ctx) # the kernel's tail plays out behind grbl's Idle
warned = None
t0 = time.time()
while time.time() - t0 < 5 and not warned:
lines = _log_lines(GRBLHAL_LOG, log0, "late events clamped")
warned = lines[-1] if lines else None
if not warned:
ctx.sleep(0.5)
x1 = _kernel_x_mm(ctx)
ev["short_stall"] = {"states": states, "underruns": hw.sysfs_int("cnc/underruns", 0) - underruns0,
"warning": warned, "kernel_dx_mm": round((x1 - x0), 3) if (x0 is not None and x1 is not None) else None,
"state_after": (st or {}).get("state")}
ctx.log("short stall: states %s, underruns +%s, warning %r, kernel dx %s mm", states,
ev["short_stall"]["underruns"], warned, ev["short_stall"]["kernel_dx_mm"])
ctx.check("TIMEOUT" not in states and str((st or {}).get("state", "")).startswith("Idle"),
"the move did not complete after the short stall (states %s)", states)
ctx.check(ev["short_stall"]["underruns"] == 0, "the short stall drained the ring (underruns +%s)",
ev["short_stall"]["underruns"])
ctx.check(warned, "the controller did not warn about the clamped events after the short stall")
ctx.check(ev["short_stall"]["kernel_dx_mm"] is not None and abs(ev["short_stall"]["kernel_dx_mm"] - 30.0) < 0.3,
"the kernel counted %s mm for a 30 mm move after the short stall", ev["short_stall"]["kernel_dx_mm"])
ctx.check(latch_locked(), "latch unlocked after the short stall")
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
# ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle
m2 = wait_running(10)
pid2 = m2["pid"]
@@ -934,8 +1043,226 @@ def deadman(ctx):
x1 = _kernel_x_mm(ctx)
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
machine_idle(ctx)
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, restart retook supervision (pid %s)",
respawn_s, halt_s, m3.get("pid"))
# ---- 4. SIGKILL during $H: the homing runner is ended, the kernel idle, one writer
homing_kill(ctx, wait_running)
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, short stall warned and completed, "
"restart retook supervision (pid %s), the kill during $H ended the runner (%s)",
respawn_s, halt_s, m3.get("pid"), ev.get("homing_kill", {}).get("runner_gone_s", "skipped"))
def _pulse_holders():
"""The processes holding the pulse device open, by name."""
out = []
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
try:
for fd in os.listdir("/proc/%s/fd" % pid):
if os.readlink("/proc/%s/fd/%s" % (pid, fd)) == "/dev/glowforge":
with open("/proc/%s/comm" % pid) as f:
out.append(f.read().strip())
break
except OSError:
continue
return out
def homing_kill(ctx, wait_running):
"""SIGKILL the GRBL controller while gfhome (the web-service homing
runner, its own process group on the inherited pulse fd) is moving
the head. The supervisor must end the runner before it starts another
controller, or two processes write the ring. Skipped when cloud mode
is not enabled on the machine (the runner needs the service)."""
import os as _os
import signal as _signal
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
if str(before.get("cloud_enabled", "")).lower() not in ("1", "true", "yes", "on"):
ctx.log("kill during $H: skipped, cloud mode is not enabled on this machine")
return
hm = before.get("homing_mode", "")
ev["homing_kill"] = {"homing_mode": hm}
if hm != "gfcloud":
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
try:
m4 = wait_running(10)
ctx.check(m4, "controller not running before the homing drill")
pid4 = m4["pid"]
off = _log_offset(FORGECTRL_LOG)
runner_up = None
try:
with ctx.grbl() as g:
clean_slate(ctx, g)
g.send_raw(b"$H\n")
runner_up = ctx.wait_for(lambda: bool(hw.pidof("gfhome.py")), 30)
ctx.check(runner_up is not None, "the homing runner never started for $H")
ctx.sleep(3.0) # into the session: the runner drives the head
ctx.log("SIGKILL sent to controller pid %d during $H (runner pid %s)",
pid4, hw.pidof("gfhome.py"))
_os.kill(pid4, _signal.SIGKILL)
except (hw.HwError, OSError) as e:
ctx.log("the Grbl connection ended with the kill: %s", e)
t_kill = time.time()
gone_s = ctx.wait_for(lambda: not hw.pidof("gfhome.py"), 15)
ev["homing_kill"]["runner_gone_s"] = gone_s
ctx.check(gone_s is not None, "the homing runner outlived its controller")
m5 = None
while time.time() - t_kill < 60:
st, m5 = fc.get("/mode")
if isinstance(m5, dict) and m5.get("controller") == "running" and m5.get("pid") != pid4:
break
ctx.sleep(0.5)
kstate = hw.sysfs_read("cnc/state")
holders = _pulse_holders()
lines = _log_lines(FORGECTRL_LOG, off, "homing runner")
halted = _log_lines(FORGECTRL_LOG, off, "halting it")
ev["homing_kill"].update({"respawn_s": round(time.time() - t_kill, 1), "mode_after": m5,
"kernel_state_at_respawn": kstate, "pulse_holders": holders,
"super_lines": [ln[-160:] for ln in lines[-3:]]})
ctx.log("kill during $H: runner gone in %s s, respawned as %s, kernel %s, pulse held by %s",
gone_s, m5 and m5.get("pid"), kstate, holders)
ctx.check(m5 and m5.get("pid") != pid4, "no respawn after the kill during $H: %s", m5)
ctx.check(lines, "the supervisor did not log the runner's end")
ctx.check(not halted, "the kernel had to be halted for the respawn: %s", halted[-1:])
ctx.check(kstate in ("idle", "disabled"), "the kernel was %s when the new controller started", kstate)
ctx.check("gfhome.py" not in holders and holders.count("grblHAL_glowfor") <= 1,
"more than one controller on the pulse device: %s", holders)
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the drill")
finally:
if hm != "gfcloud":
st, body = (fc.post("/settings", params={"homing_mode": ""}) if not hm
else fc.post("/settings", data={"homing_mode": hm}))
ctx.log("restore homing_mode=%r -> %s", hm, st)
@test("motion.respawn-gate", title="A respawn waits for the lid the way a first spawn does",
subsystem="motion", kind="operator", mode="grbl", est_min=2,
covers=_MOTION_COVERS, requires=["motion.deadman", "motion.gate-waits-for-lid"], actions=["lid"],
steps=["Bed clear. Open the lid when told and close it when told; nothing moves."],
description="The enclosure check runs before every controller spawn, respawns included. "
"With the lid open, the controller is killed: the supervisor safes the machine "
"(latch locked), reports waiting with why naming the lid, and starts no controller "
"while the lid stays open. When the lid closes the controller comes back verified, "
"without a second motion probe (the probe is once per broker hold).")
def respawn_gate(ctx):
import os as _os
import signal as _signal
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
st, m0 = fc.get("/mode")
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
pid0 = m0.get("pid")
off = _log_offset(FORGECTRL_LOG)
ctx.act("lid", "open")
try:
ctx.sleep(1.0)
_os.kill(pid0, _signal.SIGKILL)
t_kill = time.time()
ctx.log("SIGKILL sent to controller pid %d with the lid open", pid0)
waited = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("controller") == "waiting", 15)
st, m1 = fc.get("/mode")
ev["after_kill"] = {"waiting_s": waited, "mode": m1}
ctx.log("mode %.1f s after the kill: %s", time.time() - t_kill, m1)
ctx.check(waited is not None, "the supervisor did not report waiting for the lid: %s", m1)
ctx.check("lid" in (m1.get("why") or ""), "why does not name the lid: %r", m1.get("why"))
ilk = hw.sysfs_int("cnc/interlock_circuit")
ctx.check(ilk is not None and ilk & (1 << 3), "latch not locked after the kill")
ctx.sleep(4.0)
st, m2 = fc.get("/mode")
ctx.check(isinstance(m2, dict) and m2.get("controller") == "waiting" and not m2.get("pid"),
"the wait did not hold with the lid open: %s", m2)
finally:
ctx.act("lid", "close")
t_close = time.time()
came = ctx.wait_for(lambda: ((fc.get("/mode")[1] or {}).get("controller") == "running"
and (fc.get("/mode")[1] or {}).get("pid") != pid0), 60)
st, m3 = fc.get("/mode")
ev["after_close"] = {"running_s": came, "mode": m3}
ctx.log("mode %.1f s after the lid closed: %s", time.time() - t_close, m3)
ctx.check(came is not None and m3.get("motion") == "verified",
"the controller did not come back verified after the lid closed: %s", m3)
lines = _probe_lines(FORGECTRL_LOG, off)
ev["probe_lines"] = lines[-2:]
ctx.check(not lines, "the motion probe ran again for the respawn: %s", lines[:1])
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the end")
ctx.log("PASS: the respawn waited %.1f s for the lid, then came back verified in %.1f s with no probe",
t_close - t_kill, came)
@test("motion.soft-limits", title="After a home the bed is the X/Y envelope",
subsystem="motion", kind="auto", mode="grbl", est_min=3,
covers=_MOTION_COVERS, requires=["motion.jog-roundtrip", "cloud.mode-switch"],
steps=["Bed clear, lid closed. The test homes the machine through the web service if it is "
"not homed (about a minute), then sends moves the controller must refuse."],
description="The machine has no limit switches, so the core's $20 cannot be turned on and "
"the X/Y soft limits are the driver's: off while the position is not trusted, on "
"after a home, when the envelope is the bed ($130 by $131 from the home corner). "
"Homed, a program move 5 mm past X max or Y max, or past the near edge, raises "
"ALARM:2 before any motion (the kernel counters do not move), a jog past the bed "
"is refused with error 15, and a move inside the bed runs. The Z envelope is the "
"lens window, as before.")
def soft_limits(ctx):
from .cloud import gfhome_homing
fc = ctx.forgectrl
ev = ctx.evidence
with ctx.grbl() as g:
clean_slate(ctx, g)
if not fc.status().get("homed"):
gfhome_homing(ctx, ev, g)
ctx.check(fc.status().get("homed"), "the machine is not homed")
x_travel = float(grbl_setting(g, "$130"))
y_travel = float(grbl_setting(g, "$131"))
ev["travel"] = {"x": x_travel, "y": y_travel}
k0 = kernel_xy_mm(ctx)
def refused(cmd):
lines = g.command(cmd, timeout=2)
ctx.sleep(0.5)
text = "\n".join(lines) + g.drain()
k1 = kernel_xy_mm(ctx)
moved = max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1]))
st = g.status_report()["state"]
rec = {"cmd": cmd, "reply": lines, "alarm": "ALARM:2" in text, "moved_mm": round(moved, 3), "state": st}
ctx.log("%s", rec)
ctx.check(rec["alarm"], "%s was not refused with ALARM:2: %s", cmd, lines)
ctx.check(moved < 0.05, "%s moved the kernel %.3f mm before the alarm", cmd, moved)
g.realtime(0x18)
ctx.sleep(1.5)
g.drain()
unlock = g.command("$X")
ctx.check(unlock and unlock[-1] == "ok", "$X after the soft-limit alarm: %s", unlock)
st = g.status_report()["state"]
ctx.check(st.startswith("Idle"), "controller is %s after the recovery", st)
return rec
ev["refused"] = [refused("G90 G1 X%.1f F600" % (x_travel + 5)),
refused("G90 G1 Y%.1f F600" % (y_travel + 5)),
refused("G90 G1 X-1 F600")]
# Homed still: the soft-limit alarm and its reset keep the reference.
ctx.check(fc.status().get("homed"), "the soft-limit alarm and the reset un-homed the machine")
jog = g.command("$J=G91X%.1fF1200" % (x_travel + 5))
ev["jog"] = jog
ctx.check(any(l.startswith("error:15") for l in jog), "a jog past the bed was not refused with error 15: %s", jog)
# The core answers the line after an error with that error again
# until an empty line clears it (the sender's acknowledgment).
g.command("")
g.command("G90 G1 X10 Y10 F600", timeout=0.5)
peak, states, st = wait_idle(ctx, g, 20)
ev["inside"] = states
ctx.check("TIMEOUT" not in states, "a move inside the bed did not run")
g.command("G90 G1 X0 Y0 F600", timeout=0.5)
wait_idle(ctx, g, 20)
k1 = kernel_start(ctx) # at rest: grbl's Idle leads the kernel's tail
ev["kernel_back_mm"] = [round(k1[0] - k0[0], 3), round(k1[1] - k0[1], 3)]
ctx.check(max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1])) < 0.1, "the head did not come back to the corner: %s",
ev["kernel_back_mm"])
ctx.log("PASS: X max, Y max and X min refused with ALARM:2 and no motion, the jog refused with "
"error 15, a move inside the bed ran")
# ------------------------------------------------- lid / button (the factory's)
@@ -1066,9 +1393,7 @@ def button_hold_resume(ctx):
"moves; the job holds and the test sees it.")
g.command("M5")
g.command("G91")
g.command("G1X40F300", timeout=0.5) # an 8 s move
ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
start_move(ctx, g, "G1X40F300") # an 8 s move
g.drain() # the message window opens here
w = Watch(g)
ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
@@ -1144,9 +1469,7 @@ def lid_cancel_home(ctx):
"leave it open until the head has come back on its own.")
g.command("M5")
g.command("G91")
g.command("G1X40F300", timeout=0.5) # an 8 s move
ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
start_move(ctx, g, "G1X40F300") # an 8 s move
g.drain() # the message window opens here
ctx.act("lid", "open", text="Leave it open until the head has come back.", timeout=12)
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "lid opened", "running")
@@ -1239,9 +1562,7 @@ def interlock_cancel_home(ctx):
"moves and leave it open until the head has come back on its own.")
g.command("M5")
g.command("G91")
g.command("G1X60F300", timeout=0.5) # a 12 s move
ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
start_move(ctx, g, "G1X60F300") # a 12 s move
g.drain()
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
sw = (ctx.forgectrl.status().get("switches") or {})
@@ -1285,8 +1606,13 @@ def interlock_cancel_home(ctx):
def lid_policy_hold(ctx):
ev = ctx.evidence
fc = ctx.forgectrl
was = (fc.settings() or {}).get("lid_policy") or "cancel"
# What the machine had, exactly: an unset lid_policy reads as the empty
# string and behaves as cancel, and writing the word back where the
# machine had nothing is a leftover the hand-back reports. The default
# is for reading the value, never for restoring it.
was = (fc.settings() or {}).get("lid_policy", "")
ev["lid_policy_before"] = was
ev["lid_policy_in_force"] = was or "cancel"
st, _b = fc.post("/settings", data={"lid_policy": "hold"})
ctx.check(st == 200, "could not set lid_policy=hold (%s)", st)
ctx.check(((fc.settings() or {}).get("lid_policy")) == "hold", "lid_policy did not take")
@@ -1298,9 +1624,7 @@ def lid_policy_hold(ctx):
"the job parks in the door state and waits.")
g.command("M5")
g.command("G91")
g.command("G1X40F300", timeout=0.5) # an 8 s move
ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
start_move(ctx, g, "G1X40F300") # an 8 s move
g.drain()
ctx.act("lid", "open", timeout=12)
st, text = wait_state_text(ctx, g, "Door", 8)
@@ -1332,8 +1656,12 @@ def lid_policy_hold(ctx):
g.command("G90")
machine_idle(ctx)
finally:
st, _b = fc.post("/settings", data={"lid_policy": was})
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy")
# An empty value clears the key, and only the query-string form
# carries one: an empty form field never reaches the request, and
# the write is refused with "no known setting in request".
st, _b = (fc.post("/settings", params={"lid_policy": ""}) if not was
else fc.post("/settings", data={"lid_policy": was}))
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy", "")
ctx.log("lid_policy restored to %s", ev["lid_policy_restored"])
ctx.check(ev["lid_policy_restored"] == was, "lid_policy was not restored to %r", was)
ctx.log("PASS: lid_policy=hold parked the job in Door and the cycle start finished it (%.3f mm)",
@@ -1,8 +1,9 @@
"""commission.* - the first-run commissioning: the record and the
"""setup.* - the first-run setup: the record and the
controller gate, the advisories, the account and its login, the HTTPS
boundary, SSH, the cloud switch, the mDNS name, and the factory return.
boundary, SSH, the cloud switch, the machine's name, and the factory
return.
The daemon reads the commissioning record (commissioning.json in the
The daemon reads the setup record (setup.json in the
data directory) and the account record (users) once, at its start, and
keeps them in memory; only its own routes write them. A test that needs
a different record installs the file under a forgectrl restart (a
@@ -11,7 +12,7 @@ never saves a test record over the real one, and a restore is complete
when the daemon is up again.
The layer content of this work (the account replay init script, the
sshd policy, the console banner, the avahi service) is part of the
sshd policy, the hostname and console banner scripts) is part of the
platform identity that every fingerprint carries, not of a component.
No coverage map names it: a change there makes every test necessary
again.
@@ -23,10 +24,9 @@ import http.client
import json
import os
import random
import select
import re
import socket
import ssl
import struct
import time
import urllib.parse
@@ -34,21 +34,18 @@ from ..catalog import test
from .. import hw
from .forgectrl import lan_ip
RECORD = "commissioning.json"
RECORD = "setup.json"
USERS = "users"
OVERRIDE = "commissioning-override"
OVERRIDE = "setup-override"
SSH_FLAG = "ssh-enabled"
TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests)
# The image's required wizards, all at version 1 (commission.c).
# The image's required wizards, all at version 1 (setup.c).
REQUIRED_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service")
SAFETY_PHRASE = "I UNDERSTAND"
LOGIN_FAILS = 5
LOGIN_LOCK_S = 30
MDNS_NAME = "forgefirm.local"
MDNS_GROUP = "224.0.0.251"
MDNS_PORT = 5353
# ----------------------------------------------------------------- files
@@ -179,8 +176,8 @@ def record_without_wizards(record):
def complete_record(status, base=None, account_name="bench"):
"""A complete commissioning record for a machine that must count as
commissioned: every advisory at the hash GET /wiz reports, the
"""A complete setup record for a machine that must count as
set up: every advisory at the hash GET /wiz reports, the
press recorded, an account (the one of `base` when it has one),
every wizard of the catalog at its version, and the run complete.
`status` is a GET /wiz body; `base` is an existing record to keep
@@ -355,12 +352,12 @@ def gate_is_open():
# -------------------------------------------------------------- the gate
_GATE_COVERS = [("forgectrl", "src/commission.*"), ("forgectrl", "src/super.c"),
_GATE_COVERS = [("forgectrl", "src/setup.*"), ("forgectrl", "src/super.c"),
("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")]
@test("commission.gate-blocks-controllers", title="The commissioning gate spawns no controller",
subsystem="commission", kind="auto", hardware="takeover", mode="grbl", est_min=5,
@test("setup.gate-blocks-controllers", title="The setup gate spawns no controller",
subsystem="setup", kind="auto", hardware="takeover", mode="grbl", est_min=5,
covers=_GATE_COVERS, requires=["forgectrl.auth"], precheck=gate_is_open,
description="With the override removed and a record that lacks every required wizard "
"(installed under a forgectrl restart, the consent and the account kept), "
@@ -377,11 +374,11 @@ def gate_blocks_controllers(ctx):
ctx.log("before: gate %s, override %s, required %s", before.get("gate"), before.get("override"),
before.get("required"))
raw = read_file(record_path())
ctx.check(raw, "no commissioning record at %s", record_path())
ctx.check(raw, "no setup record at %s", record_path())
try:
record = json.loads(raw.decode("utf-8"))
except ValueError as e:
ctx.fail("the commissioning record is not JSON: %s", e)
ctx.fail("the setup record is not JSON: %s", e)
lacking = json.dumps(record_without_wizards(record), indent=1, sort_keys=True).encode() + b"\n"
had_override = override_present()
ev["override_before"] = had_override
@@ -394,7 +391,7 @@ def gate_blocks_controllers(ctx):
ctx.log("/mode after the restart: %s (after %s s)", m, took)
ctx.check(m.get("controller") == "gated" and m.get("gated") is True,
"the supervisor did not report the gate: %s", m)
ctx.check("commissioning required" in (m.get("why") or ""),
ctx.check("setup required" in (m.get("why") or ""),
"the reason does not name the missing wizards: %r", m.get("why"))
w = wiz(fc)
ev["gated_wiz"] = wiz_summary(w)
@@ -442,11 +439,11 @@ def gate_blocks_controllers(ctx):
ctx.log("restored: gate %s, controller %s", after.get("gate"), m.get("controller"))
@test("commission.override-until-reboot", title="The override stands until the next reboot",
subsystem="commission", kind="auto", est_min=1,
@test("setup.override-until-reboot", title="The override stands until the next reboot",
subsystem="setup", kind="auto", est_min=1,
covers=_GATE_COVERS, requires=["forgectrl.auth"],
description="The bench seed: the dev image's forgetest init script writes "
"/run/forgefirm/commissioning-override at boot. With it present, GET /wiz "
"/run/forgefirm/setup-override at boot. With it present, GET /wiz "
"reports the override and an open gate, the reason names the wizards it lifts "
"when any is required, and GET /mode reports the controller running and not "
"gated. The file lives on tmpfs (/proc/mounts), so a reboot removes it by "
@@ -489,9 +486,9 @@ def override_until_reboot(ctx):
# ------------------------------------------------------------ advisories
@test("commission.advisories-rehash", title="An advisory is accepted at its current hash only",
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/commission.*"),
@test("setup.advisories-rehash", title="An advisory is accepted at its current hash only",
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/sha256.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake")],
requires=["forgectrl.auth"],
@@ -507,7 +504,7 @@ def advisories_rehash(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
raw = read_file(record_path())
ctx.check(raw, "no commissioning record at %s", record_path())
ctx.check(raw, "no setup record at %s", record_path())
before = wiz(fc)
ev["before"] = wiz_summary(before)
docs = {d.get("id"): d for d in before.get("documents") or []}
@@ -582,7 +579,7 @@ def advisories_rehash(ctx):
# -------------------------------------------------------------- the login
@test("commission.account-login", title="The panel login over HTTPS", subsystem="commission",
@test("setup.account-login", title="The panel login over HTTPS", subsystem="setup",
kind="auto", est_min=2,
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
@@ -705,7 +702,7 @@ def login_checks(ctx, name, pw):
# ------------------------------------------------------- HTTPS boundary
@test("commission.https-only-writes", title="Writes from the LAN go to HTTPS", subsystem="commission",
@test("setup.https-only-writes", title="Writes from the LAN go to HTTPS", subsystem="setup",
kind="auto", est_min=1,
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"),
("forgectrl", "src/main.c")],
@@ -777,7 +774,7 @@ def https_only_writes(ctx):
# ------------------------------------------------------------------ ssh
def sshd_policy():
"""The three ForgeFIRM keys of the effective sshd policy (sshd -T,
"""The ForgeFIRM keys of the effective sshd policy (sshd -T,
lowercase keys), or {"error": ...} when sshd cannot report it."""
for exe in ("/usr/sbin/sshd", "sshd"):
rc, out = hw.run([exe, "-T"], timeout=15)
@@ -789,7 +786,7 @@ def sshd_policy():
for line in out.splitlines():
parts = line.split(None, 1)
if len(parts) == 2 and parts[0] in ("permitrootlogin", "permitemptypasswords",
"passwordauthentication"):
"passwordauthentication", "banner"):
policy[parts[0]] = parts[1].strip()
return policy
return {"error": "no sshd binary"}
@@ -805,8 +802,8 @@ def restore_ssh(fc, before):
write_file(flag, b"" if before.get("enabled") else None)
@test("commission.ssh-until-reboot", title="SSH is switched from the panel until the next reboot",
subsystem="commission", kind="auto", est_min=1,
@test("setup.ssh-until-reboot", title="SSH is switched from the panel until the next reboot",
subsystem="setup", kind="auto", est_min=1,
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")],
requires=["forgectrl.auth"],
description="GET /system/ssh reports enabled, running, and dev_image, and agrees with the "
@@ -814,7 +811,9 @@ def restore_ssh(fc, before):
"sshd listens on 22, with the effective policy (sshd -T) at PasswordAuthentication "
"yes, and on a release image PermitRootLogin no and PermitEmptyPasswords no (the "
"dev image turns root over SSH back on for the bench; the release gate checks "
"the release image's sshd_config as built). An enable outside 0 and 1 is refused (400). On a release "
"the release image's sshd_config as built), and with no pre-authentication "
"banner, so the machine says nothing to a client that has not logged in. "
"An enable outside 0 and 1 is refused (400). On a release "
"image POST enable=0 removes the flag and stops sshd; on the dev image the boot "
"rule keeps sshd up and the stop is never gated, so the test removes the flag "
"by hand (as a reboot does, the flag is on tmpfs) and sshd stays. The prior "
@@ -848,6 +847,13 @@ def ssh_until_reboot(ctx):
ctx.log("sshd -T: %s", policy)
ctx.check(policy.get("passwordauthentication") == "yes",
"PasswordAuthentication is %r: the account cannot log in", policy.get("passwordauthentication"))
# No pre-authentication banner: the machine says nothing to a
# client that has not logged in. The mark and the version go in
# the motd, which a login prints (image.health).
ctx.check(policy.get("banner") in (None, "none"),
"sshd sends a pre-authentication banner (%r)", policy.get("banner"))
if not before["dev_image"]:
ctx.check(policy.get("permitrootlogin") == "no" and policy.get("permitemptypasswords") == "no",
"a release image runs sshd with PermitRootLogin %r, PermitEmptyPasswords %r",
@@ -882,8 +888,8 @@ def ssh_until_reboot(ctx):
# ---------------------------------------------------------------- cloud
@test("commission.cloud-disabled-surface", title="Nothing points at the cloud while it is off",
subsystem="commission", kind="auto", mode="grbl", est_min=1,
@test("setup.cloud-disabled-surface", title="Nothing points at the cloud while it is off",
subsystem="setup", kind="auto", mode="grbl", est_min=1,
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/settings.*"),
("forgectrl", "src/wiz.c"), ("forgectrl", "src/hooks.h")],
requires=["forgectrl.settings-bounds"],
@@ -965,8 +971,8 @@ def cloud_disabled_surface(ctx):
# ---------------------------------------------------------- the operator
@test("commission.factory-return", title="The return to the factory firmware is guarded",
subsystem="commission", kind="auto", est_min=1,
@test("setup.factory-return", title="The return to the factory firmware is guarded",
subsystem="setup", kind="auto", est_min=1,
covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.js")],
requires=["update.slots-and-signature"],
description="The return itself reboots the machine as a Glowforge, so it never runs from the "
@@ -1002,165 +1008,89 @@ def factory_return(ctx):
ctx.log("PASS: the return is refused without confirm=1; the page confirms before it calls")
# ----------------------------------------------------------------- mDNS
# --------------------------------------------------- the machine's name
def mdns_query(name, qid=None):
"""A DNS query packet for the A record of name with the unicast-
response bit set (RFC 6762 5.4), so the responder answers this
socket directly."""
qid = random.randrange(1, 65536) if qid is None else qid
labels = b"".join(struct.pack("B", len(p)) + p.encode("ascii") for p in name.strip(".").split("."))
return struct.pack(">HHHHHH", qid, 0, 1, 0, 0, 0) + labels + b"\x00" + struct.pack(">HH", 1, 0x8001)
def mac_suffix():
"""The last four hex digits of the MAC address the hostname is built
from: wlan0, or eth0 on a machine with no WiFi."""
for dev in ("wlan0", "eth0"):
raw = read_file("/sys/class/net/%s/address" % dev)
mac = (raw or b"").decode("ascii", "replace").strip().replace(":", "").lower()
if mac and mac != "0" * 12:
return mac[-4:]
return ""
def _dns_name(pkt, off):
"""(name, offset after the name); follows compression pointers."""
parts = []
jumped = False
end = off
hops = 0
while True:
if off >= len(pkt):
raise ValueError("truncated name")
n = pkt[off]
if n == 0:
off += 1
break
if n & 0xC0 == 0xC0:
if off + 1 >= len(pkt):
raise ValueError("truncated pointer")
ptr = ((n & 0x3F) << 8) | pkt[off + 1]
if not jumped:
end = off + 2
jumped = True
off = ptr
hops += 1
if hops > 32:
raise ValueError("pointer loop")
continue
off += 1
parts.append(pkt[off:off + n].decode("ascii", "replace"))
off += n
if not jumped:
end = off
return ".".join(parts), end
def mdns_answers(pkt, qid=None):
"""The A records of a DNS response: [(name, address)]. A qid, when
given, must match the packet's id; the answer flag (QR) must be set."""
if len(pkt) < 12:
return []
pid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", pkt[:12])
if qid is not None and pid != qid:
return []
if not flags & 0x8000:
return []
off = 12
try:
for _ in range(qd):
_name, off = _dns_name(pkt, off)
off += 4
def cmdlines():
"""The command line of every process on the machine, one string
each."""
out = []
for _ in range(an + ns + ar):
name, off = _dns_name(pkt, off)
if off + 10 > len(pkt):
break
rtype, rclass, _ttl, rdlen = struct.unpack(">HHIH", pkt[off:off + 10])
off += 10
rdata = pkt[off:off + rdlen]
off += rdlen
if rtype == 1 and rdlen == 4:
out.append((name.lower(), socket.inet_ntoa(rdata)))
return out
except ValueError:
return []
def mdns_resolve(ip, name=MDNS_NAME, timeout=3.0, tries=3):
"""Ask the LAN for the A record of name over mDNS from the interface
that holds ip, and collect the answers: {address}. The query goes to
the multicast group; the responder on this machine answers the
unicast-response bit directly (a legacy query from a port that is
not 5353 is answered the same way), and a listener on 5353 catches
a multicast answer too."""
found = set()
qid = random.randrange(1, 65536)
q = mdns_query(name, qid)
tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rx = None
try:
tx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
tx.bind((ip, 0))
tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(ip))
tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1)
try:
rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"):
rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
rx.bind(("", MDNS_PORT))
rx.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
socket.inet_aton(MDNS_GROUP) + socket.inet_aton(ip))
except OSError:
rx = None
socks = [s for s in (tx, rx) if s is not None]
for _ in range(tries):
tx.sendto(q, (MDNS_GROUP, MDNS_PORT))
deadline = time.time() + timeout
while time.time() < deadline:
ready, _w, _x = select.select(socks, [], [], max(0.05, deadline - time.time()))
for s in ready:
try:
pkt, _peer = s.recvfrom(4096)
except OSError:
for name in os.listdir("/proc"):
if not name.isdigit():
continue
# a multicast answer carries id 0; the direct one echoes the query's
for n, addr in mdns_answers(pkt, qid if s is tx else None):
if n == name.lower():
found.add(addr)
if found:
return found
finally:
tx.close()
if rx is not None:
rx.close()
return found
raw = read_file("/proc/%s/cmdline" % name)
if raw:
out.append(raw.decode("utf-8", "replace").replace("\0", " ").strip())
return out
@test("commission.mdns-announce", title="The machine answers forgefirm.local", subsystem="commission",
kind="auto", est_min=1,
@test("setup.machine-name", title="The machine names itself from its MAC address",
subsystem="setup", kind="auto", est_min=1,
requires=["forgectrl.auth"],
description="avahi-daemon runs, its configuration names the host forgefirm on the WiFi and "
"wired links, and the service file advertises the panel on 443 and 80. An mDNS "
"query for the A record of forgefirm.local, sent to the multicast group from "
"the board's own LAN interface, is answered with the board's LAN address; that "
"is what `ping forgefirm.local` on a workstation resolves. No component "
"covers this: the avahi files are layer content, in the platform identity of "
description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex "
"digits of its WiFi MAC address, so two machines on one network answer to "
"different names and no name carries a serial number. The live name, "
"/etc/hostname (a bind-mounted copy, because the rootfs is read-only) and the "
"MAC address agree; the DHCP client sends the name as the hostname option, "
"which is what a network with dynamic DNS publishes; the console banner offers "
"the machine's addresses and nothing else, with no marker line and no .local "
"name; and no mDNS responder is on the image. No component covers this: the "
"hostname and banner scripts are layer content, in the platform identity of "
"every fingerprint.")
def mdns_announce(ctx):
def machine_name(ctx):
ev = ctx.evidence
pids = hw.pidof("avahi-daemon")
ev["avahi_pids"] = pids
ctx.check(pids, "avahi-daemon is not running")
conf = read_file("/etc/avahi/avahi-daemon.conf") or b""
ctx.check(b"host-name=forgefirm" in conf, "avahi-daemon.conf does not name the host forgefirm")
ctx.check(b"allow-interfaces=wlan0,eth0" in conf, "avahi-daemon.conf does not restrict the interfaces")
svc = read_file("/etc/avahi/services/forgefirm.service") or b""
ev["service_file_bytes"] = len(svc)
ctx.check(b"_https._tcp" in svc and b"<port>443</port>" in svc, "the service file lacks HTTPS 443")
ctx.check(b"_http._tcp" in svc and b"<port>80</port>" in svc, "the service file lacks HTTP 80")
# 1. the name, the file and the MAC address agree
suffix = mac_suffix()
ev["mac_suffix"] = suffix
ctx.check(suffix, "no MAC address to build a name from")
live = socket.gethostname()
on_file = ((read_file("/etc/hostname") or b"").decode("utf-8", "replace")).strip()
ev["hostname"] = live
ev["hostname_file"] = on_file
ctx.log("hostname %r (MAC suffix %s)", live, suffix or "none")
ctx.check(live == "forgefirm-" + suffix,
"the hostname is %r, expected forgefirm-%s", live, suffix)
ctx.check(on_file == live, "/etc/hostname holds %r, the live name is %r", on_file, live)
# 2. the DHCP client sends it (option 12), so dynamic DNS can publish it
dhcp = [c for c in cmdlines() if c.split(" ")[0].split("/")[-1] == "udhcpc"]
ev["udhcpc"] = dhcp
ctx.check(dhcp, "no DHCP client is running")
ctx.check(any("-x hostname:" + live in c for c in dhcp),
"the DHCP client does not send the machine's name: %s", dhcp)
# 3. the console banner: the addresses, and nothing that is not the banner
issue = (read_file("/etc/issue") or b"").decode("utf-8", "replace")
ev["issue"] = issue
ip = lan_ip()
ev["lan_ip"] = ip
ctx.check(ip, "cannot determine the board's LAN address")
t0 = time.time()
found = mdns_resolve(ip)
ev["answers"] = sorted(found)
ev["resolve_s"] = round(time.time() - t0, 2)
ctx.log("%s -> %s (%.1f s)", MDNS_NAME, sorted(found) or "no answer", ev["resolve_s"])
ctx.check(found, "no mDNS answer for %s from the LAN interface (%s)", MDNS_NAME, ip)
ctx.check(ip in found, "%s resolves to %s, not the LAN address %s", MDNS_NAME, sorted(found), ip)
ctx.check("Control panel:" in issue, "the console banner names no control panel")
ctx.check("https://%s/" % ip in issue,
"the console banner does not carry the LAN address %s", ip)
for junk in ("# end", "# ForgeFIRM addresses", ".local"):
ctx.check(junk not in issue, "the console banner shows %r", junk)
urls = re.findall(r"https://(\S+)/", issue)
ev["banner_urls"] = urls
for u in urls:
literal = u.startswith("[") or all(c.isdigit() or c == "." for c in u)
ctx.check(literal, "the console banner offers %r, which is not an address", u)
# 4. no mDNS on the image; the name is the only name the machine has
ev["avahi_pids"] = hw.pidof("avahi-daemon")
ctx.check(not ev["avahi_pids"], "an mDNS responder is running: %s", ev["avahi_pids"])
ctx.check(not os.path.exists("/etc/avahi"), "/etc/avahi is on the image")
# --------------------------------------------------------- the first run
@@ -1283,12 +1213,12 @@ def finished_first_run(ctx):
"the controller did not come back after the setup: %s", m)
_FIRST_RUN_REQUIRES = ["commission.gate-blocks-controllers", "commission.advisories-rehash"]
_FIRST_RUN_REQUIRES = ["setup.gate-blocks-controllers", "setup.advisories-rehash"]
@test("commission.first-run-flow", title="The first run, driven as the page drives it", subsystem="commission",
@test("setup.first-run-flow", title="The first run, driven as the page drives it", subsystem="setup",
kind="operator", hardware="takeover", est_min=3,
covers=[("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"),
covers=[("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/session.*"), ("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"),
("forgectrl", "src/sheetid.*"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/hooks.h"),
("forgectrl", "src/main.c")],
@@ -1379,11 +1309,11 @@ def first_run_flow(ctx):
ctx.log("the run completed: gate open, the controller back")
@test("commission.first-run-page", title="The first-run page, walked once", subsystem="commission",
@test("setup.first-run-page", title="The first-run page, walked once", subsystem="setup",
kind="operator", hardware="takeover", est_min=6,
covers=[("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/forms.js"), ("forgectrl", "src/ui/md.js"),
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/embed_docs.cmake"), ("forgectrl", "src/tls.*")],
requires=_FIRST_RUN_REQUIRES + ["commission.first-run-flow"],
requires=_FIRST_RUN_REQUIRES + ["setup.first-run-flow"],
actions=["button"], hands=["workstation"],
steps=["From the workstation open https://<address>/ and accept the self-signed certificate.",
"Click through the setup: scroll each document to its end and accept it (type I "
@@ -1398,7 +1328,7 @@ def first_run_flow(ctx):
"clicks through (the documents accepted, the press step with the LEDs "
"breathing teal, the account created, the run complete, the gate open, the "
"controller back). The operator confirms the panel opened. Everything the run "
"wrote is restored as commission.first-run-flow restores it.")
"wrote is restored as setup.first-run-flow restores it.")
def first_run_page(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
@@ -1434,8 +1364,8 @@ def first_run_page(ctx):
ctx.confirm("Did the control panel open after Finish?")
@test("commission.cert-page", title="The certificate is checkable before the warning",
subsystem="commission", kind="auto", est_min=1,
@test("setup.cert-page", title="The certificate is checkable before the warning",
subsystem="setup", kind="auto", est_min=1,
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.js")],
requires=["forgectrl.auth"],
@@ -1458,7 +1388,8 @@ def cert_page(ctx):
ctx.check(st == 200, "GET %s/cert -> %s, expected 200", base, st)
ctx.check("location" not in hdrs, "GET %s/cert redirected", base)
ctx.check(fp in text, "the fingerprint is not on the page from %s", base)
ctx.check("forgefirm.local" in text, "forgefirm.local is not among the names on the page")
ctx.check(socket.gethostname() in text,
"the machine's hostname is not among the names on the page")
st, body, hdrs = request(base, "GET", "/cert.pem")
ctx.check(st == 200 and body.startswith(b"-----BEGIN CERTIFICATE-----"),
"GET %s/cert.pem -> %s, not a PEM certificate", base, st)
@@ -1492,9 +1423,9 @@ def flag_of(w, level, wid):
return None
@test("commission.what-changed", title="A replaced part asks for its checks again",
subsystem="commission", kind="auto", est_min=2, hardware="takeover",
covers=[("forgectrl", "src/commission.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"),
@test("setup.what-changed", title="A replaced part asks for its checks again",
subsystem="setup", kind="auto", est_min=2, hardware="takeover",
covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html"),
("forgectrl", "src/ui/help.js")],
requires=["forgectrl.auth"],
@@ -1562,9 +1493,9 @@ def what_changed(ctx):
ctx.check(bool(mode(fc).get("gated")) == gated_before, "the gate does not read as before")
@test("commission.record-export", title="The record: JSON, the printable page, and the log bundle",
subsystem="commission", kind="auto", est_min=2,
covers=[("forgectrl", "src/commission.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"),
@test("setup.record-export", title="The record: JSON, the printable page, and the log bundle",
subsystem="setup", kind="auto", est_min=2,
covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"),
("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/wizard.html")],
requires=["forgectrl.auth", "logs.tree-tail-export"],
@@ -1573,7 +1504,7 @@ def what_changed(ctx):
"named after the sheet id; GET /wiz/record.html is a page with no script that "
"carries the sheet id and every completed wizard; without the token and without "
"a login both are refused (403); and the sanitized log export carries "
"system/commissioning.json, parseable, with the same sheet id and no panel token.")
"system/setup.json, parseable, with the same sheet id and no panel token.")
def record_export(ctx):
import gzip
import io
@@ -1613,7 +1544,7 @@ def record_export(ctx):
ctx.check(st == 200 and "text/html" in (hdrs.get("content-type") or ""), "GET /wiz/record.html -> %s %s",
st, hdrs.get("content-type"))
ctx.check("<script" not in page.lower(), "the printable page carries a script")
ctx.check(sid in page and "commissioning record" in page, "the page lacks the sheet id or its title")
ctx.check(sid in page and "setup record" in page, "the page lacks the sheet id or its title")
titles = {z["id"]: z["title"] for z in w.get("wizards") or []}
missing = [d for d in done if titles.get(d) and ("<h3>" + titles[d]) not in page]
ev["missing_on_page"] = missing
@@ -1634,11 +1565,11 @@ def record_export(ctx):
try:
tf = tarfile.open(fileobj=io.BytesIO(gzip.decompress(data)))
for m in tf.getmembers():
if m.isfile() and m.name.endswith("system/commissioning.json"):
if m.isfile() and m.name.endswith("system/setup.json"):
member = tf.extractfile(m).read()
except (OSError, tarfile.TarError, EOFError) as e:
ctx.fail("export is not a readable tar.gz: %s", e)
ctx.check(member is not None, "the bundle lacks system/commissioning.json")
ctx.check(member is not None, "the bundle lacks system/setup.json")
if member is not None:
try:
inside = json.loads(member.decode("utf-8"))
@@ -1656,12 +1587,12 @@ def record_export(ctx):
ctx.log("record %d wizards; page %d bytes; bundle carries the record", len(done), ev["page_bytes"])
@test("commission.mirror", title="One browser drives a check; a second one follows",
subsystem="commission", kind="auto", est_min=2, hardware="takeover",
@test("setup.mirror", title="One browser drives a check; a second one follows",
subsystem="setup", kind="auto", est_min=2, hardware="takeover",
covers=[("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), ("forgectrl", "src/wiz.*"),
("forgectrl", "src/session.*"), ("forgectrl", "src/ui/wizard.js"),
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.css")],
requires=["forgectrl.auth", "commission.account-login", "commission.check-sensors"],
requires=["forgectrl.auth", "setup.account-login", "setup.check-sensors"],
description="With a temporary account (made and removed as the login test does) and two "
"login sessions: the first session starts the sensors check; GET /wiz/dark "
"reports the run as owned and its own to the first session, not to the second, "
@@ -1,4 +1,4 @@
"""commission.check-* - the setup's checks (the dark wizards), driven the
"""setup.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
@@ -12,11 +12,17 @@ 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
from .setup import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
POLL_S = 1.0
# What the machine's own press prompt says, used when it sends no text.
PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your press."
# How long the purge fan's current is given to come up after the airflow
# check switches it back on. The bench reference draws its off current
# (74) for the first seconds and settles near 630.
PURGE_SPINUP_S = 30
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
@@ -76,6 +82,12 @@ def run_check(ctx, wid, on_prompt, timeout_s):
if value is not None:
st, body = answer(fc, wid, p, value)
ctx.check(st == 200, "the answer to %s -> %s %s", p.get("id"), st, body)
elif p.get("id") == "press":
# The arm press, asked for by the machine itself: the
# check opens this prompt when the button is lit and
# the tube is still dark. Nothing answers it - the
# press does, and the discharge closes it.
ctx.press_now(p.get("text") or PRESS_TEXT)
if not d.get("running"):
last = d
break
@@ -101,8 +113,8 @@ def run_check(ctx, wid, on_prompt, timeout_s):
return last
@test("commission.check-switches", title="The switches check follows the lid and the button",
subsystem="commission", kind="operator", est_min=3,
@test("setup.check-switches", title="The switches check follows the lid and the button",
subsystem="setup", kind="operator", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
requires=["forgectrl.auth"], actions=["lid", "button"],
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
@@ -136,30 +148,40 @@ def check_switches(ctx):
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,
@test("setup.check-sensors", title="The sensors check reads a plausible machine at rest",
subsystem="setup", 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.")
description="POST /wiz/sensors/start; ten seconds of readings and no question asked. 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 the "
"settings read as before (the check writes none).")
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)
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings() or {}
ev["settings_before"] = dict(before)
def on_prompt(p):
ctx.fail("the sensors check asked a question: %s", p)
last = run_check(ctx, "sensors", on_prompt, 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"))
after = fc.settings() or {}
changed = {k: (before.get(k), after.get(k)) for k in set(before) | set(after)
if before.get(k) != after.get(k)}
ctx.check(not changed, "the sensors check wrote a setting: %s", changed)
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
r.get("lid_ir_max"), r.get("hv_current_max"))
@test("commission.check-airflow", title="The airflow check measures the fans and sets the floors",
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
@test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
subsystem="setup", 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"],
@@ -167,7 +189,8 @@ def check_sensors(ctx):
"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.")
"end, the purge fan is on again and drawing over the floor the check just wrote, "
"and the controller must be running again.")
def check_airflow(ctx):
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
"cool_purge_min_current", "cool_fan_grace_s"]
@@ -186,12 +209,38 @@ def check_airflow(ctx):
for k in keys:
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
ctx.log("floors: %s", json.dumps(floors))
# The check switches purge air off to read its off current. Leaving
# it off costs the next job an airflow hold mid-cut, judged against
# the very floor written above, and nothing puts it back until the
# daemon restarts: the engine's idle phase never re-applies its own
# duties. Prove the machine is whole, not just measured.
purge_on = ctx.sysfs("head/purge_air")
ctx.check(purge_on == "1", "purge air reads %r after the check, expected 1 (on)", purge_on)
def purge_gate():
return ((ctx.forgectrl.get("/cool/status")[1] or {}).get("fan_gates") or {}).get("purge") or {}
# The check read its off current with the fan off, and the stand-down
# commands it back on. The current follows the command, it does not
# arrive with it: read in the same second and what comes back is the
# off current the check just measured. Wait for the draw the way the
# controller below is waited for.
took = ctx.wait_for(lambda: (purge_gate().get("reading") or 0) >= (purge_gate().get("floor") or 0),
PURGE_SPINUP_S)
gate = purge_gate()
ctx.evidence["purge_after"] = {"purge_air": purge_on, "gate": gate, "took_s": took}
ctx.log("purge after the check: commanded %s, drawing %s against the %s floor (%s)",
purge_on, gate.get("reading"), gate.get("floor"),
"after %.1f s" % took if took is not None else "never reached the floor")
ctx.check(took is not None,
"purge draws %s after %d s, under the %s floor the check just wrote: the next job "
"would be held", gate.get("reading"), PURGE_SPINUP_S, gate.get("floor"))
ok = ctx.wait_for(lambda: (ctx.forgectrl.get("/mode")[1] or {}).get("controller") == "running", 60)
ctx.check(ok is not None, "the controller did not come back after the check")
@test("commission.check-cameras", title="The cameras check captures both cameras",
subsystem="commission", kind="operator", est_min=2,
@test("setup.check-cameras", title="The cameras check captures both cameras",
subsystem="setup", 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 "
@@ -225,10 +274,10 @@ def check_cameras(ctx):
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,
@test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
("forgectrl", "src/lenshome.c"),
("forgectrl", "src/lenshome.*"),
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
requires=["forgectrl.auth", "motion.pacing"],
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
@@ -266,8 +315,8 @@ def check_motion(ctx):
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
@test("commission.check-flow-verify", title="The flow check runs as a setup check",
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
@test("setup.check-flow-verify", title="The flow check runs as a setup check",
subsystem="setup", 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 "
@@ -284,8 +333,8 @@ def check_flow_verify(ctx):
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,
@test("setup.cloud-header-capture", title="The cloud header check takes one print's envelope",
subsystem="setup", 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"),
@@ -1,4 +1,4 @@
"""commission.sheet - the setup's sheet (the live wizards) in one run,
"""setup.sheet - the setup's sheet (the live wizards) in one run,
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
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
@@ -18,13 +18,13 @@ restored. The page is where the real numbers go in.
import re
from ..catalog import test
from .commission_dark import run_check, Restore
from .setup_dark import run_check, Restore
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
("forgectrl", "src/sheet.*"),
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
("forgectrl", "src/super.c"),
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
@@ -36,19 +36,18 @@ SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
# 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.
# sheet), and the keys its result must carry. The arm press is not timed
# from here: each card opens a press prompt of its own when the button is
# lit and the tube still dark, and run_check presses on that.
CARDS = [
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"], 120),
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"]),
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"},
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
"focus_range_mm", "stops"], 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),
"focus_range_mm", "stops"]),
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"]),
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"]),
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"]),
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"]),
]
# Every setting a card writes, restored as found when the run ends.
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
@@ -72,6 +71,22 @@ def preview_ok(ctx, wid):
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
"no program for %s (%s)", wid, st)
ctx.evidence["program_lines"] = body.count(b"\n")
# Every Z the program commands is a height the head reaches in the
# window the machine holds now: the focus card's own numbers once it
# has them, and the conservative fallback on a head whose travel is
# not known yet. The controller refuses a Z outside it with ALARM:2,
# and it refuses it while the body is still burning, so the card dies
# where the parser is and not where the bad line is.
lens = (fc.status() or {}).get("lens") or {}
lo, hi = lens.get("reach_min"), lens.get("reach_max")
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
ctx.evidence.setdefault("program_z", {})[wid] = {
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
if lo is not None and hi is not None:
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
wid, lo, hi, out)
def emission_ok(ctx, last):
@@ -90,6 +105,61 @@ def file_card(ctx, wid):
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
# The lens settings: the head's numbers, which a fresh machine does not
# have. Cleared before the cards so the run is a fresh machine's.
LENS_SETTINGS = CARD_SETTINGS[:3]
FALLBACK_WINDOW = (10, 12)
def fresh_lens(ctx):
"""The lens as a fresh machine has it: the head's numbers unknown, so
the frame runs in the fallback window and the focus card has to find
the stops and write them before its ladder burns. The machine's own
numbers come back with the restore at the end of the run."""
fc = ctx.forgectrl
for k in LENS_SETTINGS:
st, _ = fc.post("/settings", params={k: ""})
ctx.check(st == 200, "clearing %s -> %s", k, st)
lens = (fc.status() or {}).get("lens") or {}
ctx.evidence["lens_fresh"] = lens
ctx.check(lens.get("stops_found") is False and (lens.get("below"), lens.get("above")) == FALLBACK_WINDOW,
"the lens window is not the fallback after the clear: %s", lens)
def focus_window_ok(ctx):
"""The window the focus card burned its ladder in is the one it wrote
before its controller started, and the one the machine holds now: the
result's window is the stops it found (or the fallback), the settings
hold that window, every ladder height lies in its reach, and the
program served now agrees with /status."""
fc = ctx.forgectrl
r = ((ctx.evidence.get("cards") or {}).get("laser.focus") or {}).get("result") or {}
w, stops = r.get("window") or {}, r.get("stops") or {}
s = fc.settings() or {}
held = (str(s.get("lens_stop_below_steps")), str(s.get("lens_stop_above_steps")))
ctx.check(held == (str(w.get("below")), str(w.get("above"))),
"the settings hold the window %s, the ladder ran in %s/%s", held, w.get("below"), w.get("above"))
if stops.get("found"):
ctx.check((w.get("below"), w.get("above")) == (stops.get("below"), stops.get("above")),
"the ladder's window %s is not the stops found %s", w, stops)
else:
ctx.check((w.get("below"), w.get("above")) == FALLBACK_WINDOW,
"stops not found (%s), but the ladder's window is %s", stops.get("why"), w)
zs = r.get("ladder_z") or []
lo, hi = w.get("reach_min"), w.get("reach_max")
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
ev = ctx.evidence
before = dict(ev.get("program_z") or {}).get("laser.focus")
preview_ok(ctx, "laser.focus")
ev["focus_window"] = {"window": w, "stops": stops, "ladder_z": zs,
"settings": {k: s.get(k) for k in LENS_SETTINGS},
"program_z_before": before,
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
"preview_bytes": ev.pop("preview_bytes", None),
"program_lines": ev.pop("program_lines", None)}
def thickness_answer(ctx):
"""The thickness the placement is answered with, in the machine's
units (the number prompts read ui_units), and the millimeters the
@@ -130,12 +200,12 @@ def place(ctx):
file_card(ctx, "sheet.place")
def burn(ctx, wid, answers, want, lit_s):
def burn(ctx, wid, answers, want):
"""One live card: the preview, the burn after the press with the
prompts answered from `answers`, the witnesses, the keys of the
result in `want`. The actuator waits `lit_s` for the button to light."""
result in `want`. run_check makes the arm press when the card asks
for it."""
preview_ok(ctx, wid)
ctx.arm_press(lit_timeout=lit_s)
def on_prompt(p):
return answers.get(p.get("id"))
@@ -152,30 +222,38 @@ def burn(ctx, wid, answers, want, lit_s):
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,
@test("setup.sheet", title="The setup sheet: the placement, the frame, and the five cards",
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=35,
covers=SHEET_COVERS,
requires=["forgectrl.auth", "commission.check-motion", "laser.emission-witness", "cooling.flow-verify"],
requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "cooling.flow-verify"],
actions=["button"], hands=["scrap"],
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.",
"machine, and the bench actuator makes every arm press after it, each one when the "
"card itself asks for it. With no actuator the start is the Ready answer instead, "
"and you press the button each time the page asks: six times, the frame and the "
"five cards.",
"Nothing else: the test answers every prompt itself and puts back every setting the "
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
"tails."],
description="One run over the sheet's seven wizards on one piece of wood. "
description="One run over the sheet's seven wizards on one piece of wood, as a fresh "
"machine runs them: the three lens settings are cleared first, so the frame "
"runs in the fallback window and the focus card has to find the stops and write "
"them before its ladder burns. "
"POST /wiz/sheet.place/start references the lens, takes the controller in "
"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 "
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the "
"program commands inside the reach /status reports: the frame "
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
"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 "
"lens on its hall edge, the stops found by the head accelerometer and written "
"as the window before the controller starts, twelve lines from the top of the "
"window to the bottom; the pick is line 11, the thickness kept; after it the "
"settings, the result's window, and the served program must hold the window "
"the ladder ran in); the floor card (twelve "
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
"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 "
@@ -191,6 +269,9 @@ def sheet(ctx):
"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")
fresh_lens(ctx)
for wid, answers, want in CARDS:
burn(ctx, wid, answers, want)
if wid == "laser.focus":
focus_window_ok(ctx)
ctx.log("PASS: the sheet's seven wizards ran on one piece as a fresh machine's; settings restored")
+75
View File
@@ -1,5 +1,6 @@
"""update.* - the A/B slot inventory and the firmware verification path."""
import os
import re
import tempfile
import time
import shutil
@@ -8,6 +9,7 @@ from ..catalog import test
from .. import hw
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
("forgectrl", "src/relcheck.c"), ("forgectrl", "src/relcheck.h"),
("ffboot", "**")]
@@ -117,3 +119,76 @@ def slots_and_signature(ctx):
except OSError:
pass
shutil.rmtree(work, ignore_errors=True)
_VERSION_RX = re.compile(r"^v?\d+\.\d+\.\d+")
@test("update.release-check", title="Release check answers from the releases API; the alert dismissal is per release",
subsystem="update", kind="auto", est_min=1,
covers=_UPDATE_COVERS + [("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html")],
requires=["forgectrl.auth"],
description="GET /update/release answers the kept answer of the daily check (available, version, "
"current, new, checked, dismissed). POST /update/check asks the releases API now and "
"answers the same shape; a machine with no route to the API answers 502, which the drill "
"records and steps over. A published release is a v<semver> tag with the firmware "
"file's size and its notes, and `new` follows the version order: a development build "
"sees every release as newer. POST /update/dismiss marks that version dismissed, an "
"empty version undoes it, and a version that is not a tag is refused. The dismissal "
"the machine had before the drill is put back.")
def release_check(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
keys = ("available", "version", "current", "new", "checked", "dismissed", "detail", "bytes")
st, before = fc.get("/update/release")
ctx.log("GET /update/release -> %s %s", st, {k: before.get(k) for k in keys} if isinstance(before, dict) else before)
ctx.check(st == 200 and isinstance(before, dict) and "available" in before and "checked" in before,
"GET /update/release -> %s", st)
ev["before"] = {k: before.get(k) for k in keys}
prior = before.get("version", "") if before.get("dismissed") else ""
st, now = fc.post("/update/check")
ctx.log("POST /update/check -> %s %s", st, {k: now.get(k) for k in keys} if isinstance(now, dict) else now)
if st == 502:
ctx.log("the machine has no route to the releases API; the kept answer stands")
now = before
else:
ctx.check(st == 200 and isinstance(now, dict) and "available" in now, "POST /update/check -> %s", st)
ctx.check(isinstance(now.get("checked"), int) and now["checked"] >= before.get("checked", 0),
"the check did not move `checked`: %s", now.get("checked"))
ev["after_check"] = {k: now.get(k) for k in keys}
if now.get("available"):
ctx.check(_VERSION_RX.match(str(now.get("version", ""))) is not None,
"the release version is not a v<semver> tag: %r", now.get("version"))
ctx.check(isinstance(now.get("bytes"), int) and now["bytes"] > 0,
"the firmware file's size is missing: %r", now.get("bytes"))
ctx.check(isinstance(now.get("notes"), str), "the notes are not a string")
ctx.check(isinstance(now.get("new"), bool), "`new` is not a bool")
if not _VERSION_RX.match(str(now.get("current", ""))):
ctx.check(now.get("new") is True,
"a development build (%r) must see release %s as newer", now.get("current"), now["version"])
else:
ctx.log("no release available: %s", now.get("detail"))
ctx.check(isinstance(now.get("detail"), str) and now["detail"], "an unavailable release names no reason")
try:
st, body = fc.post("/update/dismiss", params={"version": "v0.0.1; rm -rf /"})
ctx.log("POST /update/dismiss version=<not a tag> -> %s %s", st, body)
ctx.check(st == 400, "a version that is not a tag was not refused: %s", st)
tag = now["version"] if now.get("available") else "v0.0.0"
st, body = fc.post("/update/dismiss", params={"version": tag})
ctx.log("POST /update/dismiss version=%s -> %s %s", tag, st,
{k: body.get(k) for k in keys} if isinstance(body, dict) else body)
ctx.check(st == 200 and isinstance(body, dict), "POST /update/dismiss -> %s", st)
if now.get("available"):
ctx.check(body.get("dismissed") is True, "the dismissal of %s was not recorded: %r", tag, body.get("dismissed"))
st, body = fc.post("/update/dismiss", params={"version": ""})
ctx.log("POST /update/dismiss version= -> %s", st)
ctx.check(st == 200 and isinstance(body, dict) and body.get("dismissed") is False,
"the empty version did not undo the dismissal: %s %r", st, body.get("dismissed") if isinstance(body, dict) else body)
finally:
st, body = fc.post("/update/dismiss", params={"version": prior})
ctx.log("dismissal put back to %r -> %s", prior, st)
+106
View File
@@ -206,6 +206,100 @@
border-left: 4px solid var(--og-red);
}
/* ---- The release dialog -------------------------------------------- */
#rel-view h3 {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--og-dim);
margin: 12px 0 4px;
}
.relnotes {
max-height: 40vh;
overflow: auto;
border: 1px solid var(--og-line);
border-radius: 6px;
padding: 8px 12px;
margin: 4px 0 8px;
font-size: 13px;
line-height: 1.5;
}
.relnotes h1,
.relnotes h2,
.relnotes h3 {
font-size: 14px;
font-weight: 600;
margin: 10px 0 4px;
}
.relnotes h1:first-child,
.relnotes h2:first-child,
.relnotes h3:first-child {
margin-top: 0;
}
.relnotes p,
.relnotes ul {
margin: 0 0 6px;
}
.relnotes ul {
padding-left: 20px;
}
.relnotes code {
font-size: 12px;
}
.relnotes table {
border-collapse: collapse;
margin: 4px 0 8px;
}
.relnotes th,
.relnotes td {
border: 1px solid var(--og-line);
padding: 2px 8px;
text-align: left;
}
.relwarn {
background: var(--og-warn-bg);
border: 1px solid var(--og-warn);
color: var(--og-warn-txt);
border-radius: 8px;
padding: 10px 12px;
margin: 0 0 12px;
font-size: 14px;
}
.relsteps {
margin: 0 0 12px;
padding-left: 22px;
font-size: 13px;
line-height: 1.7;
}
.relsteps li {
color: var(--og-dim);
}
.relsteps li.cur {
color: var(--og-txt);
font-weight: 600;
}
.relsteps li.done {
color: var(--og-ok);
}
.relsteps li.done::after {
content: ' \2713';
}
.progress {
--bs-progress-bg: var(--og-line-soft);
--bs-progress-bar-bg: var(--og-action);
--bs-progress-height: 10px;
margin: 0 0 8px;
}
#rel-phase {
min-height: 1.4em;
font-size: 12.5px;
margin: 0;
}
#rel-err {
margin: 10px 0 0;
}
/* ---- Chrome: header, tabs, footer ---------------------------------- */
body {
margin: 0;
@@ -238,6 +332,7 @@ header {
margin-left: auto;
}
#themebtn,
#logsbtn,
#logoutbtn {
background: transparent;
color: rgba(255, 255, 255, 0.75);
@@ -249,6 +344,7 @@ header {
white-space: nowrap;
}
#themebtn:hover,
#logsbtn:hover,
#logoutbtn:hover {
color: #fff;
border-color: rgba(255, 255, 255, 0.6);
@@ -692,3 +788,13 @@ footer a {
padding-right: 12px;
}
}
/* The setup's log-bundle line, under the header: the result of the
header's Download logs, or why the machine refused it. */
.wz-logs-msg {
max-width: 1100px;
margin: 6px auto 0;
padding: 0 16px;
min-height: 16px;
text-align: right;
}
+113 -3
View File
@@ -52,6 +52,13 @@ class BaselineTests(unittest.TestCase):
# the class interface writes 'target'; the fake mirrors it into brightness
# only when the test asks (see _sync_leds)
def _led2(self, name, target, brightness):
"""Both attributes of one button LED: the trigger's commanded
level and the fade that follows it."""
for a, v in (("target", target), ("brightness", brightness)):
with open(self.leds + name + "/" + a, "w") as f:
f.write(v)
def _sync_leds(self):
for name in baseline.BUTTON_LEDS:
p = self.leds + name + "/target"
@@ -119,6 +126,21 @@ class BaselineTests(unittest.TestCase):
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
self.assertEqual(items["position"].found, [1000, 0, 0])
def test_the_counters_are_read_at_the_modes_scale(self):
# 6400 steps is 30 mm at x32 (a return within the bound; on the
# host it stops at the missing controller) and 120 mm at x8
# (beyond the bound). Read at the x8 scale, an x32 machine's
# displaced head is never jogged back.
b = self.bl()
cap = b.capture()
self._attr("cnc/x_mode", "32")
self._pos(6400, 0, 0)
items = {x.item: x for x in b.enforce("post", captured=cap)}
self.assertEqual(items["position"].action, "unrestorable: no running GRBL controller")
self._attr("cnc/x_mode", "8")
items = {x.item: x for x in b.enforce("post", captured=cap)}
self.assertEqual(items["position"].action, "unrestorable: 120.0/0.0 mm exceeds 100 mm")
def test_a_held_controller_is_reset_before_the_return_jog(self):
# a pause test that failed while held leaves the controller in
# Hold, which refuses a jog: the baseline resets out of it first
@@ -154,16 +176,32 @@ class BaselineTests(unittest.TestCase):
b = self.bl()
cap = b.capture()
# the run left 40 unplayed bytes queued in the kernel ring and the
# head 1000 counts out: the residue is reported, and the return jog
# is refused (it would replay the residue first)
# head 1000 counts out: the hand-back stands the machine down to
# empty the ring, and only refuses the return jog when the bytes
# are still there afterwards (they are here: no daemon to restart
# a controller)
self._pos_bytes(1000, 0, 0, 100, 140)
left = b.enforce("post", captured=cap)
items = {x.item: x for x in left}
self.assertIn("pulse ring", items)
self.assertEqual(items["pulse ring"].found, "40 unplayed bytes")
self.assertIn("unplayed bytes queued", items["position"].action)
self.assertIn("still queued", items["position"].action)
self.assertIn("after a controller restart", items["position"].action)
self.assertEqual(baseline.read_ring_residue(), 40)
def test_the_hand_back_stands_the_machine_down_for_residue(self):
"""It acts, it does not report: a run that left bytes in the ring
gets a controller restart out of the hand-back, because that is
what empties the ring."""
b = self.bl()
cap = b.capture()
called = []
b.stand_down = lambda why: called.append(why)
self._pos_bytes(1000, 0, 0, 100, 140)
b.enforce("post", captured=cap)
self.assertEqual(len(called), 1)
self.assertIn("unplayed bytes", called[0])
def test_clean_ring_reads_zero_residue(self):
self.assertEqual(baseline.read_ring_residue(), 0)
@@ -354,6 +392,78 @@ if __name__ == "__main__":
unittest.main()
class TransientNotLeftoverTests(BaselineTests):
"""A leftover is what a run left behind, not the machine part-way
through its own work. Found on the bench reference, where
laser.arm-wait-lid passed every check it makes and failed its
hand-back on the post-job smoke clear."""
def test_a_fading_button_led_is_not_a_leftover(self):
# the trigger fades brightness toward target: target 0 with
# brightness still high is the fade from a level the machine ended
for name in baseline.BUTTON_LEDS:
self._led2(name, target="0", brightness="900")
left = self.bl().enforce("post", captured=None)
self.assertEqual([x for x in left if x.item.startswith("leds/")], [])
def test_a_lit_button_led_is_a_leftover(self):
# a target the run left standing is dirt, whatever the fade reads
self._led2(baseline.BUTTON_LEDS[0], target="1014", brightness="0")
left = self.bl().enforce("post", captured=None)
items = [x for x in left if x.item.startswith("leds/")]
self.assertEqual(len(items), 1)
self.assertEqual(items[0].found, "1014")
class CloudOwnedSysfsTests(unittest.TestCase):
"""In cloud mode the cloud client configures the machine from the
pulse header it is playing, the lens included: z_mode comes from
ZSmd through gfhardware's set_mode_from_puls. Handing the GRBL values
back under it would be the baseline configuring another controller's
machine. In GRBL mode the pair is checked as before."""
def test_the_z_pair_is_the_cloud_clients(self):
for attr in ("head/z_current", "head/z_mode"):
self.assertIn(attr, baseline.GRBL_CONTROLLER_SYSFS)
def test_the_z_pair_is_still_checked_in_grbl_mode(self):
fixed = dict(baseline.fixed_sysfs())
self.assertEqual(fixed.get("head/z_current"), "1")
self.assertEqual(fixed.get("head/z_mode"), "1")
class PositionDeadbandTests(unittest.TestCase):
"""The counters count steps and a controller's own return lands within
a few hundredths of a millimeter, not on the step: a difference that
small is quantization, not a leftover. Found on the bench reference,
where motion.lid-cancel-home returned the head twice, each within
0.04 mm, and failed the hand-back on the four steps left over."""
def test_a_few_steps_of_xy_are_the_quantization(self):
self.assertTrue(baseline.position_quantized([0, 0, 0], [4, 0, 0]))
self.assertTrue(baseline.position_quantized([0, 0, 0], [-4, 4, 0]))
self.assertTrue(baseline.position_quantized([10, 20, 30], [10, 20, 30]))
def test_past_the_dead_band_is_a_leftover(self):
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
def test_the_dead_band_scales_with_the_counters(self):
# the same step count is a quarter of the distance at x32
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
x32 = baseline.XY_STEPS_PER_MM_OF[32]
self.assertTrue(baseline.position_quantized([0, 0, 0], [over, 0, 0], x32))
self.assertFalse(baseline.position_quantized([0, 0, 0], [4 * over, 0, 0], x32))
def test_z_is_exact_because_the_return_never_moves_it(self):
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
def test_an_unreadable_reading_is_never_quantization(self):
self.assertFalse(baseline.position_quantized(None, [0, 0, 0]))
self.assertFalse(baseline.position_quantized([0, 0, 0], None))
class BaselineModeTests(BaselineTests):
"""The baseline against a fake forgectrl: what the mode in force
owns. Reuses the fake sysfs tree of BaselineTests; only the new
+130 -42
View File
@@ -558,12 +558,16 @@ class CloudSuiteTests(unittest.TestCase):
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
def mode_switch_setup(self, hunt_lines=None, home_complete=True):
def mode_switch_setup(self, hunt_lines=None, home_complete=True, lid_late=False):
"""The fakes a mode-switch run needs: grbl to answer $H, the lid
lamp attr, homing_mode = gfcloud, the service lines landing on
the switch to cloud (the hunt reads as a run to the cooling
engine while it lasts), the re-hunt on the lid close, and gfhome
finishing the homing after $H."""
the switch to cloud - the client's start at once, its session and
hunt once the lid reads open, as on the bench where the lid opens
behind the controller's start and ahead of the hunt (the hunt
reads as a run to the cooling engine while it lasts) - the re-hunt
on the lid close, and gfhome finishing the homing after $H. With
lid_late the hunt is requested before the lid opens: the race the
test must call."""
self.grbl = helpers.FakeGrbl().start()
os.makedirs(self.sysfs + "pic", exist_ok=True)
self._attr("pic/lid_led", "236")
@@ -575,22 +579,35 @@ class CloudSuiteTests(unittest.TestCase):
hunt_part = hunt_lines(hunt_part)
fc = self.fc
def hunt():
self.append(hunt_part, delay=0.0)
time.sleep(4.0) # the hunt outlasts the session wait on the bench
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
def on_post(path, form):
if path == "/mode" and form.get("controller") == "cloud":
fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE)
def land():
self.append(pre, delay=0.0)
if lid_late:
return
t0 = time.time()
while fc.state["status"]["switches"]["lid"] and time.time() - t0 < 5:
time.sleep(0.02)
time.sleep(0.3)
self.append(hunt_part, delay=0.0)
time.sleep(4.0) # the hunt outlasts the session wait on the bench
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
hunt()
threading.Thread(target=land, daemon=True).start()
elif path == "/mode" and form.get("controller") == "grbl":
self.grbl.state = "Idle"
return None
self.fc.on_post = on_post
def lid_opened():
if lid_late:
self.append(hunt_part, delay=0.0)
self.lid(False)
def lid_closed():
self.lid(True)
self.append(close_part, delay=0.2)
@@ -605,7 +622,7 @@ class CloudSuiteTests(unittest.TestCase):
f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete "
b"(service quiet 8s, 3 motion windows)\n")
self.home_hook = homing
return {"Open the lid.": lambda: self.lid(False), "Close the lid.": lid_closed}
return {"Open the lid.": lid_opened, "Close the lid.": lid_closed}
def wait_home_command(self):
while "$H" not in self.grbl.sent:
@@ -633,6 +650,29 @@ class CloudSuiteTests(unittest.TestCase):
self.assertEqual(self.script.asked, [])
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
def test_mode_switch_opens_the_lid_behind_the_controller_and_ahead_of_the_hunt(self):
# No controller starts with the enclosure open: the switch is made
# lid closed, the lid opens once the controller is up, and the
# hunt's request lands after that - the order the test records.
hooks = self.mode_switch_setup()
threading.Thread(target=self.wait_home_command, daemon=True).start()
run = self.run_test(cloud.mode_switch, hooks=hooks, test_id="cloud.mode-switch")
self.assertFalse(run.evidence["hunt_before_lid_open"])
acts = [(a["channel"], a["state"]) for a in run.evidence["actions"]]
self.assertEqual(acts, [("lid", "open"), ("lid", "close")])
i_post = next(i for i, l in enumerate(run.lines) if "POST /mode controller=cloud" in l)
i_open = next(i for i, l in enumerate(run.lines) if "ACT lid open" in l)
self.assertLess(i_post, i_open)
def test_mode_switch_refuses_to_start_with_the_lid_open(self):
hooks = self.mode_switch_setup()
self.lid(False)
self.assertFails(cloud.mode_switch, "close the lid first", hooks=hooks)
def test_mode_switch_fails_when_the_hunt_was_requested_before_the_lid_opened(self):
hooks = self.mode_switch_setup(lid_late=True)
self.assertFails(cloud.mode_switch, "before the lid was open", hooks=hooks)
def test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self):
def refused(hunt_part):
i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)
@@ -914,22 +954,63 @@ class CloudSuiteTests(unittest.TestCase):
self.assertEqual(self.fc.posts, [])
self.assertTrue(any("PASS: lid open at the button prompt" in l for l in run.lines))
# -- the cooling verdict: the armed print waits on the warm-up ------------
def test_verdict_hold_on_the_bench_excerpt(self):
self.in_offline()
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"})
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
# -- the cooling verdict: locked until the run, a hold that never clears --
def dark_parts(self):
"""(the print prologue through the button prompt, the run and the
print's end with no pause in it)."""
lines = fixture("pause")
pre, rest = cut(lines, "waiting for button")
pre = pre + [rest[0]]
# the run and the print's end, without the pause in the middle
_, run = cut(rest, "machine:_run_loop starting run")
run = [l for l in run if "paus" not in l and "resum" not in l]
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] INFO machine:_verdict_wait "
wait = [stamp % 100000 + "waiting on the cooling engine: WARMUP (WARM-UP: coolant 21.5 C under "
"the 22.5 C start gate - heater on, hold)"]
release = [stamp % 200000 + "cooling verdict clean after WARMUP; starting the run"]
return pre, run
def test_dark_print_on_the_bench_excerpt(self):
self.in_offline()
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
pre, run = self.dark_parts()
self.offline_print_hooks(pre)
def press():
# the run: the client unlocks the latch right before it, and
# relocks when the job ends
self._attr("cnc/interlock_circuit", "37")
self.append(run[:1], delay=0.05)
def ended():
self._attr("cnc/interlock_circuit", "45")
self.append(run[1:], delay=0.0)
threading.Timer(2.0, ended).start()
hooks = {"press it. The print runs dark": press}
run_ = self.run_test(cloud.dark_print, hooks=hooks, test_id="cloud.dark-print")
ev = run_.evidence
self.assertTrue(ev["latch_locked_at_button"])
self.assertFalse(ev["latch_locked_in_run"])
self.assertTrue(ev["latch_locked_after"])
self.assertIn(":completed", ev["print finished"])
self.assertTrue(any("PASS: locked at the button" in l for l in run_.lines))
def test_dark_print_fails_when_the_latch_is_open_at_the_button(self):
self.in_offline()
pre, run = self.dark_parts()
self._attr("cnc/interlock_circuit", "37") # unlocked before the press
self.offline_print_hooks(pre)
hooks = {"press it. The print runs dark": lambda: self.append(run, delay=0.05)}
self.assertFails(cloud.dark_print, "the latch is unlocked at the button wait", hooks=hooks)
def test_verdict_refuse_on_the_bench_excerpt(self):
self.in_offline()
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16",
"cloud_hold_max_s": ""})
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
pre, run = self.dark_parts()
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] %s machine:_verdict_wait "
wait = [stamp % (100000, "INFO") + "waiting on the cooling engine: WARMUP (WARM-UP: coolant "
"21.5 C under the 29.5 C start gate - heater on, hold)"]
refuse = [stamp % (200000, "WARNING") + "cooling hold held 60 s - relocking the laser",
"2026-08-17T09:44:15.000000+00:00 gfcloud[1927] INFO basemachine:_finish_action print "
"[9006]: finished with event \":cancelled\""]
def on_post(path, form):
if path == "/settings" and form.get("cool_temp_start") not in (None, "0", "16"):
@@ -942,37 +1023,44 @@ class CloudSuiteTests(unittest.TestCase):
def press():
self.append(wait, delay=0.05)
def released():
def refused():
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True,
"armed": False, "phase": "run"})
self.append(release + run, delay=0.0)
threading.Timer(3.0, released).start()
hooks = {"press it. The print then waits": press}
run_ = self.run_test(cloud.verdict_hold, hooks=hooks, test_id="cloud.verdict-hold")
"armed": False, "phase": "idle"})
self.append(refuse, delay=0.0)
threading.Timer(2.5, refused).start()
hooks = {"press it. Nothing runs": press}
run_ = self.run_test(cloud.verdict_refuse, hooks=hooks, test_id="cloud.verdict-refuse")
ev = run_.evidence
self.assertEqual(ev["gate"], 22.5)
self.assertEqual(ev["warmup"]["verdict"], "WARMUP")
self.assertTrue(ev["log"]["cooling verdict clean after WARMUP; starting the run"])
self.assertIn(":completed", ev["print finished"])
posted = [f for p, f in self.fc.posts if p == "/settings"]
self.assertEqual(posted[0]["cool_temp_start"], "22.5")
self.assertEqual(posted[-1], {"cool_temp_min": "5", "cool_temp_start": "16"})
self.assertTrue(any("PASS: the armed print waited" in l for l in run_.lines))
self.assertEqual(ev["gate"], 29.5)
self.assertTrue(ev["latch_locked_at_button"])
self.assertEqual(ev["latch_samples"]["unlocked"], 0)
self.assertGreaterEqual(ev["latch_samples"]["n"], 1)
self.assertIn(":cancelled", ev["print finished"])
posted = [f for p_, f in self.fc.posts if p_ == "/settings"]
self.assertEqual(posted[0]["cool_temp_start"], "29.5")
self.assertEqual(posted[0]["cloud_hold_max_s"], "60")
self.assertEqual(posted[-1], {"cool_temp_min": "5", "cool_temp_start": "16", "cloud_hold_max_s": ""})
self.assertTrue(any("PASS: held" in l for l in run_.lines))
def test_verdict_hold_fails_when_the_print_runs_under_the_hold(self):
def test_verdict_refuse_fails_when_the_latch_unlocks_during_the_hold(self):
self.in_offline()
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"})
self.fc.state["cool"].update({"verdict": "WARMUP", "hold": True, "fire_ok": False, "armed": True})
lines = fixture("pause")
pre, rest = cut(lines, "waiting for button")
pre = pre + [rest[0]]
_, run = cut(rest, "machine:_run_loop starting run")
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] INFO machine:_verdict_wait "
wait = [stamp % 100000 + "waiting on the cooling engine: WARMUP (no reason given)"]
pre, run = self.dark_parts()
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] %s machine:_verdict_wait "
wait = [stamp % (100000, "INFO") + "waiting on the cooling engine: WARMUP (no reason given)"]
refuse = [stamp % (200000, "WARNING") + "cooling hold held 60 s - relocking the laser",
"2026-08-17T09:44:15.000000+00:00 gfcloud[1927] INFO basemachine:_finish_action print "
"[9006]: finished with event \":cancelled\""]
self.offline_print_hooks(pre)
hooks = {"press it. The print then waits": lambda: self.append(wait + run[:1], delay=0.05)}
self.assertFails(cloud.verdict_hold, "the run started under the warm-up hold", hooks=hooks)
def press():
self.append(wait, delay=0.05)
self._attr("cnc/interlock_circuit", "37") # unlocked under the hold
threading.Timer(2.5, lambda: self.append(refuse, delay=0.0)).start()
hooks = {"press it. Nothing runs": press}
self.assertFails(cloud.verdict_refuse, "the latch unlocked during the hold", hooks=hooks)
# -- a paused print canceled by the lid, a running one by the app --------
def cancel_parts(self):
+35
View File
@@ -586,6 +586,41 @@ class RoutingTests(unittest.TestCase):
finally:
runner_mod.hw.button_lit = saved
def test_the_press_prompt_is_what_the_actuator_presses_on(self):
"""The machine's own press prompt, not a button LED read from
here: the button is lit through parts of a live check that are
not the arm, and a press then lands before the job waits for it
and is lost. press_now presses at the moment the check asks, with
no LED reading and no waiting thread."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
self.stub.arm_press = False
run.fixture_takeover = True
saved = runner_mod.hw.button_lit
runner_mod.hw.button_lit = lambda: False # never consulted
try:
self.assertTrue(ctx.press_now())
self.assertIn(("button", "press"), self.stub.acts)
finally:
runner_mod.hw.button_lit = saved
rec = [r for r in run.evidence["actions"] if r["state"] == "arm"][-1]
self.assertEqual(rec["by"], "fixture")
def test_the_press_prompt_goes_to_the_operator_without_an_actuator(self):
"""No actuator, or one that never took this test's presses: the
notice goes up for a person, who is looking at the same prompt."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
self.stub.arm_press = False
run.fixture_takeover = False
seen = []
run.set_notice = lambda text: seen.append(text)
self.assertFalse(ctx.press_now("Press it now."))
self.assertNotIn(("button", "press"), self.stub.acts)
self.assertIn("Press it now.", seen)
def test_the_arm_press_waits_as_long_as_the_card_says_for_the_light(self):
"""A card that settles the coolant before it lights the button
takes minutes; the caller names the wait, and the actuator presses
+26
View File
@@ -84,6 +84,32 @@ class OrderTests(unittest.TestCase):
self.assertEqual(sorted(out), ["s.a", "s.b"])
class RunClockTests(unittest.TestCase):
"""The page shows the last run until the next one starts, so a
finished run's elapsed figure must be the time it took, not the time
since. Found on the bench reference, where a failed test went on
counting past 5000 s and read as still running."""
def test_a_finished_runs_clock_stops(self):
from forgetest.runner import Run
run = Run("test", "t.x", "t.x")
run.started = time.time() - 30
self.assertGreaterEqual(run.snapshot()["elapsed_s"], 30)
run.finished = {"result": "FAIL", "message": "", "duration_s": 31}
first = run.snapshot()["elapsed_s"]
time.sleep(1.1)
self.assertEqual(first, 31)
self.assertEqual(run.snapshot()["elapsed_s"], 31)
def test_a_running_runs_clock_ticks(self):
from forgetest.runner import Run
run = Run("test", "t.x", "t.x")
run.started = time.time() - 5
first = run.snapshot()["elapsed_s"]
time.sleep(1.1)
self.assertGreater(run.snapshot()["elapsed_s"], first)
class QueueTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
@@ -1,8 +1,8 @@
"""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."""
start, the run polled to its end with no question asked, the result
taken, the record read at version 1, and the settings read as before."""
import os
import sys
import unittest
@@ -12,8 +12,8 @@ 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"]
IDS = ["setup.check-switches", "setup.check-sensors", "setup.check-airflow",
"setup.check-cameras", "setup.check-motion", "setup.check-flow-verify"]
class RegistrationTests(unittest.TestCase):
@@ -25,11 +25,11 @@ class RegistrationTests(unittest.TestCase):
# 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"):
for tid in ("setup.check-switches", "setup.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"):
for tid in ("setup.check-sensors", "setup.check-airflow", "setup.check-motion",
"setup.check-flow-verify"):
self.assertEqual(self.reg[tid].kind, "auto", tid)
def test_no_check_asks_the_operator_to_prepare_anything(self):
@@ -37,7 +37,7 @@ class RegistrationTests(unittest.TestCase):
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"):
for tid in ("setup.check-airflow", "setup.check-motion", "setup.check-flow-verify"):
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
def test_every_check_covers_the_runner_and_the_page(self):
@@ -62,9 +62,17 @@ class SensorsCheckTests(unittest.TestCase):
"shots": {"lid": False, "head": False}}
self.versions = {}
self.answers = []
self.polls = 0
def on_get(path, q):
if path == "/wiz/dark":
# One poll sees the sampling; the next sees the result.
if self.dark["running"]:
self.polls += 1
if self.polls >= 2:
self.dark.update({"running": False, "prompt": None, "progress": 100,
"result": dict(self.RESULT)})
self.versions["sensors"] = 1
return 200, dict(self.dark)
if path == "/wiz":
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
@@ -74,19 +82,11 @@ class SensorsCheckTests(unittest.TestCase):
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"]}})
"prompt": None})
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 == "":
@@ -101,15 +101,35 @@ class SensorsCheckTests(unittest.TestCase):
def tearDown(self):
self.fake.stop()
def test_start_prompt_answer_result_and_restore(self):
t = catalog.load_suite()["commission.check-sensors"]
def test_start_no_question_result_and_settings_untouched(self):
t = catalog.load_suite()["setup.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(self.answers, [])
self.assertEqual(run.evidence["result"]["laser_pgood"], 1)
self.assertEqual(run.evidence["settings_before"], {"cool_temp_offset_c": "0.5"})
self.assertEqual(run.evidence["settings_before"].get("cool_temp_offset_c"), "0.5")
self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5")
self.assertNotIn("/settings", [p for p, _ in self.fake.posts])
def test_a_question_fails_the_check(self):
t = catalog.load_suite()["setup.check-sensors"]
run = Run("test", t.id, t.title)
real_get = self.fake.on_get
def on_get(path, q):
if path == "/wiz/dark" and self.dark["running"]:
d = dict(self.dark)
d["prompt"] = {"seq": 3, "id": "room-temp", "kind": "number", "text": "?",
"options": ["Set", "Skip"]}
return 200, d
return real_get(path, q)
self.fake.on_get = on_get
with self.assertRaises(Exception) as cm:
t.fn(Context(run, None, t))
self.assertIn("asked a question", str(cm.exception))
self.assertEqual(self.answers, [])
self.assertIn(("/wiz/sensors/abort", {}), [(p, f) for p, f in self.fake.posts])
if __name__ == "__main__":
@@ -14,10 +14,47 @@ 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
from forgetest.suite import setup_sheet # noqa: E402
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
# The focus card's program is read again after it ran: the served ladder
# must then lie in the window the card wrote.
PREVIEWS = ["sheet.frame", "laser.focus", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner",
"cooling.flow-load"]
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
SPM = 2.922 # the lens screw, half-steps per mm ($102)
EDGE_DEFAULT = 3.35
def lens_block(s):
"""The /status lens block as the daemon builds it from the settings: a
stop count from 1 to 40 stands, anything else is the fallback."""
def count(key, fallback):
try:
n = float(s.get(key, ""))
except ValueError:
return fallback, False
return (int(n), True) if 1 <= n <= 40 else (fallback, False)
try:
edge = float(s.get("lens_hall_edge_z_mm", ""))
except ValueError:
edge = EDGE_DEFAULT
below, a = count("lens_stop_below_steps", 10)
above, b = count("lens_stop_above_steps", 12)
return {"edge_z": round(edge, 2), "below": below, "above": above, "stops_found": a and b,
"reach_min": round(edge - below / SPM, 2), "reach_max": round(edge + above / SPM, 2)}
def ladder_z(edge, below, above):
"""The focus ladder the daemon builds: twelve whole half-steps from
the top of the window to the bottom."""
return [round(edge + round(above - (above + below) * i / 11) / SPM, 2) for i in range(12)]
# The window the focus card finds and writes before its controller starts.
STOPS = {"lens_stop_below_steps": "14", "lens_stop_above_steps": "20"}
FOCUS_WINDOW = {"below": 14, "above": 20, "reach_min": round(EDGE_DEFAULT - 14 / SPM, 2),
"reach_max": round(EDGE_DEFAULT + 20 / SPM, 2)}
# Each wizard as the daemon runs it: its prompts in order (id, kind,
# options) and the result it ends with. A "jog" prompt repeats until the
@@ -37,7 +74,9 @@ SCRIPT = {
"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}),
"contact_above": 22, "why": ""},
"window": dict(FOCUS_WINDOW), "ladder_z": ladder_z(EDGE_DEFAULT, 14, 20),
"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}),
@@ -50,10 +89,11 @@ SCRIPT = {
{"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.
# What a card writes when it starts (the focus card's window, before its
# controller starts) and when it ends.
START_WRITES = {"laser.focus": dict(STOPS)}
WRITES = {
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14",
"lens_stop_above_steps": "20"},
"laser.focus": {"lens_hall_edge_z_mm": "6.96"},
"laser.floor": {"laser_floor_density": "10"},
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
"laser.corner": {"laser_corner_gamma": "1.5"},
@@ -67,7 +107,7 @@ FOUND = {"lens_hall_edge_z_mm": "3.35", "lens_stop_below_steps": "10", "lens_sto
class RegistrationTests(unittest.TestCase):
def setUp(self):
self.t = catalog.load_suite()["commission.sheet"]
self.t = catalog.load_suite()["setup.sheet"]
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
t = self.t
@@ -78,12 +118,12 @@ class RegistrationTests(unittest.TestCase):
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"):
for want in ("setup.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"])
ids = [i for i in catalog.load_suite() if i.startswith("setup.sheet")]
self.assertEqual(ids, ["setup.sheet"])
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
covers = set(self.t.covers)
@@ -97,7 +137,7 @@ class RegistrationTests(unittest.TestCase):
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)
self.assertEqual(len(setup_sheet.CARDS), 6)
class ScriptedDaemon:
@@ -115,9 +155,11 @@ class ScriptedDaemon:
self.versions = {}
self.answers = {} # wid -> [values]
self.order = [] # the wizards as started
self.settings_at_start = {} # wid -> the settings as the wizard found them
self.previews = []
self.queue = []
self.seq = 0
self.stray_z = None # a Z the served program commands beyond the reach, when set
fake.on_get = self.on_get
fake.on_post = self.on_post
@@ -130,6 +172,10 @@ class ScriptedDaemon:
return True
def on_get(self, path, q):
s = self.fake.state["settings"]
if path == "/status":
# the lens block follows the settings, as the daemon's does
return 200, dict(self.fake.state["status"], lens=lens_block(s))
if path == "/wiz/dark":
return 200, dict(self.dark)
if path == "/wiz":
@@ -140,8 +186,18 @@ class ScriptedDaemon:
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"
# the frame and the text are M4 at the mark dose, as the daemon
# serves them; the head's Z is the edge, the tail goes back to
# it, and the focus card's ladder spans the window the settings
# hold now
lens = lens_block(s)
lines = ["; mock", "G0 Z%.2f" % lens["edge_z"], "M4 S400", "G0 X60 Y42", "G1 X120 Y42 F3000"]
if q.get("card") == "laser.focus":
lines += ["G0 Z%.2f" % z for z in ladder_z(lens["edge_z"], lens["below"], lens["above"])]
if self.stray_z is not None:
lines.append("G0 Z%.2f" % self.stray_z)
lines += ["M5", "G0 Z%.2f" % lens["edge_z"], "G0 X0 Y0", "M2"]
return 200, ("\n".join(lines) + "\n").encode(), "text/plain"
return None
def on_post(self, path, form):
@@ -151,6 +207,8 @@ class ScriptedDaemon:
if wid not in SCRIPT:
return 404, {"error": "no such wizard"}
self.order.append(wid)
self.settings_at_start[wid] = dict(s)
s.update(START_WRITES.get(wid, {}))
self.queue = list(SCRIPT[wid][0])
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
self.open_next()
@@ -188,7 +246,7 @@ class SheetRunTests(unittest.TestCase):
self.fake.stop()
def test_the_whole_sheet_on_one_piece(self):
t = catalog.load_suite()["commission.sheet"]
t = catalog.load_suite()["setup.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True # the ready gate passes; the arm cue is a notice
t.fn(Context(run, None, t))
@@ -199,7 +257,7 @@ class SheetRunTests(unittest.TestCase):
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)
self.assertEqual(d.previews, PREVIEWS)
cards = run.evidence["cards"]
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
for wid in LIVE:
@@ -208,6 +266,24 @@ class SheetRunTests(unittest.TestCase):
self.assertNotIn("emission", run.evidence)
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
self.assertEqual(run.evidence["settings_before"], FOUND)
# A fresh machine's run: the lens settings were gone before the
# frame, the frame's program ran in the fallback window, the focus
# card's window was in the settings when the floor card started,
# and the window check filed what it compared.
for k in setup_sheet.LENS_SETTINGS:
self.assertNotIn(k, d.settings_at_start["sheet.frame"], k)
self.assertEqual(run.evidence["lens_fresh"]["stops_found"], False)
self.assertEqual(run.evidence["program_z"]["sheet.frame"]["reach"], [-0.07, 7.46])
floor_start = d.settings_at_start["laser.floor"]
self.assertEqual({k: floor_start[k] for k in setup_sheet.LENS_SETTINGS},
{"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14", "lens_stop_above_steps": "20"})
fw = run.evidence["focus_window"]
self.assertEqual(fw["window"], FOCUS_WINDOW)
self.assertEqual(fw["settings"]["lens_stop_below_steps"], "14")
self.assertEqual(len(fw["ladder_z"]), 12)
self.assertEqual(fw["program_z_before"]["reach"], [-0.07, 7.46]) # the fallback ladder
self.assertEqual(fw["program_z_after"]["reach"], [2.17, 13.8]) # the written window at the new edge
self.assertEqual(len(fw["program_z_after"]["z"]), 14) # head, twelve lines, tail
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
@@ -218,7 +294,7 @@ class SheetRunTests(unittest.TestCase):
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"]
t = catalog.load_suite()["setup.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
t.fn(Context(run, None, t))
@@ -234,7 +310,7 @@ class SheetRunTests(unittest.TestCase):
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"]
t = catalog.load_suite()["setup.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed) as cm:
@@ -250,7 +326,7 @@ class SheetRunTests(unittest.TestCase):
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"]
t = catalog.load_suite()["setup.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed):
@@ -261,6 +337,44 @@ class SheetRunTests(unittest.TestCase):
finally:
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
def test_a_focus_window_the_settings_do_not_hold_fails_the_run(self):
# The defect the bench found: the focus card burned its ladder over
# the stops it found while the controller's Z limit stood on the
# settings, which did not hold them. A daemon whose focus result
# names a window the settings do not hold fails the run before the
# floor card, and the settings still go back.
from forgetest.runner import Failed
saved = dict(START_WRITES["laser.focus"])
START_WRITES["laser.focus"] = {}
try:
t = catalog.load_suite()["setup.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed) as cm:
t.fn(Context(run, None, t))
self.assertIn("the settings hold", str(cm.exception))
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus"])
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k)
finally:
START_WRITES["laser.focus"] = saved
def test_a_program_z_beyond_the_reach_fails_before_the_burn(self):
# A served program that commands a Z the lens does not reach is
# refused before the card starts: nothing burns.
from forgetest.runner import Failed
self.daemon.stray_z = 10.6
t = catalog.load_suite()["setup.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed) as cm:
t.fn(Context(run, None, t))
self.assertIn("outside the lens window", str(cm.exception))
self.assertIn("10.6", str(cm.exception))
self.assertEqual(self.daemon.order, ["sheet.place"])
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k)
if __name__ == "__main__":
unittest.main()
@@ -1,28 +1,26 @@
"""The commission.* suite on the host: the registration (ids, kinds, the
"""The setup.* 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
machine's name, the cookie parsing, the LED cue, the settle rule for a
gated supervisor, and the cloud-off surface test driven end to end
against the fake daemon."""
import json
import os
import shutil
import 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
from forgetest.suite import setup
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")
IDS = ("setup.gate-blocks-controllers", "setup.override-until-reboot",
"setup.advisories-rehash", "setup.account-login", "setup.https-only-writes",
"setup.ssh-until-reboot", "setup.cloud-disabled-surface",
"setup.factory-return", "setup.machine-name", "setup.first-run-flow",
"setup.first-run-page", "setup.what-changed", "setup.record-export",
"setup.mirror")
OPERATOR = ("setup.first-run-flow", "setup.first-run-page")
class RegistrationTests(unittest.TestCase):
@@ -37,49 +35,49 @@ class RegistrationTests(unittest.TestCase):
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
self.assertNotIn("setup.root-ssh-refused", self.reg) # folded into ssh-until-reboot
self.assertNotIn("setup.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"):
for tid in ("setup.gate-blocks-controllers", "setup.advisories-rehash",
"setup.first-run-flow", "setup.first-run-page",
"setup.what-changed", "setup.mirror"):
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
self.assertEqual(self.reg["commission.record-export"].hardware, "api")
self.assertEqual(self.reg["setup.record-export"].hardware, "api")
def test_the_lifecycle_tests_cover_their_files(self):
changed = set(self.reg["commission.what-changed"].covers)
export = set(self.reg["commission.record-export"].covers)
mirror = set(self.reg["commission.mirror"].covers)
changed = set(self.reg["setup.what-changed"].covers)
export = set(self.reg["setup.record-export"].covers)
mirror = set(self.reg["setup.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)
self.assertIn("setup.check-sensors", self.reg["setup.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"))
self.assertEqual(setup.flag_of(w, "required", "laser.floor"), "the tube was replaced")
self.assertEqual(setup.flag_of(w, "recommended", "laser.focus"), "the tray was replaced")
self.assertIsNone(setup.flag_of(w, "required", "airflow")) # a plain id is the table, not a flag
self.assertIsNone(setup.flag_of(w, "required", "laser.focus"))
self.assertIsNone(setup.flag_of({}, "recommended", "x"))
def test_the_first_run_flow_runs_unattended_with_the_fixture_and_the_page_walk_does_not(self):
flow = self.reg["commission.first-run-flow"]
flow = self.reg["setup.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"]
page = self.reg["setup.first-run-page"]
self.assertTrue(page.hands)
self.assertFalse(page.fixture_runnable(("button", "lid", "interlock")))
self.assertIn("commission.first-run-flow", page.requires)
self.assertIn("setup.first-run-flow", page.requires)
def test_the_first_run_split_keeps_the_backend_and_the_page_apart(self):
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.*"),
flow = set(self.reg["setup.first-run-flow"].covers)
page = set(self.reg["setup.first-run-page"].covers)
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
self.assertIn(want, flow, want)
self.assertNotIn(want, page, want)
@@ -87,33 +85,33 @@ class RegistrationTests(unittest.TestCase):
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
# With the bench actuator up, three setup 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.")
attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("setup.")
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
self.assertEqual(attended, ["commission.cloud-header-capture", "commission.first-run-page",
"commission.sheet"])
self.assertEqual(attended, ["setup.cloud-header-capture", "setup.first-run-page",
"setup.sheet"])
def test_the_factory_return_never_runs_the_return(self):
import inspect
t = self.reg["commission.factory-return"]
t = self.reg["setup.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_machine_name_covers_nothing_by_design(self):
self.assertEqual(self.reg["setup.machine-name"].covers, ())
def test_the_login_test_makes_its_own_account(self):
# No bench credentials, no precheck: the test installs a temporary
# account under a takeover and restores the real one.
t = self.reg["commission.account-login"]
t = self.reg["setup.account-login"]
self.assertIsNone(getattr(t, "precheck", None))
self.assertEqual(t.hardware, "takeover")
with open(commission.__file__) as f:
with open(setup.__file__) as f:
src = f.read()
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
self.assertNotIn(k, src)
@@ -126,8 +124,8 @@ class RecordTests(unittest.TestCase):
"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)
def test_complete_record_counts_as_set_up(self):
rec = setup.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")
@@ -141,16 +139,16 @@ class RecordTests(unittest.TestCase):
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)
rec = setup.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 = setup.complete_record(self.STATUS)
rec["flags"] = {"machine": {"level": "required", "reason": "x"}}
out = commission.record_without_wizards(rec)
out = setup.record_without_wizards(rec)
self.assertEqual(out["wizards"], {})
self.assertEqual(out["flags"], {})
self.assertEqual(out["advisories"], rec["advisories"])
@@ -160,14 +158,14 @@ class RecordTests(unittest.TestCase):
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")
p = os.path.join(tmp, "sub", "setup.json")
setup.write_file(p, b"{}\n")
self.assertEqual(setup.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
setup.write_file(p, None)
self.assertIsNone(setup.read_file(p))
setup.write_file(p, None) # a second remove is silent
finally:
shutil.rmtree(tmp, ignore_errors=True)
@@ -175,72 +173,82 @@ class RecordTests(unittest.TestCase):
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")
self.assertEqual(setup.record_path(), "/tmp/ffdata/setup.json")
self.assertEqual(setup.users_path(), "/tmp/ffdata/users")
self.assertEqual(setup.override_path(), "/tmp/ffrun/setup-override")
self.assertEqual(setup.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))
class MachineNameTests(unittest.TestCase):
def setUp(self):
self.root = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
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 _net(self, **devs):
"""A /sys/class/net tree; setup.mac_suffix reads it through
setup.read_file, which takes an absolute path."""
for dev, mac in devs.items():
d = os.path.join(self.root, dev)
os.makedirs(d)
with open(os.path.join(d, "address"), "w") as f:
f.write(mac + "\n")
real = setup.read_file
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 read(path):
head = "/sys/class/net/"
if path.startswith(head):
return real(os.path.join(self.root, path[len(head):]))
return real(path)
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")])
setup.read_file = read
self.addCleanup(setattr, setup, "read_file", real)
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_the_wifi_address_names_the_machine(self):
self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
self.assertEqual(setup.mac_suffix(), "b00a")
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), [])
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
self._net(eth0="00:11:22:33:44:55")
self.assertEqual(setup.mac_suffix(), "4455")
def test_an_unread_address_is_no_address(self):
self._net(wlan0="00:00:00:00:00:00")
self.assertEqual(setup.mac_suffix(), "")
def test_no_interface_is_no_address(self):
self._net()
self.assertEqual(setup.mac_suffix(), "")
class SmallHelpersTests(unittest.TestCase):
def test_cookie_parsing(self):
sid = "ab" * 32
value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid
self.assertEqual(commission.session_from_cookie(value), sid)
self.assertEqual(commission.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
self.assertIsNone(commission.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
self.assertIsNone(commission.session_from_cookie(""))
self.assertEqual(setup.session_from_cookie(value), sid)
self.assertEqual(setup.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
self.assertIsNone(setup.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
self.assertIsNone(setup.session_from_cookie(""))
def test_the_teal_breathe_cue(self):
self.assertTrue(commission.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
self.assertTrue(setup.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
"pulse_off": [0, 1400, 1400]}))
self.assertFalse(commission.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0],
self.assertFalse(setup.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0],
"pulse_off": [0, 0, 0]})) # solid green
self.assertFalse(commission.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800],
self.assertFalse(setup.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800],
"pulse_off": [1800] * 3})) # white
self.assertFalse(commission.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
self.assertFalse(commission.breathes_teal({}))
self.assertFalse(setup.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
self.assertFalse(setup.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")
self.assertEqual(setup.decode(b'{"a": 1}'), {"a": 1})
self.assertEqual(setup.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:
"""A supervisor that reports the setup 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."""
@@ -253,7 +261,7 @@ class GatedSettleTests(unittest.TestCase):
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"}
"gated": True, "why": "setup required: machine"}
lines = []
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
self.assertEqual(body["controller"], "gated")
@@ -276,7 +284,7 @@ class FirstRunSeedTests(unittest.TestCase):
"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)
rec = setup.first_run_record(self.STATUS, base)
self.assertNotIn("completed", rec)
self.assertNotIn("acceptance", rec)
self.assertNotIn("account", rec)
@@ -288,7 +296,7 @@ class FirstRunSeedTests(unittest.TestCase):
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)
rec = setup.first_run_record(self.STATUS, None)
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
@@ -304,7 +312,7 @@ class SshdPolicyTests(unittest.TestCase):
real = hw.run
hw.run = fake_run
try:
self.assertEqual(commission.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
self.assertEqual(setup.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
"permitemptypasswords": "no"})
finally:
hw.run = real
@@ -315,7 +323,7 @@ class SshdPolicyTests(unittest.TestCase):
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"})
self.assertEqual(setup.sshd_policy(), {"error": "sshd: no hostkeys available"})
finally:
hw.run = real
@@ -367,7 +375,7 @@ class CloudDisabledSurfaceTests(unittest.TestCase):
self.fake.stop()
def run_test(self):
t = catalog.load_suite()["commission.cloud-disabled-surface"]
t = catalog.load_suite()["setup.cloud-disabled-surface"]
run = Run("test", t.id, t.title)
t.fn(Context(run, None, t))
return run
+18 -3
View File
@@ -94,13 +94,28 @@ class TreeManifestTests(unittest.TestCase):
self.assertNotEqual(self.mod.layer_content(self.layer), base)
def test_layer_hash_matches_the_bbclass_rule(self):
# The same skip list as FORGEFIRM_MANIFEST_PIN_SUFFIX + *.md in
# forgefirm-image-manifest.bbclass.
# The same skip list as *.md plus FORGEFIRM_MANIFEST_PIN_SUFFIX and
# FORGEFIRM_MANIFEST_VERSION_SUFFIX in
# forgefirm-image-manifest.bbclass. The two implementations compute
# the same identity - one on the build host, one on a workstation -
# so a suffix added to either without the other is the failure this
# guards.
bbclass = os.path.join(REPO, "meta-forgefirm", "classes", "forgefirm-image-manifest.bbclass")
with open(bbclass, encoding="utf-8") as f:
text = f.read()
self.assertIn('FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"', text)
self.assertEqual(self.mod.LAYER_SKIP_SUFFIXES, (".md", "-pin.inc"))
self.assertIn('FORGEFIRM_MANIFEST_VERSION_SUFFIX ?= "forgefirm-release.inc"', text)
self.assertEqual(self.mod.LAYER_SKIP_SUFFIXES,
(".md", "-pin.inc", "forgefirm-release.inc"))
def test_layer_hash_ignores_the_release_version(self):
# The version is metadata: setting the release number must not move
# the layer hash, or the bump would invalidate the campaign that
# authorizes the release.
self.write("forgefirm-release.inc",'FORGEFIRM_RELEASE ?= "0.0.1"\n')
base = self.mod.layer_content(self.layer)
self.write("forgefirm-release.inc",'FORGEFIRM_RELEASE ?= "0.0.2"\n')
self.assertEqual(self.mod.layer_content(self.layer), base)
if __name__ == "__main__":
+1 -1
View File
@@ -11,4 +11,4 @@ overrides:
meta-freescale-distro:
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
meta-openglow:
commit: b7ad6d9377fbb4215dc4d898837906b06b47e5e9
commit: dd8f74c2d58e998913e77e28450e8dca5e164a48
@@ -44,6 +44,13 @@
FORGEFIRM_MANIFEST_DIR ?= "${sysconfdir}/forgefirm-manifest.d"
FORGEFIRM_MANIFEST_CONTENT_LAYERS ?= "meta-forgefirm meta-glowforge-bsp meta-openglow-core"
FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"
# The release version file. Like a pin file it is metadata, not platform
# content: it carries FORGEFIRM_RELEASE and nothing else, the version
# string is already outside the identity hash (below), and the release
# gate proves the number against the rootfs stamp, the .fw meta-version
# and the tag. Hashing it would make every version bump a platform
# change and invalidate the campaign that authorizes the release.
FORGEFIRM_MANIFEST_VERSION_SUFFIX ?= "forgefirm-release.inc"
do_rootfs[depends] += "virtual/kernel:do_deploy kernel-module-glowforge:do_deploy"
@@ -88,7 +95,8 @@ def forgefirm_manifest_layers(d):
and dirty flag of every layer checkout (informational)."""
import os, subprocess
content_layers = (d.getVar('FORGEFIRM_MANIFEST_CONTENT_LAYERS') or '').split()
skip = ('.md',) + tuple((d.getVar('FORGEFIRM_MANIFEST_PIN_SUFFIX') or '').split())
skip = (('.md',) + tuple((d.getVar('FORGEFIRM_MANIFEST_PIN_SUFFIX') or '').split())
+ tuple((d.getVar('FORGEFIRM_MANIFEST_VERSION_SUFFIX') or '').split()))
identity, build = {}, {}
for layer in (d.getVar('BBLAYERS') or '').split():
name = os.path.basename(layer.rstrip('/'))
+13 -13
View File
@@ -8,19 +8,19 @@ DISTRO_FEATURES:remove = " \
3g alsa avahi bluetooth bluez5 ext2 irda nfc nfs pci pcmcia \
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"
# The name the image ships in /etc/hostname. forgefirm-hostname replaces
# it at S38 in rcS with forgefirm-<xxxx>, from the machine's MAC address;
# this is what the few seconds before that show, and the fallback on a
# machine whose MAC address cannot be read.
hostname:pn-base-files = "forgefirm"
# No mDNS. The panel is reached at the machine's address, which every
# network resolves; the machine's own name (forgefirm-<xxxx>, from
# forgefirm-hostname) goes out in the DHCP request, so a network with
# dynamic DNS publishes it as well. 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.
# TLS: forgectrl serves HTTPS on 443 with a self-signed certificate
# through libmicrohttpd and ulfius, and links GnuTLS itself. GnuTLS is
@@ -1,33 +0,0 @@
# 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
@@ -1,21 +0,0 @@
<?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>
@@ -1,17 +0,0 @@
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
}
@@ -2,5 +2,5 @@
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "468ee215dd1148b92c979decacecc41aa6a75ccf"
PV = "0.1.12"
SRCREV = "da3eddcc0f4324dc6ec8a0042ab7db0f1b16fd4f"
PV = "0.1.25"
@@ -4,9 +4,9 @@
# because the component entry already identifies the pinned source
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
# meta-glowforge-bsp pins the same repository; move both together.
SRCREV = "56f134a1e6393a2096c16e8226027fbbd38d79c7"
SRCREV = "5ca279a1f6008ddf6e19a17c6d6375972d27ed7c"
# Bump PV with every SRCREV move: the hash-derived package version is not
# monotonic on its own and buildhistory QA fails the build when it sorts
# backwards.
PV = "0.1.28+git"
PV = "0.1.29+git"
@@ -1,24 +1,23 @@
#!/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.
# Rewrites /etc/issue, the serial-console login banner: the lines the
# image build wrote, then one https:// URL per global address of wlan0
# (and eth0 when the machine has one). The address is the only way in
# that the banner gives, because it is the one that works on every
# network. Called by the init script at boot and by the udhcpc hook on
# every lease event. Idempotent: the file is written only when the text
# changes.
#
# The rootfs is read-only: at the first change after boot the file is
# bind-mounted from a copy under /run/forgefirm (tmpfs) and the block is
# written through the mount. Before that the image's own file shows.
# bind-mounted from a copy under /run/forgefirm (tmpfs) and the banner
# is written through the mount. Before that the image's own file shows.
# The image's own text is kept at that moment in a second copy, so the
# banner needs no marker line in the file and shows nothing but itself.
PATH=/sbin:/usr/sbin:/bin:/usr/bin
ISSUE=/etc/issue
STATE=/run/forgefirm/issue
MARK_BEGIN='# ForgeFIRM addresses'
MARK_END='# end'
PANEL='Control panel: https://forgefirm.local/'
BASE=/run/forgefirm/issue.base
# One URL per global address; an IPv6 address gets its URL brackets.
# Tentative, deprecated and temporary addresses are left out.
@@ -37,40 +36,35 @@ addresses () {
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"
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
# file by its device numbers alone).
is_mounted () {
awk -v t="$1" '$2 == t { f = 1 } END { exit !f }' /proc/mounts
}
[ -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
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
# file by its device numbers alone).
if ! awk -v t="$ISSUE" '$2 == t { f = 1 } END { exit !f }' /proc/mounts; then
mkdir -p "${STATE%/*}" \
&& cp -p "$ISSUE" "$STATE" \
# The image's own text: the file as it is before the first write of the
# boot. /run is a tmpfs, so the copy is made once per boot, and the
# first caller is the one that finds the file untouched.
mkdir -p "${STATE%/*}" || exit 1
[ -f "$BASE" ] || cp -p "$ISSUE" "$BASE" || exit 1
if ! is_mounted "$ISSUE"; then
cp -p "$BASE" "$STATE" \
&& mount --bind "$STATE" "$ISSUE" || exit 1
fi
base=$(cat "$BASE")
addrs=$(addresses)
if [ -n "$addrs" ]; then
new=$(printf '%s\n\nControl panel:\n%s\n' "$base" "$addrs")
else
new=$(printf '%s\n\nControl panel: no network address yet\n' "$base")
fi
[ "$new" = "$(cat "$ISSUE")" ] && exit 0
tmp="$STATE.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; }
printf '%s\n\n' "$new" > "$tmp" || { rm -f "$tmp"; exit 1; }
cat "$tmp" > "$ISSUE"
rm -f "$tmp"
exit 0
@@ -1,7 +1,7 @@
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."
(/etc/issue): one control panel URL per 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"
@@ -0,0 +1,68 @@
#!/bin/sh
# Sets the machine's hostname to forgefirm-<xxxx>, where xxxx is the
# last four hex digits of the wlan0 MAC address (eth0 on a machine with
# no WiFi). The name is the same at every boot, two machines on one
# network answer to different names, and the name carries no serial
# number. The DHCP client sends it as the hostname option
# (/etc/network/interfaces), so a network with dynamic DNS resolves it.
#
# The rootfs is read-only: /etc/hostname shows a copy under
# /run/forgefirm (tmpfs), bind-mounted, and the name is written through
# the mount. The init script calls this at boot, before the network
# starts and before anything reads the name.
PATH=/sbin:/usr/sbin:/bin:/usr/bin
FILE=/etc/hostname
STATE=/run/forgefirm/hostname
PREFIX=forgefirm
WAIT_S=10
# The MAC address of the first interface that has one. An interface
# before its driver reads the address reports all zeros.
mac () {
for dev in wlan0 eth0; do
a=$(cat "/sys/class/net/$dev/address" 2>/dev/null) || continue
case "$a" in
''|00:00:00:00:00:00) continue ;;
esac
printf '%s\n' "$a"
return 0
done
return 1
}
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
# file by its device numbers alone).
is_mounted () {
awk -v t="$1" '$2 == t { f = 1 } END { exit !f }' /proc/mounts
}
# An interface registers when its driver probes, which udev does earlier
# in rcS. The wait is for a slow probe and ends at once in the normal
# case.
n=0
while [ "$n" -lt "$WAIT_S" ] && ! mac >/dev/null; do
sleep 1
n=$((n + 1))
done
suffix=$(mac | tr -d ':' | tr 'A-Z' 'a-z' | cut -c9-12)
if [ -n "$suffix" ]; then
name="$PREFIX-$suffix"
else
name="$PREFIX"
fi
[ "$(hostname)" = "$name" ] || hostname "$name"
# The file follows the live name, so every reader agrees.
[ -f "$FILE" ] || exit 0
[ "$(cat "$FILE" 2>/dev/null)" = "$name" ] && exit 0
if ! is_mounted "$FILE"; then
mkdir -p "${STATE%/*}" \
&& cp -p "$FILE" "$STATE" \
&& mount --bind "$STATE" "$FILE" || exit 1
fi
printf '%s\n' "$name" > "$FILE" || exit 1
exit 0
@@ -0,0 +1,31 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: forgefirm-hostname
# Required-Start: $local_fs
# Required-Stop:
# Default-Start: S
# Default-Stop:
# Short-Description: ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address
### END INIT INFO
# Writes the hostname before the network starts. At boot this runs at
# S38 in rcS: /run is mounted (mountall, S03), udev has probed the
# network drivers (S04), and poky's own hostname.sh (S39) reads the file
# this leaves.
case "$1" in
start|restart|reload|force-reload)
/usr/sbin/forgefirm-hostname
;;
stop)
;;
status)
hostname
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
exit 0
@@ -0,0 +1,28 @@
SUMMARY = "ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address"
DESCRIPTION = "Names the machine forgefirm-<xxxx>, where xxxx is the last \
four hex digits of the wlan0 MAC address (eth0 on a machine with no WiFi). \
The name is the same at every boot, two machines on one network answer to \
different names, and the name carries no serial number. The DHCP client \
sends it as the hostname option, so a network with dynamic DNS resolves it."
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
SRC_URI = " \
file://forgefirm-hostname \
file://forgefirm-hostname.init \
"
S = "${WORKDIR}"
inherit update-rc.d
INITSCRIPT_NAME = "forgefirm-hostname"
# 38 in rcS: after udev (S04) has probed the network drivers, before
# poky's hostname.sh (S39) reads /etc/hostname and before the network
# starts (rc5 S01).
INITSCRIPT_PARAMS = "start 38 S ."
do_install() {
install -Dm 0755 ${WORKDIR}/forgefirm-hostname ${D}${sbindir}/forgefirm-hostname
install -Dm 0755 ${WORKDIR}/forgefirm-hostname.init ${D}${sysconfdir}/init.d/forgefirm-hostname
}
@@ -2,5 +2,5 @@
# changes; keep only SRCREV and PV here - the image manifest leaves
# *-pin.inc out of the layer content hash because the component entry
# already identifies the pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "9ee624bbc1d1fd929f1f5587b6fc6acaa13a4561"
PV = "0.1.10"
SRCREV = "ecebe9c8eb8dc220e928f8039a0f922327e42c6f"
PV = "0.1.17"
@@ -48,15 +48,12 @@ IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome gfcloud v4l-utils fw
# forgefirm-users: renders 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).
# interactive root shell prints. forgefirm-hostname: names the machine
# forgefirm-<xxxx> from its MAC address, before the network starts.
# forgefirm-banner: keeps the control panel addresses in the
# serial-console banner (/etc/issue).
# forgefirm-persist: the boot timestamp and the random seed on /data.
# 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 forgefirm-persist avahi-daemon"
IMAGE_INSTALL:append = " forgefirm-users forgefirm-hostname forgefirm-banner forgefirm-persist"
# The rootfs mounts read-only on both images; /data (p3) is the writable
# partition. read-only-rootfs is poky's feature for it: the root line of
@@ -67,8 +64,9 @@ IMAGE_INSTALL:append = " forgefirm-users forgefirm-banner forgefirm-persist avah
# package whose post-install must run on the machine, and the removal of
# the packages a read-only rootfs cannot use (shadow, base-passwd,
# update-rc.d, update-alternatives; the account files stay). What must
# last or change at run time is handled file by file: the account files
# and /etc/issue (forgefirm-users, forgefirm-banner), the sshd host keys
# last or change at run time is handled file by file: the account files,
# /etc/hostname and /etc/issue (forgefirm-users, forgefirm-hostname,
# forgefirm-banner), the sshd host keys
# (recipes-connectivity/openssh), the timestamp and the random seed
# (forgefirm-persist). The facts are on the docs site,
# technical/forgefirm/image-and-bsp; scripts/release.sh checks the built
@@ -108,19 +106,81 @@ IMAGE_OVERHEAD_FACTOR = "1.0"
IMAGE_ROOTFS_EXTRA_SPACE = "40960"
IMAGE_ROOTFS_MAXSIZE = "204800"
# Version stamp: /etc/forgefirm-version (machine-readable), echoed on the
# serial-console login prompt (/etc/issue) and at SSH login (motd).
# The ForgeFIRM mark and the version stamp:
# /etc/forgefirm-version (machine-readable), and, under the OpenGlow mark
# the base image carries (base-files, meta-openglow), the ForgeFIRM mark
# with the version on its last line, right-justified to the mark's last
# column, in the two files a person reads - the serial-console login
# prompt (/etc/issue) and the motd, which every login prints, the network
# ones included. The mark names the firmware, so the version stands
# alone.
#
# The mark is written once here and rendered for each reader, because the
# two files are read by different programs:
# /etc/issue busybox getty parses it, and both a backslash and a
# percent sign start an escape (libbb/login.c,
# print_login_issue). An unrecognized escape prints the
# character and swallows the backslash, so the art goes in
# with every backslash doubled.
# /etc/motd a login writes it out as it is: nothing is doubled.
# Both keep their color: a getty passes an escape character through, and
# so does a login writing the motd. Only the ssh client escapes one, and
# it does that to a banner alone.
# The pre-authentication banner (/etc/issue.net) stays unused: the ssh
# client prints a control character in a banner as an octal escape, and
# the machine tells a client that has not logged in nothing anyway.
#
# Release images carry the release version; the dev image overrides the
# string with the build timestamp (the same DATETIME as the artifact
# name) plus a dev tag.
FORGEFIRM_RELEASE ?= "0.0.1"
#
# FORGEFIRM_RELEASE lives in its own file, which the manifest leaves out
# of the layer content hash: the version is metadata, and a bump must not
# read as a platform change and invalidate a campaign
# (forgefirm-release.inc).
require forgefirm-release.inc
FORGEFIRM_VERSION_STRING ?= "v${FORGEFIRM_RELEASE}"
write_forgefirm_version() {
echo "${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/forgefirm-version
echo "ForgeFIRM ${FORGEFIRM_VERSION_STRING}" >> ${IMAGE_ROOTFS}${sysconfdir}/issue
mark=${WORKDIR}/forgefirm-mark
cat > $mark <<'MARK'
___ ___ ___ ___ __ __
| __|__ _ _ __ _ ___| __|_ _| _ \ \/ |
| _/ _ \ '_/ _` / -_) _| | || / |\/| |
|_|\___/_| \__, \___|_| |___|_|_\_| |_|
|___/
MARK
# The version rides the mark's own last line, its last character on
# the mark's last column: under the FIRM half, in the room the
# descender of the Forge half leaves. The mark carries the name, so
# the version stands alone. Measured in columns, not in bytes: the
# color sequences take no room on the screen, and the doubling below
# is undone by the getty that reads it. A version with no room left
# keeps one space and runs past the mark rather than being cut.
stamped=${WORKDIR}/forgefirm-mark-stamped
awk -v v="${FORGEFIRM_VERSION_STRING}" '
{ line[NR] = $0
bare = $0
gsub(/\033\[[0-9;]*m/, "", bare)
col[NR] = length(bare)
if (col[NR] > w) w = col[NR] }
END { for (i = 1; i < NR; i++) print line[i]
pad = w - col[NR] - length(v)
if (pad < 1) pad = 1
gap = ""
while (length(gap) < pad) gap = gap " "
print line[NR] gap v }' $mark > $stamped
sed 's|\\|\\\\|g' $stamped >> ${IMAGE_ROOTFS}${sysconfdir}/issue
echo "" >> ${IMAGE_ROOTFS}${sysconfdir}/issue
echo "ForgeFIRM ${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/motd
cat $stamped >> ${IMAGE_ROOTFS}${sysconfdir}/motd
echo "" >> ${IMAGE_ROOTFS}${sysconfdir}/motd
rm -f $mark $stamped
}
write_forgefirm_version[vardepsexclude] += "DATETIME"
# No semicolon after a function name here or below (the vardeps rule in
@@ -0,0 +1,18 @@
# The release version, and nothing else.
#
# This file is metadata, not platform content: the image manifest leaves
# it out of the layer content hash the way it leaves out the component
# pin files (forgefirm-image-manifest.bbclass,
# FORGEFIRM_MANIFEST_VERSION_SUFFIX). A version bump therefore changes
# the version and invalidates nothing, so the number can be set at the
# moment a release is cut rather than before the campaign that authorizes
# it. Setting it inside forgefirm-image.bb instead would hash as layer
# content and invalidate every acceptance result, which is what it did
# until v0.0.1.
#
# Nothing else belongs here. The version is still proven end to end:
# scripts/release.sh reads FORGEFIRM_RELEASE from this file and requires
# it to equal the rootfs stamp, the .fw meta-version and the release tag,
# and the acceptance test image.health compares the stamp on the running
# machine with the manifest's.
FORGEFIRM_RELEASE ?= "0.0.5"
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
# ForgeFIRM acceptance - 20260910181308 (dev)
- Image: `20260910181308 (dev)` (forgefirm-image-dev)
- Manifest identity: `168e0307cef8f58f6bb871102f98ee56f9661b852e738d054891c5f072fd33b9`
- Catalog: `da9da478f3df1e5ad87319fd9d831ee224a3c88e5d3896162a09b4de14aad6b1`
- Campaign: `c-20260910181641-11a2` opened 2026-09-10T18:16:41Z
- Exported: 2026-09-10T18:32:07Z
- **Release authorized: YES**
- Tests: 83 total, 83 satisfied (57 inherited), 0 required
| Test | Kind | Result | Run at | Campaign | Inherited |
|---|---|---|---|---|---|
| `image.health` | auto, core | PASS | 2026-09-10T18:23:17Z | `c-20260910181641-11a2` | no |
| `image.license-bundle` | auto | PASS | 2026-09-10T13:11:39Z | `c-20260910131134-5cb0` | yes |
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-10T18:23:17Z | `c-20260910181641-11a2` | no |
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-10T18:23:39Z | `c-20260910181641-11a2` | no |
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-10T18:23:50Z | `c-20260910181641-11a2` | no |
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-10T18:24:08Z | `c-20260910181641-11a2` | no |
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-10T18:24:51Z | `c-20260910181641-11a2` | no |
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-10T13:13:35Z | `c-20260910131134-5cb0` | yes |
| `kernel.pic-soc-load` | auto | PASS | 2026-09-10T13:13:39Z | `c-20260910131134-5cb0` | yes |
| `forgectrl.auth` | auto | PASS | 2026-09-10T13:13:40Z | `c-20260910131134-5cb0` | yes |
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-10T13:13:41Z | `c-20260910131134-5cb0` | yes |
| `forgectrl.panel-serves` | auto | PASS | 2026-09-10T13:11:36Z | `c-20260910131134-5cb0` | yes |
| `commission.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-10T13:14:00Z | `c-20260910131134-5cb0` | yes |
| `commission.override-until-reboot` | auto | PASS | 2026-09-10T13:14:00Z | `c-20260910131134-5cb0` | yes |
| `commission.advisories-rehash` | auto, takeover | PASS | 2026-09-10T13:14:09Z | `c-20260910131134-5cb0` | yes |
| `commission.account-login` | auto, takeover | PASS | 2026-09-10T13:14:59Z | `c-20260910131134-5cb0` | yes |
| `commission.https-only-writes` | auto | PASS | 2026-09-10T13:15:00Z | `c-20260910131134-5cb0` | yes |
| `commission.ssh-until-reboot` | auto | PASS | 2026-09-10T13:15:00Z | `c-20260910131134-5cb0` | yes |
| `commission.cloud-disabled-surface` | auto | PASS | 2026-09-10T13:15:01Z | `c-20260910131134-5cb0` | yes |
| `commission.factory-return` | auto | PASS | 2026-09-10T13:15:04Z | `c-20260910131134-5cb0` | yes |
| `commission.machine-name` | auto | PASS | 2026-09-10T13:15:04Z | `c-20260910131134-5cb0` | yes |
| `commission.first-run-flow` | operator, takeover | PASS | 2026-09-10T13:15:26Z | `c-20260910131134-5cb0` | yes |
| `commission.first-run-page` | operator, takeover | PASS | 2026-09-10T17:25:08Z | `c-20260910171338-9149` | yes |
| `commission.cert-page` | auto | PASS | 2026-09-10T13:15:26Z | `c-20260910131134-5cb0` | yes |
| `commission.what-changed` | auto, takeover | PASS | 2026-09-10T13:15:36Z | `c-20260910131134-5cb0` | yes |
| `commission.record-export` | auto | PASS | 2026-09-10T13:16:52Z | `c-20260910131134-5cb0` | yes |
| `commission.mirror` | auto, takeover | PASS | 2026-09-10T13:17:25Z | `c-20260910131134-5cb0` | yes |
| `commission.check-switches` | operator | PASS | 2026-09-10T16:06:37Z | `c-20260910160456-619b` | yes |
| `commission.check-sensors` | auto | PASS | 2026-09-10T16:06:50Z | `c-20260910160456-619b` | yes |
| `commission.check-airflow` | auto, takeover | PASS | 2026-09-10T16:08:24Z | `c-20260910160456-619b` | yes |
| `commission.check-cameras` | operator | PASS | 2026-09-10T16:08:39Z | `c-20260910160456-619b` | yes |
| `commission.check-motion` | auto, takeover | PASS | 2026-09-10T16:09:05Z | `c-20260910160456-619b` | yes |
| `commission.check-flow-verify` | auto, takeover | PASS | 2026-09-10T16:16:37Z | `c-20260910160456-619b` | yes |
| `commission.cloud-header-capture` | operator, takeover | PASS | 2026-09-10T17:26:21Z | `c-20260910171338-9149` | yes |
| `commission.sheet` | live, takeover | PASS | 2026-09-10T17:39:28Z | `c-20260910171338-9149` | yes |
| `logs.tree-tail-export` | auto | PASS | 2026-09-10T13:16:14Z | `c-20260910131134-5cb0` | yes |
| `logs.routing` | auto | PASS | 2026-09-10T13:27:51Z | `c-20260910131134-5cb0` | yes |
| `logs.level-settings` | auto | PASS | 2026-09-10T13:27:52Z | `c-20260910131134-5cb0` | yes |
| `motion.pacing` | auto | PASS | 2026-09-10T18:25:07Z | `c-20260910181641-11a2` | no |
| `motion.jog-roundtrip` | auto | PASS | 2026-09-10T18:25:21Z | `c-20260910181641-11a2` | no |
| `motion.microstep-modes` | auto | PASS | 2026-09-10T18:25:46Z | `c-20260910181641-11a2` | no |
| `motion.liveness-probe` | auto | PASS | 2026-09-10T13:28:39Z | `c-20260910131134-5cb0` | yes |
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-10T13:28:56Z | `c-20260910131134-5cb0` | yes |
| `motion.cancel-abort` | auto | PASS | 2026-09-10T18:25:53Z | `c-20260910181641-11a2` | no |
| `motion.deadman` | auto | PASS | 2026-09-10T18:26:22Z | `c-20260910181641-11a2` | no |
| `motion.button-hold-resume` | operator | PASS | 2026-09-10T18:26:33Z | `c-20260910181641-11a2` | no |
| `motion.lid-cancel-home` | operator | PASS | 2026-09-10T18:26:49Z | `c-20260910181641-11a2` | no |
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-10T18:26:58Z | `c-20260910181641-11a2` | no |
| `motion.lid-policy-hold` | operator | PASS | 2026-09-10T18:27:12Z | `c-20260910181641-11a2` | no |
| `motion.step-timing-under-load` | auto | PASS | 2026-09-10T18:27:42Z | `c-20260910181641-11a2` | no |
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-10T16:10:38Z | `c-20260910160456-619b` | yes |
| `cooling.flow-verify` | auto | PASS | 2026-09-10T16:13:21Z | `c-20260910160456-619b` | yes |
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-10T16:17:38Z | `c-20260910160456-619b` | yes |
| `cooling.gate-off` | auto | PASS | 2026-09-10T16:06:57Z | `c-20260910160456-619b` | yes |
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-10T16:19:05Z | `c-20260910160456-619b` | yes |
| `cooling.tec-drive` | auto | PASS | 2026-09-10T16:19:13Z | `c-20260910160456-619b` | yes |
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-10T16:19:29Z | `c-20260910160456-619b` | yes |
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-10T16:19:36Z | `c-20260910160456-619b` | yes |
| `cooling.critical-tier` | auto | PASS | 2026-09-10T16:19:44Z | `c-20260910160456-619b` | yes |
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-10T16:07:43Z | `c-20260910160456-619b` | yes |
| `cooling.flow-under-load` | live | PASS | 2026-09-10T17:41:03Z | `c-20260910171338-9149` | yes |
| `laser.power-floor` | auto | PASS | 2026-09-10T18:27:43Z | `c-20260910181641-11a2` | no |
| `laser.emission-witness` | live, core | PASS | 2026-09-10T18:17:19Z | `c-20260910181641-11a2` | no |
| `laser.m5-rapid-dark` | live | PASS | 2026-09-10T18:18:01Z | `c-20260910181641-11a2` | no |
| `laser.disarm-in-hold` | live | PASS | 2026-09-10T18:19:29Z | `c-20260910181641-11a2` | no |
| `laser.armed-kill` | live | PASS | 2026-09-10T18:20:12Z | `c-20260910181641-11a2` | no |
| `laser.arm-wait-lid` | operator | PASS | 2026-09-10T18:28:02Z | `c-20260910181641-11a2` | no |
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-10T18:20:41Z | `c-20260910181641-11a2` | no |
| `camera.snapshot` | auto | PASS | 2026-09-10T13:19:21Z | `c-20260910131134-5cb0` | yes |
| `camera.sensor-profile` | auto | PASS | 2026-09-10T14:55:23Z | `c-20260910144945-3072` | yes |
| `camera.h264-stream` | auto | PASS | 2026-09-10T14:55:37Z | `c-20260910144945-3072` | yes |
| `camera.frame-health` | auto | PASS | 2026-09-10T14:55:50Z | `c-20260910144945-3072` | yes |
| `camera.lid-privacy` | operator | PASS | 2026-09-10T14:56:05Z | `c-20260910144945-3072` | yes |
| `camera.key-read` | auto | PASS | 2026-09-10T14:56:06Z | `c-20260910144945-3072` | yes |
| `update.slots-and-signature` | auto | PASS | 2026-09-10T13:15:03Z | `c-20260910131134-5cb0` | yes |
| `cloud.mode-switch` | operator | PASS | 2026-09-10T18:29:38Z | `c-20260910181641-11a2` | no |
| `cloud.service-protocol` | operator | PASS | 2026-09-10T18:21:52Z | `c-20260910181641-11a2` | no |
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-10T17:19:59Z | `c-20260910171338-9149` | yes |
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-10T14:58:32Z | `c-20260910144945-3072` | yes |
| `cloud.verdict-hold` | operator | PASS | 2026-09-10T17:18:09Z | `c-20260910171338-9149` | yes |
| `cloud.pause-resume` | live | PASS | 2026-09-10T18:23:07Z | `c-20260910181641-11a2` | no |
| `cloud.oversize-stream` | live | PASS | 2026-09-10T17:22:39Z | `c-20260910171338-9149` | yes |
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-10T17:18:57Z | `c-20260910171338-9149` | yes |
Artifact sha256: `191f416666d40efd92c77973fa392f38ca3d5ca160533ff83b4475d47b5fa6e3`
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
# ForgeFIRM acceptance - 20260911172215 (dev)
- Image: `20260911172215 (dev)` (forgefirm-image-dev)
- Manifest identity: `fb24c3f4508ae54592f8d0b98ed0b2a07a933274980d8c1e4e5a967618ef9bd8`
- Catalog: `ceabc830345fc4cac4832de691c0571e93a5c4e155666ce91601c844c40942fc`
- Campaign: `c-20260911182256-078f` opened 2026-09-11T18:22:56Z
- Exported: 2026-09-11T18:54:20Z
- **Release authorized: YES**
- Tests: 83 total, 83 satisfied (57 inherited), 0 required
| Test | Kind | Result | Run at | Campaign | Inherited |
|---|---|---|---|---|---|
| `image.health` | auto, core | PASS | 2026-09-11T18:22:57Z | `c-20260911182256-078f` | no |
| `image.license-bundle` | auto | PASS | 2026-09-11T17:29:26Z | `c-20260911172921-9426` | yes |
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-11T18:22:58Z | `c-20260911182256-078f` | no |
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-11T18:23:19Z | `c-20260911182256-078f` | no |
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-11T18:23:29Z | `c-20260911182256-078f` | no |
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-11T18:23:48Z | `c-20260911182256-078f` | no |
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-11T18:24:32Z | `c-20260911182256-078f` | no |
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-11T17:31:23Z | `c-20260911172921-9426` | yes |
| `kernel.pic-soc-load` | auto | PASS | 2026-09-11T17:31:26Z | `c-20260911172921-9426` | yes |
| `forgectrl.auth` | auto | PASS | 2026-09-11T17:31:27Z | `c-20260911172921-9426` | yes |
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-11T17:31:27Z | `c-20260911172921-9426` | yes |
| `forgectrl.panel-serves` | auto | PASS | 2026-09-11T17:29:23Z | `c-20260911172921-9426` | yes |
| `commission.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-11T17:31:46Z | `c-20260911172921-9426` | yes |
| `commission.override-until-reboot` | auto | PASS | 2026-09-11T17:31:46Z | `c-20260911172921-9426` | yes |
| `commission.advisories-rehash` | auto, takeover | PASS | 2026-09-11T17:31:55Z | `c-20260911172921-9426` | yes |
| `commission.account-login` | auto, takeover | PASS | 2026-09-11T17:32:44Z | `c-20260911172921-9426` | yes |
| `commission.https-only-writes` | auto | PASS | 2026-09-11T17:32:45Z | `c-20260911172921-9426` | yes |
| `commission.ssh-until-reboot` | auto | PASS | 2026-09-11T17:32:46Z | `c-20260911172921-9426` | yes |
| `commission.cloud-disabled-surface` | auto | PASS | 2026-09-11T17:32:47Z | `c-20260911172921-9426` | yes |
| `commission.factory-return` | auto | PASS | 2026-09-11T17:32:49Z | `c-20260911172921-9426` | yes |
| `commission.machine-name` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
| `commission.first-run-flow` | operator, takeover | PASS | 2026-09-11T17:54:32Z | `c-20260911172921-9426` | yes |
| `commission.first-run-page` | operator, takeover | PASS | 2026-09-11T17:55:49Z | `c-20260911172921-9426` | yes |
| `commission.cert-page` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
| `commission.what-changed` | auto, takeover | PASS | 2026-09-11T17:32:59Z | `c-20260911172921-9426` | yes |
| `commission.record-export` | auto | PASS | 2026-09-11T17:34:22Z | `c-20260911172921-9426` | yes |
| `commission.mirror` | auto, takeover | PASS | 2026-09-11T17:34:54Z | `c-20260911172921-9426` | yes |
| `commission.check-switches` | operator | PASS | 2026-09-11T17:56:07Z | `c-20260911172921-9426` | yes |
| `commission.check-sensors` | auto | PASS | 2026-09-11T17:34:34Z | `c-20260911172921-9426` | yes |
| `commission.check-airflow` | auto, takeover | PASS | 2026-09-11T17:36:26Z | `c-20260911172921-9426` | yes |
| `commission.check-cameras` | operator | PASS | 2026-09-11T17:56:22Z | `c-20260911172921-9426` | yes |
| `commission.check-motion` | auto, takeover | PASS | 2026-09-11T17:37:06Z | `c-20260911172921-9426` | yes |
| `commission.check-flow-verify` | auto, takeover | PASS | 2026-09-11T17:44:42Z | `c-20260911172921-9426` | yes |
| `commission.cloud-header-capture` | operator, takeover | PASS | 2026-09-11T17:59:41Z | `c-20260911172921-9426` | yes |
| `commission.sheet` | live, takeover | PASS | 2026-09-11T18:17:29Z | `c-20260911172921-9426` | yes |
| `logs.tree-tail-export` | auto | PASS | 2026-09-11T17:33:40Z | `c-20260911172921-9426` | yes |
| `logs.routing` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
| `logs.level-settings` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
| `motion.pacing` | auto | PASS | 2026-09-11T17:36:41Z | `c-20260911172921-9426` | yes |
| `motion.jog-roundtrip` | auto | PASS | 2026-09-11T17:45:00Z | `c-20260911172921-9426` | yes |
| `motion.microstep-modes` | auto | PASS | 2026-09-11T17:45:25Z | `c-20260911172921-9426` | yes |
| `motion.liveness-probe` | auto | PASS | 2026-09-11T17:45:33Z | `c-20260911172921-9426` | yes |
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-11T18:24:48Z | `c-20260911182256-078f` | no |
| `motion.cancel-abort` | auto | PASS | 2026-09-11T17:45:40Z | `c-20260911172921-9426` | yes |
| `motion.deadman` | auto | PASS | 2026-09-11T17:46:09Z | `c-20260911172921-9426` | yes |
| `motion.button-hold-resume` | operator | PASS | 2026-09-11T18:24:59Z | `c-20260911182256-078f` | no |
| `motion.lid-cancel-home` | operator | PASS | 2026-09-11T18:25:15Z | `c-20260911182256-078f` | no |
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-11T18:25:24Z | `c-20260911182256-078f` | no |
| `motion.lid-policy-hold` | operator | PASS | 2026-09-11T18:25:37Z | `c-20260911182256-078f` | no |
| `motion.step-timing-under-load` | auto | PASS | 2026-09-11T17:46:40Z | `c-20260911172921-9426` | yes |
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-11T17:38:38Z | `c-20260911172921-9426` | yes |
| `cooling.flow-verify` | auto | PASS | 2026-09-11T17:41:29Z | `c-20260911172921-9426` | yes |
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-11T17:47:35Z | `c-20260911172921-9426` | yes |
| `cooling.gate-off` | auto | PASS | 2026-09-11T17:35:02Z | `c-20260911172921-9426` | yes |
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-11T17:49:02Z | `c-20260911172921-9426` | yes |
| `cooling.tec-drive` | auto | PASS | 2026-09-11T17:49:10Z | `c-20260911172921-9426` | yes |
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-11T17:49:25Z | `c-20260911172921-9426` | yes |
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-11T17:49:32Z | `c-20260911172921-9426` | yes |
| `cooling.critical-tier` | auto | PASS | 2026-09-11T17:49:41Z | `c-20260911172921-9426` | yes |
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-11T17:35:45Z | `c-20260911172921-9426` | yes |
| `cooling.flow-under-load` | live | PASS | 2026-09-11T18:41:47Z | `c-20260911182256-078f` | no |
| `laser.power-floor` | auto | PASS | 2026-09-11T17:49:42Z | `c-20260911172921-9426` | yes |
| `laser.emission-witness` | live, core | PASS | 2026-09-11T18:40:15Z | `c-20260911182256-078f` | no |
| `laser.m5-rapid-dark` | live | PASS | 2026-09-11T18:42:25Z | `c-20260911182256-078f` | no |
| `laser.disarm-in-hold` | live | PASS | 2026-09-11T18:43:51Z | `c-20260911182256-078f` | no |
| `laser.armed-kill` | live | PASS | 2026-09-11T18:44:43Z | `c-20260911182256-078f` | no |
| `laser.arm-wait-lid` | operator | PASS | 2026-09-11T18:25:57Z | `c-20260911182256-078f` | no |
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-11T18:45:12Z | `c-20260911182256-078f` | no |
| `camera.snapshot` | auto | PASS | 2026-09-11T17:49:57Z | `c-20260911172921-9426` | yes |
| `camera.sensor-profile` | auto | PASS | 2026-09-11T17:50:10Z | `c-20260911172921-9426` | yes |
| `camera.h264-stream` | auto | PASS | 2026-09-11T17:50:23Z | `c-20260911172921-9426` | yes |
| `camera.frame-health` | auto | PASS | 2026-09-11T17:50:36Z | `c-20260911172921-9426` | yes |
| `camera.lid-privacy` | operator | PASS | 2026-09-11T18:26:12Z | `c-20260911182256-078f` | no |
| `camera.key-read` | auto | PASS | 2026-09-11T17:50:37Z | `c-20260911172921-9426` | yes |
| `update.slots-and-signature` | auto | PASS | 2026-09-11T17:32:49Z | `c-20260911172921-9426` | yes |
| `cloud.mode-switch` | operator | PASS | 2026-09-11T17:58:28Z | `c-20260911172921-9426` | yes |
| `cloud.service-protocol` | operator | PASS | 2026-09-11T18:46:13Z | `c-20260911182256-078f` | no |
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-11T18:47:21Z | `c-20260911182256-078f` | no |
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-11T18:26:51Z | `c-20260911182256-078f` | no |
| `cloud.verdict-hold` | operator | PASS | 2026-09-11T18:38:58Z | `c-20260911182256-078f` | no |
| `cloud.pause-resume` | live | PASS | 2026-09-11T18:49:33Z | `c-20260911182256-078f` | no |
| `cloud.oversize-stream` | live | PASS | 2026-09-11T18:52:19Z | `c-20260911182256-078f` | no |
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-11T18:52:48Z | `c-20260911182256-078f` | no |
Artifact sha256: `d7bb5d900f9237711a17ae207df90025605340c2eb2eaf34f9ed508cc3a899a6`
File diff suppressed because it is too large Load Diff
+99
View File
@@ -0,0 +1,99 @@
# ForgeFIRM acceptance - 20260912180956 (dev)
- Image: `20260912180956 (dev)` (forgefirm-image-dev)
- Manifest identity: `8bb5b6f39ca4e0a5afca9b620aa38980c4ede8cdfda240b24bbc01fd6c28fa05`
- Catalog: `f906de649b7533e3713309f141afacbc06420f96824936a60b70febd71bb2df7`
- Campaign: `c-20260912182427-e1ee` opened 2026-09-12T18:24:27Z
- Exported: 2026-09-12T19:21:35Z
- **Release authorized: YES**
- Tests: 85 total, 85 satisfied (50 inherited), 0 required
| Test | Kind | Result | Run at | Campaign | Inherited |
|---|---|---|---|---|---|
| `image.health` | auto, core | PASS | 2026-09-12T18:24:28Z | `c-20260912182427-e1ee` | no |
| `image.license-bundle` | auto | PASS | 2026-09-12T16:56:02Z | `c-20260912165539-ac51` | yes |
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-12T18:24:29Z | `c-20260912182427-e1ee` | no |
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-12T18:24:50Z | `c-20260912182427-e1ee` | no |
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-12T18:25:02Z | `c-20260912182427-e1ee` | no |
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-12T18:25:21Z | `c-20260912182427-e1ee` | no |
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-12T18:26:04Z | `c-20260912182427-e1ee` | no |
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-11T17:31:23Z | `c-20260911172921-9426` | yes |
| `kernel.pic-soc-load` | auto | PASS | 2026-09-11T17:31:26Z | `c-20260911172921-9426` | yes |
| `forgectrl.auth` | auto | PASS | 2026-09-12T16:58:12Z | `c-20260912165539-ac51` | yes |
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-12T16:58:19Z | `c-20260912165539-ac51` | yes |
| `forgectrl.panel-serves` | auto | PASS | 2026-09-12T16:55:54Z | `c-20260912165539-ac51` | yes |
| `commission.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-12T16:58:44Z | `c-20260912165539-ac51` | yes |
| `commission.override-until-reboot` | auto | PASS | 2026-09-12T16:58:44Z | `c-20260912165539-ac51` | yes |
| `commission.advisories-rehash` | auto, takeover | PASS | 2026-09-12T16:58:53Z | `c-20260912165539-ac51` | yes |
| `commission.account-login` | auto, takeover | PASS | 2026-09-12T16:59:43Z | `c-20260912165539-ac51` | yes |
| `commission.https-only-writes` | auto | PASS | 2026-09-12T16:59:44Z | `c-20260912165539-ac51` | yes |
| `commission.ssh-until-reboot` | auto | PASS | 2026-09-12T16:59:46Z | `c-20260912165539-ac51` | yes |
| `commission.cloud-disabled-surface` | auto | PASS | 2026-09-12T16:59:46Z | `c-20260912165539-ac51` | yes |
| `commission.factory-return` | auto | PASS | 2026-09-12T16:59:49Z | `c-20260912165539-ac51` | yes |
| `commission.machine-name` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
| `commission.first-run-flow` | operator, takeover | PASS | 2026-09-12T17:10:18Z | `c-20260912170819-8dec` | yes |
| `commission.first-run-page` | operator, takeover | PASS | 2026-09-11T17:55:49Z | `c-20260911172921-9426` | yes |
| `commission.cert-page` | auto | PASS | 2026-09-12T16:59:50Z | `c-20260912165539-ac51` | yes |
| `commission.what-changed` | auto, takeover | PASS | 2026-09-12T16:59:59Z | `c-20260912165539-ac51` | yes |
| `commission.record-export` | auto | PASS | 2026-09-12T17:01:26Z | `c-20260912165539-ac51` | yes |
| `commission.mirror` | auto, takeover | PASS | 2026-09-11T17:34:54Z | `c-20260911172921-9426` | yes |
| `commission.check-switches` | operator | PASS | 2026-09-12T17:10:23Z | `c-20260912170819-8dec` | yes |
| `commission.check-sensors` | auto | PASS | 2026-09-12T18:26:17Z | `c-20260912182427-e1ee` | no |
| `commission.check-airflow` | auto, takeover | PASS | 2026-09-12T18:27:51Z | `c-20260912182427-e1ee` | no |
| `commission.check-cameras` | operator | PASS | 2026-09-12T17:10:54Z | `c-20260912170819-8dec` | yes |
| `commission.check-motion` | auto, takeover | PASS | 2026-09-12T18:28:17Z | `c-20260912182427-e1ee` | no |
| `commission.check-flow-verify` | auto, takeover | PASS | 2026-09-12T17:16:41Z | `c-20260912170819-8dec` | yes |
| `commission.cloud-header-capture` | operator, takeover | PASS | 2026-09-12T17:29:14Z | `c-20260912170819-8dec` | yes |
| `commission.sheet` | live, takeover | PASS | 2026-09-12T19:02:59Z | `c-20260912182427-e1ee` | no |
| `logs.tree-tail-export` | auto | PASS | 2026-09-12T17:00:43Z | `c-20260912165539-ac51` | yes |
| `logs.routing` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
| `logs.level-settings` | auto | PASS | 2026-09-12T17:16:42Z | `c-20260912170819-8dec` | yes |
| `motion.pacing` | auto | PASS | 2026-09-11T17:36:41Z | `c-20260911172921-9426` | yes |
| `motion.jog-roundtrip` | auto | PASS | 2026-09-11T17:45:00Z | `c-20260911172921-9426` | yes |
| `motion.microstep-modes` | auto | PASS | 2026-09-12T17:17:07Z | `c-20260912170819-8dec` | yes |
| `motion.liveness-probe` | auto | PASS | 2026-09-11T17:45:33Z | `c-20260911172921-9426` | yes |
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-12T17:17:23Z | `c-20260912170819-8dec` | yes |
| `motion.cancel-abort` | auto | PASS | 2026-09-11T17:45:40Z | `c-20260911172921-9426` | yes |
| `motion.deadman` | auto | PASS | 2026-09-12T17:17:53Z | `c-20260912170819-8dec` | yes |
| `motion.button-hold-resume` | operator | PASS | 2026-09-11T18:24:59Z | `c-20260911182256-078f` | yes |
| `motion.lid-cancel-home` | operator | PASS | 2026-09-11T18:25:15Z | `c-20260911182256-078f` | yes |
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-11T18:25:24Z | `c-20260911182256-078f` | yes |
| `motion.lid-policy-hold` | operator | PASS | 2026-09-11T18:25:37Z | `c-20260911182256-078f` | yes |
| `motion.step-timing-under-load` | auto | PASS | 2026-09-11T17:46:40Z | `c-20260911172921-9426` | yes |
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-12T18:29:50Z | `c-20260912182427-e1ee` | no |
| `cooling.flow-verify` | auto | PASS | 2026-09-12T18:32:33Z | `c-20260912182427-e1ee` | no |
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-12T18:33:39Z | `c-20260912182427-e1ee` | no |
| `cooling.gate-off` | auto | PASS | 2026-09-12T18:26:26Z | `c-20260912182427-e1ee` | no |
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-12T18:35:06Z | `c-20260912182427-e1ee` | no |
| `cooling.tec-drive` | auto | PASS | 2026-09-12T18:35:15Z | `c-20260912182427-e1ee` | no |
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-12T18:35:31Z | `c-20260912182427-e1ee` | no |
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-12T18:35:38Z | `c-20260912182427-e1ee` | no |
| `cooling.critical-tier` | auto | PASS | 2026-09-12T18:35:47Z | `c-20260912182427-e1ee` | no |
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-12T18:27:10Z | `c-20260912182427-e1ee` | no |
| `cooling.flow-under-load` | live | PASS | 2026-09-12T19:05:38Z | `c-20260912182427-e1ee` | no |
| `cooling.fan-duty-readback` | auto | PASS | 2026-09-12T18:36:02Z | `c-20260912182427-e1ee` | no |
| `laser.power-floor` | auto | PASS | 2026-09-12T17:20:57Z | `c-20260912170819-8dec` | yes |
| `laser.emission-witness` | live, core | PASS | 2026-09-12T18:50:46Z | `c-20260912182427-e1ee` | no |
| `laser.m5-rapid-dark` | live | PASS | 2026-09-12T19:06:30Z | `c-20260912182427-e1ee` | no |
| `laser.disarm-in-hold` | live | PASS | 2026-09-12T19:08:04Z | `c-20260912182427-e1ee` | no |
| `laser.armed-kill` | live | PASS | 2026-09-12T19:08:58Z | `c-20260912182427-e1ee` | no |
| `laser.arm-wait-lid` | operator | PASS | 2026-09-12T18:36:21Z | `c-20260912182427-e1ee` | no |
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-12T19:09:43Z | `c-20260912182427-e1ee` | no |
| `camera.snapshot` | auto | PASS | 2026-09-12T17:10:39Z | `c-20260912170819-8dec` | yes |
| `camera.sensor-profile` | auto | PASS | 2026-09-12T17:21:13Z | `c-20260912170819-8dec` | yes |
| `camera.h264-stream` | auto | PASS | 2026-09-12T17:21:27Z | `c-20260912170819-8dec` | yes |
| `camera.frame-health` | auto | PASS | 2026-09-12T17:21:40Z | `c-20260912170819-8dec` | yes |
| `camera.lid-privacy` | operator | PASS | 2026-09-12T17:21:56Z | `c-20260912170819-8dec` | yes |
| `camera.key-read` | auto | PASS | 2026-09-12T17:21:57Z | `c-20260912170819-8dec` | yes |
| `update.slots-and-signature` | auto | PASS | 2026-09-12T16:59:48Z | `c-20260912165539-ac51` | yes |
| `update.release-check` | auto | PASS | 2026-09-12T17:21:58Z | `c-20260912170819-8dec` | yes |
| `cloud.mode-switch` | operator | PASS | 2026-09-12T18:38:00Z | `c-20260912182427-e1ee` | no |
| `cloud.service-protocol` | operator | PASS | 2026-09-12T19:10:50Z | `c-20260912182427-e1ee` | no |
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-12T19:11:59Z | `c-20260912182427-e1ee` | no |
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-12T17:24:21Z | `c-20260912170819-8dec` | yes |
| `cloud.verdict-hold` | operator | PASS | 2026-09-12T18:40:33Z | `c-20260912182427-e1ee` | no |
| `cloud.pause-resume` | live | PASS | 2026-09-12T19:14:09Z | `c-20260912182427-e1ee` | no |
| `cloud.oversize-stream` | live | PASS | 2026-09-12T19:16:55Z | `c-20260912182427-e1ee` | no |
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-12T19:17:30Z | `c-20260912182427-e1ee` | no |
Artifact sha256: `9650b1ed9862eefab808ca32cdaa8f6b9b6debd8ee640adde9d18dcdfc03ba25`
+5
View File
@@ -0,0 +1,5 @@
ForgeFIRM v0.0.4 is the first public beta release.
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
Every release below 0.1.0 is a beta release. Installation, usage, and safety are documented at https://docs.forgefirm.org/.
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
# ForgeFIRM acceptance - 20260915225440 (dev)
- Image: `20260915225440 (dev)` (forgefirm-image-dev)
- Manifest identity: `371bf2b8e0779a641b844b48e18b7fbe1a13adee0f6bf8706d1a720d3e000d22`
- Catalog: `7929ef2ce875fd4cccec3f794f7d49ceadd46bdd5068a3cab75b021947198525`
- Campaign: `c-20260915231006-07cc` opened 2026-09-15T23:10:06Z
- Exported: 2026-09-15T23:59:10Z
- **Release authorized: YES**
- Tests: 90 total, 90 satisfied (53 inherited), 0 required
| Test | Kind | Result | Run at | Campaign | Inherited |
|---|---|---|---|---|---|
| `image.health` | auto, core | PASS | 2026-09-15T23:10:06Z | `c-20260915231006-07cc` | no |
| `image.license-bundle` | auto | PASS | 2026-09-15T18:50:17Z | `c-20260915185011-0c8a` | yes |
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-15T23:10:07Z | `c-20260915231006-07cc` | no |
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-15T23:10:28Z | `c-20260915231006-07cc` | no |
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-15T23:10:38Z | `c-20260915231006-07cc` | no |
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-15T23:10:56Z | `c-20260915231006-07cc` | no |
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-15T23:11:39Z | `c-20260915231006-07cc` | no |
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-11T17:31:23Z | `c-20260911172921-9426` | yes |
| `kernel.pic-soc-load` | auto | PASS | 2026-09-11T17:31:26Z | `c-20260911172921-9426` | yes |
| `forgectrl.auth` | auto | PASS | 2026-09-15T18:51:51Z | `c-20260915185011-0c8a` | yes |
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-15T18:51:52Z | `c-20260915185011-0c8a` | yes |
| `forgectrl.panel-serves` | auto | PASS | 2026-09-15T23:11:41Z | `c-20260915231006-07cc` | no |
| `setup.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-15T18:52:10Z | `c-20260915185011-0c8a` | yes |
| `setup.override-until-reboot` | auto | PASS | 2026-09-15T18:52:11Z | `c-20260915185011-0c8a` | yes |
| `setup.advisories-rehash` | auto, takeover | PASS | 2026-09-15T18:52:19Z | `c-20260915185011-0c8a` | yes |
| `setup.account-login` | auto, takeover | PASS | 2026-09-15T18:53:08Z | `c-20260915185011-0c8a` | yes |
| `setup.https-only-writes` | auto | PASS | 2026-09-15T18:53:09Z | `c-20260915185011-0c8a` | yes |
| `setup.ssh-until-reboot` | auto | PASS | 2026-09-15T18:53:10Z | `c-20260915185011-0c8a` | yes |
| `setup.cloud-disabled-surface` | auto | PASS | 2026-09-15T18:53:11Z | `c-20260915185011-0c8a` | yes |
| `setup.factory-return` | auto | PASS | 2026-09-15T18:53:12Z | `c-20260915185011-0c8a` | yes |
| `setup.machine-name` | auto | PASS | 2026-09-15T18:53:13Z | `c-20260915185011-0c8a` | yes |
| `setup.first-run-flow` | operator, takeover | PASS | 2026-09-15T18:53:33Z | `c-20260915185011-0c8a` | yes |
| `setup.first-run-page` | operator, takeover | PASS | 2026-09-15T23:26:39Z | `c-20260915231006-07cc` | no |
| `setup.cert-page` | auto | PASS | 2026-09-15T18:53:34Z | `c-20260915185011-0c8a` | yes |
| `setup.what-changed` | auto, takeover | PASS | 2026-09-15T18:53:44Z | `c-20260915185011-0c8a` | yes |
| `setup.record-export` | auto | PASS | 2026-09-15T18:55:14Z | `c-20260915185011-0c8a` | yes |
| `setup.mirror` | auto, takeover | PASS | 2026-09-15T18:55:44Z | `c-20260915185011-0c8a` | yes |
| `setup.check-switches` | operator | PASS | 2026-09-15T18:55:49Z | `c-20260915185011-0c8a` | yes |
| `setup.check-sensors` | auto | PASS | 2026-09-15T18:55:25Z | `c-20260915185011-0c8a` | yes |
| `setup.check-airflow` | auto, takeover | PASS | 2026-09-15T18:57:23Z | `c-20260915185011-0c8a` | yes |
| `setup.check-cameras` | operator | PASS | 2026-09-15T18:57:51Z | `c-20260915185011-0c8a` | yes |
| `setup.check-motion` | auto, takeover | PASS | 2026-09-15T18:58:27Z | `c-20260915185011-0c8a` | yes |
| `setup.check-flow-verify` | auto, takeover | PASS | 2026-09-15T19:05:54Z | `c-20260915185011-0c8a` | yes |
| `setup.cloud-header-capture` | operator, takeover | PASS | 2026-09-15T23:28:02Z | `c-20260915231006-07cc` | no |
| `setup.sheet` | live, takeover | PASS | 2026-09-15T23:40:57Z | `c-20260915231006-07cc` | no |
| `logs.tree-tail-export` | auto | PASS | 2026-09-15T18:54:29Z | `c-20260915185011-0c8a` | yes |
| `logs.routing` | auto | PASS | 2026-09-15T19:05:59Z | `c-20260915185011-0c8a` | yes |
| `logs.level-settings` | auto | PASS | 2026-09-15T19:06:00Z | `c-20260915185011-0c8a` | yes |
| `motion.pacing` | auto | PASS | 2026-09-15T23:11:57Z | `c-20260915231006-07cc` | no |
| `motion.jog-roundtrip` | auto | PASS | 2026-09-15T23:12:10Z | `c-20260915231006-07cc` | no |
| `motion.microstep-modes` | auto | PASS | 2026-09-15T23:12:36Z | `c-20260915231006-07cc` | no |
| `motion.liveness-probe` | auto | PASS | 2026-09-15T20:59:39Z | `c-20260915205702-a7b9` | yes |
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-15T20:59:55Z | `c-20260915205702-a7b9` | yes |
| `motion.cancel-abort` | auto | PASS | 2026-09-15T23:12:43Z | `c-20260915231006-07cc` | no |
| `motion.deadman` | auto | PASS | 2026-09-15T23:13:29Z | `c-20260915231006-07cc` | no |
| `motion.respawn-gate` | operator | PASS | 2026-09-15T23:13:38Z | `c-20260915231006-07cc` | no |
| `motion.soft-limits` | auto | PASS | 2026-09-15T23:15:35Z | `c-20260915231006-07cc` | no |
| `motion.button-hold-resume` | operator | PASS | 2026-09-15T23:15:46Z | `c-20260915231006-07cc` | no |
| `motion.lid-cancel-home` | operator | PASS | 2026-09-15T23:16:02Z | `c-20260915231006-07cc` | no |
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-15T23:16:10Z | `c-20260915231006-07cc` | no |
| `motion.lid-policy-hold` | operator | PASS | 2026-09-15T23:16:24Z | `c-20260915231006-07cc` | no |
| `motion.step-timing-under-load` | auto | PASS | 2026-09-15T23:16:54Z | `c-20260915231006-07cc` | no |
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-15T19:00:00Z | `c-20260915185011-0c8a` | yes |
| `cooling.flow-verify` | auto | PASS | 2026-09-15T19:02:43Z | `c-20260915185011-0c8a` | yes |
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-15T21:04:26Z | `c-20260915205702-a7b9` | yes |
| `cooling.gate-off` | auto | PASS | 2026-09-15T18:55:57Z | `c-20260915185011-0c8a` | yes |
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-15T21:05:53Z | `c-20260915205702-a7b9` | yes |
| `cooling.tec-drive` | auto | PASS | 2026-09-15T21:06:01Z | `c-20260915205702-a7b9` | yes |
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-15T21:06:17Z | `c-20260915205702-a7b9` | yes |
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-15T21:06:25Z | `c-20260915205702-a7b9` | yes |
| `cooling.critical-tier` | auto | PASS | 2026-09-15T21:06:34Z | `c-20260915205702-a7b9` | yes |
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-15T18:56:43Z | `c-20260915185011-0c8a` | yes |
| `cooling.flow-under-load` | live | PASS | 2026-09-15T23:45:14Z | `c-20260915231006-07cc` | no |
| `cooling.fan-duty-readback` | auto | PASS | 2026-09-15T21:06:50Z | `c-20260915205702-a7b9` | yes |
| `cooling.fail-tier-stop` | operator | PASS | 2026-09-15T21:19:25Z | `c-20260915211732-5800` | yes |
| `laser.power-floor` | auto | PASS | 2026-09-15T23:16:56Z | `c-20260915231006-07cc` | no |
| `laser.emission-witness` | live, core | PASS | 2026-09-15T23:28:39Z | `c-20260915231006-07cc` | no |
| `laser.m5-rapid-dark` | live | PASS | 2026-09-15T23:45:54Z | `c-20260915231006-07cc` | no |
| `laser.disarm-in-hold` | live | PASS | 2026-09-15T23:47:56Z | `c-20260915231006-07cc` | no |
| `laser.armed-kill` | live | PASS | 2026-09-15T23:48:48Z | `c-20260915231006-07cc` | no |
| `laser.verdict-cut` | live | PASS | 2026-09-15T23:49:28Z | `c-20260915231006-07cc` | no |
| `laser.arm-wait-lid` | operator | PASS | 2026-09-15T23:16:59Z | `c-20260915231006-07cc` | no |
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-15T23:49:58Z | `c-20260915231006-07cc` | no |
| `camera.snapshot` | auto | PASS | 2026-09-15T18:57:38Z | `c-20260915185011-0c8a` | yes |
| `camera.sensor-profile` | auto | PASS | 2026-09-15T21:19:42Z | `c-20260915211732-5800` | yes |
| `camera.h264-stream` | auto | PASS | 2026-09-15T21:19:55Z | `c-20260915211732-5800` | yes |
| `camera.frame-health` | auto | PASS | 2026-09-15T21:20:07Z | `c-20260915211732-5800` | yes |
| `camera.lid-privacy` | operator | PASS | 2026-09-15T21:20:21Z | `c-20260915211732-5800` | yes |
| `camera.key-read` | auto | PASS | 2026-09-15T21:20:22Z | `c-20260915211732-5800` | yes |
| `update.slots-and-signature` | auto | PASS | 2026-09-12T16:59:48Z | `c-20260912165539-ac51` | yes |
| `update.release-check` | auto | PASS | 2026-09-15T21:20:23Z | `c-20260915211732-5800` | yes |
| `cloud.mode-switch` | operator | PASS | 2026-09-15T23:15:16Z | `c-20260915231006-07cc` | no |
| `cloud.service-protocol` | operator | PASS | 2026-09-15T23:51:07Z | `c-20260915231006-07cc` | no |
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-15T23:52:16Z | `c-20260915231006-07cc` | no |
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-15T21:21:03Z | `c-20260915211732-5800` | yes |
| `cloud.dark-print` | operator | PASS | 2026-09-15T21:21:58Z | `c-20260915211732-5800` | yes |
| `cloud.verdict-refuse` | operator | PASS | 2026-09-15T21:23:20Z | `c-20260915211732-5800` | yes |
| `cloud.pause-resume` | live | PASS | 2026-09-15T23:54:31Z | `c-20260915231006-07cc` | no |
| `cloud.oversize-stream` | live | PASS | 2026-09-15T23:57:18Z | `c-20260915231006-07cc` | no |
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-15T23:57:46Z | `c-20260915231006-07cc` | no |
Artifact sha256: `35e54b5db01fb23eb9091302e86d158852f9b4eaddca2a127a1bc957efb2ea0c`
+7
View File
@@ -0,0 +1,7 @@
ForgeFIRM v0.0.5 is a beta release.
It follows a review of the Grbl control path, tightening the laser emission and motion gates, the stream and protocol handling, and the machine-services supervisor. The connect banner, the soft-limit envelope after a home, and the cooling fail tiers all behave more predictably, and a fresh controller or a new sender starts from a clean state.
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
Every release below 0.1.0 is a beta release. Installation, usage, and safety are documented at https://docs.forgefirm.org/.
+217 -18
View File
@@ -39,6 +39,20 @@ reported messages:
not to where it was paused (the core restarts a held cycle through
Idle); a job abandoned in a hold and reset ends there, so the next
job's start is captured afresh where it begins
11. the cooling verdict's pause tier (OVERTEMP) holds the job under the
open window without writing the latch (a lock sets the hardware
button latch, which only a press clears); the clean verdict then
resumes it with no new button press: no prompt, no second arm
12. the verdict's fail tier (AIRFLOW) ends the job: disarmed, reset with
ALARM:3, and a ~ under the clean verdict that follows resumes nothing
13. a sender change during a re-arm cancels it: the press that follows
resumes nothing, the job stays held, and the new sender's own ~
prompts afresh
14. a jog does not hold the armed window open: with the spindle off the
grace counts down through jogs, and the window closes on time
15. a press counts only after the button has been seen up: a press that
began before the wait is not consent (the harness's own presses
follow a fresh prompt for the same reason)
The disarm grace is shortened via a temp config (GFHOME_CONF), the
cooling verdict is published hermetically (GF_VERDICT_FILE), the same
@@ -141,23 +155,29 @@ def wait_idle(sock, log):
fail("controller never returned to Idle")
def publish_verdicts(path, stop, fire_ok, armed_ack=True, ack_after_s=0.0):
"""Stand in for the cooling engine. "armed" is the engine's
acknowledgment that it has taken the controller's armed window and
applied the run airflow; the controller refuses to fire on a verdict
that lacks it, so an engine that never acknowledges (armed_ack
False) must produce a refused arm and no emission. ack_after_s
withholds the acknowledgment for that long first, which is the real
engine's case: it answers on its next tick, and the controller has
to see the refreshed verdict to get past the arm."""
def publish_verdicts(path, stop, verdict):
"""Stand in for the cooling engine. `verdict` is the session's live
dict: fire_ok, hold, resume_ok, name, armed_ack, ack_after_s. "armed"
is the engine's acknowledgment that it has taken the controller's
armed window and applied the run airflow; the controller refuses to
fire on a verdict that lacks it, so an engine that never
acknowledges (armed_ack False) must produce a refused arm and no
emission. ack_after_s withholds the acknowledgment for that long
first, which is the real engine's case: it answers on its next
tick, and the controller has to see the refreshed verdict to get
past the arm."""
t0 = time.monotonic()
while not stop.is_set():
acked = armed_ack and time.monotonic() - t0 >= ack_after_s
body = ('{"ts_mono":%.3f,"fire_ok":%s,"hold":false,'
'"resume_ok":true,"armed":%s,"reason":""}'
v = dict(verdict)
acked = v["armed_ack"] and time.monotonic() - t0 >= v["ack_after_s"]
body = ('{"ts_mono":%.3f,"fire_ok":%s,"verdict":"%s","hold":%s,'
'"resume_ok":%s,"armed":%s,"reason":"%s"}'
% (time.clock_gettime(time.CLOCK_MONOTONIC),
"true" if fire_ok else "false",
"true" if acked else "false"))
"true" if v["fire_ok"] else "false", v["name"],
"true" if v["hold"] else "false",
"true" if v["resume_ok"] else "false",
"true" if acked else "false",
"" if v["fire_ok"] else "harness: %s" % v["name"]))
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.write(body)
@@ -165,6 +185,20 @@ def publish_verdicts(path, stop, fire_ok, armed_ack=True, ack_after_s=0.0):
stop.wait(0.5)
def wait_for_new(log, needle, n_before, timeout, sock=None):
"""Wait until needle appears in the log more times than n_before: a
fresh occurrence, never a stale match from an earlier job."""
end = time.time() + timeout
while time.time() < end:
if "".join(log).count(needle) > n_before:
return True
if sock is not None:
read_avail(sock, log, 0.2)
else:
time.sleep(0.1)
return False
class Session:
"""One controller process with the lifecycle overrides applied."""
@@ -189,8 +223,10 @@ class Session:
self.set_switches(switches)
env["GF_SWITCH_FILE"] = self.switch_file
self.stop = threading.Event()
self.verdict = {"fire_ok": fire_ok, "hold": False, "resume_ok": True, "name": "OK",
"armed_ack": armed_ack, "ack_after_s": ack_after_s}
self.pub = threading.Thread(target=publish_verdicts,
args=(verdict, self.stop, fire_ok, armed_ack, ack_after_s),
args=(verdict, self.stop, self.verdict),
daemon=True)
self.pub.start()
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
@@ -220,6 +256,16 @@ class Session:
f.write("%d\n" % word)
os.replace(tmp, self.switch_file)
def set_verdict(self, name="OK", fire_ok=True, hold=False, resume_ok=True):
"""The engine's next verdicts: a pause tier (OVERTEMP: fire
blocked, hold, no resume), a fail tier (AIRFLOW: the same under a
name the client ends the job on), or clean."""
self.verdict.update({"name": name, "fire_ok": fire_ok, "hold": hold,
"resume_ok": resume_ok})
def count(self, needle):
return "".join(self.log).count(needle)
def send_raw(self, line):
"""Send a line without waiting for ok/error (the arm wait blocks
the gcode stream, so the ok only comes once the button is pressed)."""
@@ -867,13 +913,17 @@ def test_lid_open_in_wait():
def start_armed_move(s, tag, gcode="G1 X30 F60"):
"""Arm through the button and get a long move under way; returns once
the controller reports Run."""
the controller reports Run. The prompt and the armed message are
waited for as fresh occurrences: a press that lands before the
wait has begun is not consent (rule 15), so the press must follow
THIS job's prompt, not an earlier job's."""
prompts, armed = s.count(PROMPT), s.count(ARMED)
s.send_raw("M4 S100")
s.send_raw(gcode)
if not wait_for(s.log, PROMPT, 5, s.sock):
if not wait_for_new(s.log, PROMPT, prompts, 5, s.sock):
fail("[%s] no button prompt" % tag)
s.press_button()
if not wait_for(s.log, ARMED, 5, s.sock):
if not wait_for_new(s.log, ARMED, armed, 5, s.sock):
fail("[%s] the button press did not arm" % tag)
if not s.wait_state("Run", 5):
fail("[%s] the job never reported Run" % tag)
@@ -1045,9 +1095,158 @@ def test_lid_policy_hold():
s.close()
def test_verdict_pause_resumes_without_press():
"""Rule 11: the pause tier holds under the open window without a
latch write; the clean verdict resumes with no press. The window is
proven still open by the absence of any prompt and of a second arm."""
s = Session("verdict-pause", disarm_s=60, switches=SW_CLOSED)
try:
start_armed_move(s, "verdict-pause", gcode="G1 X30 F120") # 15 s of motion
time.sleep(0.5)
s.set_verdict("OVERTEMP", fire_ok=False, hold=True, resume_ok=False)
if not s.wait_state("Hold", 5):
fail("[verdict-pause] the pause tier did not hold the job (state %s)" % s.state())
if not wait_for(s.log, "fire masked, job held", 3, s.sock):
fail("[verdict-pause] the pause tier did not report the masked hold")
if "latch locked" in "".join(s.log):
fail("[verdict-pause] the pause tier wrote the latch")
if DISARMED in "".join(s.log):
fail("[verdict-pause] the pause tier closed the armed window")
time.sleep(1.5)
s.set_verdict("OK", fire_ok=True, hold=False, resume_ok=True)
if not s.wait_state("Run", 5):
fail("[verdict-pause] the clean verdict did not resume the job (state %s)" % s.state())
if not wait_for(s.log, "resuming", 2, s.sock):
fail("[verdict-pause] the resume was not reported")
if RESUME_PROMPT in "".join(s.log) or s.count(PROMPT) != 1:
fail("[verdict-pause] the resume asked for a button press")
if s.armed_count() != 1:
fail("[verdict-pause] the resume armed again (armed messages: %d)" % s.armed_count())
if "ALARM" in "".join(s.log):
fail("[verdict-pause] the pause tier raised an alarm")
s.sock.sendall(b"\x18")
print("PASS [verdict-pause]: OVERTEMP held under the open window with no latch write; "
"the clean verdict resumed with no press")
finally:
s.close()
def test_verdict_fail_tier_ends_job():
"""Rule 12: the fail tier disarms, resets the job with ALARM:3, and
nothing resumes it."""
s = Session("verdict-fail", disarm_s=60, switches=SW_CLOSED)
try:
start_armed_move(s, "verdict-fail", gcode="G1 X30 F120")
time.sleep(0.5)
s.set_verdict("AIRFLOW", fire_ok=False, hold=True, resume_ok=False)
if not s.wait_state("Alarm", 5):
fail("[verdict-fail] the fail tier did not end the job in Alarm (state %s)" % s.state())
if not wait_for(s.log, DISARMED, 3, s.sock):
fail("[verdict-fail] the fail tier did not close the armed window")
text = "".join(s.log)
if "ALARM:3" not in text:
fail("[verdict-fail] no ALARM:3 on the fail tier")
if "AIRFLOW" not in text:
fail("[verdict-fail] the fail tier did not name the verdict")
s.set_verdict("OK", fire_ok=True, hold=False, resume_ok=True)
time.sleep(1.5)
s.sock.sendall(b"~")
read_avail(s.sock, s.log, 1.0)
if not s.state().startswith("Alarm"):
fail("[verdict-fail] a ~ after the fail tier resumed something (state %s)" % s.state())
if "resuming" in "".join(s.log):
fail("[verdict-fail] the client resumed after the fail tier")
send_line(s.sock, "$X", s.log)
if not s.wait_state("Idle", 3):
fail("[verdict-fail] $X did not unlock the alarm")
print("PASS [verdict-fail]: AIRFLOW disarmed, reset the job with ALARM:3, and nothing "
"resumed it")
finally:
s.close()
def test_sender_change_during_rearm_cancels():
"""Rule 13: a sender change while a re-arm waits for the press
cancels the re-arm; the press resumes nothing and the job stays
held; the new sender's ~ prompts afresh."""
s = Session("rearm-sender-change", disarm_s=60, switches=SW_CLOSED)
try:
start_armed_move(s, "rearm-sender-change", gcode="G1 X30 F120")
time.sleep(1.0)
s.sock.close() # mid-move: held, disarmed
time.sleep(0.5)
s.sock = s.connect()
if s.wait_state("Hold", 5) is None:
fail("[rearm-sender-change] the job was not held on the sender change")
s.sock.sendall(b"~")
if not wait_for(s.log, RESUME_PROMPT, 5, s.sock):
fail("[rearm-sender-change] the resume did not prompt for the button")
# A second sender change, inside the re-arm wait. The cancel is
# reported at the disconnect, to nobody (output with no client is
# discarded), so the evidence is what the press does next: nothing.
s.sock.close()
time.sleep(0.5)
s.sock = s.connect()
st = read_state(s, '"connected":true')
if '"arming":false' not in st:
fail("[rearm-sender-change] the re-arm wait survived the sender change: %r" % st)
# A press now is the machine's resume button for the new sender:
# it resumes nothing and arms nothing, it opens a fresh re-arm
# prompt of its own (the displaced consent is gone), and only a
# second press, against that prompt, re-arms the held job.
before = s.armed_count()
prompts = s.count(RESUME_PROMPT)
s.press_button()
read_avail(s.sock, s.log, 1.0)
if s.armed_count() != before or not s.state().startswith("Hold"):
fail("[rearm-sender-change] a press after the canceled re-arm resumed the job "
"(armed %d -> %d, state %s)" % (before, s.armed_count(), s.state()))
if not wait_for_new(s.log, RESUME_PROMPT, prompts, 3, s.sock):
fail("[rearm-sender-change] the press after the cancel did not open a fresh re-arm prompt")
s.press_button()
end = time.time() + 10
while time.time() < end and s.armed_count() != before + 1:
read_avail(s.sock, s.log, 0.2)
if s.armed_count() != before + 1:
fail("[rearm-sender-change] the second press did not re-arm the held job")
s.sock.sendall(b"\x18")
print("PASS [rearm-sender-change]: the sender change canceled the re-arm; the next press "
"resumed nothing and prompted afresh, and the press after it re-armed")
finally:
s.close()
def test_jog_does_not_extend_window():
"""Rule 14: with the spindle off, jogs do not reset the disarm grace:
the window closes on time through them."""
s = Session("jog-grace", disarm_s=2)
try:
send_line(s.sock, "M4 S100", s.log)
send_line(s.sock, "G1 X1 F600", s.log)
if not wait_for(s.log, ARMED, 5, s.sock):
fail("[jog-grace] job did not arm")
send_line(s.sock, "M5", s.log)
wait_idle(s.sock, s.log)
t0 = time.time()
while time.time() - t0 < 5.0 and DISARMED not in "".join(s.log):
send_line(s.sock, "$J=G91X1F1200", s.log)
read_avail(s.sock, s.log, 0.3)
dt = time.time() - t0
if DISARMED not in "".join(s.log):
fail("[jog-grace] the window stayed open through %.1f s of jogging (grace 2 s)" % dt)
wait_idle(s.sock, s.log)
print("PASS [jog-grace]: the window closed after %.1f s of jogging with the spindle off" % dt)
finally:
s.close()
def main():
if not os.path.isfile(BIN):
fail("controller binary not found at %s" % BIN)
test_verdict_pause_resumes_without_press()
test_verdict_fail_tier_ends_job()
test_sender_change_during_rearm_cancels()
test_jog_does_not_extend_window()
test_job_window()
test_status_files()
test_laser_keys_reload()
+372 -51
View File
@@ -79,12 +79,45 @@ over TCP, then checks the dumps against the kernel feeder contract:
the default gamma of 2 than at gamma 1, and the cruise middle
renders the same - the exponent shapes only the velocity-scaled
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
24. the verdict's pause tier: with the engine's verdict at a pause
(OVERTEMP: hold, fire blocked, no resume) the client holds the job
under the open window; the deceleration into that first hold runs
lit to the stop, as a feed hold's does (the segments already planned
at speed would otherwise play dark and leave a gap in the cut), and
the stream engine's per-tick gate masks the stream from the stop on;
a ~ under that verdict, which is what a button press or a sender
does, moves the head dark for at most one client poll before the
client holds it again and says so; the clean verdict then resumes
the hold the client took with no new press, and the rest of the line
cuts lit. The latch is never written by a pause (a lock sets the
hardware button latch, which only a press clears): the latch
sideband (GFSINK_LATCH_LOG) carries the unlock at the arm and the
lock at the program end and nothing between
25. the verdict's fail tier: a verdict named AIRFLOW (fire blocked, hold,
no resume) mid-M3 ends the job: the window closes, the latch locks,
the job is reset with ALARM:3, the stream ends dark well short of the
line, and a ~ under the clean verdict that follows resumes nothing;
the sideband ends on the lock and carries no unlock after it
26. a sender change mid-M3 closes the window and holds the job, and the
deceleration into that hold ships dark: the gate follows the window
on every tick, so a fire state the core never updates cannot outlive
the consent it rode on
27. a verdict that goes stale (the engine stops publishing mid-cut)
holds the job the moment the client's cache expires, on its own
clock between two of its file reads, lit to the stop like rule 24:
no dark cut runs out while the client waits for its next read; the
engine's return resumes the cut lit
28. a scheduling stall of the step producer (GFSINK_STALL_MS: the
producer is kept off the CPU longer than its lead) maps events
behind the ship cursor. Inside an armed window that is a fault:
ALARM:17, the window closed, the stream ended dark, and no step
burst shipped (the clamped events are never compressed onto later
bytes). Unarmed it is a warning in the log and the move completes
with every step in the stream, compressed
29. a stall of the kernel write (GFSINK_WRITE_STALL_MS: the sink holds
the write the way a full ring holds it) stalls nothing but the
shipper: the producer keeps mapping at wall pace behind it, so the
job completes lit with no clamp, no burst, and a dark end
The analog sessions select the reference mode through the config; on
hardware the controller ignores it (density is the only product model -
@@ -401,28 +434,37 @@ def wait_state(sock, log, prefix, timeout=5.0):
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",
# The published verdict is clean unless a session sets a mode: "hold" is
# the engine's pause tier (OVERTEMP: hold, fire blocked, no resume) and
# "fail" its fail tier (AIRFLOW: the same flags under a name the client
# ends the job on). A ("verdict", <mode>) step sets it, ("verdict",
# "clean") clears it.
VERDICT_HOLD = threading.Event()
VERDICT_MODE = {"mode": "clean"}
VERDICT_NAMES = {"hold": "OVERTEMP", "fail": "AIRFLOW"}
def publish_verdicts(path, stop):
"""Publish a fresh cooling verdict every 0.5 s (the arm flow refuses
without one; freshness window is 2 s), clean unless VERDICT_HOLD is
set. Same-host monotonic clock, atomic rename so the reader never
sees a torn file. "armed" is the engine's acknowledgment that it has
taken the controller's armed window; the arm waits for it, so a
stand-in engine that means to let jobs run must assert it."""
without one; freshness window is 2 s), clean unless VERDICT_MODE
says otherwise. Same-host monotonic clock, atomic rename so the
reader never sees a torn file. "armed" is the engine's
acknowledgment that it has taken the controller's armed window; the
arm waits for it, so a stand-in engine that means to let jobs run
must assert it."""
while not stop.is_set():
hold = VERDICT_HOLD.is_set()
body = ('{"ts_mono":%.3f,"fire_ok":%s,"hold":%s,'
mode = VERDICT_MODE["mode"]
if mode == "stale":
stop.wait(0.5) # the engine has stopped publishing
continue
blocked = mode != "clean"
body = ('{"ts_mono":%.3f,"fire_ok":%s,"verdict":"%s","hold":%s,'
'"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 ""))
"false" if blocked else "true",
VERDICT_NAMES.get(mode, "OK"),
"true" if blocked else "false",
"false" if blocked else "true",
("harness: %s" % VERDICT_NAMES[mode]) if blocked else ""))
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.write(body)
@@ -431,19 +473,23 @@ def publish_verdicts(path, stop):
def run_session(name, steps, conf=None, workdir=None, keep=False,
arm_required=True):
arm_required=True, env_extra=None):
"""Launch the controller, run the job steps, return the dump bytes.
Pass workdir + keep to chain launches over one settings file: the
core precomputes the spindle PWM mapping once, when the spindle is
enabled, so a $35 written at runtime only takes effect on the next
controller start."""
controller start. env_extra adds to the controller's environment
(the host stall knobs). The controller's log (stderr) is kept in
run_session.err."""
if workdir is None:
workdir = tempfile.mkdtemp(prefix="laser-test-")
dump = os.path.join(workdir, "stream.bin")
verdict = os.path.join(workdir, "cooling.state")
latch_log = os.path.join(workdir, "latch.log")
env = dict(os.environ, GFSINK_DUMP=dump, GF_VERDICT_FILE=verdict,
FFLOG_STDERR="1")
GFSINK_LATCH_LOG=latch_log, FFLOG_STDERR="1")
env.update(env_extra or {})
# The lens reference the daemon leaves before a controller starts:
# forgectrl sweeps the carriage onto the hall edge and marks it, and
# the controller opens the Z envelope on that mark. Without one Z
@@ -462,7 +508,7 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
env["GFHOME_CONF"] = conf_path
stop = threading.Event()
VERDICT_HOLD.clear()
VERDICT_MODE["mode"] = "clean"
pub = threading.Thread(target=publish_verdicts, args=(verdict, stop), daemon=True)
pub.start()
@@ -494,12 +540,18 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
elif isinstance(step, tuple) and step[0] == "rt":
sock.sendall(step[1]) # a realtime character: no ok follows
elif isinstance(step, tuple) and step[0] == "wait_state":
wait_state(sock, log, step[1])
wait_state(sock, log, step[1], step[2] if len(step) > 2 else 5.0)
elif isinstance(step, tuple) and step[0] == "verdict":
if step[1] == "hold":
VERDICT_HOLD.set()
else:
VERDICT_HOLD.clear()
VERDICT_MODE["mode"] = step[1]
elif isinstance(step, tuple) and step[0] == "expect_text":
if not wait_text(sock, log, step[1], step[2] if len(step) > 2 else 5.0):
fail("[%s] the controller never said %r" % (name, step[1]))
elif isinstance(step, tuple) and step[0] == "reconnect":
# A sender change: the socket closes and a new one connects.
sock.close()
time.sleep(step[1] if len(step) > 1 else 0.3)
sock = socket.create_connection(("127.0.0.1", PORT), timeout=1)
read_avail(sock, log, 0.5)
else:
send_line(sock, step, log)
@@ -520,11 +572,19 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
proc.wait(5)
except subprocess.TimeoutExpired:
proc.kill()
try:
run_session.err = (proc.stderr.read() or b"").decode(errors="replace")
except (OSError, ValueError):
run_session.err = ""
stop.set()
pub.join(2)
VERDICT_HOLD.clear()
VERDICT_MODE["mode"] = "clean"
data = open(dump, "rb").read()
try:
run_session.latch = open(latch_log).read().split()
except OSError:
run_session.latch = []
if not data and arm_required:
fail("[%s] empty stream dump" % name)
if not keep:
@@ -532,6 +592,89 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
return data
def wait_text(sock, log, needle, timeout):
"""Drain the socket until needle appears in the accumulated log."""
end = time.time() + timeout
while time.time() < end:
if needle in "".join(log):
return True
read_avail(sock, log, 0.2)
return needle in "".join(log)
def latch_transitions(lines):
"""The sideband's lock/unlock lines with repeats collapsed: the
ownership sequence as transitions."""
out = []
for ln in lines:
if ln in ("lock", "unlock") and (not out or out[-1] != ln):
out.append(ln)
return out
def holds_in(ticks, min_run=2000):
"""The stationary stretches (no X/Y/Z step for min_run ticks) inside
the motion, as (start, end) tick spans."""
step = [1 if t & 0x25 else 0 for t in ticks]
first = step.index(1)
last = len(step) - 1 - step[::-1].index(1)
holds, run = [], 0
for i in range(first, last + 1):
if step[i]:
if run >= min_run:
holds.append((i - run, i))
run = 0
else:
run += 1
return holds
# The deceleration into a hold from F3000 (50 mm/s) at the board's
# 700 mm/s^2: 71 ms, about 2000 ticks. A gate that closes with the
# hold darkens all of it but the producer's lead (10 ms, ~280 ticks);
# a fire state that outlives the gate lights it to the last step.
DECEL_TICKS = int(50.0 / 700.0 * MACHINE_TICK_HZ)
DECEL_DARK_MIN = DECEL_TICKS - 600
def dark_lead(ticks, hold):
"""Ticks between the last FIRE tick before `hold` and its stop."""
h0 = hold[0]
last = next((i for i in range(h0 - 1, -1, -1) if ticks[i] & 0x10), None)
return h0 - last if last is not None else h0
def check_decel_dark(name, ticks, hold, what):
"""No FIRE tick in the deceleration into `hold` beyond the lead: the
gate closed with the cause (a closed window, a lost sender)."""
lead = dark_lead(ticks, hold)
if lead < DECEL_DARK_MIN:
fail("[%s] the deceleration into the hold ran lit: the last FIRE tick is %d "
"ticks before the stationary stretch, expected at least %d (%s)"
% (name, lead, DECEL_DARK_MIN, what))
return lead
# A lit deceleration ends within the producer's lead (10 ms) plus one
# shipper period (10 ms) of the stop: the gate closes when the core
# reports the hold complete, and the bytes produced ahead of the cursor
# by then ship dark. 1000 ticks is 35 ms, under 0.2 mm at the end of a
# ramp from 50 mm/s; a gate that closed before the head stopped shows
# as the whole deceleration (~2000 ticks) or more.
DECEL_LIT_MAX = 1000
def check_decel_lit(name, ticks, hold, what):
"""FIRE ran to the stop: the pause tier keeps the beam through the
deceleration it planned, so no dark motion precedes the hold."""
lead = dark_lead(ticks, hold)
if lead > DECEL_LIT_MAX:
fail("[%s] %d ticks (%.0f ms) of dark motion before the stop, expected at most %d: "
"the gate closed before the head stopped (%s)"
% (name, lead, lead * 1e3 / MACHINE_TICK_HZ, DECEL_LIT_MAX, what))
return lead
def tick_bytes(data):
"""The stream with power bytes stripped (tick bytes only)."""
return bytes(b for b in data if not b & 0x80)
@@ -1092,42 +1235,45 @@ def main():
"(%d ticks), lit from the first step out (%d fire ticks)"
% (mode, decel, dlen, accel))
# --- rule 24: a hold verdict is held again after a resume -----------
# --- rule 24: the verdict's pause tier -------------------------------
# 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.
# pause tier (OVERTEMP: hold, fire blocked, no resume): the client
# takes the feed hold, and the stream's gate darkens the deceleration
# into it; the latch is not written. 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; M2 closes the window and locks.
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"]
("sleep", 0.5), ("verdict", "clean"), WAIT_IDLE, "M5", "M2",
("expect_text", "laser disarmed")]
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")
if "fire masked, job held" not in text:
fail("[verdict-rehold] the pause tier did not report the masked hold")
if "latch locked" in text.split("Pgm End")[0]:
fail("[verdict-rehold] the pause tier locked the latch")
if "ALARM" in text:
fail("[verdict-rehold] the pause tier raised an alarm")
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)
step = [1 if t & 0x05 else 0 for t in ticks]
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
holds = holds_in(ticks)
if len(holds) != 2:
fail("[verdict-rehold] expected two holds in the stream, found %d: %s"
% (len(holds), holds))
(_h1s, h1e), (h2s, h2e) = holds
lit_lead = check_decel_lit("verdict-rehold", ticks, holds[0],
"a pause keeps the beam through its first deceleration")
between = sum(fire[h1e:h2s])
if between:
fail("[verdict-rehold] FIRE while resumed under the hold verdict: %d fire ticks "
@@ -1139,9 +1285,184 @@ def main():
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))
seq = latch_transitions(run_session.latch)
if seq != ["unlock", "lock"]:
fail("[verdict-rehold] latch ownership sequence %s, expected unlock (arm) and lock "
"(program end) only: a pause must not write the latch" % seq)
print("PASS [verdict-rehold]: held lit to %d ticks before the stop, resumed dark for "
"%d ticks (%.2f s), held again, lit after the clear (%d fire ticks), latch %s"
% (lit_lead, h2s - h1e, (h2s - h1e) / float(TICK_HZ), lit_after, seq))
# --- rule 25: the verdict's fail tier --------------------------------
# The same line; mid-move the verdict goes to AIRFLOW. The job ends:
# window closed, latch locked, ALARM:3, the stream short and dark at
# its end, and the clean verdict that follows resumes nothing.
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
("verdict", "fail"), ("wait_state", "Alarm", 5.0),
("expect_text", "laser disarmed"), ("verdict", "clean"), ("sleep", 1.0),
("rt", b"~"), ("sleep", 0.5), ("wait_state", "Alarm", 2.0), "$X"]
data = run_session("verdict-fail", steps, conf=DENSITY_CONF_FLOORED)
text = run_session.text
if "ALARM:3" not in text:
fail("[verdict-fail] the fail tier did not end the job with ALARM:3")
if "AIRFLOW" not in text:
fail("[verdict-fail] the fail tier did not name the verdict")
if "resuming" in text or "held again" in text:
fail("[verdict-fail] the fail tier was treated as a pause")
check_termination("verdict-fail", data)
line_ticks = 150.0 / 50.0 * TICK_HZ
lit = count_fire(data)
if lit > line_ticks * 0.5:
fail("[verdict-fail] %d fire ticks: the job ran on past the fail-tier verdict "
"(the whole line is %d)" % (lit, line_ticks))
seq = latch_transitions(run_session.latch)
if seq != ["unlock", "lock"]:
fail("[verdict-fail] latch ownership sequence %s, expected unlock (arm), lock "
"(the fail tier), and nothing after" % seq)
print("PASS [verdict-fail]: AIRFLOW mid-cut ended the job with ALARM:3, %d of %d ticks "
"lit, ends dark, latch %s, ~ resumed nothing" % (lit, line_ticks, seq))
# --- rule 26: a sender change mid-M3 darkens the hold's decel ---------
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
("reconnect", 0.3), ("wait_state", "Hold:0", 5.0), ("sleep", 0.5),
("rt", b"\x18"), ("sleep", 0.5)]
data = run_session("sender-drop", steps, conf=DENSITY_CONF_FLOORED)
ticks = tick_bytes(data)
# No cycle follows the hold here, so the stream ends on the hold's
# deceleration: the stop is the tick after the last step.
step = [1 if t & 0x25 else 0 for t in ticks]
if 1 not in step:
fail("[sender-drop] no motion in the stream")
stop = len(step) - step[::-1].index(1)
if count_fire(data) < 1000:
fail("[sender-drop] the cut before the sender change ran dark (%d fire ticks)"
% count_fire(data))
dark = check_decel_dark("sender-drop", ticks, (stop, stop),
"the gate must follow the window closed on the sender change")
seq = latch_transitions(run_session.latch)
if seq[:2] != ["unlock", "lock"]:
fail("[sender-drop] latch ownership sequence %s, expected unlock (arm), lock "
"(the sender change)" % seq)
print("PASS [sender-drop]: the sender change held the job with the decel dark from %d "
"ticks before the stop, latch %s" % (dark, seq))
# --- rule 27: a stale verdict holds the moment the cache expires -----
# The engine stops publishing mid-cut. The client reads the file
# every 500 ms and its cache expires on its own clock between two
# reads: the hold must land at the expiry, not at the next read, and
# the deceleration runs lit to the stop like rule 24's, so no dark
# cut runs out in between. The engine's return resumes the cut lit.
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
("verdict", "stale"), ("wait_state", "Hold:0", 6.0), ("sleep", 0.5),
("verdict", "clean"), WAIT_IDLE, "M5", "M2", ("expect_text", "laser disarmed")]
data = run_session("verdict-stale", steps, conf=DENSITY_CONF_FLOORED)
text = run_session.text
if "cooling service lost" not in text:
fail("[verdict-stale] the client did not report the engine gone")
if "resuming" not in text or "restored" not in text:
fail("[verdict-stale] the client did not resume once the engine returned")
ticks = tick_bytes(data)
holds = holds_in(ticks)
if len(holds) != 1:
fail("[verdict-stale] expected one hold in the stream, found %d: %s" % (len(holds), holds))
lit_lead = check_decel_lit("verdict-stale", ticks, holds[0],
"the hold must land at the cache's expiry, lit to the stop")
fire = [1 if t & 0x10 else 0 for t in ticks]
step = [1 if t & 0x05 else 0 for t in ticks]
last = len(step) - 1 - step[::-1].index(1)
lit_after = sum(fire[holds[0][1]:last + 1])
if lit_after < 50:
fail("[verdict-stale] the resume after the engine's return ran dark (%d fire ticks)" % lit_after)
seq = latch_transitions(run_session.latch)
if seq != ["unlock", "lock"]:
fail("[verdict-stale] latch ownership sequence %s, expected unlock (arm) and lock "
"(program end) only" % seq)
print("PASS [verdict-stale]: the expired cache held the job lit to %d ticks (%.0f ms) "
"before the stop, lit after the return (%d fire ticks), latch %s"
% (lit_lead, lit_lead * 1e3 / MACHINE_TICK_HZ, lit_after, seq))
# --- rules 28 and 29: the stalls -------------------------------------
# The step density the job plans: F3000 at 53.333 steps/mm is 2667
# steps/s, 9.5 per 100 ticks. A clamp compresses the late events onto
# consecutive bytes, which is what the window count catches.
STALL_JOB = ["G90", "G21", "G1 X150 F3000"]
ARMED_STALL_JOB = ["G90", "G21", "M3 S500", "G1 X150 F3000"]
PLANNED_PER_100 = 3000.0 / 60.0 * STEPS_PER_MM / MACHINE_TICK_HZ * 100.0
BURST_LIMIT = int(PLANNED_PER_100 * 1.3) + 1
def max_steps_per_window(ticks, win=100):
worst = 0
for i in range(0, max(1, len(ticks) - win), win // 2):
worst = max(worst, sum(1 for t in ticks[i:i + win] if t & 0x01))
return worst
# 28a: the armed stall is a fault. The job ends in ALARM:17; a reset
# and an unlock bring the controller back so the session can end.
steps = ARMED_STALL_JOB + [("wait_state", "Alarm", 6.0), ("expect_text", "laser disarmed", 3.0),
("rt", b"\x18"), ("sleep", 1.0), "$X"]
data = run_session("stall-armed", steps, conf=DENSITY_CONF_FLOORED,
env_extra={"GFSINK_STALL_MS": "300"})
text, err = run_session.text, run_session.err
if "ALARM:17" not in text:
fail("[stall-armed] a producer stall inside the armed window did not fault (no ALARM:17)")
if "late events while the laser is armed" not in err:
fail("[stall-armed] the fault was not named in the log")
ticks = tick_bytes(data)
burst = max_steps_per_window(ticks)
if burst > BURST_LIMIT:
fail("[stall-armed] %d steps in a 100-tick window (planned %.1f): the clamped events "
"were shipped as a burst" % (burst, PLANNED_PER_100))
check_termination("stall-armed", data)
seq = latch_transitions(run_session.latch)
if seq != ["unlock", "lock"]:
fail("[stall-armed] latch ownership sequence %s, expected unlock (arm), lock (the fault)" % seq)
print("PASS [stall-armed]: a 300 ms producer stall while armed faulted with ALARM:17, "
"%d steps per 100 ticks at most (planned %.1f), ends dark, latch %s"
% (burst, PLANNED_PER_100, seq))
# 28b: unarmed, the same stall is a warning and the move completes with
# every step, compressed.
data = run_session("stall-unarmed", STALL_JOB, conf=DENSITY_CONF_FLOORED,
arm_required=False, env_extra={"GFSINK_STALL_MS": "300"})
text, err = run_session.text, run_session.err
if "ALARM" in text:
fail("[stall-unarmed] an unarmed stall raised an alarm")
if "late events clamped" not in err:
fail("[stall-unarmed] the clamp was not warned about in the log")
ticks = tick_bytes(data)
total = sum(1 for t in ticks if t & 0x01)
want = round(150 * STEPS_PER_MM)
if abs(total - want) > 2:
fail("[stall-unarmed] %d X steps in the stream, expected %d: steps were lost" % (total, want))
burst = max_steps_per_window(ticks)
if burst <= BURST_LIMIT:
fail("[stall-unarmed] no step burst in the stream (%d per 100 ticks): the stall knob "
"did not stall the producer" % burst)
print("PASS [stall-unarmed]: the same stall unarmed was warned, the move completed with "
"all %d steps, the clamp visible as %d steps per 100 ticks" % (total, burst))
# 29: a write stall stalls only the shipper. The producer keeps its
# pace behind it, so nothing clamps and nothing bursts.
data = run_session("write-stall", ARMED_STALL_JOB + [WAIT_IDLE, "M5"], conf=DENSITY_CONF_FLOORED,
env_extra={"GFSINK_WRITE_STALL_MS": "300"})
text, err = run_session.text, run_session.err
if "ALARM" in text:
fail("[write-stall] a write stall raised an alarm")
if "clamped" in err:
fail("[write-stall] a write stall clamped producer events: the back-off held the lock")
ticks = tick_bytes(data)
total = sum(1 for t in ticks if t & 0x01)
if abs(total - want) > 2:
fail("[write-stall] %d X steps in the stream, expected %d" % (total, want))
burst = max_steps_per_window(ticks)
if burst > BURST_LIMIT:
fail("[write-stall] %d steps in a 100-tick window (planned %.1f): a burst" % (burst, PLANNED_PER_100))
lit = count_fire(data)
if lit < 0.8 * want:
fail("[write-stall] the cut ran dark through the stall (%d fire ticks)" % lit)
check_termination("write-stall", data)
print("PASS [write-stall]: a 300 ms write stall left the producer on pace: all %d steps, "
"%d per 100 ticks at most, %d fire ticks, ends dark" % (total, burst, lit))
# --- 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
+155 -2
View File
@@ -25,6 +25,30 @@ no root) and drives it over TCP:
does not free Z either
6. X and Y are still free after both writes
A referenced lens opens the envelope to the window the shared settings
hold, the same keys the daemon's wizards build their Z from. forgectrl
leaves the lens on its hall edge and a marker in the state directory
before the controller starts; the driver takes it as it loads its
settings and places the edge on its step grid:
7. the marker with the edge alone: Z sits at the edge's grid step, the
fallback window (10 below, 12 above) runs to its ends, and two
half-steps past either end alarms
8. the marker with the focus card's stops (14 below, 20 above): the
wider window runs to its ends, two half-steps past either alarms
9. a stop count out of range (41) falls back on its side alone
The X and Y soft limits are the driver's as well: off while the
position is not trusted (the core's $20 cannot be turned on with $22
off), on after a home, when the envelope is the bed. A scripted home
(homing_mode = gfcloud with a runner that exits 0, the host hook) sets
them:
10. after the home a program move past X or Y max raises ALARM:2
before any motion, a jog past it is refused with error 15, and a
move inside the bed runs; a stream fault or a controller start
drops them again with the reference
The binary keeps its settings in EEPROM.DAT in the working directory, so
each run starts from defaults in a temporary directory and leaves
nothing behind.
@@ -53,13 +77,24 @@ def fail(msg):
class Session:
"""One null-sink controller process with its own settings store."""
"""One null-sink controller process with its own settings store.
`conf` (key: value) is the shared config the driver reads; with
`referenced` the lens marker forgectrl leaves is in the state
directory, so the driver takes the lens reference at its start."""
def __init__(self):
def __init__(self, conf=None, referenced=False):
self.workdir = tempfile.mkdtemp(prefix="z-envelope-")
env = dict(os.environ, GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
for key in ("GFSINK", "GF_SWITCH_FILE", "GF_VERDICT_FILE"):
env.pop(key, None)
conf_path = os.path.join(self.workdir, "forgefirm.conf")
with open(conf_path, "w") as f:
for k, v in (conf or {}).items():
f.write("%s = %s\n" % (k, v))
env["GFHOME_CONF"] = conf_path
if referenced:
with open(os.path.join(self.workdir, "lens.home"), "w") as f:
f.write("edge 0 3 3\n")
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
cwd=self.workdir, env=env,
stdout=subprocess.DEVNULL,
@@ -113,6 +148,23 @@ class Session:
m = re.search(r"<(\w+)", self.send("?"))
return m.group(1) if m else "?"
def mpos_z(self):
"""The reported machine Z (no work offset is set in a session)."""
m = re.search(r"[MW]Pos:(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)", self.send("?"))
if not m:
fail("no position in the status report")
return float(m.group(3))
def wait_idle(self, timeout=15.0):
"""A move that ran plays out before the next line is judged: the
soft limit on a moving machine holds first and alarms after."""
end = time.time() + timeout
while time.time() < end:
if self.state() == "Idle":
return
time.sleep(0.1)
fail("the controller did not return to Idle")
def move(self, gcode):
"""Run one move. Returns True when the soft limit blocked it.
An alarm needs a reset and an unlock before the next move."""
@@ -160,6 +212,105 @@ def free_in_xy(s, when):
check(ran, "X and Y free %s" % when, "an X or Y move was blocked %s" % when)
def window_holds(s, steps, below, above, what):
"""The envelope of a referenced lens whose edge sits on grid step
`steps`: the ends of the reach (`below` and `above` half-steps from
the edge) run, two half-steps past either end alarms. The controller
keeps a half-step of slack beyond the reach, so one past is not
judged here; two is outside on every head."""
spm = float(s.setting("$102"))
top, bottom = (steps + above) / spm, (steps - below) / spm
s.wait_idle()
check(not s.move("G0 Z%.3f" % top), "%s: the top of the reach, Z %.3f, runs" % (what, top),
"%s: Z %.3f, the top of the reach, was blocked" % (what, top))
s.wait_idle()
check(not s.move("G0 Z%.3f" % bottom), "%s: the bottom of the reach, Z %.3f, runs" % (what, bottom),
"%s: Z %.3f, the bottom of the reach, was blocked" % (what, bottom))
over, under = (steps + above + 2) / spm, (steps - below - 2) / spm
s.wait_idle()
check(s.move("G0 Z%.3f" % over), "%s: two half-steps over the top, Z %.3f, alarms" % (what, over),
"%s: Z %.3f, two half-steps over the top, ran" % (what, over))
s.wait_idle()
check(s.move("G0 Z%.3f" % under), "%s: two half-steps under the bottom, Z %.3f, alarms" % (what, under),
"%s: Z %.3f, two half-steps under the bottom, ran" % (what, under))
def referenced_cases():
"""The lens referenced by forgectrl's marker: the envelope is the
window the shared settings hold, as the daemon's wizards read it."""
edge = 3.35
conf = {"lens_hall_edge_z_mm": "%.2f" % edge}
s = Session(conf=conf, referenced=True)
try:
spm = float(s.setting("$102"))
steps = int(round(edge * spm))
z = s.mpos_z()
check(abs(z - steps / spm) < 0.002,
"referenced: Z sits at the edge's grid step, %.3f" % z,
"referenced: Z reads %.3f, not the edge's grid step %.3f" % (z, steps / spm))
window_holds(s, steps, 10, 12, "the fallback window")
free_in_xy(s, "with the lens referenced")
finally:
s.close()
s = Session(conf=dict(conf, lens_stop_below_steps="14", lens_stop_above_steps="20"), referenced=True)
try:
window_holds(s, steps, 14, 20, "the focus card's window")
finally:
s.close()
s = Session(conf=dict(conf, lens_stop_below_steps="41", lens_stop_above_steps="20"), referenced=True)
try:
window_holds(s, steps, 10, 20, "a below count out of range")
finally:
s.close()
def homed_cases():
"""After a scripted gfcloud home the bed is the X/Y envelope."""
conf = {"lens_hall_edge_z_mm": "3.35", "homing_mode": "gfcloud", "gfcloud_home_cmd": "true"}
s = Session(conf=conf, referenced=True)
try:
free_in_xy(s, "before the home")
x_travel = float(s.setting("$130"))
y_travel = float(s.setting("$131"))
reply = s.send("$H", 5.0)
if "ok" not in reply:
fail("the scripted home was refused: %r" % reply)
s.wait_idle()
st = s.send("?")
m = re.search(r"MPos:(-?[\d.]+),(-?[\d.]+)", st)
check(m and abs(float(m.group(1))) < 0.01 and abs(float(m.group(2))) < 0.01,
"homed: X and Y sit at the origin", "homed: X/Y are not at the origin: %r" % st)
check(s.move("G90 G0 X%.1f" % (x_travel + 5)), "homed: X past the bed alarms",
"homed: a move past X max ran")
s.wait_idle()
check(s.move("G90 G0 Y%.1f" % (y_travel + 5)), "homed: Y past the bed alarms",
"homed: a move past Y max ran")
s.wait_idle()
check(s.move("G90 G0 X-1"), "homed: X past the near edge alarms",
"homed: a move past X min ran")
s.wait_idle()
reply = s.send("$J=G91X%.1fF1200" % (x_travel + 5), 1.5)
check("error:15" in reply, "homed: a jog past the bed is refused with error 15",
"homed: a jog past the bed was not refused with error 15: %r" % reply)
# The core answers the line after an error with that error again
# until an empty line (or a $ command) clears it: the sender's
# acknowledgment at compatibility level 0.
s.send("", 1.0)
check(not s.move("G90 G0 X10 Y10"), "homed: a move inside the bed runs",
"homed: a move inside the bed was blocked")
s.wait_idle()
# A settings write does not drop them: the driver re-applies the
# mask after the core clears it.
s.write_setting("$20=0")
check(s.move("G90 G0 X%.1f" % (x_travel + 5)), "homed: X past the bed still alarms after a $20 write",
"homed: a $20 write freed X")
s.wait_idle()
finally:
s.close()
def main():
if not os.path.isfile(BIN):
fail("no controller binary at %s" % BIN)
@@ -189,6 +340,8 @@ def main():
free_in_xy(s, "after the settings writes")
finally:
s.close()
referenced_cases()
homed_cases()
print("PASS z_envelope_test")
+5 -2
View File
@@ -52,8 +52,11 @@ CONTENT_LAYERS = {"meta-forgefirm": ("forgefirm", "meta-forgefirm"),
"meta-glowforge-bsp": ("meta-openglow", "meta-glowforge-bsp"),
"meta-openglow-core": ("meta-openglow", "meta-openglow-core")}
# Left out of a layer's content, as in forgefirm-image-manifest.bbclass
# (FORGEFIRM_MANIFEST_PIN_SUFFIX): documentation and the component pin files.
LAYER_SKIP_SUFFIXES = (".md", "-pin.inc")
# (FORGEFIRM_MANIFEST_PIN_SUFFIX, FORGEFIRM_MANIFEST_VERSION_SUFFIX):
# documentation, the component pin files, and the release version file.
# All three are metadata; hashing them would turn a pin bump or a version
# bump into a platform change and invalidate every acceptance result.
LAYER_SKIP_SUFFIXES = (".md", "-pin.inc", "forgefirm-release.inc")
def git(args, cwd=None, input=None):
+22 -6
View File
@@ -45,7 +45,7 @@
# forgefirm-source-v<version>.tar.gz, and refuses to pack a bundle in which
# a recipe that needs source has none.
#
# Version contract: <version> == FORGEFIRM_RELEASE in forgefirm-image.bb
# Version contract: <version> == FORGEFIRM_RELEASE in forgefirm-release.inc
# == /etc/forgefirm-version ("v<version>") in the built rootfs == .fw
# meta-version ("v<version>") == release tag ("v<version>").
@@ -55,6 +55,9 @@ REPO="$(cd "$(dirname "$0")/.." && pwd)"
DEPLOY_ROOT="$REPO/build/tmp/deploy"
DEPLOY="$DEPLOY_ROOT/images/glowforge"
IMAGE_BB="$REPO/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb"
# The release version, in its own file so a bump is not a platform change
# (the manifest leaves it out of the layer content hash).
RELEASE_INC="$REPO/meta-forgefirm/recipes-forgefirm/images/forgefirm-release.inc"
INSTALLER="$REPO/scripts/install-forgefirm.sh"
WARN_BYTES=$((170 * 1024 * 1024))
FAIL_BYTES=$((195 * 1024 * 1024))
@@ -134,7 +137,7 @@ if [ "$MODE" = "dev" ]; then
EXT4="${EXT4/forgefirm-image-glowforge/forgefirm-image-dev-glowforge}"
[ -f "$EXT4" ] || die "dev rootfs not found: $EXT4"
check_size
REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$IMAGE_BB")
REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$RELEASE_INC")
DEVVER="v${REL}-dev-$(date +%Y%m%d%H%M%S)"
OUT="$DEPLOY/forgefirm-dev.fw"
"$REPO/scripts/mkfw.sh" "$EXT4" "$DEVVER" "$OUT" "$KEY"
@@ -163,9 +166,9 @@ else
fi
# Version single-source check.
BB_REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$IMAGE_BB")
BB_REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$RELEASE_INC")
[ "$BB_REL" = "$VERSION" ] \
|| die "FORGEFIRM_RELEASE in forgefirm-image.bb is '$BB_REL', not '$VERSION'"
|| die "FORGEFIRM_RELEASE in forgefirm-release.inc is '$BB_REL', not '$VERSION'"
# The beta rule: every release below 0.1.0 is a beta, and 0.1.0 is the
# first release that is not. While the README carries the beta banner, a
@@ -362,6 +365,19 @@ else
DOCS_TAG_CMD="git -C $DOCS_DIR push origin v$VERSION"
fi
# The release notes: releases/v<version>/notes.md when the release carries
# one (staged beside the assets, not uploaded as one), GitHub's generated
# notes otherwise.
NOTES_OPT="--generate-notes"
if [ -f "$REPO/releases/v$VERSION/notes.md" ]; then
cp "$REPO/releases/v$VERSION/notes.md" "$STAGE/notes.md"
NOTES_OPT="--notes-file notes.md"
echo "notes: releases/v$VERSION/notes.md"
else
rm -f "$STAGE/notes.md"
echo "notes: generated by GitHub"
fi
cat <<EOF
== release v$VERSION staged ==
@@ -374,7 +390,7 @@ Pre-publish checklist (docs.forgefirm.org, Developers, "Release flow"):
Publish (from a directory with an authenticated gh):
cd "$STAGE"
gh release create "v$VERSION" --repo openglow-org/forgefirm \\
--title "ForgeFIRM v$VERSION" --generate-notes $PRERELEASE \\
--title "ForgeFIRM v$VERSION" $NOTES_OPT $PRERELEASE \\
$ASSETS
Push the documentation tag with it:
@@ -384,7 +400,7 @@ EOF
if [ "$PUBLISH" = "1" ]; then
command -v gh >/dev/null || die "--publish requested but gh is not on PATH"
( cd "$STAGE" && gh release create "v$VERSION" --repo openglow-org/forgefirm \
--title "ForgeFIRM v$VERSION" --generate-notes $PRERELEASE \
--title "ForgeFIRM v$VERSION" $NOTES_OPT $PRERELEASE \
$ASSETS ) \
|| die "gh release create failed"
echo "== published v$VERSION =="