564 Commits
Author SHA1 Message Date
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
ScottW514 6967308485 Release v0.0.1: the acceptance artifact for this tree
Campaign c-20260909204732-3fa9 on image 20260909193551, 83 tests, 83 satisfied (62 inherited), release authorized. It replaces the artifact of the release that was withdrawn: that one authorized an earlier rootfs, and the gate compares against the rootfs it is asked to sign.
2026-09-09 17:55:33 -04:00
ScottW514 64301d3221 Keep the configuration files inside /data/forgefirm; pin the three components
ForgeFIRM's own files live under /data/forgefirm; two configuration
files did not. The machine settings sat at /data/forgefirm.conf, in the
root of /data beside the factory's own files, and the cloud-mode
configuration sat at /data/etc/gfhome.conf, inside a directory the
factory owns. Both move:

  /data/forgefirm.conf   -> /data/forgefirm/forgefirm.conf
  /data/etc/gfhome.conf  -> /data/forgefirm/gfhome.conf

There is no migration: only the bench has ever run this firmware.
/data/etc now holds only the factory's wpa_supplicant.conf.

The acceptance check of the file modes reads the settings file at its
new path, and the two bench tools that read it directly follow. The
pins move to the revisions that carry the change, forgectrl also
bringing the fix that reads the module's disabled state as idle:

  forgectrl           468ee21 (0.1.12)
  grblhal-glowforge   9ee624b (0.1.10)
  forgefirm-app       56f134a (0.1.28+git)

The lock moves meta-openglow to b7ad6d9, which pins python3-gfhardware
on the same revision. The four upstream layers stay where they were:
`kas lock --update` moves every floating repository, and a release is
not the place to take poky, meta-openembedded and meta-freescale along
for the ride.
2026-09-09 15:32:54 -04:00
ScottW514 61030da69b Pin forgectrl on the disabled-is-idle fix
A machine out of the box sat in the module's power-on state, disabled, for the whole of its first run, and every idle gate read that as busy: the setup's sensors check refused to start, settings writes answered 409, and the cooling engine held cooldown airflow from boot. forgectrl now reads disabled as idle (the state means no program in progress), with fault and underrun still busy until acknowledged and an unreadable state still failing closed.
2026-09-09 14:42:00 -04:00
ScottW514 0107c2cad4 Remove the cold-build workflow: Yocto builds on the build host only
The yocto-cold-build workflow and its kas/ci.yml overlay built the release image on a hosted runner as a reproducibility probe. It never ran to completion, its first dispatch (2026-09-09) stopped on the runner's user-namespace rule, and a probe nobody runs is a trap. Every Yocto build, the release included, runs on the build host; the release proof is the local pipeline (release.sh) and the bench campaign. The pre-publish checklist loses its self-containment line to match.
2026-09-09 13:28:05 -04:00
ScottW514 867b1938e4 Cold build: allow unprivileged user namespaces on the noble runner
BitBake isolates the network of its tasks with a user namespace, and the ubuntu-24.04 hosted runner's AppArmor profile refuses that to an unprivileged process, so the cold build stopped before its first task (run 34381825302). The workflow lifts the restriction for the run; nothing in the layers or the image changes.
2026-09-09 13:22:12 -04:00
ScottW514 b2f50ad765 Release v0.0.1: pin meta-openglow, refresh the lock, add the acceptance artifact
The kas configuration takes the pinned-remote meta-openglow block, with
its commit in the lock file (d655e1e, the read-only rootfs), so a fresh
clone builds the release without a sibling checkout. The lock keeps the
upstream layers where they were.

releases/v0.0.1 carries the acceptance artifact the bench exported for
this image: campaign c-20260909160235-7649 on 20260909150456, 83 tests,
83 satisfied, none inherited, release authorized. The release gate
recomputes every test's fingerprint from the manifest inside the release
rootfs and signs only when the recorded results agree.
2026-09-09 13:15:37 -04:00
ScottW514 4a95595afd Pin forgectrl on the gate that waits for the enclosure; add its test
forgectrl now holds the motion check while a lid or the interlock is
open instead of starting the controller unverified: GET /mode reports
controller "waiting" with why, the button blinks amber, and the check
runs when the enclosure closes. motion.gate-waits-for-lid drives the
fixture's lid channel: the lid opens, forgectrl restarts, /mode must
read waiting with why naming the lid, no pid, motion unverified, the
button amber (sampled over a blink period: the smooth trigger's target
reads 0 through the off half) and no probe line in the log; the lid
closes, and the controller must come up verified with MOTION OK on the
first probe.

Proven on the bench reference: PASS, the controller verified 6.5 s
after the lid closed.
2026-09-09 11:03:33 -04:00
ScottW514 8af8b197ee Mount the rootfs read-only on both images
The rootfs mounted read-write, so a slot ran with its own files open to
change, and the factory-slot mounts rode along on the release image.
Both images now carry the read-only-rootfs feature: the ro root line and
the rcS default, the volatile links made at rootfs time, a writable copy
of /var/lib at boot, a build failure for a post-install that needs the
machine, and the removal of shadow, base-passwd, update-rc.d and
update-alternatives.

What must last or change at run time is handled file by file:

- forgefirm-users renders the four account files from the record into
  /run/forgefirm/accounts and bind-mounts each copy over its /etc file
  (useradd and the rest are gone with shadow); a render writes through
  the mount, and the image's own files apply until the first render.
- forgefirm-banner bind-mounts a copy of /etc/issue and writes the
  address block through it.
- sshd keeps its host keys under /data/forgefirm/ssh, so the fingerprint
  survives updates; both sshd configs carry the same HostKey lines.
- forgefirm-logging passes logrotate a state file under /var/run
  (logrotate refuses to run without one).
- forgefirm-persist points the boot timestamp and the random seed at
  /data/forgefirm.

The dev image appends the /factory slot mounts, without nofail (busybox
mount hands it to the kernel, which rejects it). The rootfs command
entries lose their semicolons: on scarthgap the value is the task's
vardeps, split on whitespace, so "name;" left the function body out of
the signature and a changed body did not remake the rootfs; with the
bodies tracked, the dev image's DATETIME string needs a vardepsexclude.
release.sh gains the read-only gate (root ro, no /factory line,
ROOTFS_READ_ONLY=yes, host keys on /data). image.health checks the
mounts, the account binds, the banner bind, the host keys and the
dev-only /factory mounts.

Proven on the bench reference (dev image 20260909140901): / ro, /data
rw, /var/lib a tmpfs copy, the four account files and /etc/issue bound
from tmpfs, the host keys in /data/forgefirm/ssh, no "Read-only file
system" line in any log; forgectrl.auth and commission.account-login (a
temporary account rendered, logged in over HTTPS and removed again),
kernel.latch-locked-idle and motion.liveness-probe PASS; logrotate runs
with the volatile state. forgetest unit tests 335 OK; both images build
clean, and debugfs on the built rootfs shows every setting above.
2026-09-09 11:02:10 -04:00
ScottW514 2936890eaa Pin forgectrl on the revision CI passed
forgectrl 6040e64 carries the three fixes behind the pinned 93fb22e: the
test header reached the way the sibling tests reach theirs, the key added
to /status carried in the panel dev-server mock, and the lens test's
carriage driven by the sweep's own steps rather than by a clock. The
shipping behavior is unchanged from 93fb22e; the pin names what passed.

Verified with bitbake -c fetch.
2026-09-09 08:43:45 -04:00
ScottW514 1ea686ef85 Re-pin forgectrl and grblHAL-glowforge on the fixed revisions
forgectrl 93fb22e fits the lens outcome text in the buffer the supervisor
shares with the probe. grblHAL-glowforge 97be92b takes the lens reference
from the realtime hook rather than settings-changed, so the controller no
longer overwrites the Z it just referenced.

Both verified with bitbake -c fetch at these revisions.
2026-09-09 08:28:39 -04:00
ScottW514 093a7bbbde Stage the lens reference in the laser-stream harness
The Z session used to reference the lens itself with M103, which is gone:
the daemon sweeps the lens onto its hall edge before a controller starts
and leaves a marker, and the controller opens the Z envelope on that. No
daemon runs behind the harness, so nothing wrote the marker and every Z
move was refused, which is what the session's first move ran into.

The runner now writes the marker the daemon writes, and the session pins
lens_hall_edge_z_mm so the moves are counted from a known height: Z3 is 9
half-steps on the screw and Z4 is 12, so a 1 mm move up and back is 3
steps each way.

This is also the check that caught the controller overwriting its own
referenced Z at start, which the panel could not show.
2026-09-09 08:27:32 -04:00
ScottW514 3e5c1a342a Pin forgectrl and grblHAL-glowforge 0.1.9: the lens reference at start
forgectrl dd40dc8 takes the lens onto its hall edge in the motion-verify
window and gates the spawn when it cannot, and carries the per-axis
anchor, homed_axes on /status, and the panel reading Z from its own bit.
grblHAL-glowforge ef0f764 takes that reference as it loads its settings,
re-zeroes the kernel counters the daemon's GPIO steps never reached, and
drops M103.

Both verified with bitbake -c fetch at these revisions.
2026-09-09 08:08:47 -04:00
ScottW514 5f19ae01cd Follow the lens reference into the harness and the catalog
The lens now takes its hall-edge reference before any controller starts,
so Z is referenced on every start and M103 is gone. The laser-stream
harness opened its Z session by referencing the lens the way a
commissioning card did; it no longer has to, because Z is already open by
the time the session runs.

forgectrl.panel-serves gains the assertions for the per-axis reference:
homed_axes is an axis mask, homed agrees with it, and with a controller
running Z is referenced and reads inside the lens reach the same document
reports. That last check is the one that catches a panel showing nothing
for a Z the controller holds.

commission.check-motion already exercised the new path, because the
motion wizard's probe runs the same sequence the supervisor does, so its
covers map gains lenshome.c and its description names the lens reference
and the hard fault behind it.
2026-09-09 08:06:04 -04:00
ScottW514 74fa8a4ee6 Update README 2026-09-09 07:01:15 -04:00
ScottW514 be9e4198ba Assignment 2026-09-08 17:06:34 -04:00
ScottW514 291f75815b Assignment 2026-09-08 16:48:43 -04:00
ScottW514 c2ca2686a7 Assignment 2026-09-08 16:21:37 -04:00
ScottW514 722de73a39 pins: forgectrl 0.1.7, grblhal-glowforge 0.1.7, forgefirm-app 0.1.26+git
The three components carry documentation-only commits: the README
becomes an index card, AGENTS.md lands, and the comments that pointed at
the retired BRINGUP.md now point at the documentation site.

The pins move because the tree is already past the image that
20260907224817 built: a comment in the kernel config fragment
glowforge.cfg changed the meta-glowforge-bsp layer content hash, which
the manifest counts as a platform change. Measured against that image's
manifest: meta-forgefirm and meta-openglow-core are unchanged,
meta-glowforge-bsp is not. A fresh image build and a full acceptance
campaign therefore precede any release, and holding these pins back
would buy nothing.

Verified: every pinned revision is on its public remote, and
bitbake -c fetch resolves all of them.
2026-09-08 13:37:13 -04:00
ScottW514 e36a322ec5 Retire BRINGUP.md and CAMPAIGN-LOG.md
Every fact in the two documents is now on the documentation site, which
is the single source of truth. This repository carries no project
documentation any more: it is the build and release base plus the
acceptance tool, the bench tools and the fixture firmware.

BRINGUP.md was the runbook, the hardware facts bank and the open-work
list. CAMPAIGN-LOG.md was the dated record of how each result was
obtained. What replaces them: the site for present state, and the
commit message for the record of what a change did and how it was
proven, so the change and its record stay together. Local open work is
the developer's own file at the tree root and is not tracked here.

README.md becomes an index card: what this is, build, test, and where
the documentation is.

The release pipeline tags the documentation. Firmware on a machine
needs the documentation that agrees with it, so release.sh now tags the
forgefirm-docs checkout with the same v<version> as the release, and
prints the command that pushes the tag with the release. The checkout
must exist and be clean, which is a new gate before the signature.
FORGEFIRM_DOCS_DIR names the checkout (default: the sibling one) and
FORGEFIRM_DOCS_SKIP releases without a tag, loudly, and is never the
default. The tag is made at staging and pushed with the release, never
before: a documentation tag for a release that never shipped is worse
than no tag.

No catalog consequence. release.sh is host-side and is in no image.
The commission.py change is one sentence of a test description, not
behavior. accel_crash_probe.py and the kas header lose pointers to the
retired files.

Checks: bash -n and sh -n on release.sh, and the tracked trees carry no
reference to either retired file.
2026-09-08 13:29:43 -04:00
ScottW514 81df43e7a4 pins: grblHAL-glowforge 0.1.6, the planner buffer index fix
grblhal-glowforge e2ba043 carries the core fork at 362577d, whose block
ring index covers $398 up to 1000. Fetch-verified with bitbake -c fetch.
Pin bump only.
2026-09-07 18:40:44 -04:00
ScottW514 59c4516c00 The planner buffer depth harness; the XY microstep set is in
scripts/bench/planner_blocks_test.py restarts the null-sink controller
at $398=400 and at 1000 on one settings store and requires an answer on
the port, the depth in the status report and a move to Idle. It runs in
the grblHAL repo's CI; registered on the bench page and in the README.
BRINGUP: $398 runs over its whole range, the spin item is closed, and
the XY microstep item is committed, pushed and pinned. CAMPAIGN-LOG: the
landing and the index fix, with the host and bench proof.

No catalog consequence beyond the core submodule the motion tests
already cover; the coverage lint is clean.
2026-09-07 18:38:39 -04:00
ScottW514 0dc8c746e5 pins: forgectrl, grblHAL-glowforge and the app at the XY microstep revisions
forgectrl 0.1.6 (b1eee4d): the xy_microsteps setting and the quiet hold route.
grblhal-glowforge 0.1.5 (48d5f1d): the XY scale, tick and stop ramp derived
from the microstep mode, plus the producer lead ceiling.
forgefirm-app 0.1.25+git (d1c47b8): the checked header keys note; the
python3-gfhardware pin in meta-glowforge-bsp moves with it.

Fetch-verified with bitbake -c fetch. Pin bump only: the acceptance
consequence is the catalog invalidation the manifest already derives.
2026-09-07 18:11:18 -04:00
ScottW514 8fc5250d6d XY microstep modes: the baseline, the catalog test and the bench tools
The baseline derives x/y_mode, step_freq, ramp_rate and the configured
markers from the xy_microsteps setting; ramp_rate joins the GRBL
controller's set (the driver writes it; the cloud client runs at the
module's). motion.microstep-modes cycles 8, 16 and 32: the save restarts
the idle controller, the kernel reads the mode with its tick and ramp,
$100/$101 are the mode's and a typed $100 is overwritten, a 40 mm jog at
top speed returns to Idle with the kernel counters over the mode agreeing
with the commanded travel and the accelerometer seeing the head move; the
setting is put back as found.

Bench tools: xy_mode_test.py (the null-sink harness the grblHAL CI runs),
raster_dry.py (a top-speed raster per mode), xy_pattern_accel.py (the
operator's pattern from home with the machine silent and the head
accelerometer listening), arc_tolerance_sweep.py (a $12 ladder on the 9
in circle: the chord rate the protocol loop feeds, about 300 a second, is
the ceiling, not the core), and the xymode and xycircle live drills.
BRINGUP carries the present state and the facts; CAMPAIGN-LOG the dated
record, including the planner-blocks spin (a $398 of 255 or more loops
forever at start, a core bug) and its recovery.
2026-09-07 18:05:28 -04:00
ScottW514 bfb5cb27d8 The tail after Idle is one queue depth, and it does not grow
The open item "Idle before the kernel drains" asked to decide between
holding Idle until the kernel drains and stopping the continuation pads
growing the lag. Measured first: on the machine, four chained 50 mm jogs
against cnc/state give 171, 175, 177 and 176 ms after Idle. One queue
depth, flat. The growth the item described is gone, so neither driver
change was made and the item is closed.

Host side agrees and says where the mechanism lives. Stream bytes are the
time axis, so a dumped stream's length is how long the machine plays it:
chained jogs produce 35755 bytes each with no growth, and the churn
session holds at 64790 bytes at a producer lead of 2 or 10 ms. Only above
the lead ceiling does it inflate. Rule 17 now holds that stream to a
budget derived from the job rather than to a recorded number, so the
inflation regime cannot return unnoticed; it fails at 5899 and 8005 ms
with the ceiling lifted and passes at 2301 against 3800.

BRINGUP carried a second error. It said every forgectrl path that stops
the controller after motion waits for cnc/state idle. super.c says
outright that POST /controller/stop is not idle-gated, because it is also
the emergency lever, and safes the machine with cnc/stop and the latch
before the signal instead. The mode switch, the cooling gate and the
daemon shutdown do gate on machine_is_idle(). Both the tail figure and
the gating claim are corrected, and CAMPAIGN-LOG carries the measurements
and the decision not to hold Idle.

Acceptance: rule 17 is a host harness rule in the grblHAL repo's CI, not
a catalog case. The catalog is unchanged because no machine behavior is:
the driver change is a bound on an out-of-range knob.
2026-09-07 12:19:16 -04:00
ScottW514 527d864eaf Register the Z envelope harness and record the rebase
A new scripts/bench python file must be entered in the bench registry as
well as in that directory's README: the registry is what the acceptance
page reads, and a file in neither is a tool nobody can find. The harness
joins the other two host-side CI harnesses, marked not a bench-page tool
for the same reason they are - it drives the host-built null-sink
controller, not the machine.

CAMPAIGN-LOG gets the dated record of the rebase onto core build
20260905: the settings-struct measurement taken before the machine was
touched, the harness and the two broken builds it was validated against,
and the bench results.
2026-09-07 11:19:04 -04:00
ScottW514 e6e5ca1c94 pins: forgectrl e27b412 (0.1.5), grblhal-glowforge 96906b6 (0.1.4)
forgectrl carries the GRBL settings store into the data directory, which
the acceptance suite already asserts. grblhal-glowforge follows the core
off the deprecated settings-changed event and moves the core submodule to
the fork rebased onto build 20260905.

Both revisions fetch.
2026-09-07 11:08:58 -04:00
ScottW514 79d2c07734 The Z envelope survives a settings write
The Z soft limit belongs to the driver, not to $20. glowforge_homing.c
owns sys.work_envelope, sys.homed and sys.soft_limits for Z, because Z is
always referenced, to the lens hall edge or to where the lens stands, and
the core knows neither. The core recomputes both masks from the settings
and drops Z when it does: $20 clears the soft-limit mask inside its
setter, and a $13x write clears the homed bit for the axis as well.

z_envelope_test.py drives the null-sink controller over TCP and holds the
rule. It checks that an unreferenced Z is collapsed to where the lens
stands and refuses a move each way, that X and Y stay free so the
reassert is Z's alone, and that neither write frees Z. It restores $132.

The harness runs in the grblHAL repo's CI, next to the laser stream and
lifecycle harnesses.
2026-09-07 11:02:54 -04:00
ScottW514 b0fa4ccaf5 A release publishes the source of the software it installs
The release build merges kas/source-bundle.yml, which turns on the Yocto
archiver: the upstream source of each recipe as upstream publishes it, the
patches with their series file, and the recipe with its includes. The
overlay adds tasks only, so the image manifest is unchanged and an
acceptance result still applies; proven on the build host, where the
archiver build and a plain rebuild of the same tree give the same
content_sha256.

scripts/source-bundle.py packs forgefirm-source-v<version>.tar.gz: the
archives, both license manifests, the license texts, the ForgeFIRM layers,
the kas configuration, the layer revisions and the build identity of the
image. What the bundle must hold comes from the image, not from a list in
the script: every recipe of license.manifest and image_license.manifest
whose license is in the include list must have an archive, or the release
stops with the recipe named. release.sh attaches the bundle and covers it
with sha256sums.txt; FORGEFIRM_SOURCE_SKIP=1 bypasses deliberately.

On the build host: 68 of the image's 111 recipes carry source, 313.7 MiB,
under the 2 GiB limit of a release asset.

No acceptance catalog consequence: the change is release tooling on the
build host and puts no file and no behavior on the machine. The host-side
proof is forgetest/tests/test_source_bundle.py, which holds the license
decision, the choice of archive and the refusal.
2026-09-07 09:49:18 -04:00
ScottW514 9f715f08de The GRBL settings store lives in /data/forgefirm
BRINGUP names the new path in the standalone start line and the
stored-settings note. forgectrl.panel-serves checks, in GRBL mode, that
the store is /data/forgefirm/EEPROM-glowforge.DAT and that nothing of it
remains at the top of /data. super.c is already in its covers map.
2026-09-06 22:22:33 -04:00
ScottW514 a86d66049f forgetest: the invalidate-all notice ends when a campaign starts after it; its epoch stays 2026-09-06 22:03:56 -04:00
ScottW514 bd624410a2 docs: the full campaign passes on image 20260907005922; BRINGUP item 8, the first-run commissioning, is closed 2026-09-06 21:58:30 -04:00
ScottW514 143ef11a60 forgetest: the liveness test waits for the probe's log line; forgectrl pinned at 0.1.4 (the probe's detail text); the campaign log records the first campaign 2026-09-06 20:57:56 -04:00
ScottW514 0df0162c8d forgetest: the sheet test covers the runner's header; the hollow generator entry is dropped 2026-09-06 20:23:03 -04:00
ScottW514 54f65746cd forgetest: the page's theme follows forgectrl's at the pinned revision 2026-09-06 20:18:47 -04:00
ScottW514 60e06c77f0 forgetest: the lens stall drills are named as shell-only in the bench registry; the campaign log records the pushes and the build 2026-09-06 20:09:44 -04:00
ScottW514 8795a6d527 pins: forgectrl, grblHAL-glowforge, and forgefirm-app at the commissioning revisions 2026-09-06 19:56:29 -04:00
ScottW514 97287aa6a9 commissioning: the layer, the acceptance tests, the harness rule, the docs, and the bench drills
meta-forgefirm: the forgefirm-users init replays the account at boot;
sshd refuses root and empty passwords and runs only while the panel
turns it on; the release image keeps an empty root password for the
console; the console banner; avahi announces forgefirm.local; https in
libmicrohttpd and ulfius; the panel on 80 and 443; the license bundle on
the rootfs; release.sh checks the root policy on the built rootfs.

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

Docs: BRINGUP carries the present state; CAMPAIGN-LOG carries the dated
record.
2026-09-06 19:56:05 -04:00
ScottW514 ff9796cde6 docs: the 2026-09-01 audit is retired
Its last finding, the request-body cap, ran on image 20260904131106 in
campaign c-20260904132654-d731. forgectrl.auth carried the case: a 4 MiB
body from a client with no token was refused with 403, and the daemon
answered /status in the same second. The campaign is 56 of 56,
authorized.

That was the only audit item left in Next work, so item 9 goes and the
list is items 1 to 8. The record of the campaign, and of the retirement,
is in CAMPAIGN-LOG. The audit file is deleted, as the 2026-07-03 and
2026-08-13 audits were before it.
2026-09-04 10:15:10 -04:00
ScottW514 da0299924f pins: forgectrl at the revision the campaign ran
The request-body cap is on the bench. Image 20260904131106 carried
forgectrl ff89288 through a build overlay, and campaign
c-20260904132654-d731 authorized it, 56 of 56.

The pin now says what the campaign ran.
2026-09-04 10:15:02 -04:00
ScottW514 637fb67007 docs: the audit's follow-through closes, its last finding stands alone
Three items in Next work tracked the 2026-09-01 audit: the remediation
follow-through, the PIC readings, and the deferred six. All three are
done, on one image, and the campaign the release gate asks for passed on
it, so they belong in the record rather than the open list.

What remains of the audit is one finding, the request-body cap, which is
now fixed and host-proven and needs an image and a campaign. It takes
their place as item 9.

forgectrl.auth gains the case that guards it: an oversized body from an
unauthenticated client is refused and the daemon is still serving after.
2026-09-03 18:41:46 -04:00
ScottW514 9c6b13e510 pins: the revisions the authorized campaign ran
forgectrl 8ef9509 at 0.1.2, grblhal-glowforge 2f5edee at 0.1.2, and the
forgefirm-app recipes at dd0ebf3, 0.1.23+git. These are the sources the
full campaign on image 20260903211413 passed against, now that their
repositories are pushed and the build no longer needs the local overlay.
2026-09-03 18:12:01 -04:00
ScottW514 5a74213919 docs: the full campaign on 20260903211413, 56 of 56, authorized
The campaign the release gate asks for, run on one flash with nothing
inherited and nothing hot-deployed. The entry records how the pause test
passed, not only that it did: its emission trail drains to zero and stays
there, against the earlier run's second kernel run inside the pause, and
the difference between them is that the actuator made the presses.

Also recorded: the two harness faults found on the way, the actuator
dropping off the network twice and being asked for silently, and what
now goes in the record so a repeat is answerable.

BRINGUP moves to the campaign image and drops the campaign from the owed
list, leaving the push and the pin bumps.
2026-09-03 18:09:05 -04:00
ScottW514 d6f648b539 forgetest: presence is proved once per test, not once per ready gate
The ready gate lives inside the arm-and-fire helper, and the kill drill
calls that helper twice, once for the expected stop and once for the
SIGKILL. So the presence gate asked the operator for a second press part
way through a test they had already proved themselves present for, with
the actuator standing by holding the presses. It is the only test in the
catalog with two ready gates.

The second gate now returns at once. Its setup line still goes up,
because the second half may want the scrap moved, but there is no press
to make.
2026-09-03 18:08:16 -04:00
ScottW514 2f330715f3 forgetest: record the actuator's radio signal and uptime with every run
The bench actuator is an ESP32 on wifi and it reports its own signal
strength, which reads -83 dBm here, close to where an association
starts dropping. It has vanished twice tonight and taken a live test
with it, and neither time did the record say anything a reader could
use: only that it was gone.

Both numbers now go into the run's evidence, so the next drop says
whether the link faded or the box restarted.
2026-09-03 17:08:02 -04:00
ScottW514 ba0bf41749 forgetest: the operator proves presence at the machine, the bench presses
A live test asked a person to click Ready on a page and then make
timing-critical presses in the middle of a burning cut. That is how
tonight's pause test became unanswerable: the actuator had dropped off
the network, the harness fell back to the operator without saying so,
and afterwards nobody could tell a second press from the machine
resuming on its own.

Where an actuator is up and wired to the button, the ready gate now
takes a press on the machine's own button as the presence check, and the
actuator performs every press in that test. The button does nothing at
Idle, so the press is only a presence check, and the gate waits for the
release so it is never read as the arm press. With no actuator the
operator does the presses and answers on the page, as before.

An actuator lost after that takeover is now said out loud, in the log
and in the evidence, instead of quietly becoming a person's press.

The live-fire cue was four lines of machine-shaped prose. It is now what
a person needs: protection, exhaust, extinguisher, scrap, lid.
2026-09-03 16:55:34 -04:00
ScottW514 da0c70703d docs: the shakedown on 20260903163543, and the arm fix under live fire
The whole catalog ran green, 56 of 56, but on a hot-deployed suite file,
so it authorizes nothing; the entry says so first. What it did was find
the one real defect, the pic-soc-load bound that decided on a count of
noise, and put the arm-acknowledgment fix under live fire: the emission
witness passed with its airflow witness finding nothing, on the same
instant button press that produced last night's burn.

Also recorded: the boot clock that never stepped, and the bench fixture
dropping off the network mid-queue with no address pinned to fall back
to.
2026-09-03 15:16:24 -04:00
ScottW514 7968ebd347 kernel.pic-soc-load: the move bound is a third of the split, not half
The check decided on one count of noise. Measured over eleven runs on the
bench the settled reader moves 3 counts off the idle regime (once 2, once
4) against a control split of 6 (once 7), so a half-split bound sits
exactly on the median: two of those eleven runs failed while the machine
read identically to the nine that passed, and the queue stopped on one of
them.

The kernel's spin is its own load level, a couple of counts under a
Python spin, so a settled reader lands above the idle regime without
reaching the busy one. Asking it to reach halfway was asking for
something the mechanism does not promise. What the check has to catch is
a settle that overshoots and lets the conversion fall back to idle, which
reads as no move at all, and a third of the split catches that with a
count of margin either way. The split collapsing is still the primary
proof, unchanged above.
2026-09-03 14:28:48 -04:00
ScottW514 0845ab32a1 docs: build p33, image 20260903163543, the campaign's image
The record of what the first attended run really found and what it cost:
the verdict had no run-session identity, so the arm opened its window on
the verdict computed for the idle session before it. The build entry
lists the four components taken from local commits and the checks made
on the built images.

BRINGUP moves to the new campaign image and says why nothing inherits.
2026-09-03 12:46:39 -04:00
ScottW514 db9acf9910 forgetest: witness the airflow behind the beam; harnesses carry the armed flag
The cooling verdict now carries the engine's own armed flag, so the
stand-in engines in both null-sink harnesses publish it. The lifecycle
harness gains two cases: an engine that never takes the armed window
must produce a refused arm and no emission, and one that takes it a
couple of seconds late must produce a wait and then a normal arm. The
late case is the one that proves the controller keeps reading the
verdict while it is blocked in the arm; without that every job would
fail there.

The emission witness gains the bench form of the same rule: no sample
may show the laser firing while the cooling engine reports a phase that
runs the fans at their idle duty. That is what a burn with no airflow
looks like from the outside, and nothing in the catalog looked for it.
2026-09-03 12:26:59 -04:00
ScottW514 aa0d86635c CAMPAIGN-LOG: the attended set stopped at its first test
The record of the live-fire failure on 2026-09-03: a job fired for about a
second with idle airflow under a warm-up hold that arrived one tick later,
the leftover start-gate setting behind it, the engine's arm-time gate gap it
exposed, and what is owed before any further live fire.
2026-09-02 21:59:31 -04:00
ScottW514 ec9e9860c9 CAMPAIGN-LOG: the unattended set on image 20260903011655, 44 of 44
The record of the campaign's unattended set on p32, the numbers the two new
kernel drills and the air-assist offset diagnostic gave under the settle,
and the forgetest restart that interrupted the last test, with the rule
that keeps it from recurring.
2026-09-02 21:49:18 -04:00
ScottW514 9e862971cf kernel.pic-soc-load: the settle's proof is the move off the idle regime
The kernel's spin is its own load level, a count or two under a Python
spin, so the third check no longer compares the settled level with the
Python-spin control; it requires the settled idle reader to have moved at
least half the control's split off the idle regime. Bench: PASS on image
20260903011655 (control split +6, settled split +1).
2026-09-02 21:46:35 -04:00
ScottW514 cf7490de78 docs: build p32, image 20260903011655, the campaign's image
BRINGUP item 9: items 10 and 11 are on image 20260903011655; owed is the
flash, the fresh-boot baseline, and the full campaign, then the push and
the pin bumps. CAMPAIGN-LOG: the p32 build and its checks.
2026-09-02 21:22:35 -04:00
ScottW514 92c69f23fa kernel.pic-soc-load drill; the PIC worked backward from its firmware
The PIC16F1713 converts its inputs in a free-running loop (10 channels,
about 0.30 ms a loop) and a read returns the last conversion of that
channel; the count follows the SoC's load at conversion time (idle 659,
busy 665 on the coolant thermistors, both tight; every channel shifts in
proportion to its count; the step lands within one PIC loop of the CPU
changing state, with a regulator's overshoot in each direction). The
kernel.pic-soc-load drill replaces kernel.pic-pacing: 200 reads after 3 ms
of sleep and 200 after 3 ms of spinning, with the module's settle off
(the control, reported) and on (the claim: the two agree). The catalog
counts 56 tests, 0 uncovered.

BRINGUP item 10 and the facts bullet describe the mechanism and the fix;
the CAMPAIGN-LOG entries record the first pass of the campaign, the PIC
study, and the mechanism's proof.
2026-09-02 21:15:34 -04:00
ScottW514 7c45642f05 image.health: the watchdog's proof is WDOG1's own WCR, not the sysfs state
The sysfs state of watchdog0 says whether a process holds the device, and
none does: the kernel's core feeds the boot-armed hardware. The check
reads WCR through /dev/mem (WDE set, a 60 s period) and expects the state
to read inactive. Bench: PASS on image 20260903003213 with WCR 0x771f. The
BRINGUP facts bullet says the same.
2026-09-02 20:46:48 -04:00
ScottW514 5d2aa41a1c docs: build p31, image 20260903003213, the campaign's image
BRINGUP item 9: items 10 and 11 are on image 20260903003213; owed is the
flash, the fresh-boot baseline, and the full campaign, then the push and
the pin bumps. CAMPAIGN-LOG: the p31 build and its checks.
2026-09-02 20:38:45 -04:00
ScottW514 358c287891 kernel.pic-pacing drill; BRINGUP item 10 and the record
kernel.pic-pacing reads a coolant thermistor twice back to back, 300
pairs, with the module's pacing off (the control, reported) and on (the
claim: the second read agrees with the first). The bench proof for the
module's pic_gap_us pacing; the catalog counts 56 tests, 0 uncovered.

BRINGUP item 10 and the facts bullet describe the pacing as it is; the
CAMPAIGN-LOG entry records the change and its proof.
2026-09-02 20:30:41 -04:00
ScottW514 1f2c795d8c docs: build p30, image 20260903000529, the campaign's image
BRINGUP item 9: the deferred batch is on image 20260903000529; owed is the
flash, the fresh-boot baseline, and the full campaign, then the push and
the pin bumps. CAMPAIGN-LOG: the p30 build and its checks.
2026-09-02 20:13:31 -04:00
ScottW514 9711a7fa06 The audit's deferred findings: the checksum exclude, the resume-lead drill, the record
B-16: BB_SIGNATURE_LOCAL_DIRS_EXCLUDE in the distro conf names __pycache__
and .pytest_cache, so a workstation's bytecode caches never enter a
file:// checksum (proven in the build VM: a cache under the package leaves
the fetch task alone, a source change reruns it).

kernel.resume-lead: two phases behind one takeover at a 1 kHz tick. E, a
resume whose lead is longer than the data ends at end-of-data within a few
ticks (a lost end-of-data would show as 255 ms). L, a 1000-byte lead over
FIRE bits with the latch unlocked and the chain unarmed keeps the FIRE
line low through the lead and drives it from the waypoint byte on. The
bench proof for the module's K-4 and K-8 fixes; the catalog counts 55
tests, 0 uncovered.

BRINGUP items 9 and 11 and the CAMPAIGN-LOG entry record the batch and the
campaign rule: no campaign until every audit finding is on one image.
2026-09-02 20:03:22 -04:00
ScottW514 abccbf48e5 docs: build p29 on the pushed pins, image 20260902230436
CAMPAIGN-LOG: the push in CI order, the pin bumps, and build p29 with its
checks. BRINGUP item 9: the remediation is pushed, pinned, and built; owed
now is the flash, the fresh-boot baseline, and the full campaign on the
image, which the release gate asks for.
2026-09-02 19:15:52 -04:00
ScottW514 77c11c52e4 forgetest CI: run the python steps with -B so no bytecode enters the recipe fetch
B-16: the forgetest recipe fetches the package directory whole, and the
file fetcher's checksum is taken before do_unpack drops __pycache__, so a
host `__pycache__` written by running the tests moves the recipe's task
hash with no source change. The three python steps run with -B now;
`src-sync` to the build VM excludes the caches as well. A developer who
runs the tests locally without -B still creates them; there is no exclude
on the file fetcher, so this is the floor, not a cure (BRINGUP item 11).
2026-09-02 19:09:07 -04:00
ScottW514 05cf7341a4 pins: forgectrl 522cdb2 (0.1.1), grblhal-glowforge b951f3a (0.1.1), forgefirm-app 1511336 (0.1.22+git)
The audit remediation's commits and the bench session's fixes in the
three repositories, pushed and bench-proven on image 20260902144848
(built from the same commits through a local pin overlay). Each PV
moves with its SRCREV; the manifest records the PV.
2026-09-02 19:00:12 -04:00
ScottW514 82c3596d6a BRINGUP: item 11, the B-16 residual and its actual plan 2026-09-02 18:59:07 -04:00
ScottW514 867136d77e BRINGUP: the Next work list runs 1 to 11, and the closing paragraph closes it 2026-09-02 18:55:25 -04:00
ScottW514 b99c92e047 BRINGUP: the audit's deferred findings as Next work item 11, with the plan for each 2026-09-02 18:51:03 -04:00
ScottW514 5cb18e64ab BRINGUP: the fielded bootloader and the watchdog path, as measured 2026-09-02 18:46:11 -04:00
ScottW514 38cce236a0 forgetest: image.health proves the boot-armed watchdog is active at 60 s
The hardware watchdog is armed by the bootloader and kept fed by the
kernel core; nothing in userspace opens it. Until now nothing in the
catalog read that it is running. image.health reads watchdog0's state,
timeout and bootstatus (CONFIG_WATCHDOG_SYSFS, meta-openglow) and
requires active at 60 s; bootstatus goes into the evidence, so a campaign
that follows a watchdog reset says so.

Bench: on 2026-09-02 the forced hang ended in the factory recovery after
the 60 s timeout; the register read WCR 0x771f. The sysfs view itself
arrives with the next image, so this check is proven there.
Catalog consequence: image.health is always-run.
2026-09-02 18:39:17 -04:00
ScottW514 380f67d666 docs: the forced kernel hang and the two measurements closed
CAMPAIGN-LOG: the forced hang on image 20260902144848 with its times,
the watchdog register read back, U-Boot's recovery branch (the purple
button), the recovery image on the lease, and the power cycle back; the
pad measurement dropped by decision. BRINGUP item 9: both measurements
closed; the pushes, the pins, one build and the campaign on that image
remain.
2026-09-02 18:30:38 -04:00
ScottW514 61f44011aa docs: the pooled bench session's second pass
CAMPAIGN-LOG: the attended set green on image 20260902144848, the three
defects the live runs and the takeover restarts found (the dwell-gap
latch rule, the log drops, the busy-start airflow) and their fixes, and
what is owed. BRINGUP item 9: both queues green; the measurements, the
pushes, the pins, one build, and the campaign on that image are what is
left.
2026-09-02 18:14:18 -04:00
ScottW514 9258dea885 forgetest: fans-quiet also proves a daemon restart on a busy machine returns to idle airflow
A forgectrl started while the kernel is not idle takes the cooldown
airflow (forgectrl's busy-start rule), and on the bench it kept it: after
kernel.fire-line's takeover restarted the daemon with the kernel in the
drill's safe state, the exhaust ran at 6200 rpm on an idle machine until
the daemon was restarted by hand. cooling.fans-quiet-after-motion gains
the case: forgectrl stopped, cnc/disable written, forgectrl started, and
within 90 s the controller must be running with the idle duties applied.
The host replay stubs the init script and holds both outcomes: the idle
duties after the start, and a daemon that keeps the cooldown duties.

Bench: with forgectrl 522cdb2, the busy start logged, idle airflow one
tick later, the duties idle 15 s after the start, PASS.

Catalog consequence: the cooling.* implementation hashes move.
2026-09-02 18:14:07 -04:00
ScottW514 970f10a9e2 forgetest: the dwell-gap latch rule judges the hardware's unlocked window, the live runs keep a trail
laser.emission-witness required the hardware button latch clear in every
sample the engine reported armed, and, after a first fix, in every sample
up to the last nonzero emission count. Both windows were drawn from
lagging signals: the engine's armed flag follows the controller's next
report, and the emission counter latches once per second and reads
nonzero about two seconds past the relock. Both reached into the tail
where the job-end relock sets the button latch by design, and the rule
refused three clean runs on image 20260902144848 (all four sides
burned; the trail shows the latch clear from the press to the relock,
emission through the fourth side, HV_ENABLE's dip in the dwell and its
return).

The rule now uses the window the hardware defines: from the first
emission, in every sample whose readback word shows the laser latch
unlocked, the button-latch bit of that same word must be clear. That
spans the kernel-run gap of the dwell and ends at the relock, and no
lagging flag can misplace it. dwell_gap() is a pure function;
tests/test_laser_dwell.py holds the relocked tail, a set inside the gap,
and a trail without emission. The recorded trail of the third run
replays to a pass (47 unlocked samples, none set).

The live runs keep a per-sample trail in the evidence (TRAIL_FIELDS: the
readback word, the switches, the lock flag, the controller's state and
messages), so a run's timeline can be read back without a rerun.

A fourth run then errored on a name the refactor had removed and one
later check still used; py_compile does not catch it and a live drill
never executes on the host, so the CI job now fails on any undefined
name in the harness (pyflakes).

Catalog consequence: the laser implementation hashes move.
2026-09-02 17:42:07 -04:00
ScottW514 3bb16a48c3 logging: the unix datagram queue holds a daemon's burst
The daemons log to /dev/log with non-blocking datagrams and drop what
the socket will not take. The kernel's default queue for a unix datagram
socket is 10; forgectrl's arm-time burst alone was about 20, so the
lines around every job start were lost, and the daemon reported 18 to
46 dropped per job on the bench. The logging init sets
net.unix.max_dgram_qlen to 512 before rsyslog and the daemons start;
nothing on the image applied sysctl files before.

Bench: set at runtime on 20260902144848 for the rest of the session.
Catalog consequence: a layer change; the meta-forgefirm content hash
moves and nothing inherits on the image that ships it.
2026-09-02 17:26:53 -04:00
ScottW514 774ae52b61 docs: the pooled bench session's first pass, and the PIC read pattern
CAMPAIGN-LOG: the unattended set on image 20260902144848, the six
harness and diagnostic defects it found and their fixes, the numbers,
and the board's state at the end of the pass. BRINGUP: item 9 names the
reset in the hang case and the session under way; the facts bank gains
the PIC read-pattern measurement; Next work item 10 is a pacing of PIC
reads in the kernel.
2026-09-02 17:02:33 -04:00
ScottW514 7605a90946 forgetest: the update drill reads the daemon's reply, and a queue start waits for the fixture probe
update.slots-and-signature's apply section required 200 from
POST /update/apply, where the daemon answers 202 with started, like
every job endpoint, so its first bench run on image 20260902144848
ended before the job did; the cleanup then deleted the staged archive
under the running job. The drill requires 202 and started, and looks
for the daemon's refusal ("archive is not signed with the ForgeFIRM
release key"). Bench: the apply started, the job ended with that
refusal, PASS.

A queue started 4 s after a forgetest restart ran 7 tests instead of
10. The bench page's /state poll had a fixture probe in flight (an mDNS
answer), probe_fixture stamped its time at its start, and the queue
start read the stale fixture, none, so the three operator tests the
fixture runs in the unattended queue were routed to nobody. The probe
now runs under a lock and is stamped when it completes: a caller that
arrives during a probe waits for its answer. tests/test_fixture.py
holds the race with a slow scripted probe; it fails on the old code.

Catalog consequence: the update implementation hash moves; the runner
change is dev-only.
2026-09-02 17:02:33 -04:00
ScottW514 1fef9c6f51 forgetest: the air-assist offset calibration runs before the heater tools
A queue runs the catalog in registration order among tests with the
same prerequisites, and cooling.aa-offset-calibrate followed
cooling.flow-verify. A flow-verify trial heats the tube water (a no-flow
trial by 17 C on the bench) and the warm slug circulates past the
coolant sensors for minutes afterward; the calibration's stationary gate
passed 44 s after the trial on image 20260902144848 and the edges read
the wave as disagreement.

The calibration is registered first now, with the reason beside it, and
tests/test_cooling_order.py holds the order.

Catalog consequence: the cooling.* implementation hashes move (the suite
file changed).
2026-09-02 16:21:13 -04:00
ScottW514 d5369630d9 forgetest: motion.deadman recovers the hung controller with a reset, then the unlock
The hang case resumed the controller and sent $X alone. The stream
fault raises Alarm 17 (motor fault), which the core treats as a critical
event: $X is refused with error:79 until a soft reset, and the reset is
what the stream takes as the operator's acknowledgment of the fault
(grblHAL 38b450e: the kernel stopped and re-armed, the stale ring
cleared, the producer armed again). The drill's first bench run on image
20260902144848 therefore ended in Alarm.

The drill now records $X before the reset and requires the error:79
refusal, sends the soft reset, unlocks, requires Idle, requires the ring
back at its idle free count (the stale bytes of the interrupted move are
gone), and then jogs. The final assertion read the state off the report
dict returned by wait_idle as a string; it never ran before because the
TIMEOUT test short-circuited it.

Bench: motion.deadman PASS on 20260902144848 (kill respawn 1.2 s, hang
to underrun 0.21 s, $X -> ALARM:17 error:79, reset + $X -> Idle, ring
33521664 of 33521664, jog Jog -> Idle, restart retook supervision).

Catalog consequence: the motion.* implementation hashes move (the suite
file changed) and the set re-ran and passed. The failed first run had
closed the campaign, so the always-required core ran again in the new
one, as the campaign rules require.
2026-09-02 15:55:08 -04:00
ScottW514 b3efab9c46 forgetest: the latch-unlock gate waits for the safety chain to release
The gate that refuses a latch unlock while the chain may hold HV_ENABLE
up (charge_pump_alive or a pulse engine not idle) ran at the start of
phases B, U and K3 of kernel.fire-line, within a second of the previous
phase's run. A run feeds the charge-pump watchdog every 200 ms and the
one-shot holds ALIVE for 0.45 s after the last feed, so the gate read
alive=1 and refused: the first bench run of the gate (forgefirm
64f552fc; the laser_pgood gate before it was vacuous) failed phase B on
image 20260902144848.

wait_hv_off() polls the chain for up to 3 s before it refuses, logs the
release when it was not immediate and records every wait in the
evidence (hv_release_s). require_hv_off and check_hv_off use it. The
bench scripts that copy the gate (fire_test.py per phase,
gate_a_kernel_drills.py K3 after K2) get the same wait.

Bench: kernel.fire-line PASS on 20260902144848 with the chain released
after 0.41 s at each of the three phase boundaries. Host:
tests/test_kernel_suite.py covers release inside the window, a chain
held past it, and a chain already off.

Catalog consequence: the kernel.* implementation hashes move (the suite
file changed); the kernel set re-ran and passed.
2026-09-02 15:39:24 -04:00
ScottW514 133b61a062 forgetest: image.health matches the kernel by identity, hash aside
The manifest lists the modules directory without the kernel's
LOCALVERSION_AUTO hash (the hash does not reproduce across a re-patch of
the same source, so the image manifest strips it). image.health still
compared the full running release against that list and failed on the
first post-flash run of image 20260902144848 with the kernel
6.12.20-fslc-fslc-g72a0b1431a9d against the manifest's 6.12.20-fslc-fslc.

kernel_ident() strips the same suffix from both sides, so a manifest with
or without the hash matches the running kernel, and a different base
release still fails. tests/test_image.py covers both forms.

Catalog consequence: only image.health's own implementation hash moves;
it is an always-run test, so no inherited result is affected.
2026-09-02 15:27:36 -04:00
ScottW514 75c4a65444 CAMPAIGN-LOG: the audit remediation, host-tested and committed locally 2026-09-02 09:59:40 -04:00
ScottW514 88ec984e28 Audit follow-through: runbook, bench tools, recipes, release tooling
BRINGUP describes the present: the 54-test catalog and its seven-test
always core, the tier counts, the shipped low-temperature gates, the
density floor ($35 = 10), the two local core commits, the ffboot env
write, the aa-offset route, the current bench image, and the bench
measurements the audit asks for (pooled into the next session). The
workstation shell notes and every em dash are gone.

forgetest: the takeover waits for the cloud client too (found by its
command line); the unauthenticated /boot probe names the endpoint's
parameter; the UI prose is American English. Recipes: forgetest
fetches its package directory and init script only and drops
__pycache__ at unpack; the dev image no longer re-adds forgectrl; the
release image's remove list drops the gfui-client the BSP no longer
has; the platform identity strips the kernel's local-version hash
from the modules directory name, so a re-patched kernel keeps its
fingerprints. grblhal restart is stop then start. release.sh --dev
packs the dev image. fixture.sh refuses a readable env file.

Bench tools: the live-fire drills measure the lid-IR baseline before
every run and point at the fire-watch thresholds the engine reads;
one thermistor conversion (gfbench.degc) serves every drill; the six
dated measurement records leave the tool directory; feeder.c names the
two sysfs writes its caller makes.

Host tests: forgetest 258 pass; the coverage lint reports no uncovered
path across 54 tests. Acceptance: forgectrl.auth covers the /boot
probe; update.* cover ffboot and the manifest identity; the runbook
and bench-tool changes have no catalog consequence.
2026-09-02 09:51:23 -04:00
ScottW514 6002da8d12 ffboot: probe slots without a journal replay
A read-only mount of an ext4 slot still replays its journal, which
writes to the partition the probe was not meant to touch. The probe
mounts with noload: the journal is left as it is and the slot's
content is read as it stands.

Acceptance: update.slots covers ffboot -l (covers map ffboot/**).
2026-09-02 09:12:01 -04:00
ScottW514 0f28427c22 bench and catalog: the controller's cancel messages spell canceled; the needles follow 2026-09-02 08:58:59 -04:00
ScottW514 3b21057dfe BRINGUP: the audit remediation follow-through is a Next work item 2026-09-02 08:22:03 -04:00
ScottW514 b182a5ab0e acceptance: helper imports move the fingerprints, and the update test verifies a foreign signature
A test's fingerprint covered its own text and its module's shared text
only, so a judge imported from a sibling suite module (laser.py takes
its motion judges from motion.py) could change without moving the
fingerprints of the tests that call it. The shared text of every sibling
module a module imports now rides along, transitively; unit test.

update.slots-and-signature claimed to refuse a tampered signature but
fed fwup one garbage file. It now makes a throwaway key pair on the
machine, signs a tiny archive, checks that the archive verifies with its
own key and fails against the shipped release key, and asks the update
job to apply it without confirm_unsigned: the job refuses it for its
signature before touching the slot.
2026-09-02 08:21:01 -04:00
ScottW514 b132965e15 docs: the runbook's settings inventory and the bench page's help links are current
BRINGUP named nine cool_* tunables (there are thirty), a cool_fire_ir_delta
key nothing reads, and panel source files that no longer exist; the
inventory is the present one. The bench page's help popovers pointed at a
documentation host and paths that do not exist; they open the site.
2026-09-02 08:15:12 -04:00
ScottW514 d9a0990510 release: the first release is 0.0.1, and a beta cannot be numbered 0.1.0
FORGEFIRM_RELEASE said 0.1.0, the first non-beta number by the settled
rule, so the first cut was either refused as 0.0.1 or shipped as a
non-beta. The recipe now says 0.0.1, and release.sh refuses a version at
or above 0.1.0 while the README carries the beta banner.
2026-09-02 08:08:20 -04:00
ScottW514 a2bc4233d5 bench: the stale-origin drill goes, drills report their verdict, the pgood gate follows the pin
The faultpos live-fire drill armed and commanded a cut at an origin it
called stale to test a refusal the design decided not to gate: its only
outcome was an emission at an unknown position. Removed from the script
and the bench page.

live_fire_drills.py discarded every drill's return value, so the bench
page recorded a failed live-fire drill as OK. The exit status is the
drill's.

laser_pgood is the supply's power-good, high on every healthy machine;
fire_test.py and the K3 drill aborted on it and pgood_probe.py inverted
it. The latch-unlock drills now gate on the safety chain holding HV off
(charge-pump watchdog dead, pulse engine idle), as the kernel suite
does, and the probe reports the pin as the kernel publishes it.

motion.deadman: the controller resumed from its hang recovers on $X and
moves again without a restart (the stream's fault acknowledgment).
2026-09-02 08:08:19 -04:00
ScottW514 8cf2212e63 install: the factory archive is written aside and verified before it is trusted
The archive was written straight to its final name and a rerun accepted
any non-empty file as complete, so a run interrupted mid-archive left a
truncated image that the next run kept and then overwrote the slot. The
archive is now written as .part and renamed on success; an existing
archive counts only with its manifest line present and a whole gzip
stream, otherwise it is archived again.
2026-09-02 08:00:51 -04:00
ScottW514 5b29a8f9d3 release: a skipped acceptance gate ships no acceptance artifact
With the gate skipped the script still staged and attached whatever
acceptance.json existed, indistinguishable from a gated release, and
sha256sums.txt never listed the artifact. A skipped gate now stages
NO-ACCEPTANCE.txt instead and publishes as a prerelease; every attached
file is listed in the sums.
2026-09-02 08:00:51 -04:00
ScottW514 0e37b0812e acceptance: a later FAIL blocks inheritance, ffboot is a component, the units fallback is valid
The inheritance walk skipped every record that was not a PASS on the
current fingerprint, so a FAIL or ERROR recorded after a PASS on the
same image was stepped over and the older PASS inherited into the next
campaign. The newest record on the fingerprint now decides: a PASS is
inherited, a FAIL or ERROR blocks it (reason failed-since), an ABORTED
run says nothing. Unit tests for all three orders.

ffboot, the tool that rewrites the boot environment on every install and
slot switch, was packaged from scripts/ outside every fingerprint. It
now lives in the recipe's files and the recipe inherits the manifest
class; the tree manifest tool fingerprints file components the same
way, and the update tests cover the component.

forgectrl.settings-bounds fell back to ui_units=mm, which the whitelist
refuses, so the always-required test failed on a fresh machine; the
fallback is metric.
2026-09-02 08:00:51 -04:00
ScottW514 0ca6c4be9f bench: the stream harness pins jogs dark and the rolloff's ratio
Rule 22: M3 S1000 with the window open, then jogs from Idle (a sender's
Fire button plus its Move panel) ship every jog tick dark, and the cut
after them is lit. Rule 23: two cuts at S300 and S1000 queued together
render the S300 cruise at the S300 density; the rolloff shapes against
the segment's own velocity ratio, never the newest S over the block's.
Both fail on the driver before its fix.
2026-09-02 07:49:36 -04:00
ScottW514 44ba07ea94 forgetest: cloud.verdict-hold drills the warm-up wait of an armed print
The start gate set just above the coolant opens the armed session under
the warm-up; the cloud client waits it out after the button, nothing
runs, and the release starts the print, which completes. The mid-run
hold and its bound are host-tested: the gates apply at session open, so
no setting can produce a hold mid-run on the bench. Bench-excerpt unit
tests: the pass, and the failure when a run starts under the hold.
2026-09-02 07:24:35 -04:00
ScottW514 c4717c6926 forgetest: kernel.deadman-close drills the dead man's switch
A pulse device closed while locked and running is the kernel dead man's
switch. The drill enables the lens driver at low current, runs a padded
stream under the takeover, drops the device with the lock held, and
checks that the engine halts at once, the latch is locked, and the head
is in its safe state (z_enable 1, z_current 1, measure laser and UV LED
off). Always required; the kernel covers map already names src/head.c.
2026-09-02 07:09:06 -04:00
ScottW514 8ff37d188d forgetest: the lid-at-button-wait drill uses a job longer than the ring
A job longer than the ring keeps its feeder alive through the button
wait, so the cancel there has to stop the feeder before the park clears
the ring. The drill now loads such a job, requires the "longer than the
ring" line, and after the cancel reads the program total twice over the
feeder's retry period (zero both times) and cnc/streaming (zero). The
bench-excerpt unit test carries the long-job line and the new evidence.

Covers map unchanged: the drill already names gfhardware/machine.py.
2026-09-02 06:52:09 -04:00
ScottW514 c88c1979ca CAMPAIGN-LOG: correction, raw content URLs do follow a transfer 2026-09-01 19:57:37 -04:00
ScottW514 8a01b239ce CAMPAIGN-LOG: the repositories move to the openglow-org organization 2026-09-01 19:54:00 -04:00
ScottW514 ef2a8607c2 repos: the sources live in the openglow-org organization
The recipe URLs, the release and install URLs, the vendor check and the CI checkouts name openglow-org, and the grblHAL core fork is openglow-org/grblHAL-core. No catalog change is owed: the recipe edits move the meta-forgefirm content hash, which every test fingerprint folds in through the platform block, so the whole catalog re-runs on its own.
2026-09-01 19:44:47 -04:00
ScottW514 758b17c5fe bench README: the power-good probe row names no login 2026-09-01 18:56:16 -04:00
ScottW514 30dba96883 forgectrl: pin 37f4f04 (the supply power-good warning) 2026-09-01 18:50:33 -04:00
ScottW514 64f552fc28 laser power-good: the line characterized, the kernel-drill guard, the probe, the dev image's mmap and ctypes
The supply's power-good line is active high, static across HV enable and emission, and driven; the facts bank and CAMPAIGN-LOG carry the measurement and the item closes. The kernel-drill latch-unlock guard read the old inverted value as HV not good, a check that was vacuous and would refuse every run once the module reads the line correctly; it now uses the chain's own witnesses, the charge-pump watchdog and the engine state. pgood_probe.py watches the line beside the chain through the kernel readbacks and is registered on the bench page. The dev image lists python3-mmap and python3-ctypes again for the pad-level bench tools the python trim had left without them.
2026-09-01 18:48:47 -04:00
ScottW514 fb804e32bd grblhal-glowforge: pin 8dcf416 (the held-job resume gate and the sender-change hold) 2026-09-01 18:05:53 -04:00
ScottW514 8a9c6a9062 laser: feed hold and resume in GRBL mode, the sender-change hold
Stream harness rule 21: a feed hold leaves no dark ground in either mode (lit into the hold, dark while held, lit from the first step out), with realtime and wait-state steps in the session runner. Lifecycle harness: the hold a sender change puts a running job into, the resume that re-arms a held job from the sender and from the button, a reset from a held job, and the resume after the grace closed the window in Hold. Live-fire drills: holdres (the pause as a corner in time, the re-arm after the grace); senderchg follows the hold. BRINGUP: the gapless-pause and sender-change items close, the facts bank gains the measured hold and resume behavior; CAMPAIGN-LOG records the proof and the bench runs. Acceptance: the pause-resume-lid-cancel text follows the behavior; the driver stays covered by src/**.
2026-09-01 18:03:45 -04:00
ScottW514 6a48cd3969 docs: the moved documents live on the documentation site
INSTALL.md, SERIAL.md, docs/COOLING.md, docs/LIGHTBURN.md,
docs/MOTION.md, docs/SAFETY.md, docs/UPDATE-SYSTEM.md, docs/VIDEO.md and
their images are pages on https://docs.forgefirm.org/ now. Every
reference in the README, BRINGUP, the kas config, the cold-build
workflow, forgetest, and the bench scripts points to the site page. The
README carries the beta banner. docs/ keeps BRINGUP.md and
CAMPAIGN-LOG.md.

No catalog consequence: the deleted files are documents, and the code
changes are comment and help-text repoints only.
2026-09-01 15:33:17 -04:00
ScottW514 8a93202c00 BRINGUP: the crash watch is done; item 6 is the head IRQ alone
No catalog consequence: a status-document edit.
2026-08-31 19:31:15 -04:00
ScottW514 a100c9c3eb Pin forgectrl: the crash-watch keys are writable settings 2026-08-31 19:04:35 -04:00
ScottW514 c7c96f9cbe Pin forgectrl: the crash watch; its catalog case and the drill's latch bit
forgectrl 161686f adds the head-accelerometer crash watch (two tiers
on the LIS2HH12's interrupt generators, armed inside the laser's
armed window). The catalog grows cooling.crash-watch-plumbing - the
unarmed half of the watch provable without a button press: accel_watch
stays at watch through an unarmed session, three zeros read as the
three crash gates off, an out-of-range threshold is refused - and
_COOL_COVERS widens to src/accel.*. The armed-tier bench drill is a
BRINGUP next-work item.

The de-risk drill script's CTRL7 write moves from bit 0 (4D_IG1) to
bit 2 (LIR1), the latch the datasheet actually puts there; the drill's
findings did not depend on latching (a continuous gravity signal).
2026-08-31 18:47:49 -04:00
ScottW514 4a0338a03f bench+docs: IG threshold LSB is FS/256; factory HA* seed values recovered
The captured pulse headers carry the factory's IG programming: hunts
off, travels abort-only (HAar=133 at +/-4 g), the cut alert-only
(HAxr=132, HAyr=112), Z and idle never armed. The factory abort value
itself rules out an FS/128 LSB (4.16 g would sit over the measurable
range), and two Z-only bench windows bracket gravity between
thresholds 100 and 150, which only FS/256 explains. The drill script's
printed g conversion is corrected, and the stale motion-profile
reading of HAxr/HAyr/HAar as accel limits is corrected in the facts
bank.

No catalog consequence: a bench script's printed conversion and the
docs record; no shipped behavior changes.
2026-08-31 18:26:58 -04:00
ScottW514 61b2913e3a bench: crash-probe drill run, coexist proven, detector is forgectrl-only
The de-risk drill ran on the bench: the IG registers program and poll
over i2c-dev (I2C_SLAVE_FORCE) with st_accel bound, raw reads intact,
so the crash detector is forgectrl-only (BRINGUP item 6, CAMPAIGN-LOG
2026-08-31). The drill found the IG needs a running ODR: st_accel
leaves the part in power-down between one-shot reads. The script now
saves CTRL1, runs the window at 800 Hz, and restores it on exit; its
old ODR test checked the axis-enable bits instead of the ODR bits.

No catalog consequence: the change is a bench drill script, its bench
page text, and the docs record of the drill; no shipped behavior
changes.
2026-08-31 18:21:08 -04:00
ScottW514 765521455d bench: head-accel crash-detector de-risk drill (item 6, first step)
accel_crash_probe.py arms the head LIS2HH12's on-chip interrupt
generator (IG_CFG1/IG_THS/IG_DUR1) and polls the latched IG_SRC1 for a
strike, reporting the axes and raw magnitude. It settles the bench fact
the crash detector rides on: coexist mode reaches the IG registers over
i2c-dev with I2C_SLAVE_FORCE while st_accel stays bound, so it proves
whether the detector can be forgectrl-only with the liveness path
untouched, or whether the accel must move under glowforge.ko. It touches
only the IG registers (0x30-0x35) plus the CTRL7 latch bit, never the
full scale, so st_accel's raw scaling is undisturbed; no emission, no
commanded motion by default.

Registered on the bench page (dry, board) and in the bench README.
BRINGUP item 6 now stages the drill as the committed first step, with
the readout path, per-state thresholds and the two-tier wiring owed
after it. Tooling only: no shipped component source changed, so no
acceptance-catalog consequence (the detector feature gets its case when
it is built); the bench registry test and coverage lint pass.
2026-08-31 17:56:53 -04:00
ScottW514 18104a0e51 BRINGUP: merge the head crash detector and head IRQ into one item
A firmware decode of the head MCU (recorded in CAMPAIGN-LOG) shows the
factory head crash detector and the head IRQ accel_irq source are one
mechanism: the head accelerometer (LIS2HH12) has an on-chip interrupt
generator the factory arms per job from the HA* header tags (bit-exact
onto its registers) and reads by polling IG_SRC1; its INT wires to the
head MCU and surfaces as reg 0x05 b1 behind HEAD_IRQ, which is
level-driven off the MCU's latched reg 0x02 and dormant until the SoC
arms an edge in reg 0x03/0x04.

Facts bank: add "The head MCU flag register and HEAD_IRQ", "The head
accelerometer", "Beam detect in the head MCU" (correcting the thinner
entry: reg 0x05 has a fourth input and a b7 processed verdict we do not
expose; beam_detect_digital is the raw comparator, not the verdict).
Next work: merge items 6 and 8, renumber 12 to 11, repoint the trailer.
CAMPAIGN-LOG: dated entry recording the decode. Docs only.
2026-08-31 17:43:43 -04:00
ScottW514 0ee48d76a7 BRINGUP: move hardware facts to the facts bank, keep Next work to owed work
Facts bank: add the head-IRQ/beam-detect entry and the coolant-ADC
offset entry; point the SDIO CRC note at the closed watch record.
Next work: trim items 6-10 to the open work with facts-bank citations,
remove the stale bench-page sentence from item 3, and retitle item 10
to the second-machine re-measure (the sensor-line scope work is
dropped by decision). Docs only, no catalog consequence.
2026-08-31 17:04:36 -04:00
ScottW514 a3127c29cd Retire next-work item 3: the debug-kernel drills passed
Load/unload under DEBUG_MUTEXES (three clean cycles) and the forced
-EPROBE_DEFER unwind both passed on the debug-kernel image, no lock
splat, machine recovered (CAMPAIGN-LOG has the run). BRINGUP: the item
closes, items 4 and up move down one.
2026-08-31 16:43:54 -04:00
ScottW514 2319735e60 bench: harden the debug-kernel drills against bench facts
Three fixes found running the drills: the splat regex no longer matches
the benign "RCU lockdep checking is enabled" boot banner; the 40 V
regulator search reaches its reg-fixed-voltage driver (regulators:40v)
so the forced-defer drill resolves the resource instead of skipping;
and require_idle waits out the run-through-'running' a forgectrl restart
passes through instead of refusing.
2026-08-31 16:42:34 -04:00
ScottW514 22ce1d0d6d kas: the debug image gets its own deploy name
forgefirm-image-dev has one deploy slot, so the debug variant was
clobbering the normal dev image (and a dev rebuild clobbered the debug
one back). A distinct IMAGE_BASENAME (forgefirm-image-dev-debug) lets
both coexist in the deploy directory.
2026-08-31 16:26:55 -04:00
ScottW514 298abf8a12 Item 3: fold the debug kernel into the closing build
kas/forgefirm-glowforge-debug.yml builds one dev image on the debug
kernel (FORGEFIRM_KERNEL_DEBUG=1, tagged dev-debug) beside the closing
release and dev images; the debug options never touch either.
scripts/bench/debug_kernel_drills.py runs the two drills (three
load/unload cycles under DEBUG_MUTEXES, a forced -EPROBE_DEFER unwind),
each read against dmesg for lock splats, refusing on a non-debug kernel
or a non-idle machine; it is registered as a board bench tool. BRINGUP
item 3 names the mechanism and stays remaining work (run it on the
closing burn); the site's Build page documents the variant.
2026-08-31 15:54:28 -04:00
ScottW514 8ba998c3ef Retire next-work item 3: the K-11 badly-answering head is proven
A head reset (its own 0xc9<-0x5a register) made the present head NAK
through its reboot; the witness reads errored (K-11 propagation) with no
spoofed positive, and the head recovered on a driver rebind
(CAMPAIGN-LOG has the drill). BRINGUP: the last physical-evidence
negative closes, items 4 and up move down one.
2026-08-31 15:43:26 -04:00
ScottW514 94f7c23ade Item 3: the failed-head-capture negative is proven; the fault note drops
A busy capture node made the real head-image path fail with the measure
laser armed; the laser ended at 0 and the camera engine recovered
(CAMPAIGN-LOG has the drill). The STATE_FAULT-recovery note leaves the
item by operator decision. What remains is the K-11 case, one bench
slot.
2026-08-31 15:32:33 -04:00
ScottW514 8d68cee5bf docs: correct the DRV8825 reset-line fact in the wedge entry
The nRESET and nSLEEP lines are not strapped: glowforge.ko drives them
(reset-gpio gpio3 18, sleep-gpio gpio3 16), confirmed by pad traces to
U20/U21/U22 pins 16 and 17. They move only as a pair in every
enable/disable cycle, together with the rail, so the reset pulse is part
of every recovery attempt and has never shortened the ladder. A logic
reset alone does not clear the wedge.
2026-08-31 15:23:00 -04:00
ScottW514 99902a1579 Retire next-work item 3: the shared-services polish is closed as a set
Diagnostics run as an engine mode behind cool_diag_take/release, the
HTTP surface carries its accept caps with bounded camera setup
children, and the busy-state arbitration is declined with its reasoning
(CAMPAIGN-LOG has the proof runs and the two cross-check lessons the
queue taught). BRINGUP: the item closed, items 4 and up move down one.
2026-08-31 15:13:08 -04:00
ScottW514 f4de5cdbfd forgetest: the TEC floor relation is the runtime clamp; pin forgectrl
cooling.tec-drive drops its expectation of a settings-level rejection;
the engine's clamp is the enforcement.
2026-08-31 14:55:30 -04:00
ScottW514 c484107779 Pin forgectrl: the start gate is not pinned under the ceiling 2026-08-31 14:41:18 -04:00
ScottW514 78c40ef1ee Pin forgectrl: the HTTP option array carries ulfius' plumbing 2026-08-31 14:26:21 -04:00
ScottW514 e2cf034422 Pin forgectrl: diagnostics as an engine mode, bounded HTTP surface
The item 3 changes ride the pin: the diag take/release contract, the
accept-side connection caps, and the bounded camera setup children.
2026-08-31 14:17:31 -04:00
ScottW514 33461556c3 Retire next-work item 6: the SDIO watch is clean
97 boots across 16 days of persisted kernel logs carry zero SDIO CRC
failures against the pre-fix baseline of one in 49 minutes; the
factory-exact uSDHC pads hold and the 25 MHz cap stays unneeded.
Items 7 and up move down one.
2026-08-31 13:56:10 -04:00
ScottW514 4e78ef03d7 BRINGUP: trim the top of next work
Limit-switch homing is its one sentence. Cameras holds only the
untested 8 MP sensor's questions; the emulator homing-image smoke moves
to the acceptance follow-through, where the tool work lives. The cloud
item closes as a status recitation: its facts live in CLOUD.md, and the
one open question rides the 8 MP first light. Items 4 and up move down
one.
2026-08-31 13:52:39 -04:00
ScottW514 fe977c0dcc Retire next-work item 1: the fire watch is armed on the bench
The factory-shaped watch is implemented, host-tested and bench-proven
with the lamp as the flame stand-in (CAMPAIGN-LOG has the run, the
fault-class mapping and the recorded interpretation). BRINGUP: the item
closed, items 2 to 18 are now 1 to 17, the references follow, and the
facts bank reads the armed posture. Pin forgectrl for its doc fix.
2026-08-31 13:48:53 -04:00
ScottW514 678a155dba forgetest: the fire-watch log checks wait out the rsyslog lag
The engine acted correctly on the bench (FLAME, hold, fire blocked) but
the test read the forgectrl log tail one second after the trip, before
rsyslog put the line on disk. The two log checks poll the tail for up
to 8 s (_tail_wait) instead of reading it once.
2026-08-31 13:46:28 -04:00
ScottW514 b506e67bce forgetest: cooling.fire-watch-tiers; pin forgectrl
Both tiers of the factory-shaped fire watch, proven with the lid lamp
as the flame stand-in: a q1 alert under the lamp's reading holds the
session as FLAME and does not survive into a fresh one; a q1 critical
latches FIRE with the laser latch locked; all four thresholds at zero
read as the four flame gates off; restored, the watch reads armed. Pin
forgectrl 77a643441c1c0c745a38aabebc68340d26204ce0 (the watch itself).
2026-08-31 13:23:03 -04:00
ScottW514 a3d921b7ee Retire next-work item 1: the TEC drive is on the bench
TEC handling is implemented, host-tested and proven at the GPIO
(CAMPAIGN-LOG has the run and the CMet/CMdt correction: readings, not
setpoints; the defaults are chosen). BRINGUP: the item closed, items 2
to 19 are now 1 to 18, the five cross-references follow.
2026-08-31 12:59:10 -04:00
ScottW514 6edd3e565c forgetest: cooling.tec-drive; pin forgectrl
The TEC policy at room temperature: declared fitted with its thresholds
under the loop, a run session drives the line on and the session's end
returns it to 0; declared not fitted the line stays 0; the cross-checks
refuse off over on and off under the floor. Pin forgectrl
7d8a58035ca7fadebcae47ea9fd53ef2dd810bac (the TEC drive).
2026-08-31 12:53:16 -04:00
ScottW514 97b10683c2 Retire next-work item 1: the low-temperature gates are on the bench
The coolant floor and the warm-up gate are implemented, host-tested and
bench-proven (CAMPAIGN-LOG has both runs, the heater-slug lesson
included). BRINGUP: item 1 closed, items 2 to 20 are now 1 to 19, the
six cross-references follow, and the facts bank carries the slug fact.
2026-08-31 12:35:37 -04:00
ScottW514 f773866f3e forgetest: the warm-up release comes no earlier than the bulk window
The first bench run released in 15 s on the heater slug; the engine now
judges a one-minute rolling minimum, and the test refuses a release
under 60 s. Pin forgectrl 9d0b757f492281d2628da6800db61a1f490fd125.
2026-08-31 12:27:52 -04:00
ScottW514 a697fe0f63 forgetest: cooling.floor-and-warm-up; pin forgectrl
The catalog case for the two low-side gates: at room temperature the
start gate 1 C above the loop holds the next session in warm-up (heater
on, fans idle, fire blocked) and releases into run at the gate; with the
start gate off, a floor above the loop holds COLD; both at 0 are off and
say so; restored, everything reads as before. Pin forgectrl
5a12f55db6ac632dfc720d9937ca581a7703b7c3 (the gates themselves).
2026-08-31 12:10:11 -04:00
ScottW514 85d266e572 Retire next-work item 1: the gap question is answered from the chain
The button latch is SET by lid-open or the SoC lock and RESET by the
button only; the charge-pump watchdog feeds HV_ENABLE, not the latch.
A kernel-run gap inside an armed job drops HV_ENABLE and leaves the
latch alone, and the next run has HV_ENABLE back before its first step.
No keepalive.

laser.emission-witness carries a G4 P2 between the second and third
sides of its square and checks cnc/button_latch clear in every armed
sample, HV_ENABLE dropped across the dwell and back with emission after
it; the operator confirms all four sides.

BRINGUP: item 1 removed, items 2 to 21 are now 1 to 20, the five
cross-references follow, the flow-band sentence is in the facts bank.
CAMPAIGN-LOG: the answer, the retired item, and the unattended set run
green on the hot-deployed board (campaign c-20260831151846).
2026-08-31 11:37:53 -04:00
ScottW514 eb8935c9b1 forgetest: aa-offset-calibrate waits for the tool's settle; pin forgectrl
cooling.aa-offset-calibrate gives the tool 540 s (its 420 s stationary
gate plus the six edges) and says so in its steps. Pin forgectrl
7dbb5e1e55a12e6bd1364d920f46c96a3ae2d4ce (the tool settles at the flow
tools' gate before its first edge).
2026-08-31 11:08:45 -04:00
ScottW514 a73ee9c6bf docs: lens shading resolved from the factory rootfs
The factory image carries a register loader that nothing calls, the app
references an apply script the rootfs does not have, and the OV5648
driver has no regs attribute. No shipped machine applies a per-unit
shading table. BRINGUP item 6 drops the claim; CAMPAIGN-LOG has the
search.
2026-08-31 10:47:31 -04:00
ScottW514 38886d5832 docs: the bench pass on image 20260831141210
CAMPAIGN-LOG: the rail policy homing pass (gfhome homed, jogs after the
resume, one 40V on at boot) and the /cool/status drills (three dark
sessions and an M8/M9 session with no smoke phase, armed false).
2026-08-31 10:27:20 -04:00
ScottW514 c01699ee94 docs: the 2026-08-31 pass over the small items
CAMPAIGN-LOG: the rail policy, the /cool/status changes, the armed-kill
placement, the image trims with the 20260831130656 manifest, the lid IR
lamp step, the SDIO count, the lens-shading search, the debug-kernel
assessment, and the bench cleanup; the four closed items in the
superseded notes.

BRINGUP: item 18 closed (the trims landed), items 19 to 22 are now 18 to
21; the release rootfs size fact reads the measured 89 MiB.
2026-08-31 10:19:20 -04:00
ScottW514 b334c4cc33 image: drop the python3 meta-package and the GnuTLS stack; pin forgectrl and grblHAL
The release rootfs carried python3-modules (tkinter, idle, 2to3, pydoc,
ensurepip, venv, asyncio, multiprocessing, xmlrpc) through the python3
meta-package, and libgnutls30, nettle, libgmp10, libunistring5 and
libtasn1 through libmicrohttpd (https) and ulfius (GnuTLS). Nothing on
the image uses either. Each Python recipe declares the module packages it
imports; forgetest declares its own, so the dev image carries no module
the release image lacks.

Pins: forgectrl 0e907f7db54b7a4c90b660f198d49e5c66b667b5 (armed shown from a
fresh report only, zero smoke phase for a dark session), grblhal-glowforge
fa9ed7834faab6f48367dc1ca5a5109b474f788d (rail enable only standalone).

BRINGUP: the lid IR lamp response measured over the full range; no camera
register file under /data; the rail policy and the cool status items
closed; laser.armed-kill stays in its domain; the debug-kernel drill
constraints.

Platform change (layer content): the full campaign is owed on the built
image.
2026-08-31 09:04:37 -04:00
ScottW514 431f0f29fa Close the laser power model item; add the side-by-side chooser
The corner rolloff's right value is per machine (this bench settled at
1.5 against the shipped default of 2), so the commissioning item gains
a side-by-side chooser: the same corner-heavy pattern cut at several
settings, the operator picks the best by eye, Apply writes the winner -
with the dose-curve recorder as the first piece of that tool family and
its template. With that, everything the laser power model item held is
answered or homed: its conclusions live in the Laser control section
and the facts bank, its dated record in the CAMPAIGN-LOG, and the
tree-root working file is deleted per its own charter. Next work item
16 closes; the later items renumber down by one.
2026-08-30 22:21:37 -04:00
ScottW514 9a9bb9a47b Pin forgectrl at the ladder-dwell fix 2026-08-30 22:10:01 -04:00
ScottW514 f2b7626d79 Pin forgectrl at the NUL-escape fix 2026-08-30 21:52:18 -04:00
ScottW514 566d836ca0 Pin forgectrl aadf30a and grblhal-glowforge 06965b6
The corner rolloff exponent on the dose curve, and the recorder that
streams its own ladder from one panel press.
2026-08-30 21:48:49 -04:00
ScottW514 58cad9a8d8 Prove the corner rolloff; document the one-press recorder
The stream harness gains rule 20: under M4 with the curve in force, the
accelerate-in head of a line renders less density at the default gamma
of 2 than at gamma 1 (0.317 against 0.537 on the host grid) while the
cruise middle renders identically - the exponent shapes only the
velocity-scaled rolloff, never the programmed level. The panel-serves
covers widen to the whole recorder (src/curverec.*). BRINGUP describes
the rolloff and the one-press recorder; the MOTION table gains
laser_corner_gamma; the panel help explains the knob.
2026-08-30 21:48:32 -04:00
ScottW514 5787260a3d Pin forgectrl 10be58c and grblhal-glowforge b54405d
The measured dose curve applied to S (laser_dose_curve, S commands a
light fraction) and the panel's owner-run dose-curve recorder.
2026-08-30 20:28:47 -04:00
ScottW514 0b941fd743 Prove the dose curve and the recorder; document S-means-light
The stream harness gains rule 19: with the bench-default curve in force
a ladder of S rungs renders the curve's densities exactly (half light
lands near 80 percent density), monotonic, floored and ceiled by
$35/$36; every other session pins laser_dose_curve = off so its
S-to-level arithmetic stays exact, and the arm-report checks carry the
curve name. The lifecycle harness asserts the published state file
names the curve in force. The catalog's forgectrl.panel-serves asserts
the curve field in the grbl report, /curve/status and the ladder
G-code, and its covers name the recorder.

BRINGUP describes S-commands-light through the measured curve and the
owner recorder; the MOTION table gains laser_dose_curve; LIGHTBURN
tells the operator power now means light and how to record their own
tube's curve from the panel.
2026-08-30 20:19:24 -04:00
ScottW514 557ed02590 Pin forgectrl edaeddd and grblhal-glowforge 09efdc1
The analog dose model's removal: density is the only model, M101 and the
model selection are gone, and the floor derives at every precompute.
2026-08-30 19:44:32 -04:00
ScottW514 d612c0099a Prove the density-only model; record the rasters and the decision
The stream harness keeps the analog rendering as the host-test
conservatism reference (rule 13's mask, the duty ladders) and drops the
M101 switch sessions; rule 18 stays as the derived-floor proof, now
satisfied from boot by the precompute. The lifecycle harness's
state-files scenario asserts the derived floor is in $$ before any arm.
The catalog's laser.power-model-switch goes and laser.power-floor reads
the one floor key with no M-code needed. The mswitch drill goes; the
m4corner drill becomes a single density pass; the dpatch drill returns
to density only.

The CAMPAIGN-LOG records the first rasters (254 and 508 DPI grayscale
wedges: tonality held to ~14 pulse slots per pixel, no dither artifact,
one benign stale-verdict suppression under CPU starvation) and the
decision that ends the analog mode - the strike transient fires a spot
at every beam-on, and the finish comparison found no advantage. BRINGUP,
LIGHTBURN, MOTION and SAFETY describe the density-only present.
2026-08-30 19:41:50 -04:00
ScottW514 67da1ab9bc Pin forgectrl f6698c4 and grblhal-glowforge a1657d7
The M101 dose-model switch with its derived per-model floors, and the
controller's published state files with their /status echo and panel
card. Both proven host-side and, for the switch, on the bench (the
mswitch drill); the catalog covers the changes through the model-aware
laser.power-floor, laser.power-model-switch and the extended
forgectrl.panel-serves.
2026-08-30 16:02:24 -04:00
ScottW514 917b4bb55e Prove the controller's published state files
The lifecycle harness gains the state-files scenario: with GF_STATE_DIR
at the session workdir, grbl.settings carries the $$ view and follows
the derived floor through an M101 switch and its M2 revert, and
grbl.state follows the sender connection (a reconnect bumps the
generation), the armed window and the dose model, with ts_mono
advancing. The panel-serves catalog test asserts the /status grbl block
in GRBL mode with a live controller (fresh age, state, sender, laser,
modals) and GET /grbl/settings, and its covers name the publisher and
the serial layer; the glowforge_status.c covers entry selects nothing
until the grblHAL pin moves past the commit that adds the file. The
CAMPAIGN-LOG records the build and its proof; BRINGUP describes the
mechanism.
2026-08-30 15:59:56 -04:00
ScottW514 3025996c86 Prove the M101 dose-model switch; record the judged dose curves
The stream harness gains rules 18 to 21: the floor is derived from the
selected model's config key at the arm and a typed $35 is overwritten;
M101 switches the rendering exactly at the boundary in both directions
with no continuous FIRE at full duty across it; a refused switch (the
spindle on) leaves the stream unchanged, and the harness resyncs with
an empty line because the core skips G-code after an error until the
sender resyncs; M2 reverts a program-scoped switch and Q1 holds. The
analog sessions pin laser_floor_analog at the density floor so the
existing duty expectations stand, and the density ladder's unfloored
run moves from a chained $35 write to the laser_floor_density key.

The catalog's laser.power-floor becomes model-aware: it reads the
configured model and the floor keys from forgectrl, switches to the
configured model with M101 so the derivation runs without a fire, and
expects $35 to be that model's floor. The new laser.power-model-switch
switches to each model with the spindle off, checks the reported
message and $35 after each switch, and checks the M2 revert. The new
mswitch bench drill runs the switch on the machine in one armed run.

Docs follow: BRINGUP's Laser control section describes the switch, the
derived floors and the measured dose response of both models; the
MOTION settings table gains the five keys; LIGHTBURN gains a Power
models section and drops the stale 30 percent floor advice; SAFETY
names the switch's refusal rule; the CAMPAIGN-LOG records the judged
depth-witness runs of 2026-08-30 and the switch's host and bench proof.
2026-08-30 15:08:34 -04:00
ScottW514 f88c7784d4 Run the depth witness under either dose model
The dpatch drill reads laser_power_model and runs row B at 100/80/60/45/30
percent duty under analog, with the thermopile labels from the analog ladder,
so the analog dose response can be judged on material the same way as the
density one. The report names the model's unit in every line.

Bench-only tool; no acceptance catalog consequence.
2026-08-30 14:08:34 -04:00
ScottW514 cd00b0d16f Warm the loop by the mixed bulk; record the flow check to the heater's ceiling
flow_warm_validate.py warms in rounds (the heater at 50 percent for three
minutes, off, 45 s of circulation, the two sensors' mean read) and judges
the target on that mixed bulk, not on the upstream sensor beside the
heater, which reached any target within two minutes while the bulk had
barely moved. Run with a 28 C target the loop plateaus at 27.2 C in a
20 C room; six checks at 26.2 to 27.2 C read every verdict right, the flow
band under 11.9 C and the no-flow band over 18.0 C against the 14.4 C
limit. CAMPAIGN-LOG records the run; BRINGUP item 1 keeps only the
button-latch check, the flow check's bands holding from 19 to 27 C.

No catalog consequence: a bench tool and documentation.
2026-08-29 19:37:26 -04:00
ScottW514 9bfdba44c8 Record the calibrate tool's accepted run on image 20260829220329
CAMPAIGN-LOG: aa-offset-calibrate with its 3 s at 8 Hz sampling reads
16.2 counts with a spread of 4.5 over six edges, the value already
applied; the compensation's whole path stands on the bench. BRINGUP item
21 keeps only a second machine's value and the lit-tube toggling.

No catalog consequence: documentation.
2026-08-29 18:16:07 -04:00
ScottW514 e32c86fb10 Pin forgectrl 42cdb71; add the coolant offset check; record the compensation on the bench
The pin carries the calibrate tool's sampling fix (3 s at 8 Hz a side).
aa_offset_check.py proves the correction on a machine: M8 brings the fans
to the run profile, dark, while the raw coolant counts, /status and the
engine's readings are averaged before, during and after; it turns the
flow check off for its session and restores it. On the bench page as
aa-offset-check. CAMPAIGN-LOG records the tool's first run, the applied
value (16 counts) and the proof: the readings held within 0.1 C under
the run profile where they dropped 1.0 C uncorrected. BRINGUP item 21
names what remains.

No catalog consequence: a bench tool and records.
2026-08-29 18:02:46 -04:00
ScottW514 5920d9bc77 Pin forgectrl 25cf969 and register the offset probe on the bench page
forgectrl 25cf969 lets status.c link without the cooling engine (the
image build compiles the status host tests), so the pin moves to it;
verified with bitbake -c fetch. offset_probe.py joins the bench-page
registry (survey, ladder and jog; the armed dwell stays a hand-run mode)
and takes "survey" as a mode word.
2026-08-29 17:47:05 -04:00
ScottW514 8831d9b20e Pin forgectrl f9b4893: the air-assist ground shift off the coolant readings
The pin carries the compensation (cool_aa_offset_counts, the
aa-offset-calibrate diagnostic, the panel's Apply); verified with
bitbake -c fetch. The catalog gains cooling.aa-offset-calibrate, which
runs the tool and checks its recommendation and spread.

scripts/bench/offset_probe.py is the differential probe that found the
source: one actuator switched at a time with both sensors at 25 Hz
(survey), the air-assist duty ladder, the gantry jogging under the fan,
and an armed dark dwell. CAMPAIGN-LOG records the four probes and the
result; BRINGUP item 21 names the setting and the calibrate as what
remains on this machine, and item 23 opens the initial commissioning
procedure.

No catalog consequence beyond the new case: a bench tool and records.
2026-08-29 17:36:02 -04:00
ScottW514 61b68c387c Add cooling.flow-under-load: the flow check with the tube lit through its window
A live catalog case: two 30 x 4 mm fills at full power on the press, the
window held open until the engine's flow verdict lands in the forgectrl
log, then M2 and the head brought back. PASS needs a verified line with
the laser's share on it (at least 0.3 C: the window and the fire
overlapped) and the judged rise at least 1 C under cool_flow_rise; an arm
refused by a gate names the gate. Passed on image 20260829190323
(heater rise 11.9 C judged, laser 0.6 off 12.5).

CAMPAIGN-LOG records the run and the day's two air-assist gate holds (a
bench connection, reseated); BRINGUP item 21 names the case.

Catalog: the case covers forgectrl src/cool.* and its neighbors; a new
test, so the manifest's coverage widens rather than moves.
2026-08-29 16:39:03 -04:00
ScottW514 5c282ce167 Add the senderchg and overrun bench drills; close BRINGUP item 20
Two live-fire drills in live_fire_drills.py: senderchg drops the sender
mid-line with the tube lit, reconnects and expects the next laser-on to
prompt again; overrun writes a 93-line fill at once into a running job
and expects the report, the alarm, the disarm and a fresh prompt after
$X. Both read hv_current and the thermopile for the instant emission
ends and open their "nothing lit" window 2.5 s after the event, since
laser_on_sampled is a one-second window count. Both passed on image
20260829190323; CAMPAIGN-LOG records the runs.

BRINGUP item 20 (the arm skipped on a stale spindle state) closes: fix on
the image, host and bench proven, covers in place. Its text moves to the
superseded notes; items 21 to 23 are now 20 to 22.

No catalog consequence: bench drills and documentation.
2026-08-29 16:05:59 -04:00
ScottW514 89731e654b Record the bench proof on image 20260829190323 and the warm-loop flow check
CAMPAIGN-LOG: the three flowload t1 runs on the new image (judged rise
11.3, 11.1 and 11.8 C with the tube lit through most of each window, the
laser's share on the line), and Test 3, the flow check from a warm loop
(23.6 to 24.9 C baselines, 0 of 6 wrong, the 14.4 C limit 2.25 C above the
flow band and 3.67 C below the no-flow band).

flow_warm_validate.py takes the warm target and the warm-up budget as
arguments (defaults 28 C, 20 min), and the bench page passes them; the
warm-up still judges on the upstream sensor beside the heater, which is
why this run's baselines stopped near 25 C (BRINGUP item 1).

BRINGUP: item 1's open half is the loop above 25 C; item 22 holds what
remains of the flow check under load.

No catalog consequence: a bench tool's arguments and documentation; no
runtime behavior of the release image changes.
2026-08-29 15:55:21 -04:00
ScottW514 d577629c80 Pin grblHAL-glowforge a7dcdca and forgectrl 2f18b16: the arm reads the window, the flow check reads means
grblHAL-glowforge a7dcdca: the arm at the first laser-on is decided by the
window alone, and an RX overrun drops the overrunning line whole and stops
the job. forgectrl 2f18b16: the flow check's baseline and end are means,
and the tube's share of the rise is taken off before the limit. Both
verified with bitbake -c fetch.
2026-08-29 15:01:49 -04:00
ScottW514 9d9f235219 Add the sender-change and RX-overrun harness scenarios and the day's records
laser_lifecycle_test.py gains sender-change-mid-job (a laser-on against a
window closed while the spindle was on must prompt again) and rx-overrun
(a job written past the RX ring is reported, stopped in alarm with the
window closed, and a clean job arms after it). The flowload drill's
verdict parser accepts the engine's laser-share suffix.

BRINGUP: item 20 holds only the owed work; item 21 opens the mid-job
sender-change discussion with the Grbl expectation; item 22 is the flow
check under a lit tube; item 23 is the power-good line's meaning.
CAMPAIGN-LOG records the driver fix and the flow-check reading, both
host-proven.

No catalog consequence: harness scenarios and documentation; no runtime
behavior of the release image changes in this commit.
2026-08-29 14:58:21 -04:00
ScottW514 b5de7cda9d Add the flowload drill and record the flow check under laser load
The flowload drill in scripts/bench/live_fire_drills.py runs the two tests
of the flow-check plan: t1 fires two CW fills on the press with the check
at its defaults, t2 fires one fill of a chosen length with the check off,
and fit reads rise against dose over the t2 records with the ADC offset
steps masked. The sampler adds the heater output, /cool/status is polled
at 1 Hz with the fan gates, and every controller reply is kept. The job
is fed against the RX buffer's free count, M5 is acknowledged before a
run, a run is refused while the window is armed, and M2 is acknowledged
and the window's close is waited for.

BRINGUP gets item 20: the arm at the first laser-on is skipped while the
driver's spindle-state record reads on, the record is not cleared on
disarm, and the serial layer drops bytes on a full RX ring, so a job with
a lost M5 lets the next job run unarmed. CAMPAIGN-LOG records the Test 1
and Test 2 runs and their numbers.

No catalog consequence: a bench drill on the dev image and documentation;
no runtime behavior of the release image changes.
2026-08-29 13:54:41 -04:00
ScottW514 f84b9df040 Point the dev image recipe comment at the acceptance page on the site
Comment only. The edit changes the content hash of meta-forgefirm, which
the acceptance manifest counts as a platform change, so the next image
needs a full campaign. Accepted: a full campaign is planned.
2026-08-26 10:27:02 -04:00
ScottW514 533ae7e46d Move the build, release, and acceptance docs to the documentation site
The documentation site, docs.forgefirm.org, is the one home for the
developer documentation. BUILD.md, kas/README.md, and docs/ACCEPTANCE.md
are on the site under Developers, and they are deleted here. Every
reference points at the site: README.md, BRINGUP.md, UPDATE-SYSTEM.md,
the kas configuration comments, release.sh, the CI workflow comments, and
the releases, forgetest, and bench READMEs.

BRINGUP.md: "Next work" item 16, step timing under CPU contention, is
closed; the video work resolved it. The items after it are renumbered.
CAMPAIGN-LOG.md records the closure.

Documentation and comments only. No layer content changes, so the
manifest identity of the next image does not change. No catalog
consequence.
2026-08-26 10:00:42 -04:00
ScottW514 d17c348e91 Add the dpatch depth-witness drill
live_fire_drills.py gains dpatch: two rows of small serpentine-filled
patches on scrap. Row A is CW at feeds that give relative doses from 1.0
to 0.25. Row B is density 100, 80, 60, 45, and 30 percent at F600. The
operator matches each row-B patch to the row-A patch of equal depth. That
reads the light fraction of a density off the material, next to the
prediction of the thermopile. The drill samples sysfs at 25 Hz, as pcurve
does, and writes a JSON record to the bench data directory.

Bench tool only, on the dev image; no catalog consequence.
2026-08-26 10:00:42 -04:00
ScottW514 ac8c4318a7 Pin grblHAL-glowforge 015b23d: set_state pushes the whole laser state
The rapids after an M5 ship dark, and a job whose M3 runs at the level
the previous job ended at fires its first cut. Fetch-verified.

Catalog consequence: laser.m5-rapid-dark and the laser live tests cover
the driver; the pin lives in the pin file, so only those tests re-run.
2026-08-25 17:43:26 -04:00
ScottW514 fff0980079 Laser: the rapids after an M5 ship dark and the next job fires; the performance-curve drill
Stream harness rules 16 and 17 with their sessions: an M5 executed with
the planner drained and the kernel run over must darken the rapids that
follow it (m5-idle), and a job whose M3 runs at the level the previous
job ended at must still fire its first cut (next-job). The second rule
is the core's contract: set_state records the rpm and the per-segment
update is skipped while it is unchanged, so the driver's set_state is
the only thing that can light that move. Both sessions run under both
dose models; the bench build that went dark on its second job fails
next-job with one fire span.

Bench drills: pcurve (a per-level ladder of 100 mm lines read from the
HV current and the head thermopile at 25 Hz, with the instrument checks
and the JSON record) and m5dark (one line, M5, two rapids, judged on the
current trace and laser_on_sampled until the armed window closes).

Catalog: laser.m5-rapid-dark, a live test of the M5 case (46 tests; the
counts in BRINGUP follow). CAMPAIGN-LOG carries the day's record: the
two curve ladders, the defect pair, the root cause, the host and bench
proof.
2026-08-25 17:41:45 -04:00
ScottW514 0416986e52 Update README 2026-08-25 06:52:06 -04:00
ScottW514 9b4ff7e0f8 docs: the SoC under a full core, measured; the heatsink question closes
CAMPAIGN-LOG gains the 2026-08-24 drill: five minutes of openssl speed on
the one core over the live camera stream, 30 C chassis, plateau 70.8 C
with no throttle and every cooling device at state 0. BRINGUP's facts bank
drops its open question (whether ForgeFIRM's load wants the heatsink the
factory never fitted) for the measured fact: 14 C of headroom to the
passive trip under full load, a hot-chassis case above roughly 44 C.

Docs only, outside every layer; no acceptance consequence.
2026-08-24 20:13:35 -04:00
ScottW514 f90c7c01a2 docs: the release authorized on dev 20260824230512; the kernel trim, the video offload and two working files close into the log
The attended nine passed after the unattended 36, so campaign
c-20260824231028-b7ca closed 45 of 45 from nothing and authorizes the
image. BRINGUP now describes the present: the 45-test catalog with the
bench actuator, the latest campaign, the board-only kernel's shape in the
facts bank (the performance governor, the SDMA clock ownership,
pstore/ramoops, MemTotal), the GPU stream path as the proven default.
Items 20 and 21 close; item 12 keeps only its leftovers and item 13 the
first release; item 16 records the hog case passing and the stream-live
drill still owed; a new item 20 carries the image trims the kernel review
left on the table.

CAMPAIGN-LOG gains the dated record of the attended run and, under the
superseded notes, the closed items as they stood plus the acceptance
burden plan and the kernel configuration review verbatim; both tree-root
working files are deleted.

No catalog consequence: documentation only.
2026-08-24 19:53:38 -04:00
ScottW514 4a7d11d48d Pin forgectrl to 78efd16: the loopback peer check in src/peer.c with its unit test 2026-08-24 19:02:52 -04:00
ScottW514 243320f65d ulfius: client_address carries the whole peer sockaddr; forgectrl.auth asserts the loopback report is accepted
The dual-stack listener reports every peer as a sockaddr_in6; ulfius
2.7.15 copied sixteen bytes of it, so forgectrl's loopback-only cooling
channel refused the controller's every report (403 loopback only) and
the engine never saw a run or an armed window. The recipe carries the
patch: a sockaddr_storage allocation and a copy of the family's length,
in the dispatcher and in ulfius_copy_request.

forgetest: forgectrl.auth asserts POST /cool/state from loopback -> 200
beside the LAN 403, and covers src/peer.*. BRINGUP item 21 and the
campaign log record how the campaign on dev 20260824215906 found it.
2026-08-24 19:02:52 -04:00
ScottW514 4d3d173445 forgetest: image.health asserts the SDMA clock enable count; the gated engine recorded
The check reads /sys/kernel/debug/clk/sdma/clk_enable_count and fails
below 1, naming the fault directly instead of the free-space mismatch it
surfaced as after the 150 s settle. BRINGUP item 21 describes the fault
and the fix; CAMPAIGN-LOG carries the record of how it was found.

Covers: image.health already maps kernel-module-glowforge/** and
linux-fslc/**.
2026-08-24 17:51:18 -04:00
ScottW514 dc43ccf3bf docs: IPv6 proven end to end (CAMPAIGN-LOG, BRINGUP item 21)
With the access point's RA and DHCPv6 off, the board took the firewall's
lease; every service answered on the global address from another VLAN
and the board reached the IPv6 WAN gateway.

Docs only; no catalog consequence.
2026-08-24 16:42:19 -04:00
ScottW514 76906f3aef docs: the second DHCPv6 responder (CAMPAIGN-LOG, BRINGUP item 21)
The missing GUA was not the firewall: its DHCPv6 server advertises an
address, but an access point on the bench VLAN still runs RA and DHCPv6
in server mode, its Advertise with nothing to give arrives first, and
busybox udhcpc6 keeps the first Advertise it sees. Dev 20260824201945
carries patch 0015 and the last stray dmesg line is gone.

Docs only; no catalog consequence.
2026-08-24 16:38:17 -04:00
ScottW514 57b428ebd4 docs: the second kernel round on the bench (CAMPAIGN-LOG, BRINGUP item 21)
The 2026-08-24 second-round record on dev 20260824200726: the dmesg
lines gone, the UP kernel on the performance governor, the two-file
WL18xx firmware set, every port answering over IPv6 on the ULA, the
export, and the two leftovers (no GUA from the DHCPv6 server, the NVS
loader line that patch 0015 removes). Item 21 says what remains.

Docs only; no catalog consequence.
2026-08-24 16:19:06 -04:00
ScottW514 7ca1c0f936 images: the release trim is a variable the dev image blanks
Removal specs accumulate and apply after every append, so a dev-recipe
IMAGE_INSTALL:remove could not give nano back. The release recipe now
removes ${FORGEFIRM_RELEASE_TRIM} (default nano) and the dev recipe sets
it empty; the spec expands when it is applied.

No catalog consequence: image composition only.
2026-08-24 16:06:37 -04:00
ScottW514 db2baa03bb dev image: keep nano by re-setting the removal list
IMAGE_INSTALL:remove applies after every append, so the release recipe's
removal of nano also stripped it from the dev image that appended it back.
The dev recipe now re-sets the removal list to gfui-client alone.

No catalog consequence: dev image composition only.
2026-08-24 16:04:09 -04:00
ScottW514 4e79ee5f92 Pin forgectrl to cf8cf1e and grblhal-glowforge to 58b8379: dual-stack listeners, the pstore export 2026-08-24 15:40:19 -04:00
ScottW514 326c3e758b IPv6 on, forgetest dual-stack, the release rootfs sheds 22 MB
The distro keeps its ipv6 feature (busybox networking and ifupdown
inet6, openssh, ntp and rsyslog follow); forgetest binds "::" by
default, one dual-stack socket that still serves IPv4. The release
image drops nano (with it file and the libmagic database, 8.7 MB; the
dev image keeps nano), the udev hardware database (7.7 MB of USB and
PCI identities for buses the board lacks, via BAD_RECOMMENDATIONS) and
urllib3's pyOpenSSL/cryptography recommendation (6 MB; nothing imports
them, TLS goes through the standard library). BRINGUP item 21 records
the round and the bench checks it owes; item 16 gets its re-measure
plan on the UP kernel with the performance governor.

Coverage: image.* covers the rootfs composition and the distro conf
(platform); forgetest's own suite covers the server.
2026-08-24 15:40:19 -04:00
ScottW514 dd6f459d7b docs: kas README is build procedure; status moves to BRINGUP and the log
kas/README.md keeps the build, lockfile, pin and release procedure and a
short build-time facts list; its push/release checklist and Scarthgap
migration backlog are archived verbatim in CAMPAIGN-LOG ("Superseded
status notes"). The real-time argument (PREEMPT, why PREEMPT_RT is not
selectable or needed) joins the BRINGUP hardware facts bank next to the
ring measurements it rests on; the camera pointers go to the 0011-0013
patch headers; the image recipe's gfui-client comment drops its README
pointer.

Docs and a recipe comment only; no catalog consequence.
2026-08-24 14:58:09 -04:00
ScottW514 1e059bb90c forgetest: the camera tests cover the IPU stride-fix crop
forgectrl's src/ipu_copy.{c,h} sit on the GPU render path that feeds
both the JPEG grab and the H.264 encoder, so every camera.* test already
exercises them; the coverage lint reported both files uncovered. Added
to _CAM_COVERS. Lint on the tree manifest: 0 uncovered paths, 45 tests.
2026-08-24 14:53:08 -04:00
ScottW514 cb9cd53bda docs: the kernel built for one board (BRINGUP item 21, CAMPAIGN-LOG)
BRINGUP gains Next-work item 21, the kernel trim and what it still owes
(a GRBL job and the acceptance campaign); CAMPAIGN-LOG records the
2026-08-24 review, the trim, and the bench drill on dev 20260824164619
through the forced-panic read-back from pstore.

Docs only; no catalog consequence.
2026-08-24 14:17:48 -04:00
ScottW514 00ded74189 forgetest: the fixture spaces its presses; an unattended refusal is an error; the baseline resets out of a hold
The first campaign with the bench actuator wired failed
motion.button-hold-resume on the tool, not the machine: the second press
was asked while the first 200 ms pulse was still on, the fixture answered
409, the runner handed the step to an operator who was not in the room,
and the post pass could not jog a controller left in Hold.

- fixture.py: a press waits for the last pulse to end (the fixture's
  pulse_ms) plus a 300 ms release, so the controller sees the edge; a
  409 for a pulse in progress is waited out against button_pulsing and
  retried once.
- runner.py: in an unattended run a fixture refusal ends the test at
  once as ERROR naming the refusal; the operator fallback stays for
  attended runs.
- baseline.py: a controller in Hold or Door gets a soft reset before the
  return jog, position kept.
- tests: the fake fixture refuses a press while one is in progress and
  reports button_pulsing; FakeGrbl records ^X and can land a reset in a
  chosen state; five new tests.
- docs: ACCEPTANCE.md fixture rules, fixture/README.md tool's side.

No catalog consequence: tool-side change, no covers map moves.
Bench: campaign c-20260824174545-0bdc 25/25 with every action by the
fixture; the hold reset proven by a dry drill.
2026-08-24 14:02:59 -04:00
ScottW514 e99888753f forgetest's page moves onto Bootstrap with forgectrl's theme; forgectrl pinned at the panel overhaul
The acceptance page is assembled by page.py from forgetest/forgetest/ui/
(index.html, page.css, help.js, app.js) plus theme.css and the vendored
Bootstrap files, which are byte for byte the ones forgectrl's panel
carries, so the two pages look like one product and share the light and
dark themes (same localStorage key). A plain file is read in a checkout;
on the dev image the recipe installs ui/ gzipped and page.py reads the
.gz sibling, inflating once at first request: the rootfs is raw ext4, so
bytes in the package are bytes on the image. The explanatory prose
(campaign rules, the queues, the campaign actions, the prerequisites
switch, the bench intro) is a "?" popover with a link into the
documentation site; operator steps, prompts, notices and the live-laser
acknowledgment stay in the page, and confirmLive() stays a blocking
dialog. The page's own rules hold: rows, prompt buttons and tool entries
are built once and updated in place, and the popovers sit on static
markup only, so no rebuild orphans one. On a phone the Run pane goes to
the top for the duration of a run.

scripts/check-ui-vendor.py compares the shared files against forgectrl
at its pinned revision (or a local checkout with --forgectrl); it runs
in forgetest-ci.yml, so the copies cannot drift.

Tests: test_page.py (the gzipped install assembles to the same bytes as
a checkout, one self-contained response, the token placeholder once, a
missing marker refused); test_server asserts the served page's
invariants; test_responsiveness keeps its rules with needles pointed at
the new files, its ASCII rule applied to our own sources (Bootstrap's
CSS carries an em dash of its own), and its self-contained rule testing
asset tags rather than the presence of https:// (the documentation links
are meant to be there). forgectrl.panel-serves gains two needles for the
panel's theme attribute and save bar. Proof: the unit suite, and the
page in Chrome against a fake catalog (both themes, popovers, the bench
tab, a full operator run with its prompt, abort).

forgectrl pinned at 9d1f6f2 (the panel on Bootstrap, one save bar, help
popovers, themes, the gzipped page); PV unchanged. The pin moves only
forgectrl's fingerprint. The forgetest changes are the harness's own and
have no catalog consequence.
2026-08-24 12:13:53 -04:00
ScottW514 bb186247d1 BRINGUP: the video offload's bench validation is complete; the campaign rides the release flow, not item 20 2026-08-24 10:48:48 -04:00
ScottW514 9887606cfc forgectrl pinned at the browser-proven H.264 view; jog coexistence in the record
The pin moves to forgectrl d97cb35: zero-based fragment timestamps and
the panel's clamped live-edge seek, the two faults MSE playback in a
real browser surfaced. CAMPAIGN-LOG carries the session record,
including the coexistence drill (a jog at commanded feed under both
live streams, underruns unmoved, planner buffer full). BRINGUP item 20
is down to one line: the full acceptance campaign on an image carrying
this pin.
2026-08-24 10:29:01 -04:00
ScottW514 62992fbd7d forgectrl pinned at the two-frame pipeline: ~14 fps on the GPU path
The pin moves to forgectrl deee6a1: the render overlaps the previous
frame' copies and encodes behind an EGL fence, measured 13.8 fps
single-viewer at ~14 percent CPU and 9.8 fps with both stream types
served at once, luma bit-clean throughout. CAMPAIGN-LOG carries the
dated record; BRINGUP item 20 is down to MSE playback, the
coexistence drill, and the campaign.
2026-08-24 09:59:32 -04:00
ScottW514 24dd2b49f6 forgectrl pinned at the 64 ms render; frame skip proven; the record
The pin moves to forgectrl 2d59d78: the chroma passes point-sample
instead of box-averaging, taking the GPU render from 140 ms to 64 and
the stream to ~9 fps at ~7 percent CPU, with luma measured bit-clean
against the CPU path (which also retired the bottom-row artifact).
The CSI hardware frame skip is live-proven with the GPU path and is
the recommended low-CPU configuration. CAMPAIGN-LOG carries the dated
second-session record; BRINGUP item 20 now lists the render-encode
overlap as the path to 15 fps, then MSE playback, the CPU measure,
coexistence, and the campaign.
2026-08-24 09:34:51 -04:00
ScottW514 904dfb4acb forgectrl pinned at the bench-proven GPU path; the session's record
The pin moves to forgectrl 6614833: the five hardware corrections from
the GPU demosaic's first bench session (surfaceless EGL config, ARGB
render targets, GR88 raw import, the ipu_copy stride-fix crop between
the GPU and the CODA, the chroma mirror width). CAMPAIGN-LOG carries
the dated record of how each was found; BRINGUP item 20 now separates
what that session proved (the full GPU -> IPU -> VPU path serving
correct frames, H.264 as valid fragmented MP4 on hardware, texture
limits, slot fit) from what remains (the 140 ms render, the bottom
row, MSE playback, the CPU measurement, frame skip, coexistence, the
campaign).
2026-08-24 09:11:50 -04:00
ScottW514 a47c0c7f6d Video offload: Mesa etnaviv in the image, forgectrl pinned, the H.264 stream in the catalog
The stream conversion now runs on the GC880 when the image carries the
GL stack, and /cam/h264 serves the same picture as fragmented MP4 in a
fraction of MJPEG's bytes (forgectrl 6573abd). This side supplies what
that needs and holds it to account:

- Distro: opengl stays a feature, with mesa trimmed to exactly the
  path used (gallium etnaviv, GLES/EGL/GBM, no GLX, no display
  platforms). Image: libegl-mesa, libgles2-mesa, libgbm,
  mesa-megadriver. forgectrl dlopens them, so an image without them
  still streams on NEON. Platform change: the next campaign is full.
- forgetest: camera.h264-stream exercises the endpoint end to end
  (codec header, init segment, media fragments, /cam/status encoder
  state) and accepts a clean 503 on a machine without the stack;
  the camera covers name the new forgectrl files.
- VIDEO.md 5.6 now describes both streams and why H.264 earns its
  place (the WiFi transmit path measures about 7 percent of the core
  per MB/s sent); BRINGUP records the bench-validation checklist as
  Next work item 20, including the release-size watch: mesa must fit
  the 200 MiB slot gate.
2026-08-24 08:10:33 -04:00
ScottW514 a2b391b8f6 forgectrl pinned at the status-page sys block; panel-serves reads it
The pin picks up /status carrying CPU and memory utilization (sys:
cpu_pct over the interval since the previous read, mem_pct from
MemTotal against MemAvailable) with the panel rows under the board
temperatures. forgectrl.panel-serves now re-reads /status after a
beat and asserts both percents are numbers in range, so the catalog
exercises the new telemetry on the bench.
2026-08-24 07:07:49 -04:00
ScottW514 f54d086625 forgetest: the fixture's resolver on a many-homed host; a missing box said once
The mDNS lookup sends its query from every local IPv4 interface (a
host whose default route is a VPN or a VM adapter never reached the
bench network), binds the mDNS port and joins the group as two
separate steps (a group join that fails left the socket half bound),
and a failed send is no answer rather than an error. The runner says
"running without it" once per reason, not on every 30 s probe.
2026-08-23 14:53:20 -04:00
ScottW514 7d982070c0 BRINGUP: the interlock loop is J8 (J6 is the speaker); the 3.3 V rail carries the fixture's coils 2026-08-23 14:35:36 -04:00
ScottW514 b54b94e8ae forgetest: the bench actuator plugs into the action seam
fixture.py: the bench's /data/forgetest/fixture.json (hostname, key,
optional ip, the channels wired, arm_press), a resolver for
<hostname>.local asked of the network directly (the image has no mDNS
resolver), and the client. The runner probes it before every run and
at most every 30 s otherwise; ctx.act asks it for a channel it covers
and still waits for the machine's own reading, falling back to the
operator's notice when the box fails. A test declares with hands=(...)
what it asks of a person beyond its typed actions; an operator test
with none, whose actions the fixture covers, is routed into the
unattended queue, its Ready gates pass, and a prompt it raises anyway
is a FAIL naming the undeclared step. Live tests never move; their arm
press stays a person's unless the bench opted in, in which case the
fixture presses when the button lights. Whatever the box still holds
after a run is released before the baseline's post pass and recorded.
The page shows what the fixture covers. Contract in ACCEPTANCE.md; the
wiring facts, with the interlock connector left to the bench to settle
(SAFETY.md and the sister map differ), in BRINGUP.

Catalog unchanged in its definitions; the cloud and laser suites'
shared code moved, so their implementation hashes move with it.
2026-08-23 14:15:53 -04:00
ScottW514 8ee4ee3946 forgefixture: the bench actuator's firmware
An ESP32-S3 DevKitC-1 on the bench network drives three relays at the
machine's connectors so the acceptance tool can open the lid loop, pull
the interlock loop and press the button with nobody in the room. Two
contacts are normally closed in loops the safety chain already reads,
the third is normally open across the button input and only ever
pulsed (20 to 500 ms, the end armed before the line rises); every line
is low at boot and after any reset, the task watchdog panics and
reboots, and the button channel needs a jumper. HTTP on port 80 under
a key in X-Fixture-Key; the hostname announced over DHCP and mDNS.

ESP-IDF v5.5 native, the mDNS component pinned in dependencies.lock;
fixture.env (git-ignored) is the one input: the wifi, the key, the
hostname. fixture.sh builds with idf.py or in the espressif/idf
container, flashes with esptool from pip. The decisions that need no
hardware live in policy.c with a gcc host test; CI runs it and builds
the firmware in the pinned container.
2026-08-23 14:15:53 -04:00
ScottW514 335c6dea9d forgetest: non-behavioral paths outside every fingerprint; hollow covers fail the lint
The coverage lint already allowed docs, CI, unit tests and licenses to
go uncovered; the same list now keeps them out of every fingerprint,
so a README edit in any component re-requires nothing. The list moves
to the manifest module as NON_BEHAVIORAL, the one place both uses read
it. And a coverage entry that selects no file of its component (a glob
without the recipe's subdirectory, a component the manifest lacks, a
glob naming docs only) fails the lint: such an entry covers nothing and
the test's fingerprint ignores the file it meant. The contract says
both. Every test whose maps reached a doc or a test file gets a new
fingerprint once.
2026-08-23 12:54:55 -04:00
ScottW514 53e4fa20e9 forgetest: the cloud tests' coverage maps follow the split
The six job tests covered all three cloud components whole, so a
one-line change anywhere re-required one real print and five attended
tests. The maps now say what each test proves: the protocol test the
web session, the emulator and its fixtures; the offline tests the run
loop, the hardware it drives, the offline dispatch and the pulse path;
cloud.mode-switch the homing path; every one of them the client's
common ground. The one real print keeps the coarse maps as the
integration and the lint's floor. Two entries of the protocol test
named app files without the recipe's subdirectory and selected nothing;
the paths are spelled out now.
2026-08-23 12:54:55 -04:00
ScottW514 e5fa4445c0 BRINGUP: the service protocol proven by the emulator; CAMPAIGN-LOG 2026-08-23
Dev image 20260823161333, campaign c-20260823161923-0dd7, 44 of 44,
release authorized. The cloud split of the acceptance plan is complete:
the protocol by the emulator with only the app to drive, the machine's
print behavior by the offline service, the two together by one real
print, and the service's hunt paid only where it is the subject.
2026-08-23 12:38:41 -04:00
ScottW514 8379aa5451 Pin forgefirm-app to 537d0db: the emulator reports idle to the cooling engine 2026-08-23 12:10:21 -04:00
ScottW514 ab0a515a85 forgetest: the supervisor's levers get a timeout above the daemon's own waits
POST /mode, /controller/start and /controller/stop answer only when the
switch is done: the old controller gone, the new one started after any
pending liveness probe, and its first job-state report in (15 s without
one). The client's 10 s timeout read a slow but honest switch as a dead
daemon and errored cloud.service-protocol on the bench; those three
paths now get 120 s. No catalog consequence: the tests and their covers
are unchanged, the client only waits longer.
2026-08-23 12:10:20 -04:00
ScottW514 908e0c76b8 Pin forgefirm-app to 351a623: gfcloud --no-hunt, the markers read first thing 2026-08-23 11:27:36 -04:00
ScottW514 969bac6013 forgetest: the service's hunt paid only where it is the subject
A cloud client the tool starts for anything but homing comes up under
the /run/gfcloud-nohunt marker: the real client back after the
emulator, a mode the runner switches to or hands back, a controller it
restarts. The service keeps the head position it has. cloud.mode-switch
and cloud.service-protocol keep their hunts, and so does the one real
print: enter_cloud reuses a running session only when that client has
hunted the machine itself (session_hunted: never the emulator's, never
a no-hunt start), otherwise it restarts the client with the hunt, since
a print placed on a head position the service only believes can run the
gantry into a rail. The markers are one start, taken down by the client
that read them first thing; the tool's own removal stays for a start
that never happened. Catalog unchanged; the cloud tests' shared code
moved, so their implementation hashes move with it.
2026-08-23 11:27:36 -04:00
ScottW514 5c7727b9bf Pin forgefirm-app to b7e8035: gfcloud --emulate keeps the service's connect-time hunt 2026-08-23 10:36:36 -04:00
ScottW514 4c9dcca7e6 BRINGUP: the emulator test built into dev image 20260823002125, its full campaign owed 2026-08-22 20:37:41 -04:00
ScottW514 1c8197faa3 forgetest: cloud.service-protocol, the service answered by the emulator in this machine's identity
The service-protocol half of the cloud catalog on its own test: the cloud
client restarted as gfutilities' emulator under the /run/gfcloud-emulate
marker signs in, passes the firmware check, opens the WebSocket, answers
the connect-time hunt and the image requests with the dev image's canned
frames, and runs a print from the app through the real download path to
':completed' - nothing moves, nothing arms, and only the app has to be
driven, by a person or an agent through the prompt API. The real client
is restarted afterward and its hunt waited out. session_live now knows
the emulator's session is not the machine's, so enter_cloud restarts it
rather than reusing it; restart_client is the one restart the offline
and emulator entries share.

The dev image adds python3-gfutilities-emulator (the fixtures, packaged
on their own in meta-openglow); forgefirm-app moves to 12ad3b1 (gfcloud
--emulate). Catalog: 44 tests, 27 auto / 9 operator / 8 live; the new
test covers the gfutilities service layer and examples/, which step 4
will take off the other cloud tests. Replays over the prompt script;
contract and BRINGUP updated. A layer change (the dev image recipe):
everything re-requires on the next image.
2026-08-22 20:19:46 -04:00
ScottW514 0cb9044e1d forgetest: the offline jobs removed after each test; the record of the first offline campaign
Every offline test now removes the jobs it wrote under /tmp/forgetest
on its way out (the bench rule: nothing left behind in the session that
put it there). CAMPAIGN-LOG gets the 2026-08-22 entry for dev image
20260822232347: the offline service dry-checked, then campaign
c-20260822233344-08de, 13 run and 43 of 43 with the four offline tests in
5.5 minutes, and what the machine said under it. BRINGUP: the offline
service is done and bench-validated.
2026-08-22 19:59:36 -04:00
ScottW514 628f2f7a7f Pin forgefirm-app to a3ca36f: gfcloud --offline
The same python3-gfhardware commit meta-openglow pins; the cloud client
learns the offline service (actions on /run/gfcloud-offline.sock, no
web session) for the acceptance tests.
2026-08-22 19:20:38 -04:00
ScottW514 9cc2e4eef4 forgetest: the machine's print behavior under the offline service
Four cloud tests no longer need the app, an account, a network, or
anything on the bed: cloud.lid-interlock-abort, lid-during-button-wait,
paused-lid-cancel and oversize-stream run under the offline service
(enter_offline restarts the cloud client with the /run marker for that
one start; Offline is the socket; offline_job writes the job). The jobs
come from forgetest/puls.py: the header of a factory print of this
machine type (134 tags, MCsn 0, so the client's limits and settings come
from where a service job's do) over a square traced at a steady feed
with a leading power byte of zero and no LASER bit anywhere - the arm
unlocks the latch, the beam is never commanded, so the tests stay live
and need no scrap. A job longer than the ring (33 MiB of ticks, an hour
of squares) is an 87 kB gzip written in a tenth of a second, in place of
a full-bed raster designed in the app.

session_live reads the offline mark as "no web session"; enter_cloud
restarts an offline client with the service, so cloud.pause-resume (the
one real print left, with cloud.mode-switch the service-protocol half of
the catalog) follows the offline tests without the operator's hand.
Replays over a fake socket; the contract and BRINGUP say how the cloud
catalog splits. Catalog consequence: the four re-ported tests move;
nothing else is invalidated.
2026-08-22 19:19:50 -04:00
ScottW514 60db95634b docs: the first campaign with the operator's part cut down
CAMPAIGN-LOG: the 2026-08-22 entry for dev image 20260822204234 - the
three harness findings (the jog witness, the fading button LED, the
duplicated app cancel), the implementation-hash change they exposed,
and campaign c-20260822220701-a1c0: 43 of 43 from nothing, the 16
attended tests in 19 minutes, with what each new witness read on the
machine. BRINGUP: bench-validated, the latest campaign, the hash rule.
2026-08-22 18:48:09 -04:00
ScottW514 2547a8eb68 forgetest: a test's implementation hash is its own function and its module's shared code
The implementation half of a domain fingerprint was the whole suite
file, so a two-line witness fix in laser.py re-required every laser
test and a rename in cloud.py every cloud test: sixty attended minutes
for changes that touched two test bodies. Now the hash is the test's
own function (its decorator included) together with the module's text
outside every @test function. A body edit moves that test alone; a
helper edit moves the tests of its module, which is what a helper does;
a file that defines no test in the @test form hashes whole. The gate
computes it the same way, from the same code.

Every recorded fingerprint moves once with this, so the next campaign is
a full one: the price of every later fix costing one test.

Also carries the re-targeted cloud replays that the previous commit
left in the working tree (the CI failure on 296fd68).
2026-08-22 18:06:27 -04:00
ScottW514 296fd686e1 forgetest: the button's commanded level, and one app cancel instead of two
laser.arm-wait-lid failed its first bench run on the witness, not the
machine: the cancel relocked, disarmed and emitted nothing, but the
"button dark" check read the LEDs' brightness the instant after, and
the smooth trigger fades it. The controller writes `target`, so that is
what hw.button_leds() reads now (brightness where no target exists),
and check_button_dark waits a few seconds for the command to land.

The operator's campaign showed cloud.oversize-stream and
cloud.pause-cancel-paths both cancelling a print from the app. A print
longer than the ring has to be ended that way, and the operator is at
the app for it anyway, so the app cancel lives there and is judged in
full (the abort tail: stop, park to the job start, relock, disarm, the
button dark, ':cancelled'); cloud.pause-cancel-paths loses its second
print and becomes cloud.paused-lid-cancel - one print, one arm press.
Replays follow. Catalog consequence: the two cloud tests and the laser
module are re-required; the count stays 43.
2026-08-22 17:47:26 -04:00
ScottW514 9139e923b1 forgetest: the jog witness judged over the sequence, as the sampler can see it
On the bench the head accelerometer's sysfs read lands two or three
samples in a one-second jog leg, and two samples on the constant-velocity
stretch read near idle with the head in full flight: the accelerometer
sees the ramps, not the travel. The first unattended queue on dev image
20260822204234 failed motion.jog-roundtrip on that (2 of 8 legs judged)
with the head plainly moving where a ramp was caught (p2p 3019, 1330,
1698, 1663). The verdict is now over the sequence: p2p across all the
legs at or above the liveness threshold, and motion on at least two
distinct legs (one jolt is not a gantry moving on every jog); the
sampler must have landed samples at all. Rerun: p2p 2897 over 17
samples, motion on 3 legs, PASS; the whole unattended queue passed (15,
then 9 re-required by the module hash) on that image.

camera.snapshot's PASS line names the half-res frame it compared (the
full-res size had been printed in its place). AccelSampler gets host
tests over a fake iio tree.
2026-08-22 17:04:12 -04:00
ScottW514 de324cc32f forgetest: the operator's part asked for by name, and fewer hands in a campaign
A campaign asked a person for about eighty things: lid, button and
interlock actions, app jobs, and sixteen confirmations by eye, most of
them as popups to read and answer while the head was already moving.
This is the forgetest-only step of cutting that down.

The operator channel. A test asks for its operator's part in four ways:
ctx.ready() pre-announces a timed step and waits for the click that
starts it; ctx.notice() is a standing instruction with no button, the
test watching the machine for the result; ctx.act(channel, state) is a
machine action by name (lid, interlock, button) - a notice for the
operator today, proven done by the switch reading or an `until`
condition, recorded in evidence.actions with who performed it, and the
seam a bench actuator plugs into through runner.fixture; ctx.confirm()
stays for the yes/no the evidence cannot answer. Tests declare
`actions`; a `precheck` refuses a start the machine cannot honor (a
reason, no result, a queue skips it and carries on).

The page shows what you will do before it is asked: the running test's
steps, a queue's attended tests still waiting, or the test whose title
you clicked while idle; notices and prompts sit under it. The campaign
card no longer carries baseline, queue and leftover notes: those go to
the runner journal (daemon.log, syslog as `forgetest`, and the run in
progress), with a Runner journal button in the footer.

The catalog, 43 tests (27 auto, 8 operator, 8 live; was 45: 25/12/8):
cloud.mode-switch absorbs cloud.hunt-lid-open and cloud.gfhome-homing
(the connect made with the lid open, the hunt judged lid-open with its
Z cycle, the re-hunt waited out after the close, the switch back, then
$H judged by gfhome's own "homing complete" line with its motion
windows; precheck homing_mode = gfcloud). kernel.fire-line is auto with
the HV-not-good precheck, camera.snapshot is auto (a second frame with
the lid lamp off differs and is smaller), motion.jog-roundtrip is auto
(the head accelerometer per leg, the supervisor's own witness). The
remaining attended tests use Ready gates and act(); the head's beam
detector and the button LEDs replace the eye, leaving two confirms: the
emission witness's mark and the app's display in cloud.pause-resume.

Proof: tests/test_operator.py (the channel, the precheck, the journal),
the cloud replays re-targeted to the merged round trip over a fake grbl
and the bench excerpts, 196 host tests green, coverage lint 0 uncovered.
Every re-ported attended test is owed one bench run on the next dev
image (BRINGUP). Catalog consequence: the merged and reclassified
tests' implementation hashes move; nothing else is invalidated.
2026-08-22 16:36:03 -04:00
ScottW514 58d1ac4746 The envelope's close-out image: 18 of 18, and what the new lines said
Dev image 20260822182931 (forgectrl b27398a, python3-gfhardware e65cfc2):
every unattended test PASS. The record keeps what the instrumentation
said on the machine: the status temps with the SoC die, the run-end
range line with all three sensors, and the cloud client's first counted
gap line on a live hunt header (30 declared ignored, 49 undecided), so
the next decision on the undecided families starts from a number. Also
the stale-layer build that was stopped and rebuilt on the way.
2026-08-22 14:48:47 -04:00
ScottW514 4e0c90b5a5 The pulse-header envelope settled; the SoC die watched; item 19 closed
forgectrl pin b27398a: the SoC die joins the watched board temperatures
(/status temps soc_c and the kernel's CPU cooling state soc_throttle,
the Status tab, the per-job range line naming a throttle), a throttle
starting or ending is logged, and the supply sensor stays a raw count by
decision (its heatsink cannot be reached with a thermometer while the
machine runs). forgefirm-app pin e65cfc2 (0.1.16+git): every pulse-header
key without an applier is declared with its reason or counted as
undecided in the job log, and CLOUD.md carries one disposition table for
the whole header.

BRINGUP: the pulse-header envelope item is closed. Its durable content is
in the facts bank ("The factory's envelope, decoded": the mandatory tags,
the empty tach windows, the factory's pause and fail tiers, the
per-sensor units, the unarmed flow controller, and ForgeFIRM's answer
with its catalog proofs) and in the "Deliberately not gated" paragraph,
which names every declared family. Item 19 is now the bench-measured
head crash and rail-contact detector; the fire-watch item holds the
header's lid IR thresholds as its prior. The facts bank also records the
SoC's own thermal guard (85 C passive, 90 C critical, no heatsink on the
factory board) and the board temperatures at idle.

Catalog: cooling.gate-off checks the die field, the unthrottled state at
idle and the widened run-end line (the unit fake mirrors it). COOLING
section 9 and the SERVICES verification status describe the present.
2026-08-22 14:24:50 -04:00
ScottW514 6458f6aec0 The ceiling at its off end stands with the default critical line
forgectrl pin 368fd0c: the critical-line cross-check binds only while the
ceiling is a gate. cooling.critical-tier pins that a POST setting the
ceiling to its off end is accepted with the default line (the unit fake
mirrors the exemption); CAMPAIGN-LOG records the gate-off failure on dev
image 20260822165832 that found it, and the critical-tier pass.
2026-08-22 13:43:36 -04:00
ScottW514 9fae47cf14 The critical tier proven on a rising loop; the board temperatures watched
forgectrl pin 76115fd: the chassis LM75 and the supply sensor ride /status
as temps (degrees and a raw count), the engine ranges them over every run
session into one run-end line, and a critical fault that clears with its
session yields the reason to the standing hold.

Bench: critical_tier_drill.py (a bench tool now, registered as
critical-tier) sets the ceiling, the resume gate and the critical line a
few tenths above the live upstream reading and lets the engine's own
flow-check heater warm the loop through them inside one M8 session;
temp_calibrate.py gains supply-watch, supply-point and supply-fit for the
supply sensor against a thermometer on its heatsink, the fit shown beside
UAPI.md's unverified guess.

Catalog: cooling.gate-off checks the /status temps fields and the run-end
board-temperature line (the unit fake mirrors both, one new failure
case); cooling.critical-tier checks the reason after a faulted session.

Docs: CAMPAIGN-LOG entries for cooling.critical-tier on dev image
20260822154257 and the warm-loop drill (OVERTEMP at 10 s, CRITICAL at
14 s, the fault ending with the session); BRINGUP item 19, the facts bank
(board temperatures at idle), COOLING section 9, the bench README.
2026-08-22 12:56:40 -04:00
ScottW514 f51140e528 The coolant critical tier, with its catalog test
forgectrl pin a1875a8: cool_temp_critical_c (default 38 C) is the fail
tier above the coolant ceiling's pause: at or over it in a run session the
verdict is CRITICAL (fire blocked, hold, no resume this job), the fault
ends with the session, the settings API keeps it above the ceiling, and
its top turns the gate off.

Catalog: cooling.critical-tier (auto, 45 tests), driven through the
settings API like cooling.gate-off: a critical line at the ceiling is
refused (and undone should it ever be accepted); with the ceiling, the
resume gate and the critical line all under the coolant's temperature a
session reads CRITICAL rather than OVERTEMP with no resume and the reason
naming the tier and the coolant; after the session the ceiling alone
holds (OVERTEMP); with the critical line at its top the gate is off
(gates_off, the run-start log line) and the ceiling alone pauses;
restored, OK with nothing off. Four unit cases against a scripted engine.
_after_session takes the condition to wait for.

Docs: COOLING (verdict table, section 5, the settings table, the quick
reference), BRINGUP item 19 and the catalog count, the CAMPAIGN-LOG
paragraph for the fan-fault session rule on dev image 20260822145201.
2026-08-22 11:41:18 -04:00
ScottW514 c1591e4f69 The fan floors proven on a pinned image; a fan fault ends with its session
The dated record of dev image 20260822135848: the campaign of every
non-operator, non-live test at 18 of 18 PASS with the measured floors and
the operating-point rule (cooling.fan-gate-trips and the hunt leg of
cloud.mode-switch as recorded), and the unplugged-exhaust-fan drill:
AIRFLOW at the grace plus three ticks with the exhaust dead, the other
fans held, the reason relayed on the Grbl port, the replugged fan ok
inside the next session's grace.

The drill showed the fault riding into idle, where the hold canceled
jogs and would have refused the cloud print that re-proves the fan.
forgectrl pin d51dbdb: the fault ends with its run session, and the next
session judges every fan afresh. cooling.fan-gate-trips checks the
verdict is OK with no hold once the tripped session is over (the unit
fake mirrors it); its covers, and the cooling tests' shared covers, gain
src/coolfmt.* (the tree manifest carries the new files at the bumped pin,
and the lint was right to ask). cloud.mode-switch samples the hunt's gate
rows twice a second: a hunt's run phase is a few seconds long.

Docs: COOLING 3a, BRINGUP item 19, CAMPAIGN-LOG.
2026-08-22 10:46:41 -04:00
ScottW514 adcd1adb9a Fan floors from the measurement; a hunt is measured, not judged
forgectrl pin 47e4256: the airflow floors set from the bench measurement
(exhaust 6400, intake 2290, air assist 6000 rpm, purge current 300, grace
15 s) and the operating-point rule: a fan is judged while the laser is
armed, when a job's profile may raise it but never lower it below the run
duty, or whenever it is commanded at the run duty; the service's hunts,
sent with the extraction fans off, are measured and published unjudged.

Catalog: cloud.mode-switch now waits out the connect-time hunt sampling
/cool/status and requires no AIRFLOW, the exhaust row unjudged and the
exhaust actually off; its covers gain forgectrl src/cool.* and
src/airflow.*. cooling.fan-gate-trips uses an 8 s test grace (the
intakes take 7 s to 90 percent and tripped under the old 2 s at the new
floor) and its off leg waits for the row state as well as gates_off.
fan_floor_measure.py names a reply that is not JSON and calls the purge
readings idle and run (the pump is always on).

Docs: COOLING 3a and the settings table, BRINGUP item 19 and a facts-bank
entry with the measured fan speeds, the bench README, and a CAMPAIGN-LOG
entry for the measurement, the two status-document finds, the hunt find
and the hot-deployed bench runs of both tests.
2026-08-22 09:56:53 -04:00
ScottW514 71a79b6675 Pin forgectrl to the CI-clean airflow commit 2026-08-21 19:05:43 -04:00
ScottW514 0bddc9eeab cooling.fan-gate-trips: a fan under its floor is a fault; pin the airflow gates
The acceptance catalog gains the test behind forgectrl 5a31d66: an
exhaust floor at the legal maximum must trip AIRFLOW after the grace and
three ticks (hold, fire blocked, no resume, the exhaust gate TRIPPED and
named in the reason); a purge current floor at the ADC rail must trip
the same way; an exhaust floor of zero must read off in gates_off and
trip nothing; restored, the next session must run OK with every fan at
or above its floor. Three host cases against a scripted engine that
models the grace and the readings. The covers map names src/airflow.*.

COOLING.md gains 3a (the airflow gates) and the five settings with their
ranges and bands; SERVICES.md the gate rule, the rows, the AIRFLOW
verdict and fan_gates; SAFETY.md names the floors among the verdict's
inputs; BRINGUP item 19 records the gates as in with provisional floors
and the catalog at 44. Pins: forgectrl 5a31d66, forgefirm-app 81027ff
(0.1.15+git); fetch-verified.
2026-08-21 19:00:06 -04:00
ScottW514 a8a02596cd Bench: fan_floor_measure.py, the numbers the airflow gates ship with
Two modes. spinup opens a run session with M8 from the engine's idle
posture, samples the four tachometers and the purge-air current once a
second, ends the session with M9 and waits for the engine to leave run;
per fan it reports the steady speed at run duty, the time to 90 percent
of it and the spread over the steady window, the purge current off and
on, and candidate floors at 55 percent of steady. cut sends nothing and
samples while the operator runs a real cut, for the spread the debounce
has to ride over under the only load that matters. Registered on the
bench page as fan-floor. Tooling only; no catalog consequence.
2026-08-21 18:44:07 -04:00
ScottW514 5249c1ecf8 CAMPAIGN-LOG: the job's limits pass through on a live session
cloud.pause-resume passed on dev image 20260821220926 with the service's
hunt windows (10 to 50 C) ignored as looser and the print's window (33 C,
5 C floor, 116 rpm air-assist floor) matched. The test now quotes the
print's job-limits line rather than the session's first (a hunt's), and
keeps the engine line that carries the header beside the last one.
forgectrl pin moves to e0b41b3 (the "not stricter" notice once per value).
2026-08-21 18:39:43 -04:00
ScottW514 d530f773e2 cloud.pause-resume reads the job's limits through; pin the pass-through
The pulse header's envelope now reaches the cooling engine: the cloud
client (python3-gfhardware c34faa1) derives the coolant window and the
fans' minimum speeds from the header and rides them on every report,
and the engine (forgectrl 57f6064) resolves each as the stricter of its
setting and the job's, never looser and never overruling an off gate.
cloud.pause-resume, which runs a real print, now also reads the client's
"job limits from the header:" line and the engine's "effective limits:"
line from the two logs; two host cases cover the failure paths. The
needle guard lists the engine's phrases as not the app's.

COOLING.md section 2 explains what a cloud job brings with it; BRINGUP
item 19 records the pass-through as landed. Pins: forgectrl 57f6064,
forgefirm-app c34faa1 (0.1.14+git); fetch-verified.
2026-08-21 18:07:41 -04:00
ScottW514 8ad3f79e26 CAMPAIGN-LOG: cooling.gate-off passes on the bench
First bench run of the gate-settings pattern: PASS on dev image
20260821210903 after the harness fix in f274eb1; the entry records the
failed first attempt and why it was the test's fault. BRINGUP item 19
carries the bench result.
2026-08-21 17:48:10 -04:00
ScottW514 f274eb1137 cooling.gate-off: let each run session end before opening the next
On the bench the test tripped the gate and then failed its off leg: its
M9 and the next M8 were 300 ms apart, the GRBL client reports at 1 Hz
and the engine samples at 1 Hz, so the engine never saw the session
end, the next M8 was not a new run start, and the ceiling was never
re-read. Every M9 now waits for the engine's phase to leave run before
the next M8, and an M8 refuses to open on a session still running.

The failure path restores the settings and then cycles a run session
too: the engine reads settings at run start only, so restoring the file
alone left the bench holding OVERTEMP against the test's 6 C ceiling
until the operator's next job. The scripted engine in the host cases
models the report period, and two cases pin both rules.
2026-08-21 17:26:14 -04:00
ScottW514 bf3483e994 cooling.gate-off: a gate setting trips in range and is off at its far end
The acceptance catalog gains the test behind forgectrl 9e44fdc: the
coolant ceiling set just over its legal minimum must trip OVERTEMP with
a hold and fire blocked at the next run start; set to its top the engine
must skip the gate (verdict OK), report it in gates_off on /status and
/cool/status, and log the run-start line; the original values are
restored, on failure too, and proven restored. Five host cases against a
scripted engine. The cooling covers map now names the files that carry
gate state (gates, settings, status, the panel) and corrects a glob that
matched nothing: the GRBL cooling client is src/glowforge_cooling.c, not
src/gfcool*. The fake forgectrl serves /logs/tail and keeps blank form
values as "clear", which is what the daemon does with them.

Docs: COOLING.md section 8 carries each setting's legal range and
recommended band and a new 8a on turning a gate off; SAFETY.md names
what no setting can reach; ACCEPTANCE.md records that gates are
exercised through the settings API, never GFCOOL_* env overrides;
BRINGUP item 19 records the pattern as landed and the catalog is 43.

forgectrl pin moves to 9e44fdc; fetch-verified.
2026-08-21 17:06:49 -04:00
ScottW514 f4b70a0826 BRINGUP: the first pin-file campaign is done; pin the cloud client
The full acceptance campaign on dev image 20260821181036, the first built on
the <recipe>-pin.inc layout, satisfied 42 of 42 and its export authorizes a
release, so the status header, the acceptance section and item 12 describe
that rather than a campaign still owed; the catalog count is 42, not 35.
CAMPAIGN-LOG records the completion. Item 7 stops listing the cloud work the
campaign closed (the oversize live print, the progress bar watched from the
app, packaged-path cloud boot, the lid flash), records the operator's
declines (SPKI pinning, emulator parity, the ten-event pause machine) and the
reasoned memory guards, and item 19 no longer lists MCsn and PDfm as
unchecked: the client refuses both, so seventeen mandatory tags are dropped,
not nineteen.

forgefirm-app pin moves to b82d379 (python3-gfhardware): CLOUD.md and three
present-tense comments, no behavior change. No catalog consequence beyond the
pin bump itself, which the manifest scopes to the component.
2026-08-21 15:34:25 -04:00
ScottW514 6c1d68f2c3 Judge the cloud resume on the lines the app logs; guard every needle against the pinned app
cloud.pause-resume failed a print that paused, resumed with its laser
lead, completed and parked: the test waited for the single line "button
pressed while paused; resuming", and the app has logged that as two
lines since its feeder work ("button pressed while paused", then
"resuming (laser lead N ticks)" from _resume_retraced). The replay
fixture carried the old wording, so the host test kept passing.

The pause and resume are now judged on PAUSE_LINES + RESUME_LINES
through one checker shared by the three tests that drive a pause
(cloud.pause-resume, the streamed pause, the pause-then-lid test), which
also fails on the app's "resume refused" line with the reason. The
fixture carries the app's two lines.

So the wording cannot drift silently again: tests/test_cloud_needles.py
reads every log phrase the cloud suite greps for out of cloud.py (the
left side of each `x in ln`, every wait_log needle, the mark tuples, and
the phrases it builds) and checks each against the logger calls in the
app sources at the revisions the recipes pin, read from the manifest
cache the tree manifest builds (the sibling checkouts locally),
placeholder-aware under a rule that never lets a placeholder stand for
the phrase itself. CI now builds the tree manifest before the unit
tests so the cache is there. The old needle fails that check.

Replays added: the second press seen but no retraced restart, and a
refused resume. 159 unit tests pass; coverage lint clean. No catalog
consequence beyond the suite module's own hash.
2026-08-21 14:09:51 -04:00
ScottW514 9585febb7c Take the fan idle reference from a settled machine, and log the cooldown
cooling.fans-quiet-after-motion sat silent for four minutes on the bench
and then failed. Its idle reference was the tachs one second after the
baseline saw the engine go idle, while the previous test's fans were
still coasting at the cooldown level (exhaust 5030 rpm against a true
idle of 0), so it then waited for the fans to come back UP to a level
that was never idle. The cooldown wait printed nothing while it waited.

The reference now needs the engine idle, the idle duty applied to both
fan channels, and three consecutive tach samples that agree; the pass
condition is the idle duty back and the tachs at or below that reference
(lower is quieter, never a fault); M8 must visibly raise the duty; and
every sample of both waits is logged with the phase and the duty, so the
run pane shows the fans coasting down rather than a hang.

Proof: tests/test_cooling_suite.py replays the test under the real
Context against a scripted machine (fake forgectrl, fake sysfs duties,
fake Grbl port): an idle machine passes, the bench case (fans coasting
when the test starts) passes with the reference taken after the coast,
fans left on fail with the reference in the message, and a reference
that never settles fails before anything is jogged. No catalog
consequence beyond the suite module's own hash.
2026-08-21 13:23:23 -04:00
ScottW514 ef8ef0ba3c Pin grblHAL-glowforge 44c801a: the job start survives a pause 2026-08-21 13:01:50 -04:00
ScottW514 9c22240480 Hold the job start across a pause in the lifecycle harness
laser.pause-resume-lid-cancel failed on the bench with the head 14.925 mm
along the first leg after the lid cancel: the driver took every resume
from a feed hold as a new job start (the core restarts a held cycle
through Idle), so the cancel returned the head to the pause point and
said "returned to the job start" about it. The driver fix is in
grblHAL-glowforge; this is its proof and the record.

The null-sink lifecycle harness gains rule 9: a job paused and resumed by
the button, then cancelled by the lid, returns to where the job began
(reproduces the bench failure to the millimeter on the old driver,
X=13.088 instead of 0); and a job abandoned in a hold and soft-reset is
over, so the next job from that spot returns to that spot. The campaign
log carries the dated account.

No catalog consequence: the acceptance test measured exactly this and
stays as it is; the harness is the CI side of the same contract.
2026-08-21 13:00:56 -04:00
ScottW514 54e1689889 Let a test declare the controller mode it needs; the runner switches to it
The cloud job tests enter cloud mode and stay there, by design, so a
queue (or an operator) that goes on to a motion test reaches it with
gfcloud as the controller and no grblHAL process to find:
motion.step-timing-under-load failed on exactly that, before it touched
the machine. Nothing in the runner put the machine into the mode a test
needed; the baseline only preserved the mode it found.

A test now declares `mode="grbl"` (or "cloud") in @test. The runner's pre
pass, after the leftovers are handled and before the preserved state is
captured, switches through POST /mode, waits for the supervisor to settle
(controller running, motion verified) and for the Grbl port to answer,
and fails the test with the reason when the mode cannot be established.
Capturing after the switch means the post pass keeps the mode the test
asked for, so the machine changes mode only where the next test asks for
it and never between tests of the same mode. The cloud job tests keep
managing their own entry (enter_cloud also waits for the service
session) and declare nothing.

Tagged: every motion.* test but the mode-agnostic liveness probe, the six
laser.* tests, cooling.fans-quiet-after-motion, cloud.mode-switch and
cloud.gfhome-homing (both start in GRBL mode). controller_pid() now says
what mode forgectrl reports when the process is missing. The page shows
the declared mode as a badge; the Grbl port probe moved to hw.

Proof: tests/test_mode.py (switch_mode against the fake forgectrl,
including a refused switch, a controller that never comes up and a port
that never opens; the runner end to end from cloud mode, from grbl mode,
an undeclared test, and a failed switch). 151 unit tests pass; the
coverage lint is clean. No catalog consequence beyond the suite modules'
own source hashes: the change is to how a test is started, not to what
it proves.
2026-08-21 12:34:03 -04:00
ScottW514 e810d52c9d Put every test group on the same column grid
The subsystems are separate tables, so each sized its own columns from
its own content and no two lined up. Reading down the page meant reading
down six different layouts, which is what made it look busy. They now
share one colgroup and a fixed table layout: Kind, Status, Last result
and the Start button are the same width in every group, and the Test
column takes the remainder.

Two things had to come out of the columns first, because both are long
enough to stretch a cell and drag one group's grid out of step with the
rest. The details block moves to a full-width row of its own, where the
prose and the operator steps have room and opening one no longer widens
the Test column. The requires note moves from under the Start button to
under Status, which is sized for it: the catalog's longest names three
prerequisites in 73 characters, and beneath a button it wrapped into a
ragged stack. It reads better there anyway, next to the reason the test
is required rather than beneath the control it disables.

Also gives a test's description its own block in the details. Without
operator steps to separate them it ran straight into the Requires line.
2026-08-21 10:44:46 -04:00
ScottW514 0258268aae Let the page run a campaign's remaining tests as a queue
A campaign is mostly waiting for the next Start. The page now offers two
queues, and each takes every test of its kinds the campaign does not
already count as satisfied: Unattended for the auto tests, which need
nobody in the room, and Operator and live for the ones that need somebody
at the machine, since they prompt and they fire the laser. The buttons
say how many they would run and ask before starting; the live queue names
the tests that fire and takes the acknowledgment once, for all of them.

A queue runs one test at a time through the runner's single slot, in
prerequisite order. Registration order otherwise, so a run reads down the
page, but a prerequisite inside the queue always goes first. It stops on
the first result that is not a PASS: a FAIL closes the campaign, and
carrying on would only open a second one behind the operator's back. A
test the runner refuses to start is skipped with the reason on the page
and the rest carry on, which is what happens to an auto test waiting on an
operator one: run the attended queue, then the unattended one again.

The queue lives in the runner, not in the tab, so reloading the page or
closing it leaves the run alone. While one is up it holds the machine
between its tests as well as during them, so a single Start and the bench
tools are refused rather than cutting in. Stop the queue cancels what is
still waiting and lets the run in progress finish; Abort ends that one
too, and lands as the non-PASS that stops the queue. Every run a queue
starts records which one put it there.

Also moves the /state ETag test to the end of its class. It invalidates,
timestamps are whole seconds, and a PASS stamped in the same second as an
invalidate is deliberately not inheritable, so on a fast run it decided
the inheritance an earlier test was checking.

forgetest is a dev-only component and can never appear in a coverage map,
so this has no acceptance catalog consequence.
2026-08-21 10:27:50 -04:00
ScottW514 474e14e0e8 Make the acceptance page answer the operator, not the timer
Presses on Start and Continue were being swallowed. Every poll rebuilt
the whole test table and the prompt buttons with innerHTML, and a button
destroyed between mousedown and mouseup raises no click event at all:
the press simply vanished. Measured on the page as it stood, a Start
button node was replaced 13 times in 40 seconds, and with a poll landing
mid-press 8 presses out of 8 were lost. Rows, prompt buttons and tool
entries are now built once and afterwards only updated in place through
setters that skip the write when the value has not changed; under the
same test 8 presses out of 8 land.

The page also felt slow because each poll re-read and re-parsed the whole
result log and recomputed all 42 domain fingerprints. A result record
carries its run log, so the file reaches megabytes over a campaign and
the poll cost grew with it. The log now parses each line once and reads
only the bytes appended since, and a fingerprint is memoized against the
manifest's content hash. On the same manifest, catalog and log, one
/state goes from 8.89 ms to 0.10 ms at 0.89 MB and from 41.79 ms to
0.23 ms at 10.62 MB, and no longer grows with the log.

Actions now report on the press instead of on the next poll: Start greys
every Start and marks the row, Continue and Abort grey themselves, and
each pulls the next poll forward. /state carries an ETag so an idle page
polls for a 304, the poll ticks faster during a run, and the connection
is kept alive rather than handshaking per request.

Keeping the connection alive exposed a hazard worth naming: a POST
refused before its body was read left that body in the socket, where the
next read took it for a request line. A refusal now ends the connection.

forgetest is a dev-only component and can never appear in a coverage map,
so this has no acceptance catalog consequence; the host tests carry the
proof, including one that fails if a per-poll innerHTML rebuild ever
comes back.
2026-08-21 09:49:35 -04:00
ScottW514 eaa0b0741b Pin forgefirm-app and forgectrl to the same tree
Same repository as the python3-gfhardware pin in meta-glowforge-bsp, moved
with it. forgectrl moves for the download guards it exposes to the panel.
2026-08-20 16:22:42 -04:00
ScottW514 54d238d67d Record the campaign behind a print longer than the ring
The work ran from 2026-08-18 to 2026-08-20 and is finished, so the plan it ran
from is retired the way plans are retired here: what is durable moved into the
documents that describe the present, and the dated record of how the result was
obtained lands here.

The entry keeps the parts that live nowhere else. That the campaign started
from a wrong belief and was corrected by the factory itself, which stages a job
into its ring in free-checked segments and then appends to it for as long as
the job lasts. That the proof was already on the machine, a 107 MB job played
through a 32 MiB ring in this board's own factory logs. The two defects the
building surfaced, a dry ring reporting success and a pause that could not
retrace on a streamed job. And what it cost to be sure: a decoder 41 times too
slow to keep a ring fed, which only showed itself against a real 53 MB job.
2026-08-20 16:20:03 -04:00
ScottW514 3f4cd2b621 Close the unobserved actions in the cloud item
They were open because the service has never sent them and cannot be asked
to. That is not a reason to leave them open: the factory application says
what each one does, and it says all four are hand-offs to programs this
machine does not have. BRINGUP's cloud item now records the disposition
rather than the question.
2026-08-20 15:39:40 -04:00
ScottW514 aaabfdf9b9 Check the progress a print reports, where a print already runs
Two tests already run a print end to end, and progress is a property of a
running print, so the checks go there rather than into a test of their own
that would cost the operator another job.

cloud.pause-resume takes the job that fits the ring: the client names the
length it is reporting against, and the operator is asked the question only a
person can answer, whether the bar actually moved.

cloud.oversize-stream takes the job that does not fit, which is where a moving
denominator would show: the kernel's program total grows all run long under a
live feed, and the test already samples it growing, so the check is that the
figure progress divides by is larger than that - the job, not the count the
ring had swallowed when the run started.

The forgetest replay plays a captured log from a build that predates the line,
so it carries the line where the current build emits it, as it already does
for the warm-up and the rest.

BRINGUP's cloud item now says a print reports itself again, and what is left
on it is a print watched from the app.
2026-08-20 14:16:27 -04:00
ScottW514 0ef2047625 Record what the factory does about progress
The campaign log gains the F1 entry: the question was which of three carriers
the factory uses for the progress bar, and the answer is that two of them
collapse into one. Progress rides an outbound WSS type:"progress" frame that is
itself the periodic settings report, every 30 s. The write-up lives in CLOUD.md;
the log records how it was gotten and what else the capture proved, including a
factory progress total that grew 256 KiB per interval because the factory
live-appends to its own ring.

BRINGUP's cloud item now lists F2 among the open work with the carrier settled,
so what is owed is emitting the frame against the feeder's job total rather
than the kernel byte counter.

Documentation only, no behavior change, so no acceptance-catalog consequence.
2026-08-20 13:28:43 -04:00
ScottW514 d122a6ff1d Check a print's warm-up and rest where a print already runs
cloud.pause-resume runs a print end to end, which is exactly what the job
lifecycle needs to be seen: a non-zero hold before the first fire, a non-zero
rest after the park, and neither on the connect-time hunt in the same
session. Folding the checks in there costs the operator nothing, where a
test of its own would cost another print.

The forgetest replay noticed first: it plays a real captured log from a build
that predates those lines. Rather than editing what the machine said that
day, the replay carries the two lines where the current build emits them.

BRINGUP's cloud item now names what is actually open on the header keys, the
park and the two periods, and records that the pause constants were looked
for in the wrong place.
2026-08-20 10:05:38 -04:00
ScottW514 e3d23b8099 Record the download guards and the feed watchdog
BRINGUP's cloud item said a print is capped by the ring and listed the two
gaps that closed when it stopped being. It now says what bounds a print
(memory, through pulse_reject_threshold_bytes) and what catches a feed that
wedges (progress against room, not ring depth), and it names the two new
cases that cannot be induced from the bench: a body past the memory guard,
because the service has no such job to send, and a wedged feed, because a
healthy machine will not stall on request.

forgectrl.settings-bounds gains a probe at the far end of the new byte
range, so the validator behind those keys is exercised rather than assumed.
2026-08-20 09:23:10 -04:00
ScottW514 0203887dc9 Pin forgefirm-app to the streamed-pause client
Same repository as the python3-gfhardware pin in meta-glowforge-bsp, moved
with it: the cloud client now sizes a pause to the history the ring kept.
2026-08-20 07:48:31 -04:00
ScottW514 942d9dde99 Drill the backtrack boundary, and pause a streamed print
kernel.backtrack-bounds plays a program, stops it, and holds the readback
to the bytes it played less the deceleration tail: a step past the boundary
has to be refused rather than quietly shortened, and the run at the boundary
has to play out and come back idle. Motors locked, latch locked, duty zero,
so nothing moves and nothing fires.

cloud.oversize-stream pauses and resumes the live-fed print it already has
running. That is the pause the kernel change makes possible, and it costs a
minute of a job that is on the bed either way.

BRINGUP's pause bullet, its ring facts and item 18 all said a ring under a
live feed has nothing left to back into. The gap the writer keeps clear says
otherwise; what is still open for GRBL mode is the bookkeeping above the
ring, not the kernel below it.
2026-08-20 07:41:41 -04:00
ScottW514 7bd9c45fd3 Accept a print longer than the ring
cloud.oversize-stream drives a job the ring cannot hold and checks the
signature of a live feed: the load reporting the job as longer than the
ring, the device in live-feed mode, the kernel's program total growing
during the run, no underrun, and a clean cancel afterwards.

read_program_total gives the catalog the counter that growth is read
from.
2026-08-19 21:21:31 -04:00
ScottW514 4300f7f4a8 Follow the pulse ring to 32 MiB
The ring and the device-tree pool that backs it are now the size the
factory firmware runs. Docs follow, and the hardware facts bank gains the
reserved-memory map: 511 MiB usable, 96 MiB reserved for DMA, ~454 MiB
left to Linux.

image.health reads the pool size and ring_mb back and fails if they
disagree, so a pool the parameter does not use cannot ship unnoticed, and
checks that free never exceeds the ring less its gap.
2026-08-19 21:21:30 -04:00
ScottW514 6e4a26ac61 Bump the forgefirm-app pin alongside python3-gfhardware
Both recipes pin the same repository and the pin files say to move them
together. Documentation only on this side: the cloud doc now records what
the factory enforces from the pulse header, and what it does not.
2026-08-19 17:25:42 -04:00
ScottW514 4a6bd5a179 docs: record what the factory does about fans, temperature and coolant
Next-work item 19 carried three open questions about the factory's
operating envelope. All three are answered now, so the item states the
policy instead of the guess.

A fan tach alert during a cut pauses the print, taking the same transition
a user pause takes, and two of the factory's three tach monitors cannot
fire at all: they treat a zero limit as unconfigured and the limits arrive
zero anyway. So a stalled extraction fan is caught by the temperature it
causes, not by its tachometer. Every temperature alert pauses as well, and
a critical fails the machine outright, which is a different state and not a
pause. The scale question behind the header's ceilings is answered by the
coolant family, which the factory carries twice, once in raw ADC counts
where the tag named min is the hot limit, and once in millidegrees. And the
factory does not verify coolant flow at all, so the cooling engine's flow
check is ahead of the factory rather than behind it.

The crash, tilt and beam-detect bullets gain the same grading: an alert
threshold that pauses and a separate abort threshold that aborts.

Also drops references to paths that resolve outside this repo, in both
documents and in two bench scripts, naming the artifact instead. In
CAMPAIGN-LOG that is a wording substitution only; no date, claim or
measurement changes.

Documentation only, so no acceptance catalog consequence.
2026-08-19 17:23:02 -04:00
ScottW514 5c242ca4b4 docs: state plainly that ForgeFIRM is free and open source
Add a "What this costs" banner to the README and INSTALL, below the
in-development notice. It states that the firmware is free in both
senses, that nothing is paywalled or held back, that the work happens in
public, and that anyone who wants to verify it can read the licenses and
the commit log instead of taking a stranger's word for it.

The banner also warns that a build bought from a third party is that
party's build, not this project's: unreviewed code driving a laser.

Documentation only. No behavior change, so no acceptance catalog
consequence.
2026-08-18 20:15:05 -04:00
ScottW514 340132e520 Say plainly at the top that there is no release yet
The repo is public and the install path is fully written up, so a reader
who finds it can follow INSTALL.md end to end without ever learning that
the release it depends on does not exist. The banner sits directly under
the title in both documents: no images are published, nothing here is
installable, and anything found elsewhere claiming to be a ForgeFIRM
image did not come from this project.

Documentation only - no behavior change, so no acceptance-catalog
consequence.
2026-08-18 16:05:31 -04:00
ScottW514 5a587c3536 Name the verdict, not the report, in the stale-verdict rule
The section describes two channels flowing opposite ways - the
controller's job-state reports and the engine's verdict - so "the report"
pointed at the wrong one directly under a lead that names the verdict.
2026-08-18 07:43:16 -04:00
ScottW514 7012c7d1b2 Homing is lid-gated in practice; only the cloud hunt is not
MOTION said jogs, homing and hunts were all ungated. Jogs are:
gfsw_visible withholds the door signal while the core is idle, jogging or
homing, so a jog both starts and runs with the lid open. Homing is not.
With homing_mode = gfcloud - the only method that works today - $H hands
the cycle to a cloud homing session, and its move to the home corner is
an ordinary motion action taking the default lid_gated=True: refused with
the lid open, stopped on a lid edge mid-run. Only the lens/Z hunt inside
that session passes lid_gated=False, and that session is also the only
place a hunt happens in GRBL mode - there is no hunt outside one.

BRINGUP gains item 18: GRBL pause and resume should leave no gap in the
cut, the way the factory's does. The beam stops at the start of the hold
(disable_laser_during_hold, on by default), so the head travels the whole
deceleration dark and the resume restarts from a standstill where the
decel ended - an unburned length, then a dwell through the accel that M3
shows as a deeper spot. The kernel waypoint backtrack cloud mode uses is
refused with EPERM on a live-streamed ring, so the equivalent has to be
built above the ring, where grblHAL still holds the planned path the
kernel has already overwritten.

Two wording fixes: the cooling verdict is described as a report rather
than a file, and gfcloud homing as using the machine's builtin
credentials.

Documentation only - no behavior change, so no acceptance-catalog
consequence.
2026-08-18 07:41:37 -04:00
ScottW514 7ad6b53647 dladder: an absent laser_power_model key now means density
The shipped default changed, so the drill's precondition would have
refused to run on a stock machine - the one configuration it most needs
to be able to test. Only an explicit analog selection is a refusal now,
and the drill reports whether the model came from the config or the
driver default.
2026-08-17 22:06:22 -04:00
ScottW514 0c2dd37b2f Cover both dose models explicitly now that density is the default
grblHAL-glowforge 03d70e0, pinned here, makes density the shipped model
and $35 the density floor at 10. Every analog session in the stream
harness now selects its model rather than inheriting it: without that the
flip would have quietly turned them into density runs and taken the
analog fallback's coverage with them. laser.power-floor carries the new
floor and its PWMSAR minimum, and its description no longer describes
$35 as a duty floor.

All ten stream sessions pass on the new defaults, with the analog duties
shifting exactly as the floor predicts - min_value 12, gradient 0.115 -
along with both C harnesses and the lifecycle harness.

Recorded in BRINGUP and the campaign log, including what the defaults
rest on and what they do not: the seven ladders ran at F300 and F100 at
constant power, so production feeds, M4 into corners and the raster path
remain unproven on hardware.
2026-08-17 22:03:56 -04:00
ScottW514 1e855ab212 The density floor closes the low end: a commanded 1 percent now marks
$35 = 10 under the density model is a density floor, not a duty floor. It
maps S onto 9.4-100 percent density, so a commanded 1 percent lands at
10.2 percent, just above the marking floor the earlier ladders measured.
A ladder reweighted to the bottom of the user scale - 1, 2, 5, 10, 20,
40, 70, 100 percent of S - marked on all eight rungs, with eight current
segments over a 42.0 s window against exactly 8 x 5.25, and segment means
climbing 136 to 968.

That meets the goal the ladders started from: a user's 1 percent is a
real visible mark rather than silence, and 100 percent is full power. It
took all three pieces - density so every level is real pulses, the
minimum pulse so they stay strikeable, the floor so the user's range sits
on the band that works.

dladder no longer tells the operator to re-run at other base periods to
choose one. The period cancels out of the low end, and what a failing
rung now indicates is a floor set too low.
2026-08-17 21:58:05 -04:00
ScottW514 d7c23cce19 A longer minimum pulse is worse: the gap is what decides striking
min_ticks 6 broke 5 percent striking - seven current segments where 3 gave
eight, on a 36.5 s fire window against 41.4 s for eight rungs. Below the
minimum the model emits min ticks every min/on periods, so the interval
between pulses is min_ticks x tick / density and the base period cancels,
which is also why periods 10, 20 and 40 gave identical results earlier.
At 5 percent that is 2.26 ms at min 3, which struck, against 4.51 ms at 6,
which did not: doubling the minimum doubles the gap as well as the pulse,
and the discharge is re-struck each pulse.

min 3 sits at the factory's own operating point - its 6.5 percent engrave
jobs place 100 us pulses 1.54 ms apart against 1.64 ms for min 3 at that
density - and 6 is outside anything the factory does. The bench is back
at 3.

Measured band for this tube: strikes from ~5 percent density, marks from
~10 percent at F300. That closes the pulse-structure route to a usable
1 percent, since the interval grows as 1/density and 1 percent implies an
11 ms gap. The low end is a scaling problem, and $35 is the control.
2026-08-17 21:52:15 -04:00
ScottW514 ff454ed537 Retract the pulse-length conclusion; show the minimum in the drill table
The fifth ladder, the first with a minimum pulse, moved the floor down a
full rung: only 5 percent failed to mark, and 5 percent now strikes. The
trace carries eight current segments where the run before it had seven,
with fire beginning at 6.2 s exactly at rung 1 and the usual flat
saturated final segment anchoring the count from the other end.

That retracts what the previous entry concluded. Pulse length is not
irrelevant: 10 percent moved from no mark at F100, with three times the
dose per millimeter, to a mark at F300 at the same density, the only
change being its pulses growing from 36-71 us stubs to 106 us. The
matched-pairs argument was sound but drawn entirely from comparisons at
or above 20 percent density, where every pulse length in play was already
long enough - it generalized from the one regime where pulse length does
not bite. Above ~100 us dose governs; below it pulse length does; below
~36 us the supply does not strike. The factory's 100 us quantum sits on
that boundary.

dladder now reads laser_pulse_min_ticks and prints what is actually
emitted. Without that its table reports the pulse density alone would
give, which is wrong wherever the minimum applies - at min 6 the bottom
four rungs all emit 213 us and vary their rate instead, and the operator
reads that table to interpret the material.
2026-08-17 21:47:40 -04:00
ScottW514 a3e83c4fd5 Cover the minimum pulse width; record what the density ladders measured
Rule 15 in the stream harness holds both halves of the minimum
(grblHAL-glowforge f7e8c17, pinned here): no emitted burst falls below
laser_pulse_min_ticks, excepting one clipped by fire going off mid-burst,
and the levels too faint to fill a window still render their exact
average density. Checked against a run at minimum 1 so it cannot pass
vacuously - level 2 goes from 444 bursts of one tick to 147 of three at
the same density, and levels already above the minimum are unchanged.

Four bench ladders settle the base period at 20. The same six rungs
marked in all of them, and the matched pairs across periods separate the
variables: at identical pulse length, halving the density killed the
mark; at identical density, varying the pulse 3x changed nothing. Feed
does not move it either - 10 percent at F100 carries 44 percent more
energy per mm than 20 percent at F300, which marks, and still left
nothing. The low-end marking limit is average power, not dose per length
and not pulse length.

The F100 trace separates two failures that look alike on the material:
seven current segments for eight rungs, anchored by a flat saturated
final segment that can only be full density, put 5 percent at no
discharge at all and 10 percent at a full 15 seconds of current with no
mark. Only the first is ours, and the minimum pulse is the answer to it.

Also recorded: the factory's Precision Power 1 runs a 19.53 percent FIRE
duty cycle at full PWM duty, and its 1-100 scale maps onto density
18.9-79.5 percent, so its 1 percent is the bottom of the useful band
rather than 1 percent of the range. Under the density model $35 and $36
are that same control - a density floor and ceiling - which makes the
user-facing scale a settings choice rather than new code.
2026-08-17 21:37:41 -04:00
ScottW514 b7091effb7 dladder: print the hv_current trace, like pthresh
The trace is the per-rung witness: the laser-off G0 between rungs reads
0, so the runs of nonzero current count the rungs that struck and their
level tracks each rung's dose. Without it a run reports only a range,
and which rungs fired has to be read off the material alone.
2026-08-17 20:55:39 -04:00
ScottW514 4eb0932099 Add the density ladder drill for choosing the dose base period
Under the FIRE-bit dose model the duty is pinned at full and the level
is carried by how many ticks of each base period fire, so a rung's pulse
is density x period - and the base period is the one parameter the host
cannot settle. dladder walks 5 to 100 percent of dose on scrap at
constant power, printing what each rung actually emits: density, mean
on-ticks, and the pulse the tube sees in ticks and microseconds. Below
one tick per period the pulse stays one tick and whole periods are
skipped instead, which the table says outright rather than quoting a
sub-tick average.

Run it at 20, then 40, then 10 on the same material. At 20 percent dose
that is 107-142 us, 249-284 us and 36-71 us respectively, which brackets
the 100 us the factory never goes below. The material answers two
questions: whether depth tracks density linearly, or the low end marks
harder than its share because every burst restarts the discharge and
carries the strike transient; and how short a burst still marks, which
is the floor a given base period can reach.

Preconditions are refusals, not warnings: laser_power_model must already
be density and $35 must be 0, since a floor lifts every rung off the
bottom of the range the drill exists to explore. The $35 message says a
runtime write is not enough - the S to duty mapping is precomputed when
the spindle is enabled. The drill writes laser_pulse_ticks itself when
it can reach the machine config, preserving every other line, and
refuses with instructions when run from a LAN host where it cannot.
2026-08-17 20:45:32 -04:00
ScottW514 c2c627d9b6 Prove the density dose model host-side; close the idle-gap level loss
Four new sessions in the stream harness cover the model (grblHAL-glowforge
2bca017, pinned here). Density renders the commanded level exactly -
levels 2, 3, 7, 15, 25 and 38 came back as 0.0158, 0.0237, 0.0551,
0.1182, 0.1969 and 0.2993 against level/127 of 0.01575, 0.02362, 0.05512,
0.11811, 0.19685 and 0.29921 - S1000 renders 1.0000 and still ends dark,
every power byte carries full duty, and a level change inside a run costs
no stream byte where analog ships one per level.

Rule 13 is the one worth having: the same job run under both models
produces an identical motion grid tick for tick, and all 20051 density
FIRE ticks fall inside the 169776 the analog run fired. The model masks
the core's fire state and never sources one, measured rather than argued.

Rule 14 covers the idle-gap fix: a standalone S between moves, from a
sender slow enough to drain the planner, now fires each move at its own
level (28338 ticks each at duties 30, 52 and 84). Before the fix duty 30
held all 85014 and the other two levels never appeared. That closes
"Next work" item 18, which this work opened earlier today.

The harness now derives its expectations from the board's floor and
chains two launches over one settings file, because the core precomputes
the S to duty mapping once when the spindle is enabled: $35 written at
runtime persists and reports immediately but only enters force at the
next controller start. That is recorded in BRINGUP beside the existing
defaults note, and laser.power-floor's failure message now says so.

Acceptance: the density path shipping off by default is inert until
laser_power_model is set, and the laser tests' covers already name
grblhal-glowforge src/**; the model's own acceptance test waits on the
bench drill that picks the base period.
2026-08-17 20:24:43 -04:00
ScottW514 cb41a6030c Commission the laser duty floor; record how the factory sets power
The pthresh ladder on scrap puts the tube's two thresholds far apart: the
discharge strikes between 2 and 3 percent duty, but nothing lases usefully
below 16 percent (PWMSAR 20), and the rungs between show only a spot at
each line start. $35 ships at 16 (grblhal-glowforge 9466f76, pinned here).

The drill said current lift-off and first mark share a rung; this run
falsifies that, so its docstring and read-the-material text now name both
thresholds and warn that a start-of-line spot is below the threshold, not
at it.

A start-of-line spot is also what a full-power leak at a kernel run start
would look like, so laser_stream_test gains a ladder session (rule 10):
every FIRE tick must ride a commanded duty, and the fire ticks must divide
evenly across rungs. Both hold exactly - six commanded duties, no others,
and 28296 fire ticks on every rung - so the spots are the tube, not the
stream. The harness now derives its expectations from the floor, which
moves the M4 session's S500 plateau from 63 to 73.

laser.power-floor is a new auto acceptance test, the suite's only
non-firing one: a machine must actually carry the commissioned floor,
since stored settings beat freshly baked defaults.

Three cloud cuts of one square at Precision Power 1, 100 and Full Power
show what the analog path is competing with: the power byte is pinned at
127 in all three, dose is FIRE-bit density on a fixed 7-tick period with
the on-count dithered between adjacent integers, and the power setting
never reaches the machine at all. Facts bank and item 17 carry the
numbers; CAMPAIGN-LOG carries both sessions.
2026-08-17 19:45:19 -04:00
ScottW514 76d43686d0 Record the step-timing campaign
The dated record for the jerky-at-2000-mm/min thread: what was wrong,
what changed, and what the bench measured.

Keeps the two results that matter. The A/B that isolated the cause -
two acceptance runs 90 s apart on one image, camera the only variable,
PASS with a nice-5 CPU hog and FAIL with the camera streaming - and the
LB-GF-OG-FM job re-run on the fix with the video live, clean at
min margin 3.1 ms of 10.

Also records that max behind is not the instrument: it reads 0.0 ms
through a passing run whose real margin fell to 4.9 ms, because the
producer never falls behind its own wakeup epoch. Only the measured
min margin shows the condition.
2026-08-17 18:20:40 -04:00
ScottW514 64a720bbab Pin the producer-lead change; record what the bench measured
Bump the grblHAL pin to the producer lead and margin instrumentation.

Rewrite BRINGUP item 16 to the present state. The scheduling half is
done - the producer runs SCHED_FIFO below the shipper, core_mx carries
priority inheritance, and clamping is reported per run - and two bench
runs 90 s apart on one image show what that does and does not cover: a
nice-5 CPU hog passes clean while the camera streaming clamps 7 runs,
because per-frame cache maintenance over a 4.8 MB non-coherent capture
buffer is kernel-context work no userspace priority can preempt.

Record the margin finding, since it explains why a 4 ms stall was
enough: the queue depth cancels between the shipper's due index and the
producer's base, so the pacing lead is the only slack. Record the
cycle-churn ceiling that caps that lead at 10 ms, and that the re-base
fix is what unlocks more.

Camera gating stays owed, with capture resolution named as the lever
that shortens the stall rather than merely spacing stalls out.
2026-08-17 17:59:11 -04:00
ScottW514 5a3bd902fe Drop the nice(1) dependency from the load test; ship htop on the dev image
The image has no `nice` binary - BusyBox ships renice only - so
motion.step-timing-under-load could not spawn its CPU hog at all. Set the
niceness from the parent with os.setpriority once the child exists, and
assert the value that actually took: a hog left at nice 0 would be a
harsher test than intended, and one left unset must not pass silently.

Add htop to the dev image for bench diagnostics. The release image is
unchanged - it carries neither forgetest nor htop.
2026-08-17 17:02:16 -04:00
ScottW514 faaa6cb40b Cover step timing under CPU contention; record the laser power model
Bump the grblHAL pin to the real-time producer change.

Add motion.step-timing-under-load: the catalog had nothing that
exercised step generation while userspace competed for the single core,
which is exactly the gap that let the condition go unnoticed - the ring
never runs dry, so cnc/underruns reads 0 through it. The test asserts
the producer and the shipper both hold SCHED_FIFO, then drives
2000 mm/min round trips against a deliberate nice-5 CPU hog and requires
the controller to report no clamped events.

Add the pthresh live-fire drill: a constant-power ladder from 2 % to
30 % of full on scrap. Because $35 is a percent of full duty and the
rungs are percents of $30 with $31 = 0, the lowest rung that marks reads
directly as the $35 value. It needs $35 = 0 for the run, or the floor
lifts every rung and hides the threshold.

Record both open items in BRINGUP. The laser one carries the finding
that the factory never uses duty as a power control - all five firing
jobs in the captured pulse files pin the power byte at 127 and modulate
dose by dithering the FIRE bit at 6.5-18.8 % density - so the captures
cannot supply a $35 default, and the duty to optical-power transfer
function of this supply has never been measured.
2026-08-17 16:45:37 -04:00
ScottW514 2fa5800519 Pins: forgectrl cb860dc, forgefirm-app 81500ae (0.1.9+git) - sensor profiles, the lid privacy gate, full-resolution HD capture 2026-08-17 14:35:15 -04:00
ScottW514 c425822b82 docs, acceptance: the cameras as users meet them, and 8 MP at full resolution
docs/VIDEO.md is the user-facing camera guide: what the endpoints return, what
the sensors can do that ForgeFIRM does not send and why, the privacy gate, and
the state of 8 MP support.

The 8 MP (OV8856) capture path now reaches the sensor's full 3264x2448 frame.
Its stock RAW10 full-resolution mode runs the link at 1.44 Gbps/lane and the
i.MX6 CSI-2 D-PHY stops at 1 Gbps; the BSP adds a RAW8 mode that carries the
same frame at half the rate, so an HD machine is no longer limited to the
binned quarter-pixel mode. kas/README.md carries the reasoning, the register
deltas and the factory configuration to fall back to if the receiver will not
lock at 720 Mbps/lane.

Acceptance: camera.sensor-profile expects the new OV8856 geometry, the camera
covers map names src/camhealth.* (the src/cam.* glob does not match it, so the
lint would have gone quiet on an uncovered file at the next pin bump), and a
new auto test camera.frame-health asserts a burst of captures with no frames
the capture queue flagged errored - a real signal about the camera ribbon even
though those frames never reach a client.
2026-08-17 14:34:36 -04:00
ScottW514 d3bab940b3 docs: user-facing guides to motion, laser drive and cooling
Two pages aimed at someone who owns the machine rather than works on it,
written for the documentation site. Nothing here is new behavior - it is
the behavior the machine already has, explained where an owner can find
it instead of spread across a kernel contract, a services contract and
three driver headers.

MOTION.md follows one thread: everything physical comes out of a single
fixed-tick byte stream, so the page starts there - the byte layout, speed
as step density rather than clock, the ring and the two ways to fill it,
and the hardware's own stop, halt and resume-with-waypoint. The laser is
presented as part of that stream rather than beside it, which is what
makes the three contract rules (power before fire, no consecutive power
bytes, end dark) and the persisting duty legible instead of arbitrary.
Then geometry and limits, device ownership and the liveness check the
operator sees, and the two modes in full: GRBL from connection through
the arming sequence, the stop/pause/fault table and homing-or-not; cloud
from the preloaded pulse file through the pause backtrack, the park that
ignores the lid, and the ring's cap on job length. A comparison table and
the motion-related settings close it.

COOLING.md explains the engine as what it is - one owner of the thermal
hardware answering a single question for whichever controller runs - and
gives the reasons behind the numbers rather than just the numbers: why
the flow check heats and measures the downstream rise, why 40 percent is
the duty (below it, convection mimics flow), why the settle gate uses a
split-half mean instead of peak-to-peak, and why one bad reading is a
suspicion rather than a fault. Over-temperature, the two diagnostics
tools and when to run them, the settings, and a situation-to-response
table. The fire watch is described honestly: the lid IR channels are
first of all a photometer for the lid lamp, the gate ships watch-only,
and it is not a fire alarm.

Both pages state what is not implemented - low-temperature gates, TEC
control, a fire watch that acts, limit-switch homing - so nobody plans
around them. Constants come from the sources that own them (the feeder
contract, cool.h, the board header, the services contract), not from
prose. README links both.

Documentation only, no behavior change and no catalog consequence: docs/
is outside every layer and .md is excluded from the layer content hash.
2026-08-17 11:52:41 -04:00
ScottW514 44393f3c11 docs: README updates, remove unused assets 2026-08-17 11:25:05 -04:00
ScottW514 05d68ba0a8 docs: BRINGUP describes the present, CAMPAIGN-LOG carries the dated record
BRINGUP had grown to 3,122 lines in which the same subject was answered
differently depending on where the reader stopped: GATE A "stays open, no
live-fire" in the phase text and closed in the campaign record, the catalog
at 24 tests in one section and 35 in another, several "bench validation
pending" headings over bodies that recorded the pass.

Split by kind rather than by age. BRINGUP (907 lines) is the present state
only - status, bench runbook, laser, lid/interlock/button policy, homing,
forgectrl, diagnostics, logging, release acceptance, the measured facts
bank, and a Next work list of the 15 items that are actually open.
CAMPAIGN-LOG (2,657 lines) takes the dated blocks verbatim, in
chronological order, and is append-only: a correction is a later entry, not
an edit. Its two reading rules are stated up front, since moved text keeps
its original "above"/"below" and its pre-split item numbers.

Facts corrected against the tree while rewriting: the liveness probe gates
at p2p 800, not 500, with the real wedge, noise and jolt figures; the panel
has seven tabs including Logs and is built from src/ui/, not ui.c; the
devserver replaced tools/mock.py; /status reports real head presence; the
estop_halts_motion opt-in is gone; core PR #999 is merged, leaving only the
step_us_min commit fork-only; the update system stands at Phase 5, the
uSDHC pads and the 2026-08-08 kernel batch have shipped, and 8 MP camera
capture is tracked here for the first time.

SAFETY and UPDATE-SYSTEM point their drill records at the log. ACCEPTANCE
records that inheritance is local: there is no import of a published
artifact, so a second bench starts from a full campaign - one of the four
open items the retired tool plan held, the rest of which are now in
BRINGUP's acceptance item.

Documentation only, no behavior change and no catalog consequence: docs/
is outside every layer and .md is excluded from the layer content hash.
2026-08-17 11:19:52 -04:00
ScottW514 1c56427cae SAFETY, LIGHTBURN: what the pause and the cancel mean, now that the button drives them
SAFETY: the chain de-energizes itself behind a pause without being asked -
the charge-pump feed ends with the run, so HV_ENABLE drops with the watchdog
about half a second in, and on the resume it is back ~216 ms before the first
step (pad measurements). A pause is deliberately not a cancel: the latch stays
unlocked and the window open, which is what lets the next press resume the
job, while emission still ends because the stream stops driving FIRE. The
disarm grace counts down through a hold, so a job left paused disarms itself,
and a lid or interlock open while paused takes the cancel path - nothing
resumes past an enclosure opening.

LIGHTBURN: the two things an operator needs before pausing a cut - the resume
restarts from where the deceleration ended and accelerates from a standstill,
which M3 shows as a deeper spot and M4 mostly hides, and a job left paused
disarms itself and asks for the button again. Stop leaves the head where it
stopped; returning to the job start belongs to the lid and interlock policy
alone.
2026-08-17 10:08:03 -04:00
ScottW514 74adaf40b8 BRINGUP: lid / button / interlock parity is done; items 4 and 12 close with it
Item 16 becomes the record of what the machine does rather than a list of
what is left: both modes cancel a job on a lid or interlock open - running
or paused - and return the head to where the job started with the lid still
open; the park ignores the lid; the arm wait cancels with the reason named;
the button pauses and resumes, with the factory's backtrack and lead in cloud
mode and feed hold / cycle start in GRBL; lid_policy = hold keeps the stock
door behavior for senders that want it. Item 4's mid-job Door hold is that
policy now, and item 12's LightBurn door handling is closed by the cancel -
LightBurn never lives in Door on the default path.

The GRBL resume dwell that was left open is decided against, on measurement
rather than on the mark: the chain re-arms within ~3 ms of the resume while
motion restarts ~219 ms later, so there is nothing to cover.

The two planning files at the tree root are merged and removed, as the audit
plans were. What was durable in them lives here now: the factory's own
reaction timings on 2.6.0-2228, the pause/resume behavior of the safety chain
at pad resolution, and why the hardware button latch is what makes the armed
window honest. The factory session log they were written from is archived
under _RESOURCES/.

Item 15 gains the catalog's current shape: 35 tests after the parity work and
the sweep that merged the tests sharing a setup, with the auto tests left
separate.
2026-08-17 10:02:25 -04:00
ScottW514 674cd1903c forgetest: take the kernel-counter reference only when the machine is at rest
Bench (image 20260817124714): motion.lid-cancel-home FAILED on a machine that
did the right thing twice. Both phases logged the cancel, the reset banner and
"returned to the job start" with grbl drift 0.000, and the counters ended at
the true rest position - but the hold phase's REFERENCE was 2.87 mm off, so the
comparison measured a transient.

The reference was sampled straight after the jogs that close the running phase,
and those wait on grblHAL's Idle. grblHAL is Idle when its planner is empty; the
kernel is still playing the stream depth and the decel tail behind that. A
counter read in that window records a position the head is only passing through
- and the resulting drift reads exactly like the failure this check exists to
catch, a move counted by grbl but never played by the machine.

So the check stays as strict as it is (it is the one that caught the stranded
ring on 2026-08-17): what changes is where the reference comes from. kernel_start()
waits for the MACHINE to be idle - the helper the motion tests already end on -
before reading the counters, and every reference capture in the lid, interlock
and live cancel tests goes through it.
2026-08-17 09:29:10 -04:00
ScottW514 0f116c8b7f forgetest: a resume is proven by leaving the hold, and status polling no longer eats [MSG:] lines
Bench (image 20260817124714): motion.button-hold-resume FAILED on a machine
that did exactly the right thing. The operator paused about 7 s into an 8 s
move, so the resume had a fraction of a second of travel left; the job was
already Idle by the next poll and the test - which insisted on catching the
Run state - called it "the second press did not resume the job". Its own
evidence contradicted the verdict: the kernel counters read 2133 = 40.000 mm,
the whole move, and the position check two lines below would have passed.

Catching the state a command moves INTO is a race whenever the remaining work
is short. What proves the press was acted on is the job LEAVING the hold, so
that is what the test waits for now (wait_left_state), with the state it left
into required to be Run or Idle - not Alarm or Door - and the existing "landed
on its target" check still doing the real work. laser.pause-resume-lid-cancel
had the same shape and gets the same treatment.

Second defect, visible in the same record as "message seen: False": the driver
DOES report "button pressed - job paused", but Grbl.status_report() began by
discarding the read buffer and then kept only what followed the report, so
every asynchronous [MSG:] line that landed during a poll was thrown away. It
now consumes status reports only - stale ones included, which is what that
discard was for - and leaves everything else for drain(). Tests that assert on
what the controller said open their window with an explicit drain() before the
prompt, so the text they judge is the text from the action.
2026-08-17 09:21:14 -04:00
ScottW514 d763f33fa2 laser.pause-resume-lid-cancel: cut in M3 so the restart mark is visible
M4 scales power with speed, which is exactly what hides a restart in the cut -
the thing the test asks the operator to look at. At constant power the resumed
cut accelerates from zero under full power and leaves the deeper spot where it
picked up, which is the artifact worth seeing (and the one the plan wanted
characterized for M3 and M4).
2026-08-17 08:46:55 -04:00
ScottW514 c23dedd89a laser.armed-kill: wait for the restarted controller's Grbl port before the second burn
/mode reporting the controller running means the supervisor has spawned it,
not that the listener is bound. Connecting straight away would fail the test
on that race - after an arm press and a burn - instead of on the behavior it
is about.
2026-08-17 08:43:33 -04:00
ScottW514 cd5098b6b3 Acceptance catalog: merge the tests that share a setup (39 -> 35, live 10 -> 7)
A sweep of the whole catalog for the overlap the drill work found. The test
applied was "do these share a SETUP", not "do these share a subsystem":
combining only pays where a human waits - an arm press, scrap, a takeover,
a mode entry, a lid choreography - and it costs failure isolation, because
the campaign inherits per test and a merged test invalidates as a unit. The
17 auto tests were left alone for exactly that reason: they cost no operator
time and separate ids give the coverage map and the domain invalidation
finer teeth.

  kernel.k3-unlock + kernel.fire-abu -> kernel.fire-line
      Four phases behind ONE takeover of the pulse device instead of two:
      A/B/U on the FIRE line, then the mid-ramp unlock. Same HV-not-good
      gate, same zero duty, same safe state on the way out.

  laser.expected-stop + laser.kill-mid-fire -> laser.armed-kill
      Both ways an armed job is killed, on one scrap setup: the supervisor's
      expected stop (with its separate operator-judged restart) and then a
      SIGKILL of the restarted controller. The second phase re-reads the pid
      after the restart, so it kills the process the supervisor just spawned.

  laser.lid-cancel-mid-fire + laser.pause-resume-live -> laser.pause-resume-lid-cancel
      One armed burn in the order the factory uses the machine's controls:
      press (pause - emission stops, latch stays UNLOCKED, armed window
      stays open), press (resume), lid (cancel, reset without alarm, return
      to the job start, button latch SET). One arm press instead of two.

  cloud.lid-abort + cloud.interlock-abort-park -> cloud.lid-interlock-abort
      Two prints in one test: the lid, then the interlock with the lid opened
      during the park. The tail both share - park complete, kernel counters
      back at the job start, ':cancelled', latch locked, armed window closed -
      is one helper now, so both triggers are judged the same way.

Not merged, though they share code: cloud.gfhome-homing and cloud.hunt-lid-open.
Both drive Machine._hunt (gfhome.py and gfcloud.py build the same gfhardware
machine, so the lid ungating is the same lines), but each is about the opposite
value of the same variable - gfhome's homing is camera-corrected and needs the
lid CLOSED in GRBL mode, the hunt test needs it OPEN through a cloud-mode
connect. Merging would put a mid-test mode switch back into the cloud tests.

Shared live-test prologue (the arm cue, the mark job, the wait for the emission
witness) and the post-kill trail/judging are helpers now.

Host proof: 104 unit tests - the merged cloud test replays the machine's own
lid-abort excerpt twice, once with the interlock substituted for the trigger,
and three negative cases hold it honest (loop already open, a park the lid can
interrupt, a stop that is not edge-driven) - and the coverage lint at 0
uncovered across 35 tests. Catalog time 137 -> 125 minutes, 96 of it attended.
2026-08-17 08:39:35 -04:00
ScottW514 e48b2c8e62 motion.interlock-cancel-home: drop the mid-park lid step it cannot honestly test
In GRBL mode the return to the job start is a G0 rapid - a few tenths of a
second over the distance the job had travelled - so an operator cannot open
the lid during it, and a step that asks them to either races the cancel (with
the lid open at cancel time the reason reads "lid opened", not "interlock
open") or lands after the park is over and proves nothing.

The park's immunity to an open lid is covered where it is real: motion.lid-cancel-home
parks with the lid open from beginning to end, and cloud.interlock-abort-park
has a ~6 s park with room for a genuine mid-park lid edge. What is left here is
the interlock as its own trigger, with its own message, and the loop still open
when the head gets back.
2026-08-17 08:13:53 -04:00
ScottW514 0870a835c4 Acceptance catalog: the rest of the lid/button/interlock drills, and a pause/resume chain-timing tool
Catalog 34 -> 39. Every remaining bench drill of the lid/button parity work
is now a test, with drills that exercise the same path combined:

  motion.lid-cancel-home   also cancels from a hold - a job paused on the
                           button is ended by the lid, never resumed - so the
                           armed window and the hardware button latch stay in
                           agreement by construction.
  motion.cancel-abort      also asserts what a sender abort must NOT do: it
                           stops where it stopped and never returns home. The
                           return-to-start belongs to the lid policy alone.
  motion.interlock-cancel-park (new)  the interlock loop cancels like the lid,
                           and the lid opened during the return home does not
                           interrupt it.
  motion.lid-policy-hold (new)  the other policy: Door park, no cancel, no
                           return, and a cycle start finishes the move. The
                           setting is restored on the way out.
  laser.pause-resume-live (new)  the button pause/resume during a live cut:
                           emission stops, the latch stays UNLOCKED and the
                           armed window open (a pause is not a cancel), the
                           next press resumes and the job finishes.
  cloud.interlock-abort-park (new)  the same interlock/park pair in cloud mode.
  cloud.pause-cancel-paths (new)  the two non-finishing ends of a print, each
                           from the state the factory ends it in: paused on the
                           button then cancelled by the lid, and cancelled from
                           the app while running.

The shared cancel tail (reason reported, reset without an alarm, position
kept, head back at the job start with the KERNEL counters confirming it) is
now one helper, so every trigger is judged the same way.

scripts/bench/resume_dark_lead.py: samples LASER_ON, FIRE, HV_ENABLE, the
charge-pump watchdog, the button and the doors off the SoC pads at ~2 kHz
through /dev/mem, with motion dated from the kernel step counters, across a
pause and a resume. Levels are taken at idle and everything after is reported
as a change from that baseline, so no polarity assumption is baked in. Dry by
default, with --auto driving the pause and resume through ! / ~ for an
unattended rehearsal; --run live adds the dark lead between FIRE and LASER_ON,
in milliseconds and in millimeters at the job's feed.

Bench registry: argument specs can name a flag (--feed 600) instead of being
positional, and an optional argument with an empty default is left off the
command line entirely.

Host proof: 104 unit tests (20 in the cloud suite - the two new cloud tests
replay the machine's own lid-abort excerpt, with the interlock and the app
cancel substituted for the trigger, and fail for the right reasons), coverage
lint 0 uncovered across 39 tests.
2026-08-17 07:54:47 -04:00
ScottW514 86ce0419e5 forgetest: cloud tests stay in cloud mode; the page can ignore prerequisites
The cloud job tests (lid-abort, lid-during-button-wait, hunt-lid-open,
pause-resume) run in cloud mode and leave the machine there: enter_cloud
reuses a live session (pid-scoped from the client's own websocket state
lines) and switches once from GRBL mode, declaring the change to the
baseline; nothing switches back. Each test judges the log from its own
window, the print by its own "print [id]: finished" line, and waits the
service's follow-up moves out (wait_quiet) before it ends. hunt-lid-open
restarts the cloud client through the supervisor's stop/start lever for
a fresh connect. The former switch-back is what failed the last bench run
of hunt-lid-open (409 machine is not idle: the service was still
re-finding the head after the lid closed).

The baseline is mode-aware: in cloud mode the client owns the GRBL
controller's init values, the lid lamp, and the position counters; the
mode itself is preserved unless the run declared the change
(Context.mode_changed); controller_mode is never handed back as a bare
setting (a bare write left the persisted mode out of step with the live
one). The undeclared-change restore now uses the captured state, which
the post pass never saw before.

The acceptance page gets an "Ignore prerequisites" switch (remembered by
the browser): POST /start {ignore_requires} starts a test whose requires
are unmet, and the run records the unmet prerequisites in its evidence
and log; they stay required for the release. The cloud tests' requires
no longer chain through cloud.mode-switch.

Proof: tests/test_cloud_suite.py replays the four tests on the bench's
own gfcloud excerpts (fixtures/) and the run loop's emitted pause/resume
lines under the real runner Context against a fake forgectrl; baseline
mode tests and the server override test; the whole suite (98) and the
coverage lint pass. Catalog consequence: cloud.* fingerprints move with
the module; the catalog hash moves with the requires.
2026-08-17 06:42:48 -04:00
ScottW514 e908db7f3a cloud.* lid/button tests: judge only the print's own window
cloud.lid-during-button-wait failed on a healthy machine: it counted
every "starting run" since the session start, and a cloud session runs
the connect-time hunt and several service moves before the print. The
checks now use the print's own window: runs between "waiting for
button" and the print's ":cancelled" (none allowed); the print's run is
the "starting run" after its button wait (lid-abort, pause-resume); the
lid edge timed against the stop is the last edge before the stop line
(an earlier open to place the scrap is not the one); hunt-lid-open
judges the hunt's own terminal line and refusals before it (service
moves after the hunt are rightly refused with the lid open).
2026-08-16 21:41:10 -04:00
ScottW514 7bf8e3d4ed forgefirm-app: bump to 37854f0 (0.1.8+git) - the park clears the ring first; cloud.lid-abort checks the kernel counters
cloud.lid-abort now proves the park with the machine's own counters
(cloud clears them at every job start, so a completed park reads back
at (0,0)); stale ring bytes replayed ahead of the park would not.
2026-08-16 20:59:54 -04:00
ScottW514 95c8cc8f54 grblhal-glowforge: bump to 0c19265 (deferred run on a busy kernel; no stranded ring bytes after a reset; cancel waits for the drain); the lid-cancel tests cover it 2026-08-16 20:47:48 -04:00
ScottW514 9878c8d3b9 forgetest: kernel counters prove the return, and ring residue is a leftover that blocks the jog
The lid-cancel tests (motion.lid-cancel-home, laser.lid-cancel-mid-fire)
now check that the KERNEL position counters returned to the job start,
not only grbl's MPos - grbl's drift read 0.000 while the head had not
moved. The baseline reports unplayed bytes queued in the kernel ring
(cnc/position total minus processed) as a leftover and refuses its
return jog while any exist: a run started on top of them replays them
first, which is what put the head into the rail. BRINGUP item 16 records
the bench finding and the stream-engine fix.
2026-08-16 20:47:32 -04:00
ScottW514 b68d790b71 baseline: take the fresh-boot reference after the controller applied its config
/mode reports controller=running at the spawn, so the reference dump
raced grblHAL's init writes and captured the supervisor's motion-probe
values (motor_lock 0, step_freq 10000, y_mode at the module default)
instead of the resting state. boot_reference() now waits for the
controller's markers (step_freq/motor_lock/y_mode at their fixed values,
bounded 20 s, GRBL mode only) plus a 1 s settle before dumping, retakes
a saved reference that shows the pre-config state while the boot is
still fresh, and marks it otherwise. Tests cover the wait, its timeout,
the non-GRBL no-op, the pre-config recognition and the retake.
2026-08-16 20:00:08 -04:00
ScottW514 d218b3de25 Pins: grblhal-glowforge 670cea0, forgectrl 67fd8bd, forgefirm-app f7e591b (0.1.7+git)
The lid/button/interlock parity change (BRINGUP item 16): the arm wait
and a running job cancel on lid or interlock the factory's way, the
button pauses/resumes, lid_policy and the cloud pause tick settings.
Cloud tests cloud.* and the new motion/laser lid tests cover the
components; the python3-gfhardware / gfutilities pins move with this in
meta-openglow.
2026-08-16 19:41:11 -04:00
ScottW514 914c72f8e5 BRINGUP item 16: the arm-wait cancel is a clean soft reset 2026-08-16 19:31:53 -04:00
ScottW514 6b35f9c534 Arm-wait lid cancel expectations: reset banner and no alarm (harness, laser.arm-wait-lid, LIGHTBURN.md) 2026-08-16 19:31:42 -04:00
ScottW514 c4ea96127c Lid/button parity: harness cases, acceptance tests, operator and safety docs, BRINGUP item 16
laser_lifecycle_test.py: the button toggle (press = Hold, press = Run;
the arming press is not a pause), lid and interlock cancel mid-job with
the return to the job start and no alarm, and lid_policy=hold.

Acceptance catalog: motion.button-hold-resume, motion.lid-cancel-home
(operator: travel job, lid open -> cancel message, banner, no alarm,
autonomous return, Idle at the start), laser.lid-cancel-mid-fire (live:
emission stops in hardware, cancelled, returned, armed=false, latch
locked, hardware button latch SET). covers name the switch and laser
sources explicitly.

LIGHTBURN.md: what the lid, Stop and the button now do; SAFETY.md: the
door policy and the button as a software layer; BRINGUP.md: item 16
records the whole parity change (host-proven, bench validation pending)
and items 4/12 point at it.
2026-08-16 19:26:11 -04:00
ScottW514 c255b265c5 Acceptance: cloud lid-abort, lid-during-button-wait, hunt-lid-open, pause-resume
Four cloud-mode catalog tests driven from the app with the operator,
proven from the client's log (the same record the wire gets), forgectrl
/status and /cool/status, and the kernel latch readback:

- cloud.lid-abort (live): the lid edge reaches the controlled stop
  within 60 ms, the head parks with the lid still open, the latch
  relocks, the armed window closes, the print ends ':cancelled';
- cloud.lid-during-button-wait (operator): the lid at the white-button
  prompt relocks and cancels; no run starts;
- cloud.hunt-lid-open (operator): the connect-time hunt runs and
  completes with the lid open;
- cloud.pause-resume (live): the button pauses (stop + backtrack) and
  resumes; the job completes and parks; nothing relocks or cancels.

Shared helpers enter/leave cloud mode the way cloud.mode-switch does
and return the head afterward.
2026-08-16 19:11:23 -04:00
ScottW514 36b4a3a5f4 Lid open during the arm wait: harness cases, laser.arm-wait-lid, operator doc
laser_lifecycle_test drives the controller's button wait through the
file-backed switch source (GF_SWITCH_FILE): a press with the lid closed
arms and nothing arms before it; the lid or the interlock loop opening
during the wait cancels the job (reason reported, alarm 3, never armed).

Acceptance catalog: laser.arm-wait-lid (operator kind - no press is
given, so nothing can fire) starts a laser job, has the operator open
the lid at the white-button prompt, and checks the cancel message,
alarm 3, armed=false, the kernel latch locked, no emission, and Idle
after $X. covers names glowforge_laser.c, glowforge_switches.c and
glowforge_switch_map.h explicitly.

LIGHTBURN.md: opening the lid or the interlock loop while the button is
lit cancels the job; a press with the lid open never arms.
2026-08-16 18:36:24 -04:00
ScottW514 b90c53c739 forgectrl: bump to 2df5122 (panel as src/ui/, bundled at build); panel-serves covers it
The panel moved out of ui.c into src/ui/{index.html,panel.css,panel.js},
bundled into index_html[] by CMake. forgectrl.panel-serves now covers
src/ui/** and checks that the served page carries no external asset
reference - the bundle is what keeps the panel a single self-contained
response, and this is where that would break. Fetch-verified and
cross-built in the build tree (bitbake forgectrl).
2026-08-16 18:22:42 -04:00
ScottW514 a9a585d44d forgectrl: bump to 5af4fb5 (panel dev container + devserver); allow the dev-environment paths
The forgectrl commit adds .devcontainer/, .vscode/tasks.json and
.env.example (the panel dev server replaces tools/mock.py, which was
already allowed under tools/**). None of it ships or changes target
behavior, so the coverage lint allows those paths for every component
rather than demanding acceptance coverage for editor setup.
Fetch-verified in the build tree (bitbake -c fetch forgectrl).
2026-08-16 17:42:20 -04:00
ScottW514 cd8a01a3d9 bench: every board-runnable tool ported to the bench page
The remaining bench diagnostics run from forgetest's #bench tab. The
tools that also run from a LAN host share scripts/bench/gfbench.py:
GF_HOST names a remote machine (host mode, sysfs through ssh, Grbl and
forgectrl over the LAN); unset, the tool runs on the board itself
(local mode, sysfs directly, everything on 127.0.0.1), which is how the
page runs them - with GF_HOST=127.0.0.1, the panel token in GF_TOKEN
and their data files under <data>/bench/ (FORGETEST_BENCH_DATA). The
helper also reads a machine setting from forgectrl, or from the settings
file on the board while forgectrl is stopped.

Ported: pwm_sweep / pwm_hold (scope = a takeover; the latch relocked,
the write refused if FIRE or LASER_ON reads active), pwm_stream_test
(PASS/FAIL exit), flow_characterize, flow_recheck_char,
flow_warm_validate and flow_matrix (takeovers: forgectrl owns the
thermal hardware, so the page's takeover replaces the tools' own
controller stop/restart, whose command line predated the supervisor;
results and logs in the bench data directory), flow_sustained,
fan_test, temp_calibrate (dry; watch bounded in seconds; the threshold
and the coolant conversion from the shared code), flow_escalate_drill
(cool_confirm_max_s shortened through forgectrl's settings for the
drill and restored; the setting's minimum is the default budget), and
live_fire_drills (<drill> [S] [F], all six drills, host from GF_HOST,
token from the board). flow_matrix joins the registry. What stays
unported cannot run against the machine at all: the two null-sink CI
harnesses and the .puls decoder.

Runner: a scope tool runs inside the takeover wrapper; the bench
environment above is passed to every tool. Tests: test_bench_registry
(registry <-> scripts/bench consistency, every ported tool builds its
command line, every script compiles, gfbench host/local modes) and the
server test (scope tool takeover, the environment reaching the tool).
Local mode smoke-run on the bench (temp_calibrate watch, setting, token)
from /tmp, removed after.

No catalog consequence: bench tools are not image components (dev-only
forgetest); the acceptance catalog is unchanged.
2026-08-16 16:28:39 -04:00
ScottW514 03947cd94c bench: run flow_sampler.py from the installed bench tools, not /data
The dev image installs the bench tools under /usr/share/forgetest/bench/,
so flow_matrix.py and flow_warm_validate.py invoke the board-side sampler
from there instead of a hand-copied /data/flow_sampler.py; the README
follows. Bench-tool path change only, no machine behavior involved.
2026-08-16 16:09:07 -04:00
ScottW514 b51e695fb1 manifest: component pins are not layer content
A component pin bump counted as a platform change: the layer content hash
in the platform identity covered the recipe carrying the SRCREV, the
platform is folded into every acceptance fingerprint, so every image
that carried any component update invalidated the whole catalog (dev
image 20260816191951: every test domain-changed after a one-line
forgectrl bump; the two manifests differ only in
platform.layers.meta-forgefirm). The component entry already identifies
the pinned source file by file; the pin double-counted it.

Component pins now live in <recipe>-pin.inc (SRCREV and the PV that
moves with it, nothing else) - forgectrl, grblhal-glowforge and
forgefirm-app here, the BSP components in meta-openglow - and
forgefirm-image-manifest.bbclass leaves *-pin.inc out of the layer
content (FORGEFIRM_MANIFEST_PIN_SUFFIX). Recipe bodies, patches, config
fragments, init scripts and third-party pins with no manifest entry stay
layer content; a pin written into a recipe body still hashes (the safe
direction). manifest-from-tree.py mirrors the rule and reads pins
through the recipe's requires; test_tree_manifest.py proves both
(pin bump: hash unchanged; recipe body or inline pin: changed).
Bitbake resolves the same SRCREV/PV for every pinned recipe.

Docs: ACCEPTANCE.md (what layer content is), kas/README.md (the pin
files in the push order), BRINGUP.md (the finding and the bench
consequence: the first image built with the pin files is itself a
platform change, so its campaign is a full one; pin bumps inherit
after it).

No catalog consequence: nothing in the image's behavior changes; the
change is to the acceptance identity computation, proven by the unit
tests and the CI lint on the tree manifest.
2026-08-16 16:06:58 -04:00
ScottW514 bf066d4e27 BRINGUP: images 20260816191838 / 20260816191951 built with forgectrl c8f6558 and the day's forgetest 2026-08-16 15:21:55 -04:00
ScottW514 57465d2c00 BRINGUP: item 15 - the acceptance tool is bench-validated; the confirmation run rides the next image 2026-08-16 15:12:48 -04:00
ScottW514 df4581134e BRINGUP: campaign 26/26 (export exercised, not a release); lid lamp setting and probe hardening landed and proven 2026-08-16 15:07:58 -04:00
ScottW514 1d9b5538d6 forgectrl: bump to c8f6558 (lid lamp idle setting; probe unmasks the axes, current step settled, threshold 800)
Fetch-verified and built in the forge-yocto tree; the binary hot-deployed
on the bench and proven by forgectrl.settings-bounds and
motion.liveness-probe (BRINGUP).
2026-08-16 15:07:35 -04:00
ScottW514 d0291ec7cb forgetest: the lid lamp idles at forgectrl's lid_lamp_idle; the liveness test masks and restarts
The lid lamp now has a resting policy in forgectrl (lid_lamp_idle,
default 236, asserted at start and at every spawn), so the baseline
expects it there instead of preserving whatever level a boot left, and
forgectrl.settings-bounds proves it: resting at the setting, 256 / -1 /
'bright' refused, a new level applied to the lamp at once, the cleared
key back to the default. Clearing a key goes through the query-string
form (an empty JSON value reads as no setting).

motion.liveness-probe adds the regression the bench needed: with every
axis masked (cnc/motor_lock=15, as a bench tool may leave it) forgectrl
is restarted and its fresh probe must read MOTION OK on the first try -
the probe unmasks the axes itself - and the controller comes up with the
mask cleared. Bench 2026-08-16: MOTION OK at p2p 2047/1341 against the
800 threshold, no ladder.

The baseline's settle no longer counts 'probe verified, spawn pending' as
settled (the post pass ran between the probe's own writes and the
controller's init writes and mis-flagged motor_lock/step_freq): it waits
for the controller to be running, with a bounded allowance for a respawn
backoff.
2026-08-16 15:07:34 -04:00
ScottW514 8654c36e99 BRINGUP: forgetest bench campaign 2026-08-16 - 22/26, motion and cloud green, the live tests remain 2026-08-16 14:22:32 -04:00
ScottW514 5002d59dd7 ACCEPTANCE: the fresh-boot reference is taken after a power cycle; the head-return rule 2026-08-16 14:22:08 -04:00
ScottW514 a80d7aabfb forgetest: cloud tests prove the session from the client's log and hand the head back
cloud.mode-switch took the optional connect-time firmware probe file as
the evidence of a live session and failed on a bench where that check is
off; the evidence is now gfcloud's own authenticate/ws-connect lines in
the unified log after the switch, the probe recorded when present. Cloud
mode's connect clears the kernel position counters at the head's start
and its hunt homes the head to the corner: the test tells the runner the
counters were re-zeroed (Context.counters_rezeroed) and jogs the head
back by the counter-measured displacement, and hands the lid lamp back
at the level it found. cloud.gfhome-homing documents that it leaves the
machine homed at the corner.

Baseline: a displaced head is jogged back along its own path by the
kernel-measured X/Y delta through the GRBL controller (bounded 100 mm,
waits out a controller respawn backoff); Z is never touched.
2026-08-16 14:21:54 -04:00
ScottW514 d4e2f85538 forgetest: motion tests end on the machine's idle; the Grbl client survives a soft reset
wait_idle returned on a stale Idle before a just-commanded move began,
and the tests declared PASS on grblHAL's Idle while the kernel still
played the stream depth and the decel tail (the baseline caught
state=running after motion.pacing). Every motion test now ends on
forgectrl's idle (machine_idle), and wait_idle ignores an Idle inside a
short grace unless a non-Idle state was seen.

status_report re-sends '?' every 0.5 s until a report arrives: a soft
reset (^X) flushes the controller's read buffer and eats a '?' that lands
in it - the bench saw motion.cancel-abort error with no report for 5 s
after the abort while the controller answered the next '?' at once.

motion.deadman: the SIGSTOP drill waited for a running controller and
got the killed pid back before the supervisor reaped it (drill 1 already
waited for a different pid); and the forgectrl-restart drill expected the
busy controller's pid to survive the retake, but a retake under the
broker is stop-at-idle, re-probe, start a supervised controller (the old
inherited fd cannot be adopted) - the check is now: the move finished
unmanaged, supervision came back running and verified. Bench-proven
2026-08-16: kill respawn 1.3 s, hang -> underrun 0.21 s, retake at idle.
2026-08-16 14:21:53 -04:00
ScottW514 0312dec22b BRINGUP: forgetest bench campaign 2026-08-16 - 14/26, the baseline rule, the masked-probe finding 2026-08-16 13:36:59 -04:00
ScottW514 13beb51e26 forgetest: kernel.k1-k2 leaves the position counters where it found them
K2 streamed 1000 masked +X steps; the counters advanced although nothing
moved, and forgectrl's position display carried the offset. The stream
now runs 1000 out and 1000 back, and the counters returning to start is
a check. Bench-proven 2026-08-16: (0,0,0) before and after.
2026-08-16 13:35:20 -04:00
ScottW514 4aaedc8080 forgetest: every run starts from, and leaves, the fresh-boot idle state
A baseline pass brackets every test and bench tool: before the run the
machine is verified against the fresh-boot idle state and anything off it
is restored; after the run - pass, fail, or abort - it is restored again.
Fixed items are the resting values the boot establishes (module defaults,
forgectrl's start-up writes, the GRBL controller's init writes) and
forgectrl's idle picture (controller running with motion verified, no
diagnostic, camera and cooling engines idle); preserved items (lid lamp
level, position counters, settings map, controller mode) are captured
before and handed back after. Deviations are leftovers: in the run pane,
in the result's evidence, and on the page - attributed to the previous run
when found before, to the run itself when found after.

forgetest takes a fresh-boot reference once per boot (within ten minutes
of boot, after the supervisor settles) as the session's resting lid-lamp
level and the check on the fixed values; the values were confirmed
against a fresh boot of the dev image on the bench (step_freq rests at
28160, the controller's default tick, not the probe's 10000).

Takeover runs capture the controller-owned kernel attributes on entry and
write them back before forgectrl restarts: the bench found the kernel
tests leaving motor_lock=15 behind, which masked the supervisor's
liveness probe - no motion by construction, a false driver-wedge verdict,
the rail-off ladder, and finally motion-fault. The takeover wrapper also
waits for the supervisor to settle on both sides (moved into baseline).

Catalog consequence: none beyond the runner; the tests' own drills are
unchanged.
2026-08-16 13:35:19 -04:00
ScottW514 799e0e829c forgetest: a takeover waits for forgectrl to settle on both sides
forgectrl's supervisor probes motion liveness on every start - a small
head move verified by the accelerometer, with a rail-off ladder of up to
about 70 s on a dead verdict. The takeover wrapper returned as soon as
forgectrl start succeeded, so a test that followed found the machine
busy (bench: cooling.flow-verify refused with 409 machine is not idle
13 s after kernel.fire-abu), and back-to-back takeover tests stopped
forgectrl mid-probe. The wrapper now waits for /mode to settle - motion
verified, motion-fault (logged as a warning), or standby - before it
stops forgectrl and again after it starts it, giving up after 10 s when
forgectrl does not answer at all (the host, or a daemon that is down).
No catalog consequence: the takeover tests' own drills are unchanged.
2026-08-16 13:09:56 -04:00
ScottW514 a4b757add5 forgetest: a diag start is 202 Accepted with started:true
forgectrl answers a started diagnostic with 202 (an async job); the
cooling.flow-verify test asserted 200 and failed on the bench before the
diagnostic ran. No catalog consequence beyond the test's own source
hash.
2026-08-16 13:08:48 -04:00
ScottW514 e19e7c5304 BRINGUP: images 20260815215236 / 20260815215332 built with the logging catalog and the fast sanitizer 2026-08-15 17:54:47 -04:00
ScottW514 d7d68c556e BRINGUP: the swept legacy logs were deleted from the bench 2026-08-15 17:51:22 -04:00
ScottW514 d17655c3c0 forgetest: logs.routing and logs.level-settings; export gets its own timeout
The routing test proves the path every logger takes and stands for the
emitters and relays in every component (fflog in forgectrl and grblHAL,
the SysLogHandler in the Python apps, the supervisor's per-controller
relay, the daemon's fifo relay, the render step): one logger daemon,
rendered rules and the effective record consistent with /logs, the tree,
the daemon's own emitter line, `logger` probes routed by program name in
the ff_line format, a stray program only in system/, kernel lines, relay
processes, nothing written outside the tree. The level-settings test
covers the validators and the configured-vs-effective / pending_reboot
contract. Both PASS on the bench with the existing logs.tree-tail-export
through the real Runner. That test's export call now uses a 300 s client:
the sanitized export took 13.9 s on the target and tripped the 10 s
default. Pin: forgectrl 4d19e9d (the sanitizer skips passes a line
cannot match; 4x faster). Coverage lint enforced: 0 uncovered.
2026-08-15 17:44:44 -04:00
ScottW514 07141d60fc BRINGUP: remote syslog verified over the real hop (UDP + TCP, collector outage + queued delivery) 2026-08-15 17:24:45 -04:00
ScottW514 2e82b13438 BRINGUP: $H gfhome routing verified; item 14 closed 2026-08-15 16:31:31 -04:00
ScottW514 7baa6a0352 BRINGUP: unified logging bench validation complete (levels, remote, rotation, export, RT, respawn, cloud routing) 2026-08-15 16:26:58 -04:00
ScottW514 16deb4896f forgetest: /fuse-identity is two-factor; the auth test asserts both refusals
forgectrl reveals the fuse identity only to the token AND the physical
button held, so the token alone must answer 403 with the button message.
The test now asserts the no-token refusal and the token-without-button
refusal and never fetches the identity itself (a 200 would have carried
the fuse password into the result log). BRINGUP: the bench campaign on
the flashed dev image, 7 of 24 passed so far.
2026-08-15 16:07:12 -04:00
ScottW514 5e04f4f4cd BRINGUP: forgetest CI is green on the pushed tree 2026-08-15 15:58:56 -04:00
ScottW514 1179d5e7c1 Release acceptance gate: release.sh refuses to sign without a matching artifact
scripts/acceptance-gate.py recomputes every catalog test's domain fingerprint
from /etc/forgefirm-manifest.json inside the release rootfs and requires the
committed releases/v<version>/acceptance.json to carry a matching PASS
(inherited results not core and newer than the invalidate epoch; the artifact
self-hashed; the catalog identical to the tree). release.sh runs it after the
build and stages the artifact as a release asset; FORGEFIRM_ACCEPTANCE_SKIP=1
bypasses loudly. scripts/manifest-from-tree.py builds the same manifest from
the recipe pins with git for CI and the workstation; forgetest-ci.yml runs the
unit tests and enforces the coverage lint (every manifest path covered by some
test). docs/ACCEPTANCE.md is the contract; the coverage currency rule and the
status live in BRINGUP.
2026-08-15 15:57:12 -04:00
ScottW514 c0f53a865f forgetest: the release acceptance tool and the bench diagnostics page
A stdlib-only daemon on the dev image (HTTP :8090) that runs the acceptance
catalog against the machine from a self-contained page, keeps the append-only
result log under /data/forgetest, and exports the release artifact the gate
reads. Tests declare kind (auto / operator / live), hardware (api / takeover),
coverage globs, prerequisites, and core membership; a test's domain
fingerprint is the hash of the manifest files its globs select plus the
platform and its own implementation, so a PASS stays valid exactly while
nothing it covers changed. Campaign rules: a FAIL ends the campaign, the core
(image health, kernel latch and drills, one live emission witness) is never
inherited, invalidate-all forces a full campaign, no SKIP. Live tests need the
operator acknowledgment and the physical arm press through the controller;
takeover tests stop forgectrl for the duration with a crash-recoverable
marker; the tool never touches the laser latch.

Catalog v1: 24 tests ported from the proven bench drills with their recorded
pass criteria (image, kernel K1-K3 and fire A/B/U, forgectrl API and logs,
motion incl. dead-man, cooling, live laser, camera, update, cloud). The bench
tab lists every scripts/bench tool and runs the board-side ones as
subprocesses (takeover tools wrapped). 44 host unit tests, including the gate
verification fixtures. Installed only by forgefirm-image-dev, with the bench
scripts under /usr/share/forgetest/bench.
2026-08-15 15:57:11 -04:00
ScottW514 9b5558dfbc meta-forgefirm: the image manifest (/etc/forgefirm-manifest.json)
Every image records the identity of its build inputs. forgefirm-manifest.bbclass
gives a component recipe a per-file source fingerprint (git ls-tree blob ids,
submodules recursed; file:// sources hashed with git hash-object); the kernel
and glowforge.ko publish theirs through do_deploy because kernel-module-split
leaves the module recipe's main package empty. forgefirm-image-manifest.bbclass
assembles the entries with the platform identity - machine, modules directory,
device tree hashes, layer content hashes (meta-forgefirm and the meta-openglow
layers by content, the kas-managed layers by revision) - into
/etc/forgefirm-manifest.json and deploys a copy next to the image. Layer
revisions and dirty flags stay outside the identity, so a rebuild from an
unchanged tree keeps its content hash and the release and dev images of one
build share it. The acceptance tool and the release gate read this file.
2026-08-15 15:57:10 -04:00
ScottW514 da3725c6de BRINGUP: drop the acceptance-tool text that was swept in from the shared working tree
It belongs to work in progress in another session; committing it here was a mistake. The working tree still carries it uncommitted.
2026-08-15 15:28:39 -04:00
ScottW514 304c4e7186 BRINGUP: unified logging bench checks on the flashed dev image 2026-08-15 15:27:48 -04:00
ScottW514 7487f907f4 logging: default rules come from the init script, not a trailing fallback rule
rsyslog flagged the fallback action after the include as unreachable (the rendered rules end in stop). The forgefirm-logging init script now writes a default rules file when the render leaves none, and the shipped rsyslog.conf carries only the include.
2026-08-15 15:27:24 -04:00
ScottW514 298d711533 rsyslog: stop last at shutdown (K95), after the controllers and forgectrl 2026-08-15 15:07:15 -04:00
ScottW514 e733bbc270 forgefirm-app: bump PV with the SRCREV move 2026-08-15 14:37:57 -04:00
ScottW514 6050c0e703 Unified logging: rsyslog as the system logger, the ForgeFIRM log tree
rsyslog replaces busybox syslogd/klogd (VIRTUAL-RUNTIME_base-utils-syslog,
trimmed PACKAGECONFIG) and becomes the only log writer: the appended
/etc/rsyslog.conf sets the inputs and the ff_line format and includes
the per-logger rules that `forgectrl --render-syslog` renders from the
machine settings at boot. forgefirm-logrotate becomes forgefirm-logging:
render before rsyslog starts (S19), sweep the pre-syslog log files into
/data/forgefirm/legacy-logs once, and rotate the tree at boot and hourly
by rename + HUP instead of copytruncate. Pins bumped to the pushed
forgectrl (syslog emitter, Logs tab, export), grblHAL-glowforge (syslog
emitter) and python3-gfhardware apps (syslog handlers, capture dir);
the CI harnesses set FFLOG_STDERR=1 so failure diagnostics keep the
controller's log lines. BRINGUP carries the bench validation checklist
(Next work item 14); this is an image change and rides the next flash.
2026-08-15 14:37:33 -04:00
ScottW514 b08e5ab929 BRINGUP: clarify the pre-rename estop references in dated records 2026-08-15 14:16:34 -04:00
ScottW514 3f81597c93 SAFETY: hv_enable run-boundary behavior is established; drop it from the open items 2026-08-15 14:11:32 -04:00
ScottW514 7319c718fe SAFETY/BRINGUP: HV watchdog one-shot measured pulse-to-drop, t_w = 454 +/- 3 ms 2026-08-15 14:01:46 -04:00
ScottW514 52b398e11d cp_watchdog_timing: do not starve the controller (no nice, sleep per pass), two jogs 4 s apart 2026-08-15 13:54:48 -04:00
ScottW514 fe6a65b7fe bench: cp_watchdog_timing.py - measure the HV watchdog one-shot from the SoC pins 2026-08-15 13:49:50 -04:00
ScottW514 d7cb506c46 BRINGUP: hv_enable readback bench-validated on image 20260815162923 2026-08-15 13:36:51 -04:00
ScottW514 87a8ccbd59 BRINGUP: image 20260815162923 built on the hv_enable pins; flash pending 2026-08-15 12:41:52 -04:00
ScottW514 f75629a4df hv_enable: EV_SW bit 4 is the HV_ENABLE readback; pins for the rename
SAFETY.md and the safing figure name GPIO4_06 for what it is - the
readback of the chain's HV_ENABLE output through U24 (the factory net
label E-STOP is kept as a note); BRINGUP records the rename, the
device-tree polarity flip that makes bit 4 read as HV_ENABLE itself,
the removal of the estop_halts_motion opt-in, and the bench check for
the flash that ships it. Recipe pins move to forgectrl 801f1f3,
grblHAL-glowforge b629c18 and python3-gfhardware c3d1790 (PV 0.1.5).
2026-08-15 12:28:58 -04:00
ScottW514 8a1fa91db5 bench: net-blip tool; BRINGUP - cloud-mode drills, lid-IR tracks the lid LED, fire gate back to watch-only 2026-08-15 12:06:16 -04:00
ScottW514 1d4c6b5a57 BRINGUP: LightBurn connects after an idle lid cycle on 20260815154622; further LightBurn door-open issues tracked as Next work item 12 2026-08-15 11:57:40 -04:00
ScottW514 8658973d5c Bump pins: grblHAL a9446fe (door signal hidden while idle/jog/homing), forgectrl f7276c5; docs
LIGHTBURN: lid open mid-job = Door, Resume continues; the lid is free at idle. SAFETY/SERVICES/BRINGUP: the visibility policy and its bench check.
2026-08-15 11:44:39 -04:00
ScottW514 81fca45cb5 BRINGUP/SAFETY: interlock latch drive and watchdog readback bench-validated on image 20260815150546
Loop pull sets interlock_latch_reset + interlock_latch within one 50 ms sample and both clear on reinsert; charge_pump_alive tracks the run and falls 0.45 s after the last charge-pump pulse (matches the measured R*C); estop follows it on both edges. Note that an interlock trip parks grblHAL in Door:0 until a cycle start.
2026-08-15 11:27:25 -04:00
ScottW514 21d3a482c6 Laser safety write-up (docs/SAFETY.md + diagram); bump pin: forgectrl a451e7c
docs/SAFETY.md documents the control board's laser safing chain as
reverse-engineered and bench-verified - parts, every net with its SoC pin,
Linux exposure and polarity, the logic, and the software layers ForgeFIRM
stacks on it - with docs/img/safety-chain.svg. Linked from the README.

BRINGUP: Next work item 11 records the interlock-latch finding (an open
loop never tripped the latch; the kernel now drives INTERLOCK_LATCH_RESET)
and the charge-pump watchdog readback, both code-complete and pinned,
bench validation on the next image.
2026-08-15 11:03:15 -04:00
ScottW514 43cc540a09 Bump pins: forgectrl 73eda9a, grblHAL 91807a2 (head-presence gate, honest labels); BRINGUP head-absent record 2026-08-15 09:19:25 -04:00
ScottW514 f919f2e622 BRINGUP: lid-IR flame signature measured - a candle reads +3..6 counts, same as a cut 2026-08-15 09:07:55 -04:00
ScottW514 984c0697e3 BRINGUP: LED sequence operator-witnessed 2026-08-15 09:02:38 -04:00
ScottW514 744376d8e8 BRINGUP: the lid-IR fire gate is set on the bench 2026-08-15 08:58:01 -04:00
ScottW514 e1207316ec bench: fd-scan tool; BRINGUP session record - dry drills, homing witness, panic drill 2026-08-15 08:52:51 -04:00
ScottW514 31ac4bbcbd Bump pins: forgectrl 3edb7bd, grblHAL 5960f05 (stop-lever fix, fetch-verified) 2026-08-15 08:43:50 -04:00
ScottW514 5848eea6f7 BRINGUP: bench session record - lid-IR gate sized, expected-stop lever defect found, fixed, re-proven 2026-08-15 08:42:32 -04:00
ScottW514 cced57da6c bench: platform drills, cutting-power lid-IR and expected-stop live drills, SIGTERM lifecycle case
- platform_drills.py (on the board, forgectrl stopped): dead-man trip
  readback, rmmod/modprobe with concurrent attr reads, decay/microstep
  readback, LED sequence.
- live_fire_drills.py: ircut (S/F selectable characterization job),
  expstop (armed job + POST /controller/stop, controller left stopped),
  ctrlstart (separate, operator-approved resume); the token header is sent
  in exact case.
- laser_lifecycle_test.py: sigterm-mid-job - SIGTERM during an armed job
  must stop it, relock the latch and exit promptly.
2026-08-15 08:38:29 -04:00
ScottW514 8ef2d2845a BRINGUP: consolidate the outstanding bench validations into Next work 2026-08-15 07:26:22 -04:00
ScottW514 21488f74fd BRINGUP: image 20260815105250 flashed; carry the two deferred daemon items in Next work
The forgefirm-app recipe states why its packages are MIT-only.
2026-08-15 07:21:17 -04:00
ScottW514 76bf87939f BRINGUP: image 20260815105250 built on the hygiene-sweep pins 2026-08-15 07:08:39 -04:00
ScottW514 e232adbe4f BRINGUP: the hygiene-sweep binaries are installed on the bench 2026-08-15 06:50:10 -04:00
ScottW514 c985991cda Bump pins to the hygiene-sweep heads; record the sweep in BRINGUP
forgectrl ed2934b, grblHAL da4c8eb, forgefirm-app (gfhardware) 6c7534a -
all pushed and fetch-verified, license population checked for the
multi-license recipes.
2026-08-15 06:41:26 -04:00
ScottW514 d92393395a kas: the BSP tree has two layers now; state the multi-license recipes 2026-08-15 06:23:03 -04:00
ScottW514 0a05b6b114 docs: present-state build and update docs; fix the ring-size arithmetic
- kas/README.md: the real-time rationale rests on the feeder's bounded
  queue depth, not ring size; the ring is 16 MiB (~84 s at 200 kHz,
  ~28 min at the 10 kHz cloud tick), a capacity for cloud-mode preload.
- BUILD.md, kas config, release checklist, cold-build workflow: only
  forgefirm and meta-openglow (branch scarthgap) are cloned as
  siblings; every ForgeFIRM source repo is fetched by pinned SRCREV.
- UPDATE-SYSTEM.md reads as the present-state design: the cloud-mode
  compatibility baseline is the cloud client's configured firmware
  version, not release metadata; decisions and open items listed
  plainly.
- README.md states what GRBL mode still needs the Glowforge service
  for (camera-referenced homing) and what runs without it.
- BRINGUP.md: generic build-host and fwup-lab references, the retained
  reproductions of the no-fire drill, the System tab.
- LIGHTBURN.md: the arm-window timeouts are machine settings.
- forgefirm-image.bb describes forgectrl as the machine-services
  daemon and points at the right backlog entry.
- American spelling throughout.
2026-08-15 06:12:51 -04:00
ScottW514 ff117c4f6b bench: reach the board through a configurable ssh client; document every tool
The host-side coolant and fan tools call the ssh on PATH, or the client
named by GF_SSH (a wrapper such as a WSL distro's ssh), instead of a
fixed wrapper. The bench README lists every committed tool and data
file, and explains how the coolant-flow fire-gate threshold was
derived from the committed flow-matrix data and how to re-run that
derivation on another machine.
2026-08-15 06:12:39 -04:00
ScottW514 ee25b89799 Bump grblHAL pin to b2cad8d (parked-state pacing fix) + pacing bench test
Pulls in the coarse-pacing fix for parked wait-for-operator states (a
machine left in Door or Hold no longer pins ~28% of the core), plus
P10's grblHAL CI/tests and the mlockall-root-only change. Fetch-verified.
Adds pacing_test.py (dry hold/resume + CPU measurement) and records the
diagnosis and bench validation in BRINGUP.
2026-08-14 22:06:46 -04:00
ScottW514 b18cb15072 Record G-4 and X-2 host unit tests closing the deferred bench items 2026-08-14 21:38:56 -04:00
ScottW514 55b940e702 Note forgectrl pin bump in the liveness-fix bench record 2026-08-14 21:18:18 -04:00
ScottW514 3dfee18b96 Bump forgectrl pin to 424f185 (liveness doors-bit fix)
Pulls in the inverted-doors-bit fix in the liveness probe enclosure
guard (the probe was skipping on every spawn with the lid closed and
would have moved the gantry with it open) plus the P10 -Werror warning
cleanup. Source pushed to origin/main; bitbake -c fetch verified.
2026-08-14 21:17:57 -04:00
ScottW514 ea0642f942 Record live-fire drill results: emission witness, HV, lid-IR, X-3, G-10
Phase 5 A-1 emission witness and A-5 HV telemetry pass on live burns;
A-2 lid-IR characterized as a weak signal at 40% (gate left watch-only);
pgood confirmed unusable as a witness on this PSU. Phase 4 X-3 (0.1s
job-based disarm on M2) and G-10 (disarm counts down in Hold) pass.
Adds live_fire_drills.py (arm-lifecycle sampler over TCP + HTTP).
2026-08-14 21:14:57 -04:00
ScottW514 97a4cae346 Record GATE A closure: kernel drills pass, liveness guard defect found and fixed
K1/K2/K3 pass on image 20260814223300 with software witnesses
(fire_test A/B/U reproduce the scope-pinned reference); GATE A is
closed and live fire permitted. The campaign caught the liveness
probe's inverted doors-bit guard (forgectrl 424f185, hot-deployed,
probe live-verified MOTION OK).
2026-08-14 20:41:00 -04:00
ScottW514 3afd64dbc7 Add GATE A kernel drill script and lid-IR ambient baseline
gate_a_kernel_drills.py stages the three remaining GATE A kernel
drills (controlled-stop deceleration floor, resume waypoint with the
latch locked, mid-ramp latch unlock) with software witnesses and the
PSU-connector scope point. lid_ir_ambient_baseline.csv is the
fire-watch ambient anchor (600 samples, lid closed, machine idle);
BRINGUP records the baseline statistics and the idle verification of
the emission/fire/HV evidence plumbing.
2026-08-14 20:18:58 -04:00
ScottW514 572ee8a3d7 BRINGUP: record audit Phase 10 - the safety rules are CI-enforced
grblHAL CI runs the emission harness, the new armed-window lifecycle
harness, and the switch-map truth table on every push; forgectrl and
the kernel module build -Werror in CI. All three pipelines green.
2026-08-14 19:16:07 -04:00
ScottW514 26ed519348 bench: surface the controller stderr when the harness cannot connect
A startup failure previously reported only cannot-connect with the
process stderr discarded - undiagnosable in CI.
2026-08-14 19:10:38 -04:00
ScottW514 9e9d03ba94 bench: armed-window lifecycle harness (null-sink, host-side)
Four sessions against the reported messages: arm once per job with
M5/M3 persistence and the M2 close (grace pushed beyond the horizon so
only the program-end path can disarm); a displaced sender must re-arm;
the disarm grace counts down in Hold; a blocking cooling verdict
refuses the arm. Test-the-test proven: a build with the job-based
window reverted fails at the first discriminating assertion. Wired
into the controller repo CI alongside the emission harness.
2026-08-14 19:07:29 -04:00
ScottW514 2aa92bc63a BRINGUP: image 20260814223300 built - all audit rows aboard
Pins pushed/bumped/fetch-verified; built-image checks pass (locked
root on release, no watchdog daemon, logrotate, K-link order, pinned
kernel config, console-only DTB fallback, hardened glowforge.ko).
Bench campaign is next.
2026-08-14 18:48:17 -04:00
ScottW514 e10784b2d8 Bump recipe pins to the audit-remediation heads
forgectrl aa99d30, grblHAL-glowforge e74f53f, and the forgefirm-app
sources at python3-gfhardware c9d602a (PV 0.1.3) - the Phase 1-9
remediation work, pushed and pinned.
2026-08-14 18:30:11 -04:00
ScottW514 aee0d18baa BRINGUP: record audit Phase 9 - all image rows are complete
Build/BSP/release engineering landed; the image build + flash now
carries every kernel and image fix across all phases. Bench items
listed.
2026-08-14 18:23:59 -04:00
ScottW514 58d4d91948 Build and release engineering: teardown order, slot safety, release gates
- Controllers stop at K80, before forgectrl at K90: runlevel 0/6 no
  longer tears down the cooling engine, fire gates, and broker while a
  controller may still be executing a job.
- The grblhal/gfcloud init scripts are real emergency levers: stop
  routes through the supervisor (POST /controller/stop - a bare pkill
  was safed and respawned seconds later), start resumes supervision,
  status exists, and the pkill fallback matches full executable paths
  instead of truncated names or bare substrings.
- slotmigrate: the partition grow gets the same 2048-sector tolerance
  as the filesystem branch (an exact compare rewrote the MBR at S02 on
  every boot on disks where the grow cannot land on the last sector),
  verifies it made progress, and the resize2fs retry is bounded at
  three attempts with the counter kept on p3 itself.
- Installer: archive product/platform are verified after the signature,
  and a validly signed OLDER release now requires an explicit yes
  instead of installing as a silent downgrade. All predictable /tmp
  paths in the installer and ffboot are mktemp now.
- release.sh rejects multiple positional versions (the last one used to
  win silently) and a release without factory-era verification dies
  unless explicitly bypassed; mkfw.sh refuses to pack when the public
  key for the post-sign self-check is missing.
- forgefirm-logrotate: size-capped rotation (boot + hourly) for the
  /data logs - a full /data breaks settings, update staging, and the
  controllers own writes.
- Bench build scripts derive every path from their own location or
  FF_SRC_TOP/FF_BUILD_TOP and log to mktemp files.
2026-08-14 18:23:13 -04:00
ScottW514 cbabf7d111 BRINGUP: record audit Phase 8 - the kernel rows are complete
All kernel/image fixes across the audit phases now wait on the one
image build + flash; Phase 8 bench items listed.
2026-08-14 17:53:57 -04:00
ScottW514 341c6399a0 BRINGUP: record audit Phases 6 and 7
Motion integrity (mid-run fault detection, homing-anchor lifecycle,
kernel ring/backtrack/fault-recovery hardening riding the image flash)
and cloud-mode robustness (fail toward stopped-and-safe, input clamps,
log hygiene); bench items listed.
2026-08-14 17:37:08 -04:00
ScottW514 e41b10d655 BRINGUP: record audit Phase 5 (physical-evidence instrumentation)
Emission witness, lid IR fire watch (watch-only until characterized),
faults/HV telemetry, measure-laser cleanup, and the head I2C error fix
(rides the pending image flash); bench items listed.
2026-08-14 17:12:03 -04:00
ScottW514 ea3fce2efd Record audit Phase 4; LIGHTBURN: the armed window is job-based
BRINGUP gains the Phase 4 (stale-gate cluster) record - code-complete,
host-verified, hot-deployable, bench drills listed. LIGHTBURN.md now
describes the job-based armed window: relock at program end, on a
sender change, or after the spindle-off grace (counting even in Hold).
2026-08-14 16:58:35 -04:00
ScottW514 daa76ca828 BRINGUP: record audit Phase 3 (broker ownership / dead-man second pass)
Code-complete and host-verified across forgectrl, the GRBL controller,
the cloud clients, and the kernel module; kernel rows ride the pending
image flash, bench drills listed.
2026-08-14 16:41:38 -04:00
ScottW514 e9443a60ef Keep debug-tweaks out of release images; harden the installer
- Move the passwordless-root debug-tweaks image feature out of the
  shared kas config into forgefirm-image-dev.bb, so the release
  forgefirm-image built from the same config is not passwordless-root.
  release.sh gains a gate that reads the built rootfs /etc/shadow and
  fails on an empty root password, plus a config-level guard that
  debug-tweaks is not present in the resolved kas dump. (B-1)
- The installer copies ffboot out of the signature-verified new rootfs
  it already mounts, instead of fetching and executing it from a mutable
  GitHub raw ref. (B-2)
- Record audit remediation Phase 2 (GATE B) status in BRINGUP.md,
  including the bench pass still required to close the gate.
2026-08-14 16:20:02 -04:00
ScottW514 cc927aca5f Add laser-safety and regulatory documentation; scrub bench identity
- LIGHTBURN.md: mandatory "Before you cut" safety section; the
  walkthrough now reflects the firing machine (dry runs need the
  layer output off or M5; live first-cut instructions); the homing
  entry documents homing_mode and the gfcloud method; the machine
  address is a placeholder.
- README.md: condensed safety section linking the full text and the
  regulatory notes.
- INSTALL.md: "Regulatory and legal" section ahead of the install
  steps; routine updates route through the panel updater rather than
  the installer.
- BRINGUP.md: the release signing key is described as held offline
  (no on-disk path); bench address and credential notes removed;
  Next-work item 7 corrected (the installer embeds the production
  release key); status entry for audit remediation Phases 0-1; the
  GATE A kernel drills join the pending image-flash checklist.
- bench scripts: the target host comes from GF_HOST (or argv) instead
  of a hardcoded address.
- laser_stream_test.py: per-session controller runs with a hermetic
  cooling-verdict publisher; new assertions that every stream
  terminates with FIRE clear (including M3 held to stream end) and
  that no FIRE bit rides a zero-step gap; a cycle-churn session
  exercises the stop/start seams.

Audit findings D-1, D-2, D-3, D-5, D-10, D-12, B-10, and the harness
half of D-4/G-1.
2026-08-14 15:38:24 -04:00
ScottW514 5dddea12ee docs: track the kernel platform-hygiene batch in BRINGUP
Add Next work item 9: the panic handler, control_12v removal, decay-mode
state tracking, module build hygiene, SDMA/EPIT/GPIO probe guards and
head_make_safe are code-complete and build-verified, with the bench check
each one still needs and a note that the batch ships with a full image
flash rather than a module hot-swap.

kas/README: drop the control_12v residue (the node is gone), state that
the buzzer driver is not part of ForgeFIRM, and record that the pre-SPDX
LICENSE strings remain only in layers this build does not use.
2026-08-13 15:41:09 -04:00
ScottW514 89ba97e310 docs: BRINGUP.md - safety mapping implemented; pin grblhal e616548, forgectrl e779554
The controller now maps the door pair and the interlock loop onto the core's
safety-door signal, with the e-stop bit behind a machine setting. Records what
is gated and what is deliberately not, the answer to the audit's latch-reset
question, and the bench items the change still needs.
2026-08-13 13:51:23 -04:00
ScottW514 08c83917af docs: BRINGUP.md - close the interlock readback and trip-recovery items
The interlock_circuit bitmask is fully mapped: b0/b1/b3 from the scope
experiment already recorded in the gate section, b2/b4 from the factory decode
the attributes were ported from, and the armed kill-mid-FIRE drills read the
mask consistently across armed, firing, idle and disarmed states. Trip recovery
was exercised in commissioning runs.

The safety-mapping item now says what is actually left of it: door and e-stop
evdev into the controller's feed-hold and halt path, plus the question of a
software reset path for the laser latch.
2026-08-13 13:40:54 -04:00
ScottW514 562631d9bf docs: describe the firmware that exists; ship the NXP firmware EULA
README: replace the stalled REST/GUI roadmap with what the firmware does -
GRBL mode over TCP for LightBurn/UGS/cncjs, optional cloud mode, the web
control panel, cameras, A/B install beside the factory firmware - plus the
supported hardware incl. the 8 MP camera limitation and a real roadmap.

kas/README: xvclk is the board's 24 MHz oscillator; the pin/push rule covers
every source repo; camera and motion sections are hardware-validated, with the
OV8856 caveat spelled out; the device-tree item closes with the control_12v
residue named; forgectrl is the machine-services daemon with two supervised
controllers.

LIGHTBURN.md: drop the pre-first-light status line.

BUILD.md: state the NXP firmware licensing and where the EULA lives on the
machine. The image now installs firmware-imx-lic, so the EULA text ships beside
the VPU/EPDC blobs it covers; a bbappend declares that package at parse time,
which is what makes it installable from an image recipe.
2026-08-13 12:34:48 -04:00
ScottW514 e92e500d91 docs: BRINGUP.md - no idle-rail-off in the rail item; forgectrl c099a28 2026-08-13 12:02:58 -04:00
ScottW514 2c1d73880a docs: BRINGUP.md - shared machine services closed out; forgectrl 96027b1
Record the finished shared-services state: forgectrl is the one
machine-services daemon behind both controller modes, both controllers
are cooling-engine clients, and the drill and soak coverage that proves
it. Attribute the cooling policy records to the engine's present home.
Add the remaining polish as Next work item 8 - diagnostics as engine
modes, the rail-policy remainder, cloud per-job fan profile
confirmation, /cool/status cosmetics, and button edge detection.
2026-08-13 12:00:01 -04:00
ScottW514 9fd49ba6ad forgefirm-app: PV 0.1.2 2026-08-13 05:19:46 -04:00
ScottW514 b08d3e5f30 forgefirm-app: pin 7a4973b 2026-08-13 05:17:42 -04:00
ScottW514 95ae38916d forgectrl: pin 6dfbceb (controllers inherit only the broker fd) 2026-08-13 05:14:15 -04:00
ScottW514 107374c2ae forgefirm-app: PV 0.1.1 (hash-derived version sorted backwards after the pin move) 2026-08-12 17:24:35 -04:00
ScottW514 7e43355011 grblhal: bump to 3f22d1f (armed-window refire across kernel runs, null-sink write gate) 2026-08-12 17:18:33 -04:00
ScottW514 44cdda0109 Bump forgectrl/grblhal pins: quiet fans through homing motions 2026-08-12 16:35:44 -04:00
ScottW514 12ecebe3fa grblhal: bump to 20201fe (armed-window idle disarm fix) 2026-08-12 15:46:58 -04:00
ScottW514 1b47597133 Bump SRCREVs: the shared-machine-services release
forgectrl 21edf30 - cooling engine, controller-mode supervisor,
pulse-device broker, motion-liveness gate, crash watchdog, docs.
grblhal-glowforge 05c3b2d - cooling thin client, broker-inherited
pulse device, heater kill on engine loss.
forgefirm-app (python3-gfhardware) 40d0de2 - coolsvc client, broker
mode, rail-write gating, gfhome completion hardening, factory NTC
curve.

Ship the three together: the migrated controllers no longer establish
the thermal posture and expect the broker - forgectrl must be current
first (deployment ordering per forgectrl docs/SERVICES.md).
2026-08-11 20:14:32 -04:00
ScottW514 683511f563 BRINGUP: present-state sweep for the shared-services architecture
The runbook operational sections now describe the machine as it runs:
forgectrl as the machine-services daemon (supervisor, pulse-device
broker, motion-liveness gate, cooling engine, cameras), controllers
spawned and supervised rather than init-started, standalone driver
runs as the bench/debug exception, diagnostics suspending the active
controller through the supervisor, the cooling engine as the thermal
owner with the drivers as thin clients, and the new /mode and /cool
endpoints. A dated correction closes the no-motion record with the
DRV8825 wedge diagnosis and the liveness/homing hardening; the
cold-start section gains the first-light and shared-services
milestones.
2026-08-11 20:07:24 -04:00
ScottW514 1e3b2716b7 BRINGUP: DRV8825 rail-glitch wedge, accel liveness, probe direction
Hardware facts from the 2026-08-11 bench session: the DRV8825 drivers
can come out of a 40 V rail power-up unserviceable (playback and
counters run, motors dead; recovery is a longer true power-off, at
worst a machine power cycle) - so counters/anchors are never proof of
motion and the rail stays up. The head accelerometer is the motion
truth (device mapping, bench-characterized thresholds, read-rate
limits), and every probe move goes +X first - a cable lives at the
end of left travel.
2026-08-11 20:01:24 -04:00
ScottW514 58bc5e293a grblhal.init: defer to the forgectrl supervisor
The controller lifecycle belongs to the forgectrl supervisor (it
spawns the selected controller as a direct child); the init script no
longer starts grblHAL and remains only as a manual emergency stop.
2026-08-11 18:41:08 -04:00
ScottW514 6063b93683 BRINGUP: first GRBL-mode burn landed 2026-08-11 2026-08-11 16:58:40 -04:00
ScottW514 1c1830ae8d grblhal: bump to b7264bf (40V rail settle); BRINGUP: no-motion root cause record 2026-08-11 16:46:28 -04:00
ScottW514 3c095ccbd1 laser milestone: grblhal SRCREV -> 09bc882, host stream test, docs
BRINGUP item 2: grbl-mode laser software implemented + bench-verified
without fire (record in the gate list); first light pending.
LIGHTBURN.md: arming/button-press operation, S-max 1000, fire gates.
scripts/bench/laser_stream_test.py: host-side stream-dump contract
verification against the null-sink build.
2026-08-09 16:13:03 -04:00
ScottW514 2219ec24f7 Repoint merged recipe pins to the default branches
The cloud-action-surface branches are merged: forgectrl builds from main
(same revision), and the forgefirm-app include pins the python3-gfhardware
master head (adds docs/CLOUD.md and the clean-shutdown gfhome).
2026-08-09 15:18:57 -04:00
ScottW514 3d9e9ebd60 Move the web-service apps to python3-gfhardware/forgefirm-app
gfhome.py, ffmachine.py, and gfcloud.py (with the gfcloud init script
and gfhome conf sample) live in the python3-gfhardware repository's
forgefirm-app/ directory. The three recipes fetch that repo through a
shared include (forgefirm-app.inc) carrying a single pinned SRCREV,
replacing the local file:// copies. Package names, dependencies, and
installed paths are unchanged.
2026-08-09 15:02:09 -04:00
ScottW514 be7927c60a gfhome: use ws_connect now that it returns the client
ws_connect() returns the running WsClient, so gfhome no longer needs to
build the client by hand to get a clean disconnect; the exit path uses
WsClient.shutdown() to stop and join the socket thread.
2026-08-09 14:53:51 -04:00
ScottW514 050eaf5f4c Pin forgectrl to the cloud-action-surface branch for the test image
Points the forgectrl recipe at the pushed cloud-action-surface commit
(controller_mode=cloud + the compatibility banner) so a test image
carries the cloud-mode UI. Repoint to main once the branch is merged.
2026-08-08 19:31:04 -04:00
ScottW514 01d336875e Record the factory service version for the compatibility banner
Enable the read-only firmware-version probe and point it at
/data/forgefirm/gf-latest.json, where the web-service client records the
latest firmware Glowforge advertises and the version this release was
tested against. forgectrl reads that file for the cloud-mode
compatibility banner.
2026-08-08 19:14:32 -04:00
ScottW514 abf85fccfe Add the gfcloud full-cloud controller daemon
gfcloud runs the machine under the Glowforge web service (the factory
cloud experience: the app drives homing, framing, printing). Its init
service starts it only when controller_mode = cloud - mutually exclusive
with grblHAL, which already stands down for that mode - so it owns
/dev/glowforge exclusively. Persistent GFUIService loop with a clean
SIGTERM shutdown that safes the hardware; the init script mirrors
grblhal.init. The image installs gfcloud and python3-ffmachine.
2026-08-08 19:08:13 -04:00
ScottW514 5d7ca46c07 Share the web-service machine glue between gfhome and gfcloud
Extract the shared-config identity overrides and the forgectrl-routed
ForgectrlMachine from gfhome into an installed module (python3-ffmachine);
gfhome imports it instead of carrying its own copy, so the coming gfcloud
daemon builds the same machine the same way.
2026-08-08 19:08:13 -04:00
ScottW514 cd2107fe3c docs/update-system: add the tested_against_gf release-metadata field
Record the Glowforge service/firmware version each release validated
cloud mode against, kept distinct from the version cloud mode advertises;
forgectrl reads it to warn when the live service has moved past it.
2026-08-08 18:51:06 -04:00
ScottW514 26069a8258 gfhome: route actions through the shared cloud dispatcher
Replace the local dispatch table with gfutilities' dispatch_action
(allow_print=False), so the homing runner and GFUIService share one
action surface and cannot drift.
2026-08-08 18:51:06 -04:00
ScottW514 2b25a937a5 Bump grblhal-glowforge pin (gfcloud homing timeout fix)
b5c6d9c declares the shared config readers in glowforge_homing.c;
without the declaration the hard-float build read the homing-session
timeout from the wrong register and SIGTERMed every gfcloud homing
runner ~55 ms after spawn.
2026-08-08 17:47:08 -04:00
ScottW514 6811bbf825 Bump grblhal-glowforge pin to HEAD (CI workflow; no binary change) 2026-08-08 17:01:56 -04:00
ScottW514 25f2721500 forgectrl: wireless radio-policy revision; runbook: regulatory record
Bump to the forgectrl revision whose startup pass reloads
regulatory.db (required while the flashed kernel still has built-in
cfg80211 - its boot-time load fails pre-mount and stays failed
without a reload), hints a region only when one is set (unset =
automatic: the AP's 802.11d country, else world; a 00 hint over the
kernel's own world default reports the confusing intersection alias
"country 98"), and pins wlan0 power save off.

BRINGUP.md: bench record for the wireless-regulatory diagnosis and
the live-verified wifi_country / power-save flow; the kernel-batch
note now carries CFG80211/MAC80211=m, DEFAULT_PS off, and the lm75
vs-supply.
2026-08-08 16:51:06 -04:00
ScottW514 d2131c161b forgectrl: wireless region support; add iw runtime dep
Bump to the forgectrl revision that adds the wifi_country setting
(System-tab region picker); the daemon applies it with iw reg set at
startup and on change, so iw joins RDEPENDS. regulatory.db itself
ships via the glowforge-image base (wireless-regdb-static).
2026-08-08 15:32:47 -04:00
ScottW514 501e1d4591 installer: confirm target-slot content; record semantic version
Before writing the target slot, the installer now shows what it holds
(factory firmware v<ver>, ForgeFIRM, an unrecognized filesystem, or
unknown/unreadable content). Factory images are archived as before;
anything else requires the operator to type ERASE, since it is
overwritten without a backup. The archive manifest now records the
semantic FIRMWARE_VERSION (ver=), which the update manager displays in
the restore list. Bump forgectrl to the matching GUI change.
2026-08-08 14:54:28 -04:00
ScottW514 e9d3ce40a8 Bump forgectrl (legacy/sd slots shown only when present) 2026-08-08 14:42:13 -04:00
ScottW514 630c9c3da4 ffboot: display the factory semantic version, not the datetime
A factory slot's displayed version is now the semantic FIRMWARE_VERSION
from /etc/build (e.g. v2.6.0-2228) rather than the build datetime in
/etc/version - what the factory itself calls the release, and what the
update-manager GUI shows for each slot. The datetime still orders
releases for the -e newest-factory selection (a monotonic counter;
mashing the semantic version's digits would misorder a major-version
rollover). Bench: slot shows v2.6.0-2228, -e picks it correctly.
2026-08-08 14:26:01 -04:00
ScottW514 00f77f7985 docs: Phase 4 update manager bench-verified end-to-end 2026-08-08 14:19:30 -04:00
ScottW514 b26a687daa docs: Phase 4 update manager implemented 2026-08-08 14:03:23 -04:00
ScottW514 d1bbecb9ee forgefirm-keys: verification trust anchors; forgectrl update manager
/etc/forgefirm/keys ships the ForgeFIRM release-signing public key
and the Glowforge factory keyring (public keys only) - the update
manager verifies release downloads/uploads against the former and
factory archives against the latter. forgectrl SRCREV bumped to the
update-manager commit; runtime deps on ffboot, fwup, the keyring, and
curl made explicit.
2026-08-08 14:02:15 -04:00
ScottW514 419710ef4b installer: production release-signing key
The embedded pubkey is the production key from the signing ceremony;
release.sh's key-match gate now refuses any other signer. Verified:
production-signed archives pass fwup 1.16 and the factory's 0.14.2;
dev-signed archives are rejected.
2026-08-08 13:48:39 -04:00
ScottW514 96c96b63c6 docs: decision-gate statuses; key-ceremony procedure defined (gate 8) 2026-08-08 13:38:50 -04:00
ScottW514 6c418bfb92 docs: no bench-machine identity in public documents; re-pin forgectrl
The runbook no longer records the bench machine's fuse identity (a
fuse identity cannot be rotated, so a public document must never
carry it); the forgectrl pin follows that repo's history rewrite.
2026-08-08 13:37:22 -04:00
ScottW514 a2f1b8e9e8 CI: dispatch-only cold-build reproducibility workflow
Proves a fresh clone builds the release image on a hosted runner
(sibling checkouts for meta-openglow and the kernel-module externalsrc,
rm_work to fit the disk budget) and publishes artifact checksums for
comparison against locally built releases. Never produces release
artifacts - releases are built and signed on the maintainer's host.
2026-08-08 13:32:00 -04:00
ScottW514 fcf183eefd release.sh: the release pipeline
Gates (clean tree, version single-source across FORGEFIRM_RELEASE /
rootfs stamp / .fw meta-version / tag, rootfs-vs-slot size with early
warning, installer-embedded pubkey must match the signing key,
factory-era fwup verification of the packed archive), then build,
pack, sign, checksum, and stage forgefirm.fw + sha256sums.txt +
forgefirm-image-glowforge.rootfs.wic.gz with the gh publish command
(--publish runs it where gh is authenticated). release.sh --dev packs
a dev-key-signed forgefirm-dev.fw from the release rootfs for the GUI
upload path. Signing keys are always passed explicitly - no defaults.
kas/README release order and the plan doc updated to match.
2026-08-08 13:28:17 -04:00
ScottW514 4f13d8a43f docs: panel rework live-verified; fuse identity confirmed on hardware 2026-08-08 13:27:59 -04:00
ScottW514 21251bc89f Bump forgectrl (units, fuse identity, always-on position, viewer) 2026-08-08 13:26:57 -04:00
ScottW514 d8dfdcdd18 docs: bench back on SD dev; Can't-open-blockdev root cause pinned 2026-08-08 13:14:50 -04:00
ScottW514 0f5dec57e9 probes: reuse existing slot mounts; explicit -t ext4
The image's fstab keeps the factory slots mounted under /factory, and
busybox mount's auto-type iteration against an already-mounted ext4
device provokes a cosmetic kernel 'Can't open blockdev' for each
foreign-type claim (reproduced and pinned on the bench: ext3-typed
mount of an ext4-held device prints it; ext4-typed does not). Probes
now reuse an existing mountpoint from /proc/mounts and mount fresh
targets with an explicit -t ext4.
2026-08-08 13:13:20 -04:00
ScottW514 42a01940f1 docs: Phase 2 slot install bench-proven end-to-end 2026-08-08 13:05:28 -04:00
ScottW514 9c1d0c5073 INSTALL.md: single-stage slot install flow
Documents the new installer: A/B slot install with no repartitioning,
factory archives to /data, signature-verified forgefirm.fw, ffboot
switching, offline install, and the automatic legacy-layout
migration.
2026-08-08 13:04:58 -04:00
ScottW514 cea0439e17 slotmigrate: sector counts from sysfs; log to kmsg
The image's busybox has no blockdev applet, so the grow path silently
skipped. Sector counts now come from /sys/class/block (no external
tool); log lines also go to /dev/kmsg so migration results are visible
in dmesg after boot.
2026-08-08 12:59:21 -04:00
ScottW514 63792a8ed2 installer: select the fw_env config the way ffboot does
Newer factory firmware's generic /etc/fw_env.config points at the
wrong device; its per-device /etc/fw_env_mmcblk2.config is the correct
one for the eMMC environment. The read-back verify caught the failed
write and aborted before the flip, as designed.
2026-08-08 12:55:13 -04:00
ScottW514 39a973df02 installer: live progress for the archive steps
dd|gzip runs backgrounded while the installer prints compressed MB
every few seconds (old busybox dd has no status=progress); dd's exit
status is captured through a file so a device read failure is not
masked by gzip succeeding on truncated input.
2026-08-08 12:53:33 -04:00
ScottW514 b9a63e677f docs: fuse-identity viewer in the offline-verified panel record 2026-08-08 12:49:39 -04:00
ScottW514 6c9cce3184 gfhome: derive the service hostname from the effective serial
The gf_hostname override is gone (the forgectrl UI no longer offers
it): the hostname is a pure derivation of the serial - base 23 over
the factory consonant alphabet - so a gf_serial override re-derives
MACHINE.HOSTNAME and the fuse derivation stands otherwise. BRINGUP
records the panel rework (units, fuse identity, always-on position)
as offline-verified; board deploy and pin bumps held during the
firmware-upgrade bench testing.
2026-08-08 12:44:56 -04:00
ScottW514 882a1b1b2d installer: probe slots under /tmp; tolerate offline ffboot refresh
Newer factory firmware (2024) has no /factory/imgN mounts and a
read-only rootfs, so slot probing and post-write verification mount
under /tmp, with the active slot read from the running root. The
target-slot unmount sweeps /proc/mounts (older firmware DOES mount the
slots). A failed ffboot download keeps an existing /data/ffboot
instead of aborting, so a local-.fw install works fully offline.
2026-08-08 12:43:17 -04:00
ScottW514 1097184e13 installer v2: single-stage slot install; slotmigrate legacy reclaim
install-forgefirm.sh is now single-stage and never repartitions: run
from factory firmware, it archives every factory slot version plus the
recovery boot partitions to /data/forgefirm/archive (manifest with
md5s), verifies the signed forgefirm.fw against the embedded ForgeFIRM
pubkey (raw 32-byte form for the factory's fwup 0.14.2; dev key until
the production key ceremony), applies it to the INACTIVE slot with the
factory's own fwup, post-verifies the written rootfs, installs
/data/ffboot, and flips the saved env with read-back verification. The
booted factory slot stays installed and bootable; /data is untouched
beyond the archive. Fixed release asset name forgefirm.fw (version in
the fwup metadata and release tag).

slotmigrate (new recipe, rcS before mountall) reclaims the legacy
layout on eMMC-slot boots: deletes p4, grows p3 to the end of the
disk (sfdisk + partx BLKPG - works with a sibling partition as root),
then e2fsck+resize2fs. Every step is keyed off the actual disk state,
so interrupted runs resume and factory-layout disks are a no-op; SD
boots never touch the eMMC.
2026-08-08 12:26:33 -04:00
ScottW514 5035b2744e docs: TEC ships on Pro per published specs, not verified per unit
Basic/Plus share the passive closed-loop cooling and the 60-75 F
window per Glowforge's tech specs and owners-forum consensus; the
Pro's solid-state TEC buys 60-81 F. Spec-level only - tec_present
stays a user setting regardless, since tec_on has no readback.
2026-08-08 12:18:11 -04:00
ScottW514 55fdaebf69 docs: Phase 1 (ffboot v2) complete and hardware-verified 2026-08-08 12:14:37 -04:00
ScottW514 63dffdc596 docs: laser-milestone specs for low-temp gates/warm-up and TEC handling
Low side: factory floors (CM* window minimums ~1-4 C, the ~16 C
'warming up' operating floor) and the planned cool_temp_min /
cool_temp_start keys with a heater-driven warm-up phase. TEC:
presence is undetectable (tec_on is write-only, Pro-only hardware on
a common board) so tec_present becomes a user setting, with
hysteresis control toward the factory ~18 C setpoints when enabled.
2026-08-08 12:11:57 -04:00
ScottW514 3fdb0c1636 docs: bench eMMC slot contents (ffboot -l probe)
Slot 1 = factory 20240612194245 (newest, the factory-archive
candidate), slot 2 = factory 20220810204015, legacy p4 = ForgeFIRM
v0.1.0. Factory /etc/version is a numeric datetime stamp.
2026-08-08 12:10:46 -04:00
ScottW514 11fec891ed ffboot v2: slot inventory, verified atomic env flips, target probe
-l inventories every bootable partition (SD, eMMC slots 1/2, legacy
p4) as machine-parsable key=value lines: firmware type
(forgefirm/factory), version, kernel presence, booted/next markers,
plus the saved-env selection - the shared probe for the installer and
the forgectrl update manager.

Boot switching now writes all four selection variables (mmcdev,
mmchwpart, mmcpart, mmcroot) in one fw_setenv -s transaction and
read-back verifies, falling back from the libubootenv script format
to the classic u-boot-tools format to per-variable writes - the same
script works on factory firmware and ForgeFIRM. mmchwpart was
previously never set and a mixed env could survive a mid-flip power
cut. Switch targets must pass a content probe (rootfs mounts, kernel
present) unless -f. -e picks the newest factory slot by probing,
excluding slots occupied by ForgeFIRM.

The new ffboot recipe installs it as /usr/sbin/ffboot with
/etc/fw_env.config (factory-identical redundant env layout at eMMC
0x80000/0x82000) - previously the image shipped fw_setenv with no
config. Added to forgefirm-image.
2026-08-08 12:08:14 -04:00
ScottW514 9d410c646a docs: Phase 0 of the update system complete and hardware-verified
Slot-agnostic boot proven on the bench: the same release ext4 boots
from SD and from eMMC p4 steered by the saved env alone. fwup
cross-version compatibility proven; slot-sized rootfs, size gate,
ext4 artifact and mkfw.sh in place. Found for Phase 1: the image
ships no /etc/fw_env.config.
2026-08-08 12:01:45 -04:00
ScottW514 b7eb5fdac5 docs: cooling GUI + diagnostics; bump forgectrl and grblhal
BRINGUP: the conf-backed cooling tunables, the Diagnostics runner
model and both cooling tools, and the 2026-08-08 bench record
(conf re-read drill, takeover semantics, flow-verify PASS 2:42,
flow-calibrate 8:45 recommending 14.8 vs the hand-derived 14.4).
Pins: forgectrl -> 86ff78b (Machine-tab cooling card + Diagnostics
tab + diag runner), grblhal -> ae85682 (cool_* conf keys re-read
per flood start).
2026-08-08 11:48:15 -04:00
ScottW514 2aac59e0e4 image: slot-sized release rootfs, ext4 artifact, fwup, mkfw.sh
The release image now targets the 200 MiB factory eMMC slot: content
plus 40 MiB working space, hard build failure past the slot size. The
raw ext4 is deployed alongside the wic; scripts/mkfw.sh packs it into
a signed .fw with factory-pattern upgrade.a/upgrade.b tasks. fwup
1.16.0 recipe (applies ForgeFIRM and Glowforge-signed archives on
device) is installed in both images. Dev images stay SD-sized with a
256 MiB working margin and no ceiling.

Verified on the 20260808153331 build: release ext4 180.8 MiB; signed
.fw applies byte-exact with fwup 1.16.0 and with the factory's 0.14.2
(raw-format pubkey), and 0.14.2 -V verifies the signature.
2026-08-08 11:37:19 -04:00
ScottW514 888e51d84e docs: eMMC boot/recovery architecture + install/update system plan
BRINGUP: the measured eMMC map (factory MBR, U-Boot in boot0 at 1KiB,
saved env at user-area 0x80000/0x82000, default-env recovery boot, the
boot0/boot1 recovery images, factory .fw/fwup internals) and the traced
SD kernel-load path.

UPDATE-SYSTEM: phased plan for the factory A/B slot scheme end-to-end -
fwup-packaged signed releases, single-stage installer, GUI update
manager, offline factory restore, legacy-p4 migration, recovery
refresh; invariants and decision gates.
2026-08-08 11:34:08 -04:00
ScottW514 75e0eb24da Bump grblhal (flow suspicion state machine) 2026-08-08 10:59:47 -04:00
ScottW514 af3a693c0b bench+docs: flow suspicion drills, coolant-flow triage record
flow_confirm_drill.py walks the driver's suspicion/confirmation state
machine through every verdict with real pump-off transients in one M8
session; flow_escalate_drill.py exercises the starved-re-check
escalation against a short GFCOOL_CONFIRM_MAX_S. BRINGUP records the
triage resolution (the 2026-08-03 faults were a real transient
stagnation, probable pump airlock - the check was right), the slug/
circulation measurements, and the new check semantics.
2026-08-08 10:59:47 -04:00
ScottW514 548d25a4ff Bump forgectrl (firmware version in the panel) 2026-08-07 21:32:59 -04:00
ScottW514 8baed9aa93 images: version stamp in /etc, shown at console and SSH login
Every image writes /etc/forgefirm-version and echoes "ForgeFIRM
<version>" on the serial-console login prompt (/etc/issue, beneath the
OpenGlow banner) and at SSH login (motd). Release images carry
v${FORGEFIRM_RELEASE}; the dev image stamps the build timestamp - the
same DATETIME as the artifact name - tagged (dev) so a bench machine
is never mistaken for a release.
2026-08-07 21:19:50 -04:00
ScottW514 3ba1d9c769 docs: SD images 20260808011035 built 2026-08-07 21:12:39 -04:00
ScottW514 4da39f6131 grblhal init: boot-time controller-mode dispatch
The init script consults controller_mode in /data/forgefirm.conf:
'cloud' (once that mode exists, with its own service reading the same
key) keeps grblHAL down; grbl, unset, or a missing config starts it.
Board-verified both ways: cloud in the conf leaves the controller
stopped with a clear message, grbl starts and serves normally.
2026-08-07 20:59:43 -04:00
ScottW514 2f1cb88452 docs: mode selector + idle settings lock in the panel runbook 2026-08-07 20:56:49 -04:00
ScottW514 607ceb32e7 Bump forgectrl (controller-mode selector, idle settings lock) 2026-08-07 20:56:16 -04:00
ScottW514 85914c16d0 Bump forgectrl (remote-interlock semantics); bench model + interlock facts in the runbook 2026-08-07 20:48:26 -04:00
ScottW514 4184e84558 Bump forgectrl and grblhal-glowforge to the OpenGlow-identity release
forgectrl ad0b441: OpenGlow branding, operational /status dashboard.
grblHAL-glowforge c472a13: position anchor at homing for the status
readers. Runbook updated.
2026-08-07 20:27:46 -04:00
ScottW514 bcf476be74 Bump forgectrl and grblhal-glowforge to the control-panel release 2026-08-07 20:06:17 -04:00
ScottW514 8cd98de605 gfhome: identity overrides from the shared config; budget from env
Non-empty gf_serial / gf_password / gf_hostname in /data/forgefirm.conf
(set from the forgectrl GF Cloud tab) are applied with set_cfg before
Machine() is built, so they beat the OCOTP fuse identity - Machine sets
its fuse values with keep_value. The --timeout default comes from
GFHOME_TIMEOUT_S when the controller provides it, so one GUI setting
governs the whole session. Docs: control-panel runbook notes.
2026-08-07 20:05:49 -04:00
ScottW514 8ba0ce84f3 docs: repos pushed and recipe pins bumped for the homing release 2026-08-07 19:29:54 -04:00
ScottW514 c1d781a137 Bump forgectrl and grblhal-glowforge to the homing release
forgectrl 0b05e48: /settings homing-mode store + UI, snapshot lamp
override. grblHAL-glowforge 948f1c7: runtime-selectable $H with the
Glowforge web-service homing session (stream suspend/resume, gfhome
runner orchestration).
2026-08-07 19:27:52 -04:00
ScottW514 bb3dedcd68 docs: live gfcloud homing verified; estop-during-motion hardware fact 2026-08-07 19:14:25 -04:00
ScottW514 cf24bf3200 gfhome: capture through forgectrl; factory-board machine config
The runner's Machine subclass fetches lid/head images from the
forgectrl snapshot endpoint - forgectrl owns the imx-media pipeline
whenever a stream client (LightBurn) is connected, so direct V4L2
grabs fail busy. Head captures request lamp=0 (the cloud's focus
analysis needs torch-off images); the HCil measure-laser LED is still
driven directly. Direct capture remains the fallback when the daemon
is unreachable.

The sample config gains the factory-board settings: the estop motion
gate stays off (the board's estop sense reads low during any motion),
and the forgectrl URL is configurable.
2026-08-07 19:13:28 -04:00
ScottW514 2108b9706b docs: homing runbook - gfcloud mode implemented, live run pending 2026-08-07 18:33:53 -04:00
ScottW514 f2a5d0f6b6 gfhome: one-shot Glowforge web-service homing runner
New recipe installing /usr/sbin/gfhome.py and /etc/gfhome.conf.sample
(copied to /data/etc/gfhome.conf on first run). The runner signs the
machine in with its fused identity, opens the WSS control channel with
a held client reference (a clean disconnect is impossible through
ws_connect), and drives the GFUIService dispatch table itself - minus
print - so the service's camera homing sequence (settings -> hunt ->
lid image -> corner move -> lid image) runs against the real hardware
Machine. The service ends the sequence silently, so completion is a
hunt plus at least one motion followed by a configurable quiet window;
the lens is then re-referenced against the hall sensor for a
deterministic Z. Lid/e-stop are checked before the session, print
actions are refused, and exit codes distinguish configuration and
connection failures from an incomplete homing.

Installed in both images; invoked by the grblHAL controller for $H
when homing_mode = gfcloud in /data/forgefirm.conf.
2026-08-07 18:32:53 -04:00
ScottW514 fcfa4e6d65 BRINGUP: whole-image boot verified on flashed SD - both services healthy from image binaries 2026-08-07 17:50:59 -04:00
ScottW514 ad865bb3c3 grblhal-glowforge SRCREV 683a36a (comment-free core fix); BRINGUP cites the fork commit hash-free 2026-08-07 17:42:53 -04:00
ScottW514 4d22905c2f grblhal-glowforge SRCREV bdab31c: core tracks upstream master (PR 999 merged) + step_us_min fix 2026-08-07 17:31:15 -04:00
ScottW514 b30afd7171 grblhal-glowforge SRCREV 26298a3: fd-blocking pacing, step_us_min fix, accel homing removed 2026-08-07 17:22:36 -04:00
ScottW514 d7f7318e5d BRINGUP: accelerometer homing retired - limit-switch homing planned; $22=0, $H rejected 2026-08-07 17:21:29 -04:00
ScottW514 a12ef810f4 BRINGUP: fd-blocking protocol pacing (~2% idle); fortify step_us_min overflow fixed - pre-fix images boot with a dead controller 2026-08-07 17:10:01 -04:00
ScottW514 392bf49e5b forgectrl SRCREV 73283b6; BRINGUP: cached capture path bench-verified on flashed image 2026-08-07 16:43:47 -04:00
ScottW514 e791475703 BRINGUP: cached capture buffers + stream FPS cap; fallback and cap bench-verified 2026-08-07 16:11:21 -04:00
ScottW514 29e0c588c9 BRINGUP: homing rework state of play - soak incomplete, idle-death investigation open 2026-08-03 18:40:12 -04:00
ScottW514 51161703b4 BRINGUP: homing tuned to 23 s; pull-off arming rule and sd guard 2026-08-03 17:56:53 -04:00
ScottW514 980176ecc7 BRINGUP: homing timing from mid-bed; status-silence and TCP stale-session notes 2026-08-03 17:38:26 -04:00
ScottW514 061def2343 grblhal-glowforge: recipe with boot autostart
gitsm-pinned build of the controller with a sysvinit script (defaults 92, after forgectrl), installed in both images. Reboot-verified on the bench: controller and forgectrl come up unattended and Grbl answers on TCP:23.
2026-08-03 17:22:07 -04:00
ScottW514 7b320c34ac forgectrl: bump SRCREV (VPU errored-frame handling) 2026-08-03 17:05:09 -04:00
ScottW514 962ac5f98c BRINGUP: 16 MiB ring bench-verified on the flashed image 2026-08-03 17:02:42 -04:00
ScottW514 1a723fa303 BRINGUP: correct the hot-swap note - kernel overlay edits require an image flash 2026-08-03 16:15:12 -04:00
ScottW514 32cc3ecdee BRINGUP: 16 MiB ring facts, hot-swap vermagic workflow, cloud-mode review item 2026-08-03 16:10:15 -04:00
ScottW514 f201f95c73 BRINGUP: coda JPEG capture-buffer sizing fact (noise frames overflow the default) 2026-08-03 15:21:20 -04:00
ScottW514 b422887ca5 BRINGUP: homing done - $H bench-verified; +Y-is-front direction fact 2026-08-03 15:14:08 -04:00
ScottW514 06b9dc5f92 Homing spike: accelerometer bump-detect proven on hardware
The head lis2hh12 (i2c-3 0x1e, direct-I2C at ~530 Hz - st_accel sysfs one-shots are ~6 Hz and the kernel has no IIO triggers) sees rail contact as a 20-40x jolt over the creep baseline within ~4 ms. bump_seek.py: 3/3 detected hits, zero false positives over ~180 mm of creep, jog-cancel stop and back-off. BRINGUP carries the full record and the driver integration design.
2026-08-03 14:26:12 -04:00
ScottW514 8d798fa4b9 forgectrl: promote to its own repo, recipe fetches the pinned git source
Sources live at github.com/ScottW514/forgectrl (history extracted, layout src/ + init/); the recipe pins SRCREV and installs the init script from the repo. Adds the build-forgectrl.sh bench cross-compile script.
2026-08-03 14:06:25 -04:00
ScottW514 421909a6a8 forgectrl: NEON demosaic - 15 fps, sensor-limited
NEON kernel for the YUV420 superpixel convert (vld2q deinterleave, rounding-halving greens, mlal/rshrn luma, pairwise-add chroma block sums), bit-identical to the scalar path and proven so on a live frame via the FORGECTRL_NEON_CHECK one-shot memcmp. Convert 75 to 18 ms; the stream now runs at the OV5648 frame rate. Motion coexistence re-proven at 15 fps (clamped 0).
2026-08-03 13:46:08 -04:00
ScottW514 d75ff717ac forgectrl: VPU JPEG encode for the camera stream - 7.9 fps
Demosaic the superpixels straight to planar YUV420 and encode on the CODA960 (mainline coda V4L2 mem2mem, node found by personality); libjpeg stays as the automatic fallback and the snapshot path. All camera paths now demosaic from a cached bounce copy of the frame: the V4L2 MMAP capture buffers are uncached, and reading them in-place costs ~340 ms/frame vs 43 ms memcpy + 75 ms cached convert. Per-frame stats logged every 100 frames; /cam/status reports the encoder.
2026-08-03 13:31:48 -04:00
ScottW514 44137ae650 BRINGUP: record the VPU JPEG offload path for the camera stream 2026-08-03 13:18:19 -04:00
ScottW514 82d5911a0e BRINGUP: LightBurn consumes the camera stream directly (operator-verified) 2026-08-03 13:15:24 -04:00
ScottW514 dc34fc6602 forgectrl: streams preempt - the newest camera request wins the mux
A stream request for the other camera kicks current stream clients via a generation counter (their streams end cleanly; viewers freeze on the last frame) and switches. The index page retry consults /cam/status first so a preempted view does not steal the camera back.
2026-08-03 12:47:16 -04:00
ScottW514 247cfacb40 forgectrl: cross-camera snapshot borrow and switch grace
Snapshots of the non-streaming camera no longer fail 409: the worker borrows the mux for one frame (stream viewers see a short freeze). Stream switches wait up to 3 s for the previous pin to drain. Arbitration compares the engine home camera, not the transiently borrowed pipeline camera. Index page: single toggled stream with retry plus a head-peek button.
2026-08-03 12:39:34 -04:00
ScottW514 b3a8924c91 forgectrl: implement the camera MJPEG service
Persistent imx-media capture engine (lid/head via the shared video-mux, 409 arbitration, 10 s idle teardown), 1296x972 MJPEG stream via 2x2 Bayer superpixel, full-resolution bilinear snapshots, mjpg-streamer-compatible aliases, sysvinit script; installed in both images. Bench-verified: 3.2 fps stream, clamped 0 during simultaneous motion.
2026-08-03 12:26:23 -04:00
ScottW514 f396dbde34 Remove history narrative and audit references from docs and tooling
temp_calibrate now cross-checks the factory B-equation instead of the retired linear guess.
2026-08-03 11:54:27 -04:00
ScottW514 f92147093e Flow-detection experimental record and bench tooling
Adds the design matrix and its supporting tools, and records in BRINGUP
what the 60-run matrix overturned: sub-40-percent duty mimics flow
(three of five dead-pump trials looked healthier than a working pump),
the operating point and threshold now rest on 25 pooled observations,
periodic re-checks are thermally free with the fans running, and the
settle gate closes a bench-proven miss. Also records what is NOT yet
validated - warm-loop baselines and behaviour under laser heating - as
first-light commissioning items.
2026-08-02 23:51:31 -04:00
ScottW514 8fa10f7b28 Docs: corrected coolant curve and the rebuilt flow check
Records why the temperature formula changed (and that everything
derived from the old one had to be re-derived), the flow
characterization data at both heater duties, the false negative that
killed the dT-threshold design, and the downstream-rise check that
replaced it. Adds flow_characterize.py to the bench kit.
2026-08-02 19:12:00 -04:00
ScottW514 3d1c9edfeb bench: coolant temperature calibration helper
The UAPI raw->Celsius formula is an explicitly unverified best guess and
every absolute coolant threshold inherits it. temp_calibrate.py records
reference points (measured temperature paired with averaged raw ADC
readings) and least-squares fits the real line per sensor, printing it
against the guess with a difference table. Modes: watch / point / fit.
2026-08-02 18:12:37 -04:00
ScottW514 3ab4e39e72 BRINGUP: cooling v2 - job-scoped heater, thermal cooldown, over-temp pause drilled 2026-08-02 18:00:41 -04:00
ScottW514 ea93a194a6 BRINGUP: coolant flow verification done - heater trick characterized and live-verified 2026-08-02 17:45:34 -04:00
ScottW514 6f53e628f3 Fan/thermal control done and bench-verified; docs updated
BRINGUP records the gate as complete with tach-verified results;
LIGHTBURN.md documents the per-layer Air Assist toggle driving the
cut-profile ventilation; fan_test.py joins the bench kit.
2026-08-02 17:31:53 -04:00
ScottW514 4c6cca1511 BRINGUP: homing design decided - accel bump-detect, soft-bump fallback
No factory X/Y home switches (unpopulated IO header only). Current
sensing is a dead end (PIC attrs are setpoints; stalled steppers do
not draw more current). Primary: head lis2hh12 accelerometer contact
detection (needs IIO bring-up). Fallback: low-current soft-bump
against the hard stops. Camera homing stays a future option.
2026-08-02 17:20:57 -04:00
ScottW514 855a4f1b28 BRINGUP: fan/thermal control is an operator-mandated laser-on prerequisite 2026-08-02 17:12:53 -04:00
ScottW514 009944a29b FIRE drop gate PASSED both paths - all pre-live-fire scope gates closed
Scope on GPIO2_IO30 (the SoC FIRE drive): two 2.000 s FIRE windows,
the second terminated only by the SDMA end-of-data backstop. Measured
2.0000 s exactly with clean edges on BOTH termination paths - normal
completion and true underrun (streaming=1, kernel underrun state
reached and acked; fire_test.py gains the U mode). The backstop drops
FIRE within one tick regardless of how the stream dies.

With the waveform, stream-path, latch, topology, and interlock-
semantics results from earlier today, every standing hardware
verification gate for live fire is now passed; what remains is the
laser-milestone software and a chain-armed first-light procedure.
2026-08-02 17:07:05 -04:00
ScottW514 2907072f0b Laser latch + safety-chain gating scope-verified; interlock semantics pinned
fire_test.py phases A/B with the scope on the PSU LASER_ON pin, power
byte 0 throughout, HV unpowered, operator-executed unlock: latch LOCKED
severs FIRE entirely (pin flat + kernel laser_enable 0 under 40k
streamed fire bits); latch unlocked with the chain unarmed shows FIRE
live kernel-side while the PSU pin stays flat - the factory board
gates LASER_ON behind OK_2_FIRE exactly like the OpenGlow AND design.
interlock_circuit semantics pinned by the 13-to-7 transition: b0
LASER_ON monitor active low, b1 FIRE active high, b3 latch 1=locked.
The 1-tick FIRE drop measurement is deferred (SoC net inaccessible;
PSU pin needs an armed chain) - documented with both closure paths.
2026-08-02 16:49:02 -04:00
ScottW514 e8b52b4469 Stream-path power bytes scope-verified; contract rules hardware-proven
Power-bytes-only program played by the pulse engine with the scope on
LASER_PWM: full duty staircase observed, run-start 100 percent duty
reset confirmed on the pin, consecutive-power-byte drop confirmed
(second byte of a back-to-back pair discarded), and duty persists
after end-of-data - the laser-off guarantee rests entirely on FIRE.
Zero motion (motor_lock=15 + no step bytes; position counters pinned)
and the laser chain silent throughout. Adds pwm_stream_test.py to the
bench kit and records the FIRE/OK_2_FIRE/LASER_ON/HV_EN naming from
the OpenGlow LASER SAFING sheet.
2026-08-02 16:25:49 -04:00
ScottW514 fb956971b7 LASER_PWM waveform gate PASSED - scope-verified on the physical pin
Direct PWMSAR duty steps with the machine in the locked state
(controller stopped, cnc disabled so steppers are unpowered, laser
latch locked, lid closed; laser_on_sampled stayed 0 throughout).
Measured on the pin: 25.0 us period / 40 kHz stable across the full
duty range; 50/25/75 percent confirmed visually; low end
cursor-measured 6.4 vs 6.3 percent commanded (PWMSAR=8), clean pulse.
Matches the register-level audit numbers (divider 13 x 127 counts).

Adds pwm_sweep.py / pwm_hold.py to the bench kit and records the two
remaining pre-live-fire gates (1-tick laser drop at underrun via the
stream path with FIRE observable under a locked latch; interlock
readback semantics) in BRINGUP.
2026-08-02 16:08:34 -04:00
ScottW514 c7e419ff6b BRINGUP: first real LightBurn job, operator-verified
Full design job (rapid / M4 cut trace / return rapid) ran smoothly end
to end on grblHAL-glowforge over TCP:23, laser locked. Records the two
driver fixes the first attempts surfaced: locked laser spindle and
continuation-wakeup cursor alignment.
2026-08-02 15:32:23 -04:00
ScottW514 c08f1ad7e3 docs: LightBurn setup and operating guide (motion-only stage) 2026-08-02 14:53:22 -04:00
ScottW514 53d4e7f935 BRINGUP: milestone 2 human-verified - motion is butter smooth
Operator confirmed smooth, near-silent motion on a full observation run
(slow/fast/diagonal/zigzag jogs to 200 mm/s) under grblHAL-glowforge
with the factory-true analog config. Milestone 2 closed.
2026-08-02 14:48:05 -04:00
ScottW514 78875f8e89 Controller promoted to canonical driver repo grblHAL-glowforge
The step backend now lives in github.com/ScottW514/grblHAL-glowforge
  (grblHAL driver convention: core submodule, driver.c HAL, board header).
  Add build-glowforge.sh, update the BRINGUP runbook (no -t throttle,
  EEPROM path, kill-before-scp, producer stats line, reset/disconnect
  semantics), and teach bench_m2.py to parse the F: status field (no
  spindle registered = no FS: field). Settings-write crash fix is
  upstream as grblHAL/core PR 999.
2026-08-02 14:19:53 -04:00
ScottW514 d69bfad77a Milestone 2: factory-true motion tuning, bench-verified
Extract the factory motion profile from the _RESOURCES pulse streams
(new puls_profile.py): 700/590 mm/s2 X/Y accel on v2.6.0 firmware,
202 mm/s travel peak at the 28160 Hz travel tick, PIC currents X 135/33
and Y 22/5 run/hold, decay mode 1. Add the bench_m2.py round-trip jog +
feed-hold suite (all green 2026-08-02: sustained 200 mm/s, exact
returns, clean hold/resume, zero underruns) and update BRINGUP.md:
board moved to a fixed lease at 172.16.1.97, analog config now applied
by the sink itself, $RST=$ note for stale stored settings.
2026-08-02 12:51:38 -04:00
ScottW514 1a5fc27530 docs: bring-up runbook + bench tools from the audit corrective work
BRINGUP.md is the cold-start reference: project status (audit phases
0-5 complete, Phase 6 spike achieved - first grblHAL-commanded motion
2026-07-26), bench/board access, build+deploy procedures, the step
backend runbook incl. the required analog machine config, the measured
hardware facts bank, and the ordered next-work list. scripts/bench/
preserves the hardware-verification tools (underrun feeder, end-of-
data protocol bench, PWM register check, cross-build scripts).
2026-07-26 18:55:53 -04:00
ScottW514 14470e9122 ulfius: keep -Wconversion non-fatal under the arm32 time64 ABI
ulfius builds itself with -Werror -Wconversion; Yocto Scarthgap sets
-D_TIME_BITS=64 on arm32, making a long-long-to-long time conversion
in u_websocket.c fatal. Exclude conversion warnings from -Werror.
2026-07-26 16:18:40 -04:00
ScottW514 83627a6aea scripts/docs: fail loudly, fix names, document bring-up-only status
install-forgefirm.sh completed silently broken when the download, flash
write, mount or uEnv rewrite failed - add die() checks around every
critical step (audit N13/M12) and download the release asset under the
exact Scarthgap artifact name (forgefirm-image-glowforge.rootfs.wic.gz)
so uploads need no renaming. ffboot no longer depends on the never-
provisioned /etc/fw_env_mmcblk2.config: it falls back to
/etc/fw_env.config (which both the factory and ForgeFIRM images ship,
pointing at the eMMC env) and checks fw_setenv results (audit N16).
BUILD.md gets the real artifact name and marks the built u-boot
reference-only; INSTALL.md drops the stale script/ogboot names and the
cloud-connect promise, stating the image is bring-up-only (audit N11).
2026-07-26 16:15:25 -04:00
ScottW514 e92cce2b7e forgefirm-image-dev: build on forgefirm-image
The dev image required the shared glowforge-image base directly, so it
was not a superset of forgefirm-image (it lacked v4l-utils, audit N12).
Require forgefirm-image and add forgectrl + debug tooling on top.
2026-07-26 16:15:25 -04:00
ScottW514 4a52a4dd05 ulfius: pin the babelouest chain; add orcania and yder; fix directory typo
The ulfius recipe tracked AUTOREV master and could never build: master
requires orcania/yder versions that have no release tags, no recipe for
either existed in any layer, and curl (default-ON WITH_CURL) was
missing from DEPENDS (audit M10). Pin the coherent release set - ulfius
v2.7.15 + orcania v2.3.3 + yder v1.4.20 - add the two new recipes,
add curl, convert to SPDX LGPL-2.1-or-later, and rename the misspelled
recipes-extended/uflius directory.
2026-07-26 16:15:24 -04:00
ScottW514 e0da938e4b kas/README: record the real-time strategy decision
CONFIG_PREEMPT=y (factory behavior); PREEMPT_RT unavailable on arm32
6.12 and unnecessary for the pulse feeder - the 128 MiB SDMA ring at
1 byte per EPIT tick holds minutes of stream, so deep buffering plus
SCHED_FIFO rides out any PREEMPT-scale scheduling latency. Documents
the audit M9/N15 arithmetic next to the migration backlog.
2026-07-26 15:54:16 -04:00
ScottW514 0596a08f01 kas: record the push/release order and lock upstream layers
kernel-module-glowforge and python3-gfhardware are now pushed and pinned by
SRCREV in meta-openglow (no AUTOREV anywhere), and the meta-openglow
Scarthgap port is published on its scarthgap branch. Record the full
push/release sequencing in kas/README.md (incl. the release-time flip to the
pinned-remote meta-openglow block and the release-asset naming rule), and
commit the kas lockfile pinning poky/meta-openembedded/meta-freescale(-distro)
at the revisions the working build used.

The untracked kas/externalsrc-dev.yml overlay (local python3-gfhardware via
/mnt/c) is removed: the pinned SRCREV replaces it.
2026-07-26 13:30:47 -04:00
ScottW514 527660c2f5 forgefirm-image: add v4l-utils for camera bring-up
v4l2-ctl (alongside the already-present media-ctl) configures the
imx-media pipeline and grabs raw frames from /dev/video4 while the
gfhardware capture path is ported off the factory NXP V4L2 model.
2026-06-19 16:39:30 -04:00
ScottW514 43707b4aca Bump copyright year to 2026
OpenGlow/ForgeFIRM copyright notices (COPYING.MIT, scripts) -> 2026.
2026-06-19 13:41:23 -04:00
ScottW514 00ef5a675b forgefirm-image: drop the gfui-client cloud client
gfui-client connects to Glowforge's servers; ForgeFIRM replaces it with a
local grblHAL controller (forgectrl). Remove it from the image via an
override, leaving the shared glowforge-image base untouched.
2026-06-19 10:33:58 -04:00
ScottW514 d42a1588c7 Port meta-forgefirm to Scarthgap
- LAYERSERIES_COMPAT -> scarthgap
- Yocto 3.4+ override syntax (:remove/:append/:${PN})
- ulfius: explicit protocol=https;branch=master on the git SRC_URI
2026-06-19 10:33:52 -04:00
ScottW514 90384350b3 Migrate the build from the Google repo manifest to kas
Replace the repo + default.xml + setup-environment workflow with kas:

- add kas/forgefirm-glowforge.yml (Scarthgap layers pinned, meta-openglow
  as a local sibling) and kas/README.md
- remove default.xml, base/conf/forgefirm-bblayers.conf,
  scripts/setup-environment
- .gitignore the kas-managed layers/ and build caches
- BUILD.md: kas host setup and build instructions
2026-06-19 10:33:45 -04:00
ScottW514 624f5ea11b Update INSTALL.md 2022-10-06 10:11:09 -04:00
ScottW514 cd255b3f2e Update README.md 2022-10-06 10:07:41 -04:00
ScottW514 be36eefb4a Create SERIAL.md 2022-10-06 10:05:41 -04:00
ScottW514 7edeffdaef Add files via upload 2022-10-06 09:48:02 -04:00
ScottW514 1631b35e66 Update README.md 2021-02-25 11:55:32 -05:00
ScottW514 768c67be4c Create INSTALL.md 2021-02-25 11:53:08 -05:00
ScottW514 bf75739b63 Add files via upload 2021-02-25 11:47:18 -05:00
ScottW514 42f7c1de37 Create .gitkeep 2021-02-25 11:46:19 -05:00
ScottW514 73d8f87b70 Update README.md 2020-06-01 18:23:48 -04:00
ScottW514 73aa6e973d fixed ffboot source 2020-04-15 21:50:37 -04:00
ScottW514 2566a71e23 yocto version zeus to master 2020-04-15 21:40:24 -04:00
ScottW514 9d520e04d9 initial commit 2020-04-15 17:13:51 -04:00
ScottW514 29ce635570 Add files via upload 2020-04-15 17:09:41 -04:00
244 changed files with 132218 additions and 660 deletions
+56
View File
@@ -0,0 +1,56 @@
# forgefixture: the bench actuator's firmware, proven on the host.
#
# - the policy test: the decisions that need no hardware (channel
# names, the loop request words, the button pulse clamp, the key
# comparison), compiled with gcc and run
# - the firmware build: the whole project in the pinned ESP-IDF
# container, with a placeholder fixture.env, so a change that does
# not compile never reaches a bench
name: fixture-ci
on:
push:
paths:
- 'fixture/**'
- '.github/workflows/fixture-ci.yml'
pull_request:
paths:
- 'fixture/**'
workflow_dispatch:
jobs:
policy:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Policy host test
working-directory: fixture
run: CC=gcc sh test/run.sh
firmware:
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: espressif/idf:v5.5.5
steps:
- uses: actions/checkout@v4
- name: Placeholder fixture.env (the build needs one; the values are not used)
working-directory: fixture
run: |
printf 'WIFI_SSID=ci-network\nWIFI_PSK=ci-passphrase\nAPI_KEY=%s\nHOSTNAME=forgefixture\n' \
0123456789abcdef0123456789abcdef > fixture.env
- name: Build
working-directory: fixture
shell: bash
run: |
. /opt/esp/idf/export.sh >/dev/null
idf.py set-target esp32s3 >/dev/null
idf.py build
- name: Size
working-directory: fixture
shell: bash
run: |
. /opt/esp/idf/export.sh >/dev/null
idf.py size | tail -n 20
+90
View File
@@ -0,0 +1,90 @@
# forgetest: the release acceptance tool's host-side proof.
#
# - unit tests: campaign rules, fingerprints, artifact build/verify (the
# release gate's decision, including the negative fixtures), runner +
# HTTP API end to end with a fake catalog and a fake bench tool, the
# suites replayed on the machine's own log lines, and the check that
# every log phrase the cloud suite greps for is one the pinned cloud
# app can log (it reads the app sources from the manifest cache)
# - the source bundle of a release: which recipe of the image needs
# source, which archive answers for it, and the refusal that stops a
# release with source missing
# - shared UI files: theme.css and the vendored Bootstrap the page
# carries are byte-identical to forgectrl's at its pinned revision
# - coverage lint: every source path of every component pinned by the
# recipes must be selected by some catalog test's coverage globs (the
# tree manifest is generated from the pins with git - no Yocto build);
# enforced: an uncovered path fails the job (https://docs.forgefirm.org/developers/acceptance/).
name: forgetest-ci
on:
push:
paths:
- 'forgetest/**'
- 'scripts/acceptance-gate.py'
- 'scripts/manifest-from-tree.py'
- 'scripts/source-bundle.py'
- 'kas/source-bundle.yml'
- 'meta-forgefirm/recipes-forgefirm/**'
- '.github/workflows/forgetest-ci.yml'
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout forgefirm
uses: actions/checkout@v4
with:
path: forgefirm
- name: Checkout meta-openglow (sibling)
uses: actions/checkout@v4
with:
repository: openglow-org/meta-openglow
ref: scarthgap
path: meta-openglow
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Tree manifest from the recipe pins (also fetches the pinned app sources the needle check reads)
working-directory: forgefirm
run: python scripts/manifest-from-tree.py --out tree-manifest.json
- name: Unit tests
working-directory: forgefirm/forgetest
# -B: no bytecode caches under the package directory (the recipe's fetch
# checksum would otherwise move without a source change)
run: python -B -m unittest discover -s tests -v
- name: No undefined names (the live drills never execute on the host)
working-directory: forgefirm/forgetest
run: |
pip install pyflakes
if python -B -m pyflakes forgetest tests | grep -E "undefined name"; then exit 1; fi
- name: Shared UI files match forgectrl at its pinned revision
working-directory: forgefirm
run: python scripts/check-ui-vendor.py
- name: Coverage lint (enforced)
working-directory: forgefirm/forgetest
run: python -B -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY"
- name: Gate self-check on the tree manifest
working-directory: forgefirm
run: |
# The gate must load the catalog and refuse a missing/unauthorized
# artifact cleanly (exit 1/2, never a traceback).
set +e
echo '{}' > empty.json
python scripts/acceptance-gate.py empty.json tree-manifest.json --quiet
rc=$?
set -e
test "$rc" -eq 1
+22
View File
@@ -0,0 +1,22 @@
# kas-managed upstream layer checkouts (poky, meta-openembedded, meta-freescale...)
/layers/
# Yocto build output (incl. deploy/images) and caches
/build/
/downloads/
/sstate-cache/
# kas
*.lock.yml.bak
# release.sh staging output
/release-staging/
# Python bytecode
__pycache__/
# manifest-from-tree.py fetch cache
/.manifest-cache/
# manifest-from-tree.py default output
/tree-manifest.json
+398
View File
@@ -0,0 +1,398 @@
# AGENTS.md
Instructions for AI coding agents that work in this repository. A human
contributor is welcome to read it too: it is the same set of rules.
## ForgeFIRM repository rules
### Safety on the machine
- The laser never fires with the coolant pump deliberately off. The heater-only
flow-calibrate and flow-verify tools are the only pump-off state, and the
tube stays dark during them.
- The 40 V motor rail stays up while the machine is on. Never cycle it for a
controller handover. A rail glitch can leave the stepper drivers
unserviceable.
### The iteration loop
- Edit, cross-build in the Yocto build environment, hot-deploy to the
machine, prove. Then one commit, one push, one pin bump for the proven
change.
- A full image build happens once, at the end of a work item, never per fix.
- Kernel and BSP changes ride one image flash, batched. Validate a `.ko` or
overlay change on the image that ships it. Never hot-swap a `.ko` onto a
board that is about to be reflashed.
### Acceptance coverage
- Evaluate every change against the acceptance catalog in
`forgetest/forgetest/suite/`. Does an existing test exercise the changed
behavior? Does its `covers` map name the files you touched? If not, add or
widen one in the same change. The coverage lint
(`python3 -m forgetest.coverage --enforce`) is the floor.
- The `SRCREV` of a component and the `PV` that moves with it live in
`<recipe>-pin.inc` next to the recipe. Nothing else goes in that file. The
image manifest keeps `*-pin.inc` out of the layer content hash. Thus a pin
bump invalidates only the acceptance tests that cover that component. A
pin in a recipe body counts as a platform change and needs a full
acceptance campaign.
- Test a harness change yourself, on the host, before you hand it to the
operator for a bench run. A harness that fails on its own defect costs the
operator a full re-run cycle.
- A test declares its `mode`, and the runner switches. Cloud tests stay in
cloud mode. Keep `requires` lists minimal, and never gate one test behind a
chain of others.
- Never exercise a gate or a limit through the `GFCOOL_*` environment
overrides in a test. Use the settings API, and restore it in the teardown.
### CI couplings that set the push order
- The CI of `grblHAL-glowforge` gets the laser harnesses from `scripts/bench/`
at the head of `master`, unpinned. Push a harness change here before the
driver change that needs it.
- The acceptance page shares its theme and its vendored Bootstrap with the
`forgectrl` panel, byte for byte. Land a UI change in `forgectrl` first.
Push it and pin it. Only then push this repository.
- The CI of `kernel-module-glowforge` cross-builds against the BSP from
`meta-openglow`. Push `meta-openglow` before a module change that needs a
BSP change.
### Builds and images
- Builds run only in the dedicated Yocto build environment that the Build page
of the site defines (kas and bitbake). Never use a native host build of a
component as proof, not even for a compile check.
- Every build makes both images: `forgefirm-image` (the release image) and
`forgefirm-image-dev` (the bench image, with `forgetest` on port 8090). The
dev image needs an explicit `--target`. Report both artifact names.
- After a kernel-module pin bump, rebuild the kernel and the module in the
same run: `bitbake -c cleansstate linux-fslc kernel-module-glowforge`, then
the images. The kernel version suffix is not reproducible across
re-checkouts, and a rootfs whose kernel and module disagree fails.
- Read the layer HEAD lines at the top of a build log before you trust a build
that moves a pin.
### The record of the work
This repository carries no status document and no dated log. Both were
retired: every fact about the machine and the firmware is on the
documentation site, and **the record of a piece of work is the commit message
that carries it**: what was done, how it was proven, and what it replaced, in
the same place as the change itself.
A measurement session or a bench drill is written up in the commit that
lands its result. Nothing accumulates in a file.
### On the bench
These rules apply whenever an agent works with the operator at a machine.
- **One armed run per turn.** Start the run, let it finish, stop, report what
happened against what was expected, and wait for the operator's
confirmation before any further armed run or machine command. Never chain a
jog, a fix, or a re-run onto a burn in the same step. Dry runs (latch
locked, no emission) can be batched. Say which is which before you start.
- **Zero inference during live fire.** Report only what the combined
observations confirm: the operator's eyes, the drill output, the logs, the
sysfs readbacks. Everything else is labeled "unconfirmed" and is not acted
on.
- **One Grbl connection.** Never open a second connection to TCP port 23 while
a drill, an acceptance test, or a sender holds one. The last connection
wins, the displaced client's cleanup never arrives, and its queued commands
wait behind the arm gate. Read state through `forgectrl` (HTTP) and sysfs
only. After a displaced or aborted drill: soft reset, confirm that the RX
buffer reads full, `M5`, confirm that `armed` is false, before anyone
touches the button.
- **The operator touches the machine.** The operator runs the install, the
service restart, and the reflash on the machine. Never modify the software
of a running machine unless the operator asks for that exact step.
- **Bench hygiene.** Stage test files in `/tmp`. A file that must survive a
reboot goes in `/data/bench-scratch/`, and that directory is deleted whole
at the end of the session. Nothing goes loose in `/data`. Remove everything
you put on the board, in the same session, and list `/data` before you end.
A bench tool worth a second use goes in `scripts/bench/`. The dev image
installs those tools under `/usr/share/forgetest/bench/`. Run them from
there.
## Project-wide rules
The sections above are specific to this repository. The rules below apply to
every repository of the OpenGlow ForgeFIRM project: the firmware components,
the build base, the documentation, and the hardware designs. Where a
repository rule and a rule below conflict, the safety, proof, and hygiene
rules below win. The documentation site, <https://docs.forgefirm.org/>, is
the source of truth for every fact about the machine and the firmware. Read
its Developers section before you change code.
### What ForgeFIRM is
ForgeFIRM is open firmware for stock Glowforge lasers (Basic, Plus, Pro). It
runs on the factory NXP i.MX6 control board with no hardware modification. It
replaces the cloud-dependent factory userspace with an open Linux image that
controls the machine locally. In GRBL mode the machine is a standard grblHAL
controller for LightBurn and other senders on TCP port 23. Cloud mode runs the
factory experience through the Glowforge web service, and it is a deliberate,
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
and cloud mode runs end to end.
One controller runs at a time under `forgectrl`, the machine-services daemon,
which owns the pulse device, the cooling engine, the cameras, and the control
panel. The kernel module plays the pulse stream into the stepper drivers and
owns the laser latch and the safety readbacks. The Technical section of the
site describes the whole stack.
### The repositories
All repositories live under the OpenGlow organization on GitHub,
<https://github.com/openglow-org>. For development, check every repository out
as a sibling in one base directory named `openglow-forgefirm`. The build
scripts expect that layout. Each repository is its own git repository. The
base directory is not.
| Repository | Role | Default branch | License |
|---|---|---|---|
| `forgefirm` | The build and release base: the `meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the install and release scripts, the acceptance tool `forgetest/`, the bench tools `scripts/bench/`, and the bench actuator firmware `fixture/`. It carries no project documentation. | `master` | MIT (layer metadata) |
| `meta-openglow` | The BSP layers `meta-glowforge-bsp` and `meta-openglow-core`: kernel, device tree, U-Boot, board recipes. | `scarthgap` | MIT (layer metadata) |
| `forgectrl` | The machine-services daemon (C) on HTTPS port 443, with the read-only routes on HTTP port 80: supervisor, pulse-device broker, motion-liveness gate, cooling engine, cameras, telemetry, settings, diagnostics, control panel, A/B updates. | `main` | MIT |
| `grblHAL-glowforge` | The grblHAL driver for the stock board, the GRBL-mode controller. The core is a submodule at `src/grbl`. Machine constants are in `src/boards/glowforge.h`. | `main` | GPL-3.0-or-later |
| `grblHAL-core` | The fork of the grblHAL core that the driver uses. Minimum change, no rationale comments. | `forgefirm` | The upstream grblHAL license |
| `kernel-module-glowforge` | `glowforge.ko`: the SDMA + EPIT pulse engine, the laser latch, the safety readbacks, the sensors. | `master` | GPL-2.0-or-later |
| `python3-gfhardware` | The `gfhardware` hardware library and the cloud-mode applications in `forgefirm-app/`. | `master` | MIT, with one LGPL-2.1-or-later component |
| `Glowforge-Utilities` | `gfutilities` on PyPI: the factory protocol and service layer that cloud mode uses. | `master` | MIT |
| `forgefirm-docs` | The documentation site. | `main` | CC BY-SA 4.0 |
| `openglow-serial-adapter` | The USB-C serial-console adapter for the control board. | `main` | CC BY-NC-SA 4.0 |
The full table, the license details, and the component diagram are on the
site: <https://docs.forgefirm.org/developers/>.
### Safety first, and in that order
The machine is a laser, and the operator is the safety authority at it. These
rules bind every change, in every repository, that can affect what the machine
does.
- The hardware chain is the safety boundary. Software only adds gates on top
of it. `LASER_ON` is never a bare GPIO. The kernel laser latch is locked by
default, and every close of the pulse device locks it again.
- Never defeat a switch, an interlock, or a readback, in hardware or in
software, not even for a test.
- Never change a fire gate, a thermal limit, or a safety default to make a
test pass.
- The order of work is absolute: emission and motion first, robustness and
hygiene after. Review a change that touches emission or motion for that
before anything else.
- A change that can put energy where it was not commanded gets a regression
test with the fix, in the same commit, never after.
- Position counters, homing anchors, and a homed flag are not proof of
physical motion. The head accelerometer is, and so are the operator's eyes.
### Proof before done
A feature or fix is not complete until it is proven. The order of preference:
1. A host test that runs in CI. The Test page of the site lists the host tests
of each repository.
2. A bench drill on the bench reference, recorded in the commit message that
carries the change.
3. Documented reasoning.
Also:
- Run the host tests of this repository before every commit. Where the build
uses `-Werror`, a warning is a failure.
- A component that ships in the image is also evaluated against the release
acceptance catalog in the `forgefirm` repository (Test page, "Coverage
currency"). A behavior change with no catalog consequence gets a sentence
of justification in the commit message.
- For a component that ships in the image, the proof build is the Yocto
cross-build that the Build page of the site defines, never a native host
build.
- Report outcomes faithfully. Quote failing output as it is. Name a skipped
step as skipped. Never narrate an expected result as an observed one.
### The workflow: prove locally, push when proven
- Nothing goes to a public repository until it is proven: in the host tests
for host-only changes, on the bench for changes that alter what the machine
does.
- Iterate locally. Then make one commit and one push for the proven change.
No chains of fix-up pushes. Every push runs CI, and CI minutes are a budget.
- Commit only finished work, and only when asked. Never commit intermediate
findings, working notes, plans, audits, or status files. If a conclusion is
still moving, it is not ready for a repository. Unpushed mistakes come out
with `git reset --mixed <base>`, which keeps the working tree.
- "Commit" and "commit and build" include the push of every touched
repository, in dependency order. Never hold a push back to protect earlier
unpushed commits on the same branch. The only exception is an explicit "do
not push" for that session.
- Never push, force-push, tag, cut a release, refresh a lockfile, bump a
submodule pointer, or bump a pin unless asked.
### Pins
A firmware component reaches the image only through its pin: an exact
`SRCREV` in `meta-forgefirm` (in the `forgefirm` repository) for ForgeFIRM
components, or in `meta-openglow` for BSP components. There is no `AUTOREV`.
A commit in a component repository changes nothing on a machine until its pin
moves. The order is always: push the source repository, bump its pin, then
run `bitbake -c fetch <recipe>` to make sure that the pin resolves. Some
pairs of repositories have a CI coupling that fixes the push order between
them. The Release flow page of the site lists them, and each affected
repository names its own in the section above.
### Git conventions
- A commit is attributed to the human who is responsible for it, in both the
author and the committer fields. Never list an AI assistant as an author or
a co-author. Never add an AI attribution trailer or a "Generated with" line
to a commit message or a pull request. In a fresh clone, set the local
`user.name` and `user.email` before the first commit.
- A commit message says what changed and why, in the present tense, in
Simplified Technical English. The "why" of a change to fork code lives here
and nowhere else.
- Keep line endings LF in every file that Linux tooling consumes: patches,
shell scripts, recipes, device trees. Check the bytes after an edit from a
Windows tool. A CRLF patch breaks `do_patch`.
- Never commit build output, `__pycache__`, virtual environments, editor
files, a local knowledge graph (`graphify-out/`), captured machine data that
carries an identity, tokens, keys, or passwords.
- Never rewrite published history unless the operator asks for it.
- A pull request to an upstream repository is never automatic. The developer
decides if and when to send one. You can remind them that upstream
candidates exist, but the decision is theirs.
### Writing rules
They apply to everything: documentation, README files, code comments,
docstrings, log and console text, commit messages, issue text, and replies.
- **American English.** analyze, behavior, color, center, gray, catalog,
license, judgment, program, artifact, percent, toward, among, while,
learned. The style lint in `forgefirm-docs` rejects the British forms.
- **ASD-STE100 Simplified Technical English.** Short sentences: 20 words in
a procedure, 25 in a description. One topic per sentence, one instruction
per sentence. Active voice, imperative in procedures, simple present in
descriptions. "Must" for necessity, "can" for possibility. No "should",
"would", "could", "may". Prefer: do, make sure, use, start, show, occur,
before, after, because, for example, that is. No "etc.", no "and/or".
Noun clusters of three words at most. Warnings and cautions come before the
step they protect. Warm and direct is good. A joke that shades a fact is
not.
- **No em dashes.** Never, anywhere. Use a colon, a period, or a comma.
- **Present tense, present state.** Text describes the code and the machine
as they are. No history narrative, no "the old version did", no "evolved
from", no story of how the code got here. Upstream copyright and
derivation attribution to public projects stays: that is license credit,
not narrative.
- **No workstation paths.** Nothing outside a repository appears as a path in
it: no drive letters, no WSL mounts, no home directories, no sibling-repo
relative paths, no private reference directories. Name the artifact
instead: "the captured factory pulse files", "that bench session".
- **No bench-machine identity.** Never a hostname, a serial number, a fuse
value, a private IP address, or a login of the bench machine, in any file,
comment, commit message, sample, or test fixture. Use neutral placeholders
of the `ABC-123` form. Record the fact of a verification, never the values.
- **No factory firmware code.** Never cite the factory firmware binary or its
code: no offsets, no addresses, no quoted or decompiled code. State the
recovered fact (a formula, a constant, a behavior) and stop. Hardware
addresses (IOMUX pad values, sensor registers, boot offsets) are fine.
- **No rationale comments in fork code.** Code destined upstream (for
example, the grblHAL core fork) carries the minimum change and no "why we
did this" comments. The reasoning goes in the commit message. Patches
carried in the Yocto layers are exempt. The `ForgeFIRM:` comment convention
there is deliberate.
- **Interface docs describe the lever, not the policy.** A sysfs attribute
doc says what the attribute is, its range, its units, its conversion. How
the factory firmware drives it does not belong there.
- **No definitive legal or regulatory statements.** The project is not a
lawyer. The only permitted form: modifying the machine, including
replacing its firmware, may have legal and regulatory ramifications, and it
is the end user's responsibility to adhere to the laws, regulations,
certifications, and insurance terms that apply where they are. Warranty
wording is "may void your warranty", never "voids". Never cite a regulation,
a standard, or a certification regime as applying or not applying. The
affiliation disclaimer lives in the site footer only. It is also the user's
responsibility to make sure that they stay within the vendor's terms of
service when they use a feature that calls that vendor's cloud services.
- **Copyright.** Copyright in this project belongs to 514 LLC d/b/a
OpenGlow. Every new source file carries two lines in its header, in the
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
`Written by Scott Wiederhold`. Keep the year, or the year range, that
the file already has. The copyright notices of other holders stay
unchanged.
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
under the license of its repository.
### Where documentation goes
- **One home.** A fact lives on the documentation site, or it does not exist.
A repository README says what the repository is, how to build and test it,
and where the documentation is. Nothing else.
- **The currency rule.** A change carries a documentation commit when it adds,
removes, or renames an interface (a sysfs attribute, an HTTP route, a
settings key, a G-code or `$` setting), or when it corrects a measured
fact. A measured fact says how it was obtained, and on what: unless the page
says otherwise, a measurement on the site was taken on the bench reference,
which the site defines.
- **A moved document is deleted.** No stub and no redirect stays at the old
path.
- **Every diagram is Mermaid.** No ASCII art and no image of a diagram.
- There is no roadmap page. Open items are tracked as GitHub issues once the
repositories accept them.
- Do not add plan files, audit files, working notes, or status files to any
repository. The project has **no status document and no dated log**: the
site describes the present, and the record of what was done, how it was
proven, and what it replaced goes in the commit message that carries the
change.
### Analysis and reporting
- Rank engineering options on technical merit only: performance, correctness,
robustness, verifiability. Process cost (an acceptance campaign, an image
flash, a new dependency) is a one-line footnote, never a ranking factor.
The operator decides the process cost.
- Never infer or assume a hardware fact. Label every unverified claim as
unverified, and say how to verify it.
- Report only what the observations confirm. Everything else is labeled
"unconfirmed" and is not acted on.
### General practice for agents
- Read the README of this repository, this file, and the Developers section
of the site before you change code.
- Orient in the code before you edit. If a local knowledge graph
(`graphify-out/`) exists in the working tree or its parent, query it first.
It is a local artifact and is never committed.
- Keep a change minimal and scoped to the request. No drive-by reformatting,
no unrelated cleanups, no renames for taste. Match the style of the
surrounding code.
- Do not add a dependency without a stated reason. The target is a
single-core machine with a small eMMC, and every package rides the image.
- Extend an existing test or tool instead of adding a parallel one.
- Never create a file in the repository that the request does not need.
- Ask before anything destructive or outward-facing: a push, a force-push, a
tag, a release, a history rewrite, or any change to a machine.
- When a fact is unknown, say so and say how to find out. Never fill a gap
with a plausible number.
- Use the exact names of the project: kas, bitbake, pin, SRCREV, campaign,
fingerprint, drill, sysfs, the latch, the armed window. Do not invent names.
### Before you commit
1. The host tests of this repository pass, with `-Werror` where the build
uses it.
2. The change is proven at the highest level available: CI test, bench drill,
or documented reasoning.
3. For a component that ships in the image: the acceptance catalog covers the
changed behavior, or the commit message says why it has no catalog
consequence.
4. New files carry the 514 LLC d/b/a OpenGlow copyright, the `Written by`
line, and an SPDX line. Line endings are LF.
5. No em dashes, no British spellings, no workstation paths, no bench
identity, no factory firmware code, no history narrative, no AI
attribution.
6. Documentation is current: interfaces and measured facts (the currency
rule).
7. The commit is one settled change, attributed to the human author, and it
is pushed only when the operator asks.
-51
View File
@@ -1,51 +0,0 @@
# Build
Instructions are for a Linux environment supported by Yocto.
To get the build OpenGlow/ForgeFIRM you need to have `repo` installed:
## Install the `repo` utility:
```console
mkdir ~/bin
curl http://commondatastorage.googleapis.com/git-repo-downloads/repo > ~/bin/repo
chmod a+x ~/bin/repo
PATH=${PATH}:~/bin
```
Add the following to the end of ```~/.bashrc``` to permanently add ```repo``` to your path:
```console
export PATH=~/bin:$PATH
```
## Download the BSP source:
```console
mkdir forgefirm
cd forgefirm
repo init -u https://github.com/ScottW514/forgefirm.git -b master -m default.xml
repo sync
```
At the end of the commands you have all meta packages you need to build the basic firmware images.
## Build the OpenGlow/ForgeFIRM image for factory control boards
First time environment setup:
```console
(in forgefirm directory)
MACHINE=glowforge DISTRO=forgefirm . setup-environment build
bitbake forgefirm-image
```
Subsequent builds:
```console
(in forgefirm directory)
. setup-environment build
bitbake forgefirm-image
```
## Bootable Image:
The bootable image can be found in ```forgefirm/build/tmp/deploy/images/```.
It can be written directly to an SDCARD:
```console
(in forgefirm/build directory)
cd tmp/deploy/images/glowforge
sudo zcat forgefirm-image-glowforge.wic.gz | dd of=/dev/sdX bs=1M
```
+2 -2
View File
@@ -1,5 +1,5 @@
Copyright 2018-2020
Scott Wiederhold <s.e.wiederhold@gmail.com>
Copyright 2018-2026 514 LLC d/b/a OpenGlow
Written by Scott Wiederhold
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+83 -12
View File
@@ -1,15 +1,86 @@
# OpenGlow/ForgeFIRM Firmware for Glowforge
This repository contains opensource firmware for Glowforge brand CNC lasers.
# OpenGlow / ForgeFIRM firmware for Glowforge
* [Latest Release](https://github.com/ScottW514/forgefirm/releases)
* [Installation Instructions](https://community.openglow.org/t/dual-boot-install-of-openglow-on-factory-flash/697)
* [Build Instructions](https://github.com/ScottW514/meta-forgefirm/blob/master/BUILD.md)
* [Community Support](https://community.openglow.org)
> ### BETA
>
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
> Expect problems, and expect frequent updates. Upgrade whenever a newer
> release is available, and report what you find on the
> [community forum](https://community.openglow.org).
# Roadmap
* Base image that supports all Glowforge hardware (complete)
* Support Glowforge web service - Python implementation (mostly complete)
* REST API w/ G-Code support (in progress)
* Browser-based GUI (pending)
Open firmware for Glowforge brand CNC lasers. ForgeFIRM replaces the
cloud-dependent factory software on the **stock control board**, with no
hardware modification, and gives the machine a local controller, a local web
control panel, and a standard Grbl interface. The factory cloud experience
stays available as an option.
This project is for experimental purposes only, and is not supported or endorsed by Glowforge.
This repository is the **base of the build and of the release**: the
`meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the
install and release scripts, the acceptance tool (`forgetest/`), the bench
tools (`scripts/bench/`), the bench actuator firmware (`fixture/`), and the
release artifacts (`releases/`).
## Start here
**<https://docs.forgefirm.org/>** is the documentation, and the source of
truth for every fact about the machine and the firmware.
| | |
|---|---|
| Read this first | [Safety](https://docs.forgefirm.org/safety/) |
| Put it on a machine | [Installation](https://docs.forgefirm.org/install/) |
| Use it | [Usage](https://docs.forgefirm.org/usage/), [LightBurn](https://docs.forgefirm.org/usage/lightburn/) |
| How the machine works | [Technical](https://docs.forgefirm.org/technical/machine/) |
| How ForgeFIRM works with it | [ForgeFIRM internals](https://docs.forgefirm.org/technical/forgefirm/) |
| Build, test, release | [Developers](https://docs.forgefirm.org/developers/) |
| Downloads | [Releases](https://github.com/openglow-org/forgefirm/releases) |
| Questions | [Community forum](https://community.openglow.org) |
## Build
```sh
kas build kas/forgefirm-glowforge.yml
```
[Build](https://docs.forgefirm.org/developers/building/) covers the host
setup, the two images, the source variant and the debug kernel.
[Release flow](https://docs.forgefirm.org/developers/release-flow/) covers the
pins, the push order and the signing pipeline.
## Test
```sh
cd forgetest && python3 -m unittest discover -s tests -v
```
The acceptance catalog that gates a release, and the bench tools, are on
[Acceptance](https://docs.forgefirm.org/developers/acceptance/) and
[The bench](https://docs.forgefirm.org/developers/bench/).
## Contributing
[AGENTS.md](AGENTS.md) carries the rules for this repository and for the
project: safety ordering, proof before done, the push order, and the writing
rules. They apply to human contributors too, and
[Contribute](https://docs.forgefirm.org/developers/contributing/) is the same
set on the site.
## What this costs
Nothing. ForgeFIRM is free in both senses, under MIT and GPL licenses. There
is no paid tier, no license key, no subscription and no Pro edition. If
someone offers to sell it to you, the licenses allow it, but what you take
home is their build rather than this one: get it from the source.
## Safety
**These machines contain a CO2 laser: it burns, blinds, and starts fires.**
Never defeat the lid switches or the interlock. Never leave a running job
unattended. Keep a fire extinguisher within reach. Read
[Safety](https://docs.forgefirm.org/safety/) before you cut your first job,
and [Regulatory and legal](https://docs.forgefirm.org/install/#regulatory-and-legal)
before you install.
**This is experimental software. Use of it could seriously maim or kill you or
others, and it may void your warranty. Use it at your own risk.**
This project is not affiliated with or endorsed by Glowforge.
-23
View File
@@ -1,23 +0,0 @@
POKY_BBLAYERS_CONF_VERSION = "2"
BBPATH = "${TOPDIR}"
BSPDIR := "${@os.path.abspath(os.path.dirname(d.getVar('FILE', True)) + '/../..')}"
BBFILES ?= ""
BBLAYERS = " \
${BSPDIR}/sources/poky/meta \
${BSPDIR}/sources/poky/meta-poky \
\
${BSPDIR}/sources/meta-openembedded/meta-oe \
\
${BSPDIR}/sources/meta-freescale \
${BSPDIR}/sources/meta-freescale-distro \
\
${BSPDIR}/sources/meta-openembedded/meta-python \
${BSPDIR}/sources/meta-openembedded/meta-networking \
\
${BSPDIR}/sources/meta-openglow/meta-openglow-core \
${BSPDIR}/sources/meta-openglow/meta-glowforge-bsp \
\
${BSPDIR}/sources/forgefirm/meta-forgefirm \
"
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Repo Manifest -->
<manifest>
<default sync-j="4" revision="master"/>
<remote fetch="https://git.yoctoproject.org/git" name="yocto"/>
<remote fetch="https://github.com/Freescale" name="freescale"/>
<remote fetch="https://github.com/openembedded" name="oe"/>
<remote fetch="https://github.com/ScottW514" name="scottw514"/>
<project remote="yocto" revision="master" name="poky" path="sources/poky"/>
<project remote="yocto" revision="master" name="meta-freescale" path="sources/meta-freescale"/>
<project remote="freescale" revision="master" name="meta-freescale-distro" path="sources/meta-freescale-distro"/>
<project remote="oe" revision="master" name="meta-openembedded" path="sources/meta-openembedded"/>
<project remote="scottw514" revision="master" name="meta-openglow" path="sources/meta-openglow"/>
<project remote="scottw514" revision="master" name="forgefirm" path="sources/forgefirm">
<copyfile dest="base/conf/bblayers.conf" src="base/conf/forgefirm-bblayers.conf"/>
<copyfile dest="setup-environment" src="scripts/setup-environment"/>
</project>
</manifest>
+14
View File
@@ -0,0 +1,14 @@
# The bench's own wifi details and API key: never committed.
fixture.env
# ESP-IDF build products and the per-checkout config (regenerated from
# sdkconfig.defaults).
build/
sdkconfig
sdkconfig.old
managed_components/
# Host test binaries.
test/policy_test
test/*.o
+8
View File
@@ -0,0 +1,8 @@
# forgefixture: the ForgeFIRM bench actuator (ESP32-S3, ESP-IDF).
# The wifi details and the API key come from fixture.env next to this
# file (see main/CMakeLists.txt and README.md); nothing else is needed.
cmake_minimum_required(VERSION 3.16)
set(SDKCONFIG_DEFAULTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.defaults")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(forgefixture)
+122
View File
@@ -0,0 +1,122 @@
# forgefixture: the bench actuator
The acceptance campaign asks a person for about eighty small things,
and most of them are the same three: open the lid, pull the interlock,
press the button. This is the box that does those on request, so the
tool can run an operator test with nobody in the room. An ESP32-S3
DevKitC-1 on the bench network drives three relays wired into the
machine's own connectors; the acceptance tool (`forgetest`, on the
machine) asks it over HTTP and then watches the machine for the result,
the way it watches an operator's hand today.
Nothing here touches the laser's safety chain. Two of the contacts are
normally closed and sit in series with loops the chain already reads;
the third is normally open across the button input and is only ever
pulsed. With the fixture unpowered, unplugged, rebooting or crashed, the
machine is a stock machine.
## Hardware
| Part | What |
|---|---|
| ESP32-S3-DevKitC-1 | any flash size; powered from its USB port by a **USB wall adapter** (see grounds) |
| 3 × 1-channel 3.3 V optocoupler relay modules, high-level trigger | VCC, GND, IN; coil ~100 mA each |
| a 2-pin header and jumper | the button channel's enable |
| the interposer harness at the machine | bench-local; described in the project's hardware facts bank, not here |
Pins on the DevKit (all plain GPIOs, no strapping, USB, flash or PSRAM
role):
| Signal | GPIO | Relay contact | Where in the machine |
|---|---|---|---|
| lid | 4 | **NC**, in series with the lid-switch loop | energized = the loop opens = lid open |
| interlock | 5 | **NC**, in series with the interlock loop | energized = the loop opens = interlock pulled |
| button | 6 | **NO**, across the button input | energized = pressed; pulsed only, 20 to 500 ms |
| button enable | 7 | input, internal pull-up | a jumper to GND enables the button channel; no jumper, no presses |
Relay coils from the machine's 3.3 V (the modules' VCC and GND), the
three IN pins from the GPIOs above, the DevKit's GND to the modules'
GND. The modules' opto inputs share GND with their coils, so the ESP32
and the machine share a ground: power the DevKit from a wall adapter,
not from a PC, unless you want that PC's ground on the machine.
## Build and flash
The only input is `fixture.env`: the wifi network, the API key, the
hostname. Everything else is pinned (ESP-IDF v5.5, the mDNS component in
`dependencies.lock`).
./fixture.sh env # fixture.env from the example, with a fresh key; fill in the wifi
./fixture.sh build # idf.py if installed, else the espressif/idf container (docker or podman)
./fixture.sh flash COM5 # or /dev/ttyUSB0; esptool from pip talks to the board directly
./fixture.sh monitor COM5 # the log
Either USB port of the DevKit flashes; the one marked UART shows the log
too. `fixture.env` is git-ignored and never leaves the bench. A build in
the container works anywhere docker or podman runs (Git Bash on Windows
included); `pip install esptool pyserial` is the whole host-side
requirement for flashing and watching.
## What it does on the network
It joins the wifi as `forgefixture` (the `HOSTNAME` in `fixture.env`),
sends that name in its DHCP request, and announces `forgefixture.local`
over mDNS with a `_forgefixture._tcp` service. It reconnects forever and
never sleeps the radio. The API is HTTP on port 80, JSON, LAN only, no
OTA (the flash happens over USB and nowhere else).
Every request carries the key in `X-Fixture-Key`; without it, or with
a wrong one, every path answers 401 and the attempt is logged with the
caller's address.
| Request | Does |
|---|---|
| `GET /` or `/state` | identity, firmware version, uptime, the three channels' states, whether the button is enabled, the wifi link |
| `POST /lid {"state":"open"}` | energizes the lid channel (the loop opens); `"close"` releases it |
| `POST /interlock {"state":"open"}` | the same for the interlock loop |
| `POST /button {"ms":200}` | one press, `ms` clamped into 20 to 500 (200 when absent); 409 while the enable jumper is out or a press is still in progress |
| `POST /release` | every channel released |
An action answers with the state as `GET /` shows it. The fixture does
not read the machine's switches; the tool verifies every action through
the machine's own readings, which is the point.
curl -s -H "X-Fixture-Key: $KEY" http://forgefixture.local/
curl -s -H "X-Fixture-Key: $KEY" -d '{"state":"open"}' http://forgefixture.local/lid
## The tool's side
`forgetest` looks for `/data/forgetest/fixture.json` on the machine:
{"hostname": "forgefixture", "key": "<the API_KEY>", "ip": null,
"channels": ["lid", "interlock", "button"], "arm_press": false}
`ip` overrides the mDNS lookup (the tool resolves `<hostname>.local`
itself; the image has no mDNS resolver). `channels` names what is
wired. `arm_press` stays false unless the fixture may press the button
to arm the laser for a live test; by default that press is a person's.
The file is mode 0600 and bench-local. With the fixture up, an
`operator` test whose actions it covers runs in the unattended queue;
`live` tests keep their kind and their acknowledgment, and every action
records who performed it. Presses are spaced by the tool: the next one
waits for the last pulse to end plus a 300 ms release, so the machine
sees two presses and not one long one.
## What keeps it safe
- Every line is driven low first thing at boot, before the radio or the
server exist, and after any reset.
- The button is a pulse whose end is armed before the line rises; a hung
task trips the task watchdog, which panics and reboots to all-low.
- The button channel needs the jumper; a LAN key leak cannot press the
button on a fixture whose jumper is out.
- NC contacts in the loops: a fixture fault can only add an open, never
mask a real lid open or a pulled interlock.
- No OTA, no configuration over the network, nothing persisted beyond
the wifi driver's own calibration.
## Layout
main/ the firmware: main.c, wifi.c, api.c, relays.c, policy.c (the pure decisions)
test/ the host test of policy.c (./fixture.sh test)
fixture.env.example, sdkconfig.defaults, dependencies.lock
+21
View File
@@ -0,0 +1,21 @@
dependencies:
espressif/mdns:
component_hash: e81ca7a7f53ea34e78274df054da692c272e1315572876b237b1748e267c013b
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.11.3
idf:
source:
type: idf
version: 5.5.5
direct_dependencies:
- espressif/mdns
- idf
manifest_hash: 90c16fd2316c504ccf02e5191bcb9886c6850e5b89a8d08c35e1539ead49703d
target: esp32s3
version: 2.0.0
+16
View File
@@ -0,0 +1,16 @@
# forgefixture build settings. Copy to fixture.env (git-ignored) and fill
# in; `./fixture.sh env` does the copy and generates the API key.
# KEY=value, one per line; quotes around a value are optional.
# The wifi network the fixture joins (2.4 GHz; the ESP32-S3 has no 5 GHz).
WIFI_SSID=
WIFI_PSK=
# The key every API request must carry in X-Fixture-Key. The same value
# goes into the bench's /data/forgetest/fixture.json. Any string; 32 hex
# characters from `openssl rand -hex 16` is what `./fixture.sh env` writes.
API_KEY=
# The fixture's hostname: announced over mDNS (<HOSTNAME>.local) and in
# its DHCP request. Leave empty for forgefixture.
HOSTNAME=
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# forgefixture: build, flash and watch the bench actuator's firmware.
#
# ./fixture.sh env fixture.env from the example, with a fresh API key
# ./fixture.sh build build (native idf.py if installed, else the IDF container)
# ./fixture.sh flash [PORT] write the build to the board over USB (esptool)
# ./fixture.sh monitor [PORT] the board's log (idf.py monitor, or pyserial's miniterm)
# ./fixture.sh test the host test of the policy (needs a C compiler)
#
# The container build needs docker or podman and pulls espressif/idf once
# (IDF_IMAGE below); flashing never runs in the container: esptool is a
# pip package (pip install esptool) and talks to the port directly.
set -euo pipefail
cd "$(dirname "$0")"
IDF_IMAGE="${IDF_IMAGE:-docker.io/espressif/idf:v5.5.5}"
CHIP=esp32s3
BAUD="${BAUD:-460800}"
have() { command -v "$1" >/dev/null 2>&1; }
runner() {
if have docker; then echo docker; elif have podman; then echo podman; else
echo "neither idf.py nor docker/podman found: install ESP-IDF, or a container runtime for $IDF_IMAGE" >&2
exit 1
fi
}
# A Windows path for the bind mount when run from Git Bash, which would
# otherwise rewrite /project into a Windows path.
host_path() {
if have cygpath; then cygpath -w "$PWD"; else echo "$PWD"; fi
}
cmd_env() {
if [ -e fixture.env ]; then
echo "fixture.env exists; edit it, or remove it first" >&2
exit 1
fi
key=$( (have openssl && openssl rand -hex 16) || python3 -c 'import secrets; print(secrets.token_hex(16))')
sed -e "s/^API_KEY=.*/API_KEY=$key/" fixture.env.example > fixture.env
chmod 600 fixture.env 2>/dev/null || true
echo "fixture.env written with a fresh API key; fill in WIFI_SSID and WIFI_PSK"
}
cmd_build() {
[ -e fixture.env ] || { echo "no fixture.env: run ./fixture.sh env first" >&2; exit 1; }
# The file holds the WiFi PSK and the API key: nobody but the owner
# reads it (an MSYS shell cannot express the mode, so it is not judged).
if [ "$(uname -o 2>/dev/null)" != "Msys" ] && [ -n "$(find fixture.env -perm /077 2>/dev/null)" ]; then
echo "fixture.env is group- or world-readable: chmod 600 fixture.env" >&2
exit 1
fi
if have idf.py; then
idf.py set-target "$CHIP" >/dev/null
idf.py build
else
r=$(runner)
MSYS_NO_PATHCONV=1 "$r" run --rm -v "$(host_path):/project" -w /project -e HOME=/tmp "$IDF_IMAGE" \
bash -c "idf.py set-target $CHIP >/dev/null && idf.py build"
fi
}
port_arg() {
if [ -n "${1:-}" ]; then echo "$1"; elif [ -n "${PORT:-}" ]; then echo "$PORT"; else
echo "which port? ./fixture.sh flash PORT (COM5, /dev/ttyUSB0, /dev/cu.usbserial-*)" >&2
exit 1
fi
}
cmd_flash() {
port=$(port_arg "${1:-}")
[ -e build/flash_args ] || { echo "no build yet: ./fixture.sh build" >&2; exit 1; }
if have idf.py; then
idf.py -p "$port" flash
else
have esptool.py || have esptool || { echo "esptool not found: pip install esptool" >&2; exit 1; }
tool=$(have esptool.py && echo esptool.py || echo esptool)
(cd build && "$tool" --chip "$CHIP" -p "$port" -b "$BAUD" --before default_reset --after hard_reset \
write_flash @flash_args)
fi
}
cmd_monitor() {
port=$(port_arg "${1:-}")
if have idf.py; then
idf.py -p "$port" monitor
else
python3 -m serial.tools.miniterm --raw "$port" 115200
fi
}
cmd_test() {
sh test/run.sh
}
case "${1:-}" in
env) cmd_env ;;
build) cmd_build ;;
flash) cmd_flash "${2:-}" ;;
monitor) cmd_monitor "${2:-}" ;;
test) cmd_test ;;
*) sed -n '2,12p' "$0"; exit 1 ;;
esac
+64
View File
@@ -0,0 +1,64 @@
# The fixture's one input is fixture.env at the project root: KEY=value
# lines, read here into a generated header. No file, or a required key
# left empty, stops the build with the reason.
set(FIXTURE_ENV "${CMAKE_CURRENT_LIST_DIR}/../fixture.env")
if(NOT EXISTS "${FIXTURE_ENV}")
message(FATAL_ERROR
"fixture.env not found at ${FIXTURE_ENV}: copy fixture.env.example to "
"fixture.env and fill in WIFI_SSID, WIFI_PSK and API_KEY (./fixture.sh env does it)")
endif()
set(FIXTURE_WIFI_SSID "")
set(FIXTURE_WIFI_PSK "")
set(FIXTURE_API_KEY "")
set(FIXTURE_HOSTNAME "")
# Read the file whole and split on newlines ourselves: file(STRINGS)
# would also split on semicolons, which a passphrase may carry.
file(READ "${FIXTURE_ENV}" _env_text)
string(REPLACE ";" "\\;" _env_text "${_env_text}")
string(REGEX REPLACE "\r?\n" ";" _env_lines "${_env_text}")
foreach(_line IN LISTS _env_lines)
if(_line MATCHES "^[ \t]*#" OR _line MATCHES "^[ \t]*$")
continue()
endif()
if(_line MATCHES "^[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=(.*)$")
set(_key "${CMAKE_MATCH_1}")
set(_val "${CMAKE_MATCH_2}")
string(STRIP "${_val}" _val)
string(REGEX REPLACE "^\"(.*)\"$" "\\1" _val "${_val}")
string(REGEX REPLACE "^'(.*)'$" "\\1" _val "${_val}")
set(FIXTURE_${_key} "${_val}")
else()
message(FATAL_ERROR "fixture.env: cannot read line: ${_line}")
endif()
endforeach()
foreach(_key WIFI_SSID API_KEY)
if("${FIXTURE_${_key}}" STREQUAL "")
message(FATAL_ERROR "fixture.env: ${_key} is empty")
endif()
endforeach()
if("${FIXTURE_HOSTNAME}" STREQUAL "")
set(FIXTURE_HOSTNAME "forgefixture")
endif()
string(LENGTH "${FIXTURE_HOSTNAME}" _hostlen)
if(NOT FIXTURE_HOSTNAME MATCHES "^[a-z0-9][a-z0-9-]*$" OR FIXTURE_HOSTNAME MATCHES "-$" OR _hostlen GREATER 32)
message(FATAL_ERROR "fixture.env: HOSTNAME must be a DNS label (lowercase letters, digits, hyphens; 32 at most)")
endif()
# Into C string literals.
foreach(_key WIFI_SSID WIFI_PSK API_KEY HOSTNAME)
string(REPLACE "\\" "\\\\" FIXTURE_${_key} "${FIXTURE_${_key}}")
string(REPLACE "\"" "\\\"" FIXTURE_${_key} "${FIXTURE_${_key}}")
endforeach()
set(_gen_dir "${CMAKE_CURRENT_BINARY_DIR}/generated")
file(MAKE_DIRECTORY "${_gen_dir}")
configure_file("${CMAKE_CURRENT_LIST_DIR}/fixture_config.h.in" "${_gen_dir}/fixture_config.h" @ONLY)
idf_component_register(
SRCS "main.c" "wifi.c" "relays.c" "api.c" "policy.c"
INCLUDE_DIRS "." "${_gen_dir}"
REQUIRES nvs_flash esp_wifi esp_netif esp_event esp_http_server json esp_timer driver
)
+229
View File
@@ -0,0 +1,229 @@
/*
* forgefixture API (see api.h).
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "api.h"
#include <string.h>
#include <sys/socket.h>
#include "cJSON.h"
#include "esp_http_server.h"
#include "esp_idf_version.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "lwip/sockets.h"
#include "policy.h"
#include "relays.h"
#include "version.h"
#include "wifi.h"
static const char *TAG = "api";
static const char *s_key;
static const char *s_hostname;
#define BODY_MAX 256
/* The peer's address for the log: every action is attributed. */
static void peer(httpd_req_t *req, char *buf, size_t n)
{
struct sockaddr_in addr; /* IPv4 only: the fixture speaks nothing else */
socklen_t len = sizeof(addr);
int fd = httpd_req_to_sockfd(req);
buf[0] = '\0';
if (fd >= 0 && getpeername(fd, (struct sockaddr *)&addr, &len) == 0 && addr.sin_family == AF_INET)
inet_ntoa_r(addr.sin_addr, buf, n);
}
static esp_err_t send_json(httpd_req_t *req, const char *status, cJSON *obj)
{
char *text = cJSON_PrintUnformatted(obj);
cJSON_Delete(obj);
if (text == NULL)
return httpd_resp_send_500(req);
httpd_resp_set_status(req, status);
httpd_resp_set_type(req, "application/json");
esp_err_t r = httpd_resp_send(req, text, HTTPD_RESP_USE_STRLEN);
cJSON_free(text);
return r;
}
static esp_err_t send_error(httpd_req_t *req, const char *status, const char *msg)
{
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "error", msg);
return send_json(req, status, o);
}
static cJSON *state_json(void)
{
relays_state_t st = relays_state();
char ip[40];
wifi_ip(ip, sizeof(ip));
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "device", "forgefixture");
cJSON_AddStringToObject(o, "hostname", s_hostname);
cJSON_AddStringToObject(o, "version", FIXTURE_VERSION);
cJSON_AddStringToObject(o, "idf", esp_get_idf_version());
cJSON_AddNumberToObject(o, "uptime_s", (double)(esp_timer_get_time() / 1000000LL));
cJSON *ch = cJSON_AddObjectToObject(o, "channels");
for (int i = 0; i < CH_COUNT; i++)
cJSON_AddStringToObject(ch, policy_channel_name((channel_t)i),
policy_state_name((channel_t)i, st.energized[i]));
cJSON_AddBoolToObject(o, "button_enabled", st.button_enabled);
cJSON_AddBoolToObject(o, "button_pulsing", st.button_pulsing);
cJSON *w = cJSON_AddObjectToObject(o, "wifi");
cJSON_AddBoolToObject(w, "connected", wifi_connected());
cJSON_AddStringToObject(w, "ip", ip);
cJSON_AddNumberToObject(w, "rssi", wifi_rssi());
return o;
}
/* The key check, before anything else on every path. */
static bool authorized(httpd_req_t *req)
{
char presented[96] = {0};
esp_err_t r = httpd_req_get_hdr_value_str(req, "X-Fixture-Key", presented, sizeof(presented));
bool ok = (r == ESP_OK) && policy_key_matches(presented, s_key);
if (!ok) {
char who[48];
peer(req, who, sizeof(who));
ESP_LOGW(TAG, "%s %s from %s: refused (%s)", http_method_str(req->method), req->uri, who,
r == ESP_OK ? "wrong key" : "no key");
send_error(req, "401 Unauthorized", "X-Fixture-Key missing or wrong");
}
return ok;
}
/* The JSON body, or NULL (an error already sent). An empty body parses
* as an empty object so "POST /button" alone means the default pulse. */
static cJSON *body_json(httpd_req_t *req)
{
char buf[BODY_MAX + 1];
if (req->content_len > BODY_MAX) {
send_error(req, "413 Payload Too Large", "body over 256 bytes");
return NULL;
}
size_t got = 0;
while (got < req->content_len) {
int n = httpd_req_recv(req, buf + got, req->content_len - got);
if (n <= 0) {
send_error(req, "400 Bad Request", "body not received");
return NULL;
}
got += (size_t)n;
}
buf[got] = '\0';
cJSON *o = got ? cJSON_Parse(buf) : cJSON_CreateObject();
if (o == NULL || !cJSON_IsObject(o)) {
cJSON_Delete(o);
send_error(req, "400 Bad Request", "body is not a JSON object");
return NULL;
}
return o;
}
static esp_err_t h_state(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
return send_json(req, "200 OK", state_json());
}
static esp_err_t h_loop(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
int ch = policy_channel_from_path(req->uri);
cJSON *body = body_json(req);
if (body == NULL)
return ESP_OK;
const cJSON *state = cJSON_GetObjectItemCaseSensitive(body, "state");
bool energize;
if (!cJSON_IsString(state) || !policy_parse_loop_state(state->valuestring, &energize)) {
cJSON_Delete(body);
return send_error(req, "400 Bad Request", "state must be \"open\" or \"close\"");
}
cJSON_Delete(body);
char who[48];
peer(req, who, sizeof(who));
ESP_LOGI(TAG, "%s %s by %s", policy_channel_name((channel_t)ch), energize ? "open" : "close", who);
relays_set_loop((channel_t)ch, energize);
return send_json(req, "200 OK", state_json());
}
static esp_err_t h_button(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
cJSON *body = body_json(req);
if (body == NULL)
return ESP_OK;
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(body, "ms");
int want = cJSON_IsNumber(ms) ? (int)ms->valuedouble : 0;
cJSON_Delete(body);
int pulse = policy_button_ms(want);
char who[48];
peer(req, who, sizeof(who));
esp_err_t r = relays_pulse_button(pulse);
if (r == ESP_ERR_NOT_ALLOWED) {
ESP_LOGW(TAG, "button press by %s refused: enable jumper out", who);
return send_error(req, "409 Conflict", "button disabled: the enable jumper is out");
}
if (r == ESP_ERR_INVALID_STATE) {
ESP_LOGW(TAG, "button press by %s refused: a pulse is in progress", who);
return send_error(req, "409 Conflict", "a button pulse is in progress");
}
ESP_LOGI(TAG, "button %d ms by %s", pulse, who);
cJSON *o = state_json();
cJSON_AddNumberToObject(o, "pulse_ms", pulse);
return send_json(req, "200 OK", o);
}
static esp_err_t h_release(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
char who[48];
peer(req, who, sizeof(who));
ESP_LOGI(TAG, "release by %s", who);
relays_release();
return send_json(req, "200 OK", state_json());
}
static esp_err_t h_404(httpd_req_t *req, httpd_err_code_t err)
{
(void)err;
return send_error(req, "404 Not Found", "no such path");
}
void api_start(const char *api_key, const char *hostname)
{
s_key = api_key;
s_hostname = hostname;
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
cfg.server_port = 80;
cfg.lru_purge_enable = true;
cfg.max_open_sockets = 4;
httpd_handle_t srv = NULL;
ESP_ERROR_CHECK(httpd_start(&srv, &cfg));
static const httpd_uri_t routes[] = {
{.uri = "/", .method = HTTP_GET, .handler = h_state},
{.uri = "/state", .method = HTTP_GET, .handler = h_state},
{.uri = "/lid", .method = HTTP_POST, .handler = h_loop},
{.uri = "/interlock", .method = HTTP_POST, .handler = h_loop},
{.uri = "/button", .method = HTTP_POST, .handler = h_button},
{.uri = "/release", .method = HTTP_POST, .handler = h_release},
};
for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++)
ESP_ERROR_CHECK(httpd_register_uri_handler(srv, &routes[i]));
httpd_register_err_handler(srv, HTTPD_404_NOT_FOUND, h_404);
ESP_LOGI(TAG, "listening on :%d", cfg.server_port);
}
+25
View File
@@ -0,0 +1,25 @@
/*
* forgefixture API: HTTP on port 80, JSON, every request under the key.
*
* GET / identity, uptime, the channels' states, the
* enable jumper, the wifi link
* GET /state the same
* POST /lid {"state": "open" | "close"}
* POST /interlock {"state": "open" | "close"}
* POST /button {"ms": 200} one pulse, clamped to 20..500 ms
* POST /release every channel released
*
* The key travels in the X-Fixture-Key header; without it, or with a
* wrong one, every path answers 401. A lid or interlock "open" energizes
* the channel (the loop opens); "close" releases it. The button is
* refused with 409 while the enable jumper is out or a pulse is still in
* progress. Errors are {"error": "..."}; actions answer with the state
* as GET / shows it.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#pragma once
void api_start(const char *api_key, const char *hostname);
+7
View File
@@ -0,0 +1,7 @@
/* Generated from fixture.env by main/CMakeLists.txt. Do not edit. */
#pragma once
#define FIXTURE_WIFI_SSID "@FIXTURE_WIFI_SSID@"
#define FIXTURE_WIFI_PSK "@FIXTURE_WIFI_PSK@"
#define FIXTURE_API_KEY "@FIXTURE_API_KEY@"
#define FIXTURE_HOSTNAME "@FIXTURE_HOSTNAME@"
+5
View File
@@ -0,0 +1,5 @@
## The managed components this firmware uses (fetched by the IDF
## component manager on the first build; dependencies.lock pins them).
dependencies:
idf: ">=5.3"
espressif/mdns: "^1.11.3"
+41
View File
@@ -0,0 +1,41 @@
/*
* forgefixture: the ForgeFIRM bench actuator. An ESP32-S3 on the bench
* network drives three relays at the machine's connectors (the lid
* loop, the interlock loop, the button) so the acceptance tool can open
* a lid, pull an interlock or press the button without a hand in the
* room. The firmware's whole job is to do exactly that on request, and
* nothing at all otherwise: every line low at boot and after any reset,
* the button only ever pulsed, every request under a key.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "esp_log.h"
#include "nvs_flash.h"
#include "api.h"
#include "fixture_config.h"
#include "relays.h"
#include "version.h"
#include "wifi.h"
static const char *TAG = "forgefixture";
void app_main(void)
{
/* The relays first: whatever else happens at boot, the lines are low
* before the radio or the server exist. */
relays_init();
esp_err_t r = nvs_flash_init();
if (r == ESP_ERR_NVS_NO_FREE_PAGES || r == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
r = nvs_flash_init();
}
ESP_ERROR_CHECK(r);
ESP_LOGI(TAG, "forgefixture %s, hostname %s", FIXTURE_VERSION, FIXTURE_HOSTNAME);
wifi_start(FIXTURE_HOSTNAME, FIXTURE_WIFI_SSID, FIXTURE_WIFI_PSK);
api_start(FIXTURE_API_KEY, FIXTURE_HOSTNAME);
}
+76
View File
@@ -0,0 +1,76 @@
/*
* forgefixture policy (see policy.h). No ESP-IDF here: test/policy_test.c
* compiles this file with a host compiler.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "policy.h"
#include <string.h>
static const char *const NAMES[CH_COUNT] = {"lid", "interlock", "button"};
const char *policy_channel_name(channel_t ch)
{
return (ch >= 0 && ch < CH_COUNT) ? NAMES[ch] : "?";
}
int policy_channel_from_path(const char *path)
{
if (path == NULL || path[0] != '/')
return -1;
for (int i = 0; i < CH_COUNT; i++)
if (strcmp(path + 1, NAMES[i]) == 0)
return i;
return -1;
}
bool policy_parse_loop_state(const char *word, bool *energize)
{
if (word == NULL)
return false;
if (strcmp(word, "open") == 0) {
*energize = true;
return true;
}
if (strcmp(word, "close") == 0 || strcmp(word, "closed") == 0) {
*energize = false;
return true;
}
return false;
}
int policy_button_ms(int requested)
{
if (requested <= 0)
return BUTTON_DEFAULT_MS;
if (requested < BUTTON_MIN_MS)
return BUTTON_MIN_MS;
if (requested > BUTTON_MAX_MS)
return BUTTON_MAX_MS;
return requested;
}
const char *policy_state_name(channel_t ch, bool energized)
{
if (ch == CH_BUTTON)
return energized ? "pressed" : "idle";
return energized ? "open" : "closed";
}
bool policy_key_matches(const char *presented, const char *expected)
{
if (presented == NULL || expected == NULL)
return false;
size_t lp = strlen(presented), le = strlen(expected);
size_t n = lp > le ? lp : le;
unsigned diff = (unsigned)(lp != le);
for (size_t i = 0; i < n; i++) {
unsigned char a = i < lp ? (unsigned char)presented[i] : 0;
unsigned char b = i < le ? (unsigned char)expected[i] : 0;
diff |= (unsigned)(a ^ b);
}
return diff == 0;
}
+53
View File
@@ -0,0 +1,53 @@
/*
* forgefixture policy: the decisions that need no hardware, kept apart
* so the host test can hold them to account. What a channel is called,
* what a request may ask of it, and how long a button press may last.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
/* The three channels, in the order of the GPIO table in relays.c. */
typedef enum {
CH_LID = 0, /* NC contact in the lid-switch loop: energized = loop open */
CH_INTERLOCK = 1, /* NC contact in the interlock loop: energized = loop open */
CH_BUTTON = 2, /* NO contact across the button input: energized = pressed */
CH_COUNT = 3
} channel_t;
/* A button press is a pulse and nothing else: never held, never longer
* than BUTTON_MAX_MS whatever the request says, never shorter than the
* debounce the machine's input needs to see it. */
#define BUTTON_DEFAULT_MS 200
#define BUTTON_MIN_MS 20
#define BUTTON_MAX_MS 500
/* The channel's name on the API and in the log. */
const char *policy_channel_name(channel_t ch);
/* The channel named by an API path ("/lid", "/interlock", "/button"), or
* -1. */
int policy_channel_from_path(const char *path);
/* The level a lid or interlock request asks for: "open" energizes the
* channel (the loop opens), "close" or "closed" releases it. Returns
* false for any other word. */
bool policy_parse_loop_state(const char *word, bool *energize);
/* The pulse a button request gets: the default for 0 or an absent
* value, otherwise the request clamped into [BUTTON_MIN_MS,
* BUTTON_MAX_MS]. A negative request is the default too. */
int policy_button_ms(int requested);
/* What the state of a channel is called on the API: a loop channel is
* "open" or "closed", the button "pressed" or "idle". */
const char *policy_state_name(channel_t ch, bool energized);
/* Constant-time equality of two keys, so a wrong key costs the same
* whichever byte is wrong. */
bool policy_key_matches(const char *presented, const char *expected);
+128
View File
@@ -0,0 +1,128 @@
/*
* forgefixture relays (see relays.h).
*
* The safety argument lives here: the lid and interlock contacts are
* normally closed and the button contact normally open, so a line that
* is low leaves the machine exactly as it is without the fixture. Every
* line is driven low first thing at boot, after any reset, and by
* relays_release(); the button is only ever pulsed, its end set by a
* one-shot timer that is armed before the line goes high.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "relays.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
static const char *TAG = "relays";
static const gpio_num_t GPIOS[CH_COUNT] = {RELAY_GPIO_LID, RELAY_GPIO_INTERLOCK, RELAY_GPIO_BUTTON};
static SemaphoreHandle_t s_lock;
static esp_timer_handle_t s_pulse_end;
static bool s_energized[CH_COUNT];
static bool s_pulsing;
static void drive(channel_t ch, bool level)
{
gpio_set_level(GPIOS[ch], level ? 1 : 0);
s_energized[ch] = level;
}
static void pulse_end(void *arg)
{
(void)arg;
xSemaphoreTake(s_lock, portMAX_DELAY);
drive(CH_BUTTON, false);
s_pulsing = false;
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "button released");
}
void relays_init(void)
{
/* Low before the pins become outputs: the DevKit's pull state at
* reset is not a relay's idea of off. */
for (int i = 0; i < CH_COUNT; i++) {
gpio_reset_pin(GPIOS[i]);
gpio_set_level(GPIOS[i], 0);
gpio_set_direction(GPIOS[i], GPIO_MODE_OUTPUT);
gpio_set_level(GPIOS[i], 0);
s_energized[i] = false;
}
gpio_reset_pin(ENABLE_GPIO_BUTTON);
gpio_set_direction(ENABLE_GPIO_BUTTON, GPIO_MODE_INPUT);
gpio_set_pull_mode(ENABLE_GPIO_BUTTON, GPIO_PULLUP_ONLY);
s_lock = xSemaphoreCreateMutex();
const esp_timer_create_args_t args = {
.callback = pulse_end,
.name = "button-pulse",
};
ESP_ERROR_CHECK(esp_timer_create(&args, &s_pulse_end));
ESP_LOGI(TAG, "lid GPIO%d, interlock GPIO%d, button GPIO%d (enable jumper GPIO%d): all released",
RELAY_GPIO_LID, RELAY_GPIO_INTERLOCK, RELAY_GPIO_BUTTON, ENABLE_GPIO_BUTTON);
}
static bool button_enabled(void)
{
return gpio_get_level(ENABLE_GPIO_BUTTON) == 0;
}
esp_err_t relays_set_loop(channel_t ch, bool energize)
{
if (ch != CH_LID && ch != CH_INTERLOCK)
return ESP_ERR_INVALID_ARG;
xSemaphoreTake(s_lock, portMAX_DELAY);
drive(ch, energize);
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "%s %s", policy_channel_name(ch), policy_state_name(ch, energize));
return ESP_OK;
}
esp_err_t relays_pulse_button(int ms)
{
if (!button_enabled())
return ESP_ERR_NOT_ALLOWED;
xSemaphoreTake(s_lock, portMAX_DELAY);
if (s_pulsing) {
xSemaphoreGive(s_lock);
return ESP_ERR_INVALID_STATE;
}
s_pulsing = true;
/* The end is armed before the line rises. */
ESP_ERROR_CHECK(esp_timer_start_once(s_pulse_end, (uint64_t)ms * 1000ULL));
drive(CH_BUTTON, true);
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "button pressed for %d ms", ms);
return ESP_OK;
}
void relays_release(void)
{
xSemaphoreTake(s_lock, portMAX_DELAY);
esp_timer_stop(s_pulse_end); /* harmless when not running */
for (int i = 0; i < CH_COUNT; i++)
drive((channel_t)i, false);
s_pulsing = false;
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "all released");
}
relays_state_t relays_state(void)
{
relays_state_t st;
xSemaphoreTake(s_lock, portMAX_DELAY);
for (int i = 0; i < CH_COUNT; i++)
st.energized[i] = s_energized[i];
st.button_pulsing = s_pulsing;
xSemaphoreGive(s_lock);
st.button_enabled = button_enabled();
return st;
}
+47
View File
@@ -0,0 +1,47 @@
/*
* forgefixture relays: the three channels on their GPIOs, the button's
* pulse timer, and the enable jumper that gates the button.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#pragma once
#include <stdbool.h>
#include "esp_err.h"
#include "policy.h"
/* DevKitC-1 pins with no strapping, USB, flash or PSRAM role. Active
* high into the relay modules' opto inputs. */
#define RELAY_GPIO_LID 4
#define RELAY_GPIO_INTERLOCK 5
#define RELAY_GPIO_BUTTON 6
/* The enable jumper for the button channel: input with the pull-up on,
* the jumper shorts it to GND. No jumper = high = button disabled. */
#define ENABLE_GPIO_BUTTON 7
typedef struct {
bool energized[CH_COUNT];
bool button_enabled; /* the jumper is in */
bool button_pulsing; /* a press is in progress */
} relays_state_t;
/* Every line low before anything else runs. */
void relays_init(void);
/* Lid or interlock: hold the channel energized (loop open) or released
* (loop closed). ESP_ERR_INVALID_ARG for the button, which is never
* held. */
esp_err_t relays_set_loop(channel_t ch, bool energize);
/* The button: one pulse of `ms` (already clamped by policy_button_ms).
* ESP_ERR_NOT_ALLOWED without the enable jumper, ESP_ERR_INVALID_STATE
* while a pulse is still in progress. */
esp_err_t relays_pulse_button(int ms);
/* Everything low, a pulse in progress cut short. */
void relays_release(void);
relays_state_t relays_state(void);
+4
View File
@@ -0,0 +1,4 @@
/* forgefixture firmware version, reported by GET /. */
#pragma once
#define FIXTURE_VERSION "1.0.0"
+119
View File
@@ -0,0 +1,119 @@
/*
* forgefixture wifi (see wifi.h).
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "wifi.h"
#include <string.h>
#include "esp_event.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "mdns.h"
static const char *TAG = "wifi";
static esp_netif_t *s_netif;
static volatile bool s_connected;
static esp_ip4_addr_t s_ip;
static char s_hostname[32];
static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data)
{
(void)arg;
(void)data;
if (id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (id == WIFI_EVENT_STA_DISCONNECTED) {
const wifi_event_sta_disconnected_t *d = data;
s_connected = false;
ESP_LOGW(TAG, "disconnected (reason %d), reconnecting", d ? d->reason : -1);
/* A short pause keeps a wrong passphrase from becoming a storm;
* the event loop task tolerates it. */
vTaskDelay(pdMS_TO_TICKS(1000));
esp_wifi_connect();
}
}
static void on_ip(void *arg, esp_event_base_t base, int32_t id, void *data)
{
(void)arg;
(void)base;
if (id == IP_EVENT_STA_GOT_IP) {
const ip_event_got_ip_t *ev = data;
s_ip = ev->ip_info.ip;
s_connected = true;
ESP_LOGI(TAG, "joined: " IPSTR " as %s.local", IP2STR(&ev->ip_info.ip), s_hostname);
} else if (id == IP_EVENT_STA_LOST_IP) {
s_connected = false;
ESP_LOGW(TAG, "address lost");
}
}
static void mdns_start(const char *hostname)
{
ESP_ERROR_CHECK(mdns_init());
ESP_ERROR_CHECK(mdns_hostname_set(hostname));
ESP_ERROR_CHECK(mdns_instance_name_set("ForgeFIRM bench fixture"));
mdns_txt_item_t txt[] = {
{"device", "forgefixture"},
{"channels", "lid,interlock,button"},
};
ESP_ERROR_CHECK(mdns_service_add(NULL, "_forgefixture", "_tcp", 80, txt, 2));
ESP_ERROR_CHECK(mdns_service_add(NULL, "_http", "_tcp", 80, txt, 2));
}
void wifi_start(const char *hostname, const char *ssid, const char *psk)
{
strncpy(s_hostname, hostname, sizeof(s_hostname) - 1);
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
s_netif = esp_netif_create_default_wifi_sta();
ESP_ERROR_CHECK(esp_netif_set_hostname(s_netif, hostname)); /* DHCP option 12 */
wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&init));
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &on_wifi, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &on_ip, NULL));
wifi_config_t cfg = {0};
strncpy((char *)cfg.sta.ssid, ssid, sizeof(cfg.sta.ssid) - 1);
strncpy((char *)cfg.sta.password, psk, sizeof(cfg.sta.password) - 1);
cfg.sta.threshold.authmode = psk[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
cfg.sta.pmf_cfg.capable = true;
cfg.sta.pmf_cfg.required = false;
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &cfg));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
mdns_start(hostname);
ESP_LOGI(TAG, "joining %s", ssid);
}
void wifi_ip(char *buf, size_t n)
{
if (s_connected)
snprintf(buf, n, IPSTR, IP2STR(&s_ip));
else
snprintf(buf, n, "0.0.0.0");
}
int wifi_rssi(void)
{
wifi_ap_record_t ap;
if (!s_connected || esp_wifi_sta_get_ap_info(&ap) != ESP_OK)
return 0;
return ap.rssi;
}
bool wifi_connected(void)
{
return s_connected;
}
+23
View File
@@ -0,0 +1,23 @@
/*
* forgefixture wifi: a station that joins the bench network and stays
* joined, announces its hostname over DHCP and mDNS, and never sleeps
* the radio (the API answers in milliseconds, not on the next beacon).
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
void wifi_start(const char *hostname, const char *ssid, const char *psk);
/* The station's address as text ("0.0.0.0" while not joined). */
void wifi_ip(char *buf, size_t n);
/* Signal strength in dBm, 0 while not joined. */
int wifi_rssi(void);
bool wifi_connected(void);
+36
View File
@@ -0,0 +1,36 @@
# forgefixture build defaults (ESP32-S3 DevKitC-1). idf.py merges these
# into sdkconfig on the first configure; sdkconfig itself is not tracked.
CONFIG_IDF_TARGET="esp32s3"
# Any DevKitC-1 has at least 8 MB; a 4 MB layout with one large app
# partition fits every one of them and leaves nothing to choose.
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
# A hung task reboots the board to every relay line low rather than
# holding a button press: the task watchdog panics, and a panic resets.
CONFIG_ESP_TASK_WDT_INIT=y
CONFIG_ESP_TASK_WDT_PANIC=y
CONFIG_ESP_TASK_WDT_TIMEOUT_S=5
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=y
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=y
CONFIG_ESP_INT_WDT=y
CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT=y
# Brownout (the relays share the board's 3.3 V with nothing else, but the
# USB supply is whatever it is): reset rather than limp.
CONFIG_ESP_BROWNOUT_DET=y
# The log on both USB ports: the UART bridge and the native USB-Serial-JTAG.
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
# HTTP server: a few clients, small requests.
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
CONFIG_HTTPD_MAX_URI_LEN=128
CONFIG_HTTPD_PURGE_BUF_LEN=32
# Wifi station only; no Bluetooth.
CONFIG_BT_ENABLED=n
CONFIG_ESP_WIFI_NVS_ENABLED=y
CONFIG_LWIP_IPV6=n
+73
View File
@@ -0,0 +1,73 @@
/*
* forgefixture policy, held to account on the host: gcc -o policy_test
* policy_test.c ../main/policy.c (see run.sh).
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include <stdio.h>
#include <string.h>
#include "../main/policy.h"
static int failures;
#define CHECK(cond) do { if (!(cond)) { failures++; printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); } } while (0)
int main(void)
{
/* names and paths */
CHECK(strcmp(policy_channel_name(CH_LID), "lid") == 0);
CHECK(strcmp(policy_channel_name(CH_INTERLOCK), "interlock") == 0);
CHECK(strcmp(policy_channel_name(CH_BUTTON), "button") == 0);
CHECK(policy_channel_from_path("/lid") == CH_LID);
CHECK(policy_channel_from_path("/interlock") == CH_INTERLOCK);
CHECK(policy_channel_from_path("/button") == CH_BUTTON);
CHECK(policy_channel_from_path("/lids") == -1);
CHECK(policy_channel_from_path("lid") == -1);
CHECK(policy_channel_from_path(NULL) == -1);
/* a loop request: open energizes, close/closed releases, nothing else */
bool e = false;
CHECK(policy_parse_loop_state("open", &e) && e);
CHECK(policy_parse_loop_state("close", &e) && !e);
CHECK(policy_parse_loop_state("closed", &e) && !e);
CHECK(!policy_parse_loop_state("Open", &e));
CHECK(!policy_parse_loop_state("on", &e));
CHECK(!policy_parse_loop_state("", &e));
CHECK(!policy_parse_loop_state(NULL, &e));
/* the button pulse: a default, a floor, a ceiling, never a hold */
CHECK(policy_button_ms(0) == BUTTON_DEFAULT_MS);
CHECK(policy_button_ms(-5) == BUTTON_DEFAULT_MS);
CHECK(policy_button_ms(1) == BUTTON_MIN_MS);
CHECK(policy_button_ms(250) == 250);
CHECK(policy_button_ms(500) == BUTTON_MAX_MS);
CHECK(policy_button_ms(501) == BUTTON_MAX_MS);
CHECK(policy_button_ms(60000) == BUTTON_MAX_MS);
CHECK(BUTTON_MAX_MS <= 500);
/* state names */
CHECK(strcmp(policy_state_name(CH_LID, true), "open") == 0);
CHECK(strcmp(policy_state_name(CH_LID, false), "closed") == 0);
CHECK(strcmp(policy_state_name(CH_INTERLOCK, true), "open") == 0);
CHECK(strcmp(policy_state_name(CH_BUTTON, true), "pressed") == 0);
CHECK(strcmp(policy_state_name(CH_BUTTON, false), "idle") == 0);
/* the key: exact, whole, and never by prefix */
CHECK(policy_key_matches("abc123", "abc123"));
CHECK(!policy_key_matches("abc12", "abc123"));
CHECK(!policy_key_matches("abc1234", "abc123"));
CHECK(!policy_key_matches("", "abc123"));
CHECK(!policy_key_matches("abc123", ""));
CHECK(!policy_key_matches(NULL, "abc123"));
CHECK(!policy_key_matches("ABC123", "abc123"));
if (failures) {
printf("%d failure(s)\n", failures);
return 1;
}
printf("policy: all checks passed\n");
return 0;
}
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# The host test of the fixture's policy: needs only a C compiler.
set -e
cd "$(dirname "$0")"
${CC:-cc} -std=c11 -Wall -Wextra -Werror -o policy_test policy_test.c ../main/policy.c
./policy_test
+47
View File
@@ -0,0 +1,47 @@
# forgetest - the ForgeFIRM release acceptance tool
The daemon behind `http://<machine>:8090/` on the dev image: runs the
acceptance catalog against the machine, keeps the append-only result log,
decides which results still apply to the image that is running, exports
the release artifact `scripts/release.sh` gates on, and serves the bench
diagnostics page. The contract - catalog, campaigns, fingerprints,
inheritance, the gate, the coverage rule - is
[the Acceptance page of the documentation site](https://docs.forgefirm.org/developers/acceptance/).
## Run the host tests
cd forgetest
python3 -m unittest discover -s tests -v
## Run the daemon on a workstation (against a mock or a manifest file)
FORGETEST_DATA=/tmp/ft FORGETEST_MANIFEST=../tree-manifest.json \
FORGECTRL_URL=http://<machine> python3 -m forgetest --port 8090
`scripts/manifest-from-tree.py` produces `tree-manifest.json` from the recipe
pins; the coverage lint is `python3 -m forgetest.coverage --manifest ...`.
## Environment
| Variable | Default | Purpose |
|---|---|---|
| `FORGETEST_DATA` | `/data/forgetest` | results.jsonl, bench.jsonl, token, export/ |
| `FORGETEST_MANIFEST` | `/etc/forgefirm-manifest.json` | the image manifest |
| `FORGETEST_PORT`, `FORGETEST_HOST` | 8090, 0.0.0.0 | listener |
| `FORGETEST_BENCH_DIR` | `/usr/share/forgetest/bench` | the installed bench scripts |
| `FORGETEST_BENCH_DATA` | `<FORGETEST_DATA>/bench` | passed to bench tools: where they keep their data files (with `GF_HOST=127.0.0.1` and the panel token in `GF_TOKEN`) |
| `FORGETEST_MARKER` | `/run/forgetest.active` | takeover marker |
| `FORGECTRL_URL`, `FORGECTRL_TOKEN_FILE` | `http://127.0.0.1`, `/data/forgefirm/panel.token` | forgectrl client (HTTP; the token authorizes writes from the board) |
| `FORGECTRL_TLS_URL` | `https://127.0.0.1` | forgectrl over HTTPS (self-signed, unverified), for the login test |
| `GF_SYSFS_ROOT` | `/sys/glowforge/` | kernel module sysfs |
| `GRBL_HOST`, `GRBL_PORT` | 127.0.0.1, 23 | Grbl TCP |
## Adding a test
Register it in the subsystem module under `forgetest/suite/` with
`@test(...)`: id `subsystem.name`, kind, hardware, `mode` (the controller
mode the test needs; the runner switches to it first), `covers`,
`requires`, `always`, steps. The body gets a `Context` (`log`, `check`, `fail`,
`prompt`, `confirm`, `instruct`, `sleep`, `evidence`, `forgectrl`, `sysfs`,
`grbl`, `takeover`). Return normally for PASS, raise `runner.Failed` for
FAIL. Then run the unit tests and the coverage lint.
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: forgetest
# Required-Start: $network forgectrl
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: ForgeFIRM release acceptance tool (dev image, HTTP :8090)
### END INIT INFO
# Dev-image only. The daemon reads /etc/forgefirm-manifest.json, keeps its
# state under /data/forgetest, and serves the acceptance + bench pages on
# port 8090 (FORGETEST_PORT). It starts after forgectrl and stops before
# the controllers at shutdown; a takeover left behind by a hard stop is
# recovered at the next start (forgectrl is started again).
PIDFILE=/var/run/forgetest.pid
DATA=/data/forgetest
LOG=$DATA/daemon.log
case "$1" in
start)
echo "Starting forgetest"
mkdir -p "$DATA"
# The bench seed: the commissioning override lifts the wizard part of
# the controller gate until the next reboot (/run is tmpfs), so a
# campaign runs on a machine whose required wizards are not complete.
# Nothing lifts the agreements or the account: the bench accepts them
# once, and that stays in /data.
mkdir -p /run/forgefirm
: > /run/forgefirm/commissioning-override
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
;;
stop)
echo "Stopping forgetest"
start-stop-daemon -K -q -p $PIDFILE
rm -f $PIDFILE
;;
restart)
$0 stop
sleep 1
$0 start
;;
status)
if [ -f $PIDFILE ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then
echo "forgetest is running"
exit 0
fi
echo "forgetest is not running"
exit 3
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
exit 0
+10
View File
@@ -0,0 +1,10 @@
"""forgetest - the ForgeFIRM release acceptance tool.
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
against the machine, keeps the append-only result log, decides which
results still apply to the image that is running, and exports the
release artifact the release gate reads. The bench diagnostics page rides
the same daemon.
"""
VERSION = "0.1.0"
+68
View File
@@ -0,0 +1,68 @@
"""forgetest daemon entry point.
python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH]
Environment: FORGETEST_DATA (state directory, default /data/forgetest),
FORGETEST_MANIFEST, FORGETEST_BENCH_DIR, FORGETEST_MARKER, and the hw.py
overrides.
"""
import argparse
import os
import signal
import sys
import threading
from . import VERSION
from . import bench as _bench
from . import catalog as _catalog
from . import manifest as _manifest
from . import server as _server
from .log import Log, data_dir
from .runner import Runner, configure_journal
def main(argv=None):
ap = argparse.ArgumentParser(prog="forgetest")
ap.add_argument("--port", type=int, default=int(os.environ.get("FORGETEST_PORT") or 8090))
ap.add_argument("--host", default=os.environ.get("FORGETEST_HOST") or "::")
ap.add_argument("--manifest", default=None)
ap.add_argument("--version", action="version", version="forgetest %s" % VERSION)
args = ap.parse_args(argv)
try:
manifest = _manifest.Manifest.load(args.manifest)
except (OSError, ValueError) as e:
print("forgetest: cannot load the image manifest: %s" % e, file=sys.stderr)
return 2
registry = _catalog.load_suite()
os.makedirs(data_dir(), exist_ok=True)
configure_journal()
log = Log()
bench = _bench.Bench()
runner = Runner(log, manifest, registry, bench)
token = _server.load_token()
app = _server.App(runner, token)
srv = _server.make_server(app, args.host, args.port)
stop = threading.Event()
def on_signal(*_):
stop.set()
signal.signal(signal.SIGTERM, on_signal)
signal.signal(signal.SIGINT, on_signal)
print("forgetest %s: %d tests, image %s (%s), listening on %s:%d"
% (VERSION, len(registry), manifest.version, (manifest.content_sha or "")[:12],
args.host, args.port), file=sys.stderr, flush=True)
th = threading.Thread(target=srv.serve_forever, name="forgetest-http", daemon=True)
th.start()
try:
while not stop.is_set():
stop.wait(1.0)
finally:
srv.shutdown()
srv.server_close()
return 0
if __name__ == "__main__":
sys.exit(main())
+189
View File
@@ -0,0 +1,189 @@
"""The release artifact (acceptance.json / acceptance.md) and its verification.
The exporter serializes the campaign state with, for every catalog test,
the winning result record (same-campaign PASS or inherited PASS, with its
origin) and the fingerprint it was recorded under. The gate
(scripts/acceptance-gate.py) calls verify() with the artifact, the
release build's manifest, and the catalog from the same source tree, and
recomputes every fingerprint - a PASS applies to the release exactly when
the recomputation matches. The artifact is self-hashed so an edited file
is caught before any of that.
"""
import json
from . import VERSION
from . import campaign as _campaign
from . import manifest as _manifest
from .log import now_ts
FORMAT = 1
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
"duration_s", "message", "evidence", "answers", "log", "operator")
def _record(rec):
return {k: rec.get(k) for k in _RESULT_KEYS if k in rec}
def build(state, tests, manifest, records, catalog_hash):
"""The artifact dict (self-hash included). records: the full log,
used to find the winning record for each test."""
results = [r for r in records if r.get("t") == "result"]
by_key = {}
for r in results:
by_key[(r.get("test"), r.get("ts"), r.get("campaign"))] = r
tests_out = []
for t in tests:
st = state["tests"][t.id]
origin = st.get("origin")
rec = None
if origin:
rec = by_key.get((t.id, origin.get("ts"), origin.get("campaign")))
entry = t.definition()
entry.update({
"title": t.title,
"source_sha": t.source_sha,
"fingerprint": st["fingerprint"],
"satisfied": st["satisfied"],
"inherited": st["status"] == "inherited",
"record": _record(rec) if rec else None,
})
tests_out.append(entry)
art = {
"format": FORMAT,
"tool_version": VERSION,
"exported_at": now_ts(),
"image": {"name": manifest.image_name, "version": manifest.version},
"manifest_sha": manifest.content_sha,
"identity_sha": manifest.identity_sha(),
"catalog_hash": catalog_hash,
"campaign": state["campaign"],
"invalidate": state["invalidate"],
"authorized": state["authorized"],
"counts": state["counts"],
"manifest": manifest.data,
"tests": tests_out,
}
art["sha256"] = _manifest.sha256_text(_manifest.canonical(art))
return art
def to_json(art):
return json.dumps(art, sort_keys=True, indent=1) + "\n"
def to_markdown(art):
lines = []
img = art.get("image", {})
lines.append("# ForgeFIRM acceptance - %s" % img.get("version", "?"))
lines.append("")
lines.append("- Image: `%s` (%s)" % (img.get("version", "?"), img.get("name", "?")))
lines.append("- Manifest identity: `%s`" % (art.get("manifest_sha") or "?"))
lines.append("- Catalog: `%s`" % (art.get("catalog_hash") or "?"))
c = art.get("campaign") or {}
lines.append("- Campaign: `%s` opened %s" % (c.get("id", "-"), c.get("ts", "-")))
inv = art.get("invalidate")
if inv:
lines.append("- Full campaign required since %s: %s" % (inv.get("ts"), inv.get("reason")))
lines.append("- Exported: %s" % art.get("exported_at"))
lines.append("- **Release authorized: %s**" % ("YES" if art.get("authorized") else "NO"))
cnt = art.get("counts", {})
lines.append("- Tests: %s total, %s satisfied (%s inherited), %s required"
% (cnt.get("total"), cnt.get("satisfied"), cnt.get("inherited"), cnt.get("required")))
lines.append("")
lines.append("| Test | Kind | Result | Run at | Campaign | Inherited |")
lines.append("|---|---|---|---|---|---|")
for t in art.get("tests", []):
rec = t.get("record") or {}
kind = t.get("kind", "")
if t.get("always"):
kind += ", core"
if t.get("hardware") == "takeover":
kind += ", takeover"
lines.append("| `%s` | %s | %s | %s | `%s` | %s |" % (
t.get("id"), kind, rec.get("result", "-"), rec.get("ts", "-"),
rec.get("campaign", "-"), "yes" if t.get("inherited") else "no"))
lines.append("")
lines.append("Artifact sha256: `%s`" % art.get("sha256"))
lines.append("")
return "\n".join(lines)
def verify(art, release_manifest, tests, catalog_hash, expect_machine=None):
"""Gate decision. Returns (ok, rows, problems).
rows: per-test dicts for the report. problems: list of strings; empty
means the artifact authorizes the release manifest.
"""
problems = []
rows = []
body = dict(art)
sha = body.pop("sha256", None)
if sha != _manifest.sha256_text(_manifest.canonical(body)):
problems.append("artifact self-hash mismatch (edited or truncated file)")
return False, rows, problems
if art.get("format") != FORMAT:
problems.append("artifact format %r, expected %r" % (art.get("format"), FORMAT))
if not art.get("authorized"):
problems.append("artifact does not claim authorization")
if expect_machine and release_manifest.platform.get("machine") != expect_machine:
problems.append("release manifest machine %r, expected %r"
% (release_manifest.platform.get("machine"), expect_machine))
if art.get("catalog_hash") != catalog_hash:
problems.append("catalog changed since the campaign (artifact %s, tree %s)"
% ((art.get("catalog_hash") or "?")[:12], catalog_hash[:12]))
tests = list(tests)
by_id = {t.id: t for t in tests}
art_tests = {t["id"]: t for t in art.get("tests", [])}
for tid in sorted(set(by_id) - set(art_tests)):
problems.append("test %s is in the catalog but not in the artifact" % tid)
for tid in sorted(set(art_tests) - set(by_id)):
problems.append("test %s is in the artifact but not in the catalog" % tid)
epoch = (art.get("invalidate") or {}).get("ts")
for t in tests:
a = art_tests.get(t.id)
if a is None:
continue
row = {"id": t.id, "always": t.always, "inherited": bool(a.get("inherited"))}
rec = a.get("record")
ok = True
why = []
if a.get("covers") != [list(c) for c in t.covers] or a.get("always") != t.always \
or a.get("kind") != t.kind or a.get("requires") != list(t.requires):
ok = False
why.append("definition differs from the catalog in the tree")
if a.get("source_sha") != t.source_sha:
ok = False
why.append("test implementation changed since the campaign")
if not rec:
ok = False
why.append("no PASS recorded")
else:
if rec.get("result") != _campaign.PASS:
ok = False
why.append("recorded result is %s" % rec.get("result"))
fp = t.fingerprint(release_manifest)
row["fingerprint"] = fp
if rec.get("fingerprint") != fp:
ok = False
why.append("fingerprint differs from the release build (domain changed)")
if a.get("inherited"):
if t.always:
ok = False
why.append("always-required test may not be inherited")
if epoch and (rec.get("ts") or "") <= epoch:
ok = False
why.append("inherited PASS predates the invalidate-all")
row["ts"] = rec.get("ts")
row["ok"] = ok
row["why"] = why
rows.append(row)
if not ok:
problems.append("%s: %s" % (t.id, "; ".join(why)))
return not problems, rows, problems
File diff suppressed because it is too large Load Diff
+395
View File
@@ -0,0 +1,395 @@
"""The bench diagnostics page: registry of the bench tools and the
subprocess runner behind the #bench tab.
The registry lists every tool of scripts/bench (the README is the human
index; this is the machine one) with its safety class and argument spec.
A tool is runnable from the page once `ported` is set: the script is
installed under the tool directory (/usr/share/forgetest/bench on the
image, override FORGETEST_BENCH_DIR) and runs as a subprocess with the
form's arguments, output streamed to the page. Unported tools are listed
so the catalog of what exists is complete, with Start disabled.
Safety classes:
dry reads or dry motion, no emission, forgectrl stays up
takeover needs forgectrl stopped and the pulse device free
scope a takeover whose result only means something with the named
instrument on the bench (a scope on LASER_PWM / LASER_ON)
live laser emission possible (operator acknowledgment required)
The tools that also run from a LAN host (gfbench.py: GF_HOST) run on the
board here with GF_HOST=127.0.0.1, the panel token in GF_TOKEN, and
their data files under FORGETEST_BENCH_DATA (<data>/bench/). Bench runs
are recorded in <data>/bench.jsonl and never enter a campaign.
"""
import json
import os
import shlex
import sys
import threading
from .log import data_dir, now_ts
DEFAULT_TOOL_DIR = "/usr/share/forgetest/bench"
def _arg(name, type="str", default=None, help="", choices=None, flag=None):
"""One form field. `flag` names the option the value is passed as
(--feed 600); without it the value is positional, in registry order."""
a = {"name": name, "type": type, "default": default, "help": help}
if choices:
a["choices"] = list(choices)
if flag:
a["flag"] = flag
return a
TOOLS = [
# -- board-side, dry ------------------------------------------------------
{"id": "check-pwm", "title": "Laser PWM register check", "script": "check_pwm.py",
"safety": "dry", "where": "board", "ported": True, "args": [],
"desc": "Reads PWM2 PWMCR/PWMPR via /dev/mem; expects divider 13 x ~127 counts = ~40 kHz. Read-only."},
{"id": "pacing-test", "title": "Protocol-loop pacing check", "script": "pacing_test.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("mm", "float", 30.0, "jog distance (+X first)"), _arg("feed", "float", 600.0, "feed rate")],
"desc": "Dry motion: idle/parked states coarse-paced, active motion tight-paced, hold/resume mid-move keeps position."},
{"id": "bench-m2", "title": "Motion-quality bench", "script": "bench_m2.py",
"safety": "dry", "where": "board", "ported": True, "args": [],
"argv_fixed": ["127.0.0.1"],
"desc": "Bounded round-trip jogs (sanity, max-rate, diagonal) + feed-hold/resume; reports peak feed, transitions, drift."},
{"id": "bench-phase2", "title": "End-of-data protocol bench", "script": "bench_phase2.py",
"safety": "takeover", "where": "board", "ported": True, "args": [],
"desc": "Underrun detection/ack, parked no-replay guard, resume(0), continuous feed, run/underrun cycles. Motors locked, laser latched."},
{"id": "cp-watchdog", "title": "HV charge-pump watchdog timing", "script": "cp_watchdog_timing.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("duration_s", "float", 14.0, "capture length")],
"desc": "Latches CHG_PUMP feed pulses, polls the watchdog readbacks while commanding short local jogs. Motion only, laser locked."},
{"id": "accel-fast", "title": "Head accelerometer sampler", "script": "accel_fast.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("duration_s", "float", 5.0, "capture length"),
_arg("jog1", "str", None, "optional mid-capture jog, e.g. $J=G91X20F2400"),
_arg("jog2", "str", None, "optional second jog")],
"desc": "Direct-I2C sampler for the head-bus LIS2HH12s with optional mid-capture jogs. CSV to /tmp/accel.csv."},
{"id": "bump-seek", "title": "Accelerometer bump-seek homing prototype", "script": "bump_seek.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("direction", "choice", "-", "X direction", ["-", "+"]),
_arg("feed", "int", 120, "creep feed"), _arg("segment_mm", "float", 15.0, "jog segment"),
_arg("max_mm", "float", 200.0, "travel bound")],
"desc": "Creeps toward a rail in bounded jog segments, detects the contact jolt, jog-cancels and backs off."},
{"id": "accel-crash-probe", "title": "Head accel crash-detector probe", "script": "accel_crash_probe.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("seconds", "float", 20.0, "arm-and-watch window"),
_arg("mode", "choice", "coexist", "coexist keeps st_accel bound (forgectrl up); unbind frees it",
["coexist", "unbind"], flag="--mode"),
_arg("ths", "int", 40, "per-axis threshold register value 0..255", flag="--ths"),
_arg("dur", "int", 0, "IG_DUR1 duration counter (ODR samples)", flag="--dur"),
_arg("axes", "str", "xyz", "axes to arm for high events", flag="--axes"),
_arg("jog", "str", None, "optional one jog at t=2 s, e.g. $J=G91X5F1000 (+X only)", flag="--jog")],
"desc": "Arms the head LIS2HH12's on-chip interrupt generator (IG1) and polls IG_SRC1 for a latched strike. "
"coexist mode reaches the IG registers over i2c-dev with st_accel still bound (the forgectrl-only "
"path proof); provoke a trip by hand or with --jog. Touches the IG registers and CTRL1 "
"(saved, run at 800 Hz, restored; the IG only samples at a running ODR), no emission, "
"no full-scale change. CSV to /tmp/accel_crash.csv."},
# -- board-side, takeover / scope ------------------------------------------
{"id": "pwm-sweep", "title": "LASER_PWM scope sweep", "script": "pwm_sweep.py",
"safety": "scope", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "check", "check = read-only, sweep = duty staircase", ["check", "sweep"])],
"desc": "check: readbacks + PWM2 dump; sweep: PWMSAR through 50/25/75/6/100 percent with 4 s holds. "
"Locked state (the takeover): latch relocked, refuses if FIRE or LASER_ON reads active."},
{"id": "pwm-hold", "title": "LASER_PWM scope hold", "script": "pwm_hold.py",
"safety": "scope", "where": "board", "ported": True,
"args": [_arg("sar", "int", 64, "PWMSAR value"), _arg("seconds", "int", 10, "hold time")],
"desc": "Holds one PWMSAR value for a scope window, then restores. Locked state (the takeover): "
"latch relocked, refuses if FIRE or LASER_ON reads active."},
{"id": "fire-test", "title": "FIRE drop-timing test (A/B/U)", "script": "fire_test.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "A", "A latch locked, B unlocked/unarmed, U true underrun", ["A", "B", "U"])],
"desc": "Duty 0 throughout; refuses to unlock if HV reports good. Software witnesses + the PSU-connector LASER_ON scope point."},
{"id": "pwm-stream", "title": "LASER_PWM stream-path test", "script": "pwm_stream_test.py",
"safety": "takeover", "where": "board", "ported": True, "args": [],
"desc": "Streams power bytes only (no steps, no FIRE, motor_lock=15, latch locked) through /dev/glowforge; "
"PASS = counters unmoved, idle at the end, no FIRE/emission read back. The scope on LASER_PWM sees the duty steps."},
{"id": "gate-a-kernel", "title": "Kernel laser-safety drills K1/K2/K3", "script": "gate_a_kernel_drills.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("drill", "choice", "K1", "K1 stop floor, K2 resume honors latch, K3 mid-ramp unlock", ["K1", "K2", "K3"])],
"desc": "Software witnesses (cnc/state, laser_enable, laser_on, interlock bit 3); K3 refuses if HV reports good."},
{"id": "platform-drills", "title": "Kernel platform drills", "script": "platform_drills.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("drill", "choice", "decay", "deadman / rmmod / decay / led / all",
["deadman", "rmmod", "decay", "led", "all"])],
"desc": "Dead-man trip readback, rmmod/modprobe cycles under load, decay/microstep readback, LED sequence."},
# -- cooling ------------------------------------------------------------------
{"id": "flow-confirm", "title": "Coolant flow suspicion/confirmation drill", "script": "flow_confirm_drill.py",
"safety": "dry", "where": "board", "ported": True, "args": [],
"desc": "One M8 session walks the verdict state machine through real pump-off transients; PASS/FAIL per transition."},
{"id": "flow-escalate", "title": "Coolant starved re-check escalation drill", "script": "flow_escalate_drill.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("budget_s", "int", 60, "cool_confirm_max_s for the drill (60-3600), restored after")],
"desc": "With the pump off the job-start check reads SUSPECT and the engine must escalate to FAULT when "
"the confirmation budget expires; the budget setting is shortened for the drill and restored."},
{"id": "flow-characterize", "title": "Coolant flow characterization", "script": "flow_characterize.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("duty", "int", 30, "heater duty percent")],
"desc": "Baseline -> flow -> no-flow -> recovery with the factory temperature curve; aborts past 45 C "
"downstream. Drives the heater and pump directly (about 9 minutes)."},
{"id": "flow-sustained", "title": "Coolant sustained re-check run", "script": "flow_sustained.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("minutes", "float", 30.0, "how long to hold M8")],
"desc": "Long run of the real re-check cadence via M8: verdicts, false faults, loop heat accumulation."},
{"id": "flow-warm", "title": "Coolant warm-baseline validation", "script": "flow_warm_validate.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("cycles", "int", 3, "cycles per case (flow / no-flow)"),
_arg("target_c", "float", 28.0, "warm the upstream sensor to this before each check"),
_arg("warm_min", "float", 20.0, "warm-up budget per check, minutes")],
"desc": "Runs the real check (40 percent / 50 s, cut-profile fans) from a heater-warmed baseline, alternating "
"flow and no-flow; results to the bench data directory. Slow: about 15 minutes per cycle."},
{"id": "offset-probe", "title": "Coolant-sensor offset probe", "script": "offset_probe.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "survey", "survey: one actuator at a time; ladder: the air-assist "
"duty ladder; jog: the air assist at run duty while the gantry jogs",
["survey", "ladder", "jog"])],
"desc": "Which actuator moves both coolant thermistors together? Switches the exhaust (100/50/25 "
"percent), intakes, air assist, purge, heater, pump, TEC and lid lamp one at a time, dark, "
"with both sensors at 25 Hz, and scores the common-mode step at every edge and the level "
"toggling inside every dwell; every value is restored on exit. The pump stops once for "
"8 s with the tube dark and the heater off. About five minutes."},
{"id": "aa-offset-check", "title": "Coolant offset correction under the run airflow", "script": "aa_offset_check.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("dwell", "float", 20.0, "seconds to read under the run profile")],
"desc": "M8 brings the fans to the run profile (no laser-on, no press) while the raw coolant "
"counts, /status and the engine's readings are averaged before, during and after: with "
"cool_aa_offset_counts at the machine's value the readings hold still while the raw counts "
"step; at zero they drop by about a degree. About a minute."},
{"id": "critical-tier", "title": "Coolant critical-tier warm-loop drill", "script": "critical_tier_drill.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("max_seconds", "int", 1200, "give up after this long without CRITICAL", flag="--max-seconds")],
"desc": "Sets the ceiling, the resume gate and the critical line a few tenths above the live upstream "
"reading and lets the engine's own flow-check heater warm the loop through them inside one M8 "
"session: OVERTEMP at the ceiling, then CRITICAL (fire blocked, hold, no resume), the fault ending "
"with the session; settings restored and re-read. Results to the bench data directory."},
{"id": "flow-recheck", "title": "Coolant re-check characterization", "script": "flow_recheck_char.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("heater_pct", "int", 50, "heater duty percent"), _arg("window_s", "int", 30, "re-check window")],
"desc": "Short in-run re-checks and the differential metric, flow vs no-flow from a settled loop; "
"aborts past 45 C downstream (about 5 minutes)."},
{"id": "flow-matrix", "title": "Coolant flow-detection design matrix", "script": "flow_matrix.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("duties", "str", "10,15,20,30,40,50", "heater duties, percent, comma-separated"),
_arg("repeats", "int", 5, "interleaved repeats per case")],
"desc": "duty x flow/no-flow x repeats from a common cooled baseline; cost and precision tables and a "
"ranked shortlist (the derivation of cool_flow_rise). Very slow: about 1.6 h for the full matrix; "
"resumable from the results file in the bench data directory."},
{"id": "flow-sampler", "title": "Coolant sampler", "script": "flow_sampler.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("duration_s", "int", 30, "capture length"), _arg("interval_s", "float", 1.0, "sample interval")],
"desc": "Prints elapsed,raw_down,raw_up at the interval; the sampler behind the flow tools."},
{"id": "temp-calibrate", "title": "Coolant temperature spot-check", "script": "temp_calibrate.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "watch", "coolant: watch / point / fit; supply (pic/pwr_temp): "
"supply-watch / supply-point / supply-fit",
["watch", "point", "fit", "supply-watch", "supply-point", "supply-fit"]),
_arg("value", "str", None, "point: the thermometer reading in C; watch: seconds (default 60)")],
"desc": "Pairs a measured temperature with averaged raw readings; fits a per-machine line. The coolant "
"sensors, or the power supply's (thermometer on its heatsink, against the documented unverified "
"guess). Points accumulate in the bench data directory."},
{"id": "fan-test", "title": "Fan/coolant bench", "script": "fan_test.py",
"safety": "dry", "where": "board", "ported": True, "args": [],
"desc": "Snapshots fan PWMs/tachs/temps, drives M8 -> cut fans, M9 -> cooldown -> idle; the tach "
"readbacks in each snapshot are the evidence."},
{"id": "fan-floor", "title": "Fan floor measurement", "script": "fan_floor_measure.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "spinup", "spinup (M8, from idle) / cut (sample only, during a real cut)",
["spinup", "cut"]),
_arg("seconds", "int", 120, "sampling window", flag="--seconds"),
_arg("steady", "int", 60, "spinup: the steady window at the end", flag="--steady")],
"desc": "The numbers the airflow gates ship with: per fan the steady speed at run duty, the time to "
"90 percent and the spread, the purge current off and on, and (cut) the spread under a real "
"cut; results to the bench data directory."},
# -- laser (live) --------------------------------------------------------------
{"id": "live-fire", "title": "LIVE laser drills", "script": "live_fire_drills.py",
"safety": "live", "where": "board", "ported": True,
"args": [_arg("drill", "choice", "witness", "witness / hold / ircut / expstop / ctrlstart",
["witness", "hold", "ircut", "expstop", "ctrlstart"]),
_arg("power", "int", 1000, "ircut: S value"), _arg("feed", "int", 300, "ircut: F value")],
"desc": "Emission witness, disarm grace in Hold, lid-IR characterization cut, armed "
"kill on the expected-stop path (+ the separate controller restart). The operator's arm press is "
"required for every drill; eye protection, fire watch, extinguisher, exhaust."},
{"id": "resume-dark-lead", "title": "Pause / resume chain timing (dark lead)", "script": "resume_dark_lead.py",
"safety": "live", "where": "board", "ported": True,
"args": [_arg("run", "choice", "dry", "dry travel, or LIVE FIRE", ["dry", "live"], flag="--run"),
_arg("mode", "choice", "m3", "laser mode for a live run", ["m3", "m4"], flag="--mode"),
_arg("power", "int", 400, "live: S value", flag="--power"),
_arg("feed", "float", 600.0, "feed rate", flag="--feed"),
_arg("len", "float", 60.0, "move length in mm (+X)", flag="--len"),
_arg("passes", "int", 1, "alternating +X/-X moves", flag="--passes"),
_arg("secs", "float", 45.0, "sampling window", flag="--secs"),
_arg("auto", "str", "", "dry only: 'P,R' seconds to send ! and ~ unattended", flag="--auto")],
"desc": "Samples LASER_ON, FIRE, HV_ENABLE and the charge-pump watchdog straight off the SoC pads "
"across a pause and a resume, with motion dated from the kernel counters: how long HV survives "
"a pause, how fast the chain re-arms, and - on a live run - the dark lead between FIRE and "
"LASER_ON that a resumed cut loses. Dry by default; --run live needs the arm press, eye "
"protection, fire watch, extinguisher, exhaust."},
{"id": "pgood-probe", "title": "Supply power-good line against the chain", "script": "pgood_probe.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("secs", "float", 90.0, "sampling window", flag="--secs")],
"desc": "Watches the supply's power-good line (cnc/laser_pgood, reported as the raw pin level) beside "
"LASER_ON, FIRE, the charge-pump watchdog, HV_ENABLE and the doors through the kernel readbacks at a "
"few hundred hertz, with hv_current at 20 Hz, and prints every transition. It only watches: drive "
"the machine meanwhile (a jog, an armed cut, a pause, a lid open). Needs no pad mapping."},
# -- host-side harnesses (CI) ------------------------------------------------------
{"id": "laser-stream-test", "title": "Laser pulse-stream emission harness", "script": "laser_stream_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Null-sink controller stream capture against the feeder contract. A CI harness (the grblHAL repo): "
"it needs the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "laser-lifecycle-test", "title": "Armed-window lifecycle harness", "script": "laser_lifecycle_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Arm/disarm lifecycle on the null-sink controller. A CI harness (the grblHAL repo): needs the "
"host-built null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the "
"lens stands, and neither a $20 nor a $132 write frees it. A 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"])],
"desc": "For each mode given: stores xy_microsteps, waits for the restarted controller, streams a 60-pass "
"raster of 150 mm at F12000 with the laser off, and reports the peak feed, the controller CPU, the "
"kernel counters against the start, underruns and clamped events. Needs the head with 150 mm of "
"free +X and 12 mm of free +Y travel, no other Grbl client. Ends at x8."},
{"id": "xy-pattern-accel", "title": "XY microstep modes by the head accelerometer, the machine silent",
"script": "xy_pattern_accel.py", "safety": "dry", "where": "board", "ported": True,
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
"desc": "From home at F12000: to (18, 9) in, to (9, 9) in, a 9 in circle from its mid-bottom back to "
"(9, 9), to (9, 0), home. Per mode: every fan, the coolant pump and the TEC commanded off through "
"the engine's quiet hold (the machine silent, the modes audible), the fixed 10 s wait, the head "
"accelerometer sampled over the bus at 800 Hz through the pattern; "
"cruise-window RMS and peak-to-peak per leg and overall, the kernel counters against home, "
"underruns; JSON with the trace to the bench data directory. Needs the machine homed and at home, "
"the lid closed, no other Grbl client, the bed clear. Ends at x8."},
{"id": "arc-tolerance-sweep", "title": "How fine an arc the controller can plan ($12 ladder)",
"script": "arc_tolerance_sweep.py", "safety": "dry", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "16", "the XY microstep mode", ["8", "16", "32"], flag="--mode"),
_arg("ladder", "str", "0.002 0.001 0.0005 0.00025 0.0001", "the $12 values to try, in order")],
"desc": "From home, the machine silent: to (9, 9) in, then the 9 in circle at F12000 once per $12 in the "
"ladder. Per rung: the chords and boundaries a second, the circle time against the ideal, the lowest "
"feed mid-circle, the fewest free planner blocks, the controller CPU, clamped events, underruns, the "
"accelerometer's cruise RMS. $12 goes back to what it was; the head returns home. Needs the machine "
"homed and at home, the lid closed, no other Grbl client."},
{"id": "planner-blocks-test", "title": "Planner buffer depth harness", "script": "planner_blocks_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "A deep planner buffer starts: the controller restarted at $398=400 and at $398=1000 answers "
"on the port, reports the depth in its status report and runs a move. A CI harness (the grblHAL "
"repo): needs the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "xy-mode-test", "title": "XY microstep mode harness", "script": "xy_mode_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The XY scale is the microstep mode's, never typed: xy_microsteps sets $100/$101 and the "
"machine tick, a typed $100 is overwritten, and $110/$111 are held under a lowered tick. A CI "
"harness (the grblHAL repo): needs the host-built null-sink controller, not the machine, so it "
"is not a bench-page tool."},
{"id": "puls-profile", "title": "Factory .puls profile decoder", "script": "puls_profile.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Decodes factory pulse streams into velocity/accel profiles. Runs anywhere; needs a .puls file "
"(the reference captures live off the machine), so it is not a bench-page tool."},
{"id": "debug-kernel-drills", "title": "Debug-kernel lock drills (load/unload, forced defer)",
"script": "debug_kernel_drills.py",
"safety": "takeover", "where": "board", "ported": True, "args": [],
"desc": "Runs only on the debug-kernel image (kas/forgefirm-glowforge-debug.yml): three glowforge.ko "
"load/unload cycles under DEBUG_MUTEXES and a forced -EPROBE_DEFER unwind, each read against "
"dmesg for lock splats. Cycles the 40 V rail; refuses on a non-debug kernel or a non-idle machine."},
]
# Files in scripts/bench the page never offers: the helper module the
# host/board tools share (the C feeder and the build scripts are not
# python), and the two lens stall drills. A stall slips the lens stepper's
# rotor under full torque, so those run from the shell on the bench
# reference machine only (scripts/bench/README.md).
NOT_TOOLS = ("gfbench.py", "lens_travel.py", "lens_stop_accel.py")
class Bench:
def __init__(self, tools=None, tool_dir=None, index_path=None):
self.tools = list(tools if tools is not None else TOOLS)
self._by_id = {t["id"]: t for t in self.tools}
self._tool_dir = tool_dir
self.index_path = index_path or os.path.join(data_dir(), "bench.jsonl")
self._lock = threading.Lock()
def tool_dir(self):
return self._tool_dir or os.environ.get("FORGETEST_BENCH_DIR") or DEFAULT_TOOL_DIR
def get(self, tool_id):
return self._by_id.get(tool_id)
def command(self, tool, args):
"""argv for a tool with the form's arguments. Returns
(ok, argv, error)."""
script = os.path.join(self.tool_dir(), tool["script"])
if not os.path.exists(script):
return False, None, "script not installed: %s" % tool["script"]
argv = [sys.executable, script] + list(tool.get("argv_fixed", []))
for spec in tool.get("args", []):
raw = args.get(spec["name"], spec.get("default"))
if raw is None or raw == "":
if spec.get("default") in (None, ""):
continue # optional and absent: not passed at all
raw = spec["default"]
try:
if spec["type"] == "int":
val = str(int(raw))
elif spec["type"] == "float":
val = repr(float(raw))
elif spec["type"] == "choice":
if str(raw) not in spec["choices"]:
return False, None, "%s must be one of %s" % (spec["name"], spec["choices"])
val = str(raw)
else:
val = str(raw)
if any(ch in val for ch in "\0\n\r"):
return False, None, "%s: invalid characters" % spec["name"]
except (TypeError, ValueError):
return False, None, "%s: invalid %s" % (spec["name"], spec["type"])
if spec.get("flag"):
argv.append(spec["flag"])
argv.append(val)
argv += list(tool.get("argv_fixed_after", []))
return True, argv, None
def record(self, tool, args, run):
rec = {"ts": run.started_ts, "tool": tool["id"], "args": args, "result": run.finished,
"log_tail": run.lines[-50:]}
with self._lock:
os.makedirs(os.path.dirname(self.index_path), exist_ok=True)
with open(self.index_path, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, sort_keys=True, separators=(",", ":")) + "\n")
def last_runs(self):
out = {}
try:
with open(self.index_path, "r", encoding="utf-8") as f:
for line in f:
try:
rec = json.loads(line)
except ValueError:
continue
out[rec.get("tool")] = {"ts": rec.get("ts"), "result": rec.get("result"),
"args": rec.get("args")}
except OSError:
pass
return out
def listing(self):
last = self.last_runs()
items = []
for t in self.tools:
item = {k: t[k] for k in ("id", "title", "script", "safety", "where", "ported", "args", "desc")}
item["installed"] = os.path.exists(os.path.join(self.tool_dir(), t["script"]))
item["last"] = last.get(t["id"])
items.append(item)
return items
def describe_command(self, tool, args):
ok, argv, err = self.command(tool, args)
return " ".join(shlex.quote(a) for a in argv) if ok else err
+170
View File
@@ -0,0 +1,170 @@
"""Campaign rules: which results apply, what is required, and whether a
release is authorized.
A campaign is bound to one image (manifest content hash) and one catalog
(catalog hash). It is open from its record until a FAIL/ERROR result in
it, an invalidate-all, an explicit reset, or a different image or
catalog. Rules per test T with domain fingerprint F(T):
satisfied by campaign a PASS in the open campaign with fingerprint F(T)
satisfied by inheritance (never for the always-required core) the newest
PASS anywhere in the history with fingerprint F(T)
and newer than the last invalidate-all
required otherwise (reason: always / never-passed /
domain-changed)
authorized <=> a campaign is open and every catalog test is satisfied
Pure functions over the record list; nothing here touches hardware or
the filesystem, which keeps the rules unit-testable and lets the release
gate reason with the same code.
"""
PASS, FAIL, ERROR, ABORTED = "PASS", "FAIL", "ERROR", "ABORTED"
CLOSING = (FAIL, ERROR)
def _summary(rec):
if rec is None:
return None
keys = ("ts", "result", "campaign", "image", "fingerprint", "duration_s", "message")
return {k: rec.get(k) for k in keys if k in rec}
def open_campaign(records, manifest_sha, catalog_hash):
"""(open_campaign_record_or_None, last_campaign_record_or_None,
closed_by_or_None, invalidate_record_or_None)."""
current = None
closed_by = None
invalidate = None
for r in records:
t = r.get("t")
if t == "campaign":
current = r
closed_by = None
elif t == "result":
if current and r.get("campaign") == current.get("id") and r.get("result") in CLOSING:
closed_by = closed_by or "fail"
elif t == "invalidate":
invalidate = r
if current:
closed_by = closed_by or "invalidate"
elif t == "reset":
if current:
closed_by = closed_by or "reset"
if current and not closed_by:
if current.get("manifest_sha") != manifest_sha:
closed_by = "image"
elif current.get("catalog_hash") != catalog_hash:
closed_by = "catalog"
return (current if current and not closed_by else None), current, closed_by, invalidate
def compute(records, tests, manifest, catalog_hash, running=None):
"""The state the page shows and the exporter serializes.
tests: iterable of catalog.Test. running: id of the test in progress
(marked in the per-test status), or None.
"""
results = [r for r in records if r.get("t") == "result"]
campaign, last, closed_by, invalidate = open_campaign(records, manifest.content_sha, catalog_hash)
epoch = invalidate.get("ts") if invalidate else None
open_id = campaign.get("id") if campaign else None
# The invalidate record keeps its epoch for good: nothing older than it
# inherits. The page and the artifact report it only while it still asks
# for something, that is until a campaign has started after it.
invalidate_shown = invalidate
if invalidate:
seen = False
for r in records:
if r is invalidate:
seen = True
elif seen and r.get("t") == "campaign":
invalidate_shown = None
break
by_test = {}
for r in results:
by_test.setdefault(r.get("test"), []).append(r)
out_tests = {}
tests = list(tests)
for t in tests:
fp = t.fingerprint(manifest)
hist = by_test.get(t.id, [])
last_r = hist[-1] if hist else None
origin = None
status = "none"
satisfied = False
reason = None
same = None
if open_id:
for r in reversed(hist):
if r.get("campaign") == open_id and r.get("result") == PASS and r.get("fingerprint") == fp:
same = r
break
if same is not None:
status, satisfied, origin, reason = "pass", True, same, "campaign"
elif t.always:
reason = "always"
else:
# The newest record on the current fingerprint decides: a PASS
# is inherited, a FAIL or ERROR after it blocks the inheritance
# (the test has to be run again), an ABORTED run says nothing.
inh = None
blocked = None
for r in reversed(hist):
if r.get("fingerprint") != fp:
continue
if epoch and (r.get("ts") or "") <= epoch:
continue
res = r.get("result")
if res == PASS:
inh = r
break
if res in (FAIL, ERROR):
blocked = r
break
if inh is not None:
status, satisfied, origin, reason = "inherited", True, inh, "inherited"
elif blocked is not None and any(r.get("result") == PASS and r.get("fingerprint") == fp
and (r.get("ts") or "") < (blocked.get("ts") or "")
for r in hist):
reason = "failed-since"
else:
reason = "domain-changed" if any(r.get("result") == PASS for r in hist) else "never-passed"
if not satisfied and last_r is not None:
status = {PASS: "stale", FAIL: "fail", ERROR: "error", ABORTED: "aborted"}.get(last_r.get("result"), "none")
if running == t.id:
status = "running"
out_tests[t.id] = {
"fingerprint": fp,
"status": status,
"satisfied": satisfied,
"required": not satisfied,
"reason": reason,
"last": _summary(last_r),
"origin": _summary(origin),
}
# requires: a prerequisite counts when it is satisfied (campaign or
# inherited); the always-required core is the freshness mechanism.
for t in tests:
missing = [r for r in t.requires if not out_tests.get(r, {}).get("satisfied")]
out_tests[t.id]["missing_requires"] = missing
out_tests[t.id]["requires_met"] = not missing
n_sat = sum(1 for v in out_tests.values() if v["satisfied"])
n_inh = sum(1 for v in out_tests.values() if v["status"] == "inherited")
counts = {"total": len(tests), "satisfied": n_sat, "inherited": n_inh,
"required": len(tests) - n_sat}
authorized = bool(campaign) and n_sat == len(tests) and len(tests) > 0
return {
"campaign": campaign,
"last_campaign": last,
"closed_by": closed_by,
"invalidate": invalidate_shown,
"authorized": authorized,
"counts": counts,
"tests": out_tests,
}
+338
View File
@@ -0,0 +1,338 @@
"""The acceptance catalog: test definitions and the registry.
A test is a function decorated with @test(...). The decorator records
what the release gate needs to know without running anything: the id,
the subsystem, the kind (auto / operator / live), how it takes the
hardware (api / takeover), the controller mode it needs (if any), what
source it covers, what it requires, and whether it belongs to the
always-required core. Two more fields describe the operator's part
without affecting the gate: `actions`, the machine actions the test asks
for by name (the page lists them before a start; a bench actuator can
perform them), and `precheck`, a condition the machine must meet for the
test to start at all (a reason string refuses the start, the way an
unmet prerequisite does, and records no result). The function body runs
under the runner with a Context (log, prompts, evidence, hardware
helpers) and reports by returning normally (PASS) or raising
runner.Failed (FAIL).
"""
import ast
import hashlib
import inspect
import os
import re
import threading
from . import manifest as _manifest
KINDS = ("auto", "operator", "live")
HARDWARE = ("api", "takeover")
MODES = ("grbl", "cloud")
# The machine actions a test may ask of the operator (Context.act):
# the lid, the remote-interlock loop, and the big button. Everything a
# test needs done to the machine is one of these, so a bench actuator
# that covers a channel can stand in for the hands on it.
ACTIONS = ("lid", "interlock", "button")
_ID_RX = re.compile(r"^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$")
REGISTRY = {}
class Test:
def __init__(self, id, title, subsystem, kind, hardware, covers, requires,
always, est_min, steps, description, fn, mode=None, actions=(),
precheck=None, hands=()):
self.id = id
self.title = title
self.subsystem = subsystem
self.kind = kind
self.hardware = hardware
self.mode = mode
self.covers = tuple((str(c), str(g)) for c, g in covers)
self.requires = tuple(requires)
self.always = bool(always)
self.est_min = est_min
self.steps = tuple(steps)
self.actions = tuple(actions)
self.hands = tuple(hands)
self.precheck = precheck
self.description = description or (fn.__doc__ or "").strip()
self.fn = fn
self._source_sha = None
self._fp = (None, None) # (manifest content sha, fingerprint)
@property
def source_sha(self):
"""The hash of the test's own implementation: its function (the
decorator included) together with the code its module shares
among its tests - everything outside the @test functions. Part
of the fingerprint: a change inside one test's body invalidates
that test's earlier passes and no other; a change to a helper
invalidates the tests of that module. A test the module does not
define in the @test form hashes its whole file."""
if self._source_sha is None:
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
self._source_sha = implementation_sha(path, self.id)
return self._source_sha
def fingerprint(self, manifest):
"""The domain fingerprint on this manifest, memoized by the
manifest's content hash: the page recomputes every test's
fingerprint on every poll, and a manifest never changes under a
running tool."""
key = manifest.content_sha
if key and self._fp[0] == key:
return self._fp[1]
fp = _manifest.fingerprint(manifest, self.covers, extra=[self.source_sha])
if key:
self._fp = (key, fp)
return fp
def definition(self):
"""The gate-visible definition (no implementation, no prose)."""
return {
"id": self.id,
"subsystem": self.subsystem,
"kind": self.kind,
"hardware": self.hardware,
"covers": [list(c) for c in self.covers],
"requires": list(self.requires),
"always": self.always,
}
def describe(self):
d = self.definition()
d.update({"title": self.title, "est_min": self.est_min, "mode": self.mode,
"steps": list(self.steps), "actions": list(self.actions),
"hands": list(self.hands), "precheck": bool(self.precheck),
"description": self.description})
return d
def fixture_runnable(self, channels):
"""Whether a bench actuator covering `channels` lets this test
run with nobody in the room: an operator test whose every action
is a covered channel and which asks a person for nothing else. A
live test never qualifies (the arm press and the fire watch are
a person's); an auto test needs no fixture."""
if self.kind != "operator" or self.hands or not self.actions:
return False
return all(a in channels for a in self.actions)
def cannot_start(self):
"""The reason the test cannot start on the machine as it is, or
None. Evaluated right before a start; never a result."""
if self.precheck is None:
return None
try:
reason = self.precheck()
except Exception as e: # noqa: BLE001 - a broken precheck refuses, it never crashes the runner
return "precheck errored: %s: %s" % (type(e).__name__, e)
return reason or None
def source_file_sha(path):
with open(path, "rb") as f:
data = f.read().replace(b"\r\n", b"\n")
return hashlib.sha256(data).hexdigest()
_PARTS = {} # path -> (shared sha, {test id: own sha}); the files never change under a run
_PARTS_LOCK = threading.Lock()
def module_parts(path):
"""(shared sha, {test id: own sha}) for a suite module: the test ids
are read from the @test("...") decorators on the module's top-level
functions; a test's own text runs from its first decorator line to
the end of its body; the shared text is every other line of the
file. Line endings normalized."""
with _PARTS_LOCK:
hit = _PARTS.get(path)
if hit is not None:
return hit
with open(path, "rb") as f:
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
lines = text.split("\n")
spans = {}
try:
tree = ast.parse(text)
except SyntaxError:
tree = None
for node in (tree.body if tree is not None else []):
if not isinstance(node, ast.FunctionDef):
continue
for d in node.decorator_list:
if (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == "test"
and d.args and isinstance(d.args[0], ast.Constant) and isinstance(d.args[0].value, str)):
start = min([dd.lineno for dd in node.decorator_list] + [node.lineno])
spans[d.args[0].value] = (start, node.end_lineno)
owned = set()
for a, b in spans.values():
owned.update(range(a, b + 1))
shared = "\n".join(ln for i, ln in enumerate(lines, 1) if i not in owned)
own = {tid: _manifest.sha256_text("\n".join(lines[a - 1:b])) for tid, (a, b) in spans.items()}
parts = (_manifest.sha256_text(shared), own)
with _PARTS_LOCK:
_PARTS[path] = parts
return parts
def sibling_imports(path, seen=None):
"""The suite modules a module imports with `from .x import ...`, as
paths, transitively, in a stable order. A helper a test calls from
a sibling module is part of the judge, so its text belongs in the
test's fingerprint."""
if seen is None:
seen = []
with open(path, "rb") as f:
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
try:
tree = ast.parse(text)
except SyntaxError:
return seen
here = os.path.dirname(path)
for node in tree.body:
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
if os.path.isfile(sib) and sib not in seen and sib != path:
seen.append(sib)
sibling_imports(sib, seen)
return seen
def implementation_sha(path, test_id):
"""The implementation hash of one test (see Test.source_sha): the
test's own text, the shared text of its module, and the shared text
of every sibling suite module the module imports."""
shared, own = module_parts(path)
if test_id not in own:
return source_file_sha(path)
helpers = [module_parts(p)[0] for p in sorted(sibling_imports(path))]
return _manifest.sha256_text("%s:%s" % (":".join([shared] + helpers), own[test_id]))
def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers=(),
requires=(), always=False, est_min=1, steps=(), description="", actions=(),
precheck=None, hands=()):
"""`mode` names the controller mode the test needs live when it starts
("grbl" or "cloud"); the runner switches the machine there before the
test and leaves it there, so a queue crosses modes only where a test
asks it to. None means the test runs in whatever mode it finds (or
manages the mode itself, as the cloud tests do through enter_cloud,
which also waits for the service session).
`actions` names the machine actions (ACTIONS) the test performs
through Context.act; an `auto` test declares none. `hands` names what
the test asks of a person beyond those actions ("app" for a job in
the Glowforge app, "scrap", "mark" for an eye on the result...): an
operator test with none, whose actions a bench actuator covers, runs
unattended when one is up. `precheck` is a callable returning a
reason string when the machine cannot run the test as it is (None
when it can)."""
if not _ID_RX.match(id):
raise ValueError("test id %r must look like subsystem.name" % id)
if kind not in KINDS:
raise ValueError("test %s: kind %r" % (id, kind))
if hardware not in HARDWARE:
raise ValueError("test %s: hardware %r" % (id, hardware))
if mode is not None and mode not in MODES:
raise ValueError("test %s: mode %r" % (id, mode))
for c, g in covers:
if c in _manifest.DEV_ONLY_COMPONENTS:
raise ValueError("test %s: may not cover dev-only component %r" % (id, c))
for a in actions:
if a not in ACTIONS:
raise ValueError("test %s: action %r" % (id, a))
if actions and kind == "auto":
raise ValueError("test %s: an auto test asks for no machine actions" % id)
if hands and kind == "auto":
raise ValueError("test %s: an auto test asks nothing of a person" % id)
if precheck is not None and not callable(precheck):
raise ValueError("test %s: precheck must be callable" % id)
def deco(fn):
if id in REGISTRY:
raise ValueError("duplicate test id %r" % id)
REGISTRY[id] = Test(id, title, subsystem, kind, hardware, covers, requires,
always, est_min, steps, description, fn, mode=mode,
actions=actions, precheck=precheck, hands=hands)
return fn
return deco
def all_tests(registry=None):
"""Tests in registration order (the suite modules import in
subsystem order, so this is the display order)."""
return list((registry if registry is not None else REGISTRY).values())
def get(id, registry=None):
return (registry if registry is not None else REGISTRY).get(id)
def order_by_requires(tests, selected):
"""`selected` ids in an order that runs a prerequisite before the test
that names it.
Registration order otherwise, so a run reads down the page. A
prerequisite outside the selection places nothing: either it is
already satisfied, or the run that needs it will be refused and
recorded as skipped. validate() has ruled out cycles; the stack check
only keeps a malformed registry from recursing forever.
"""
want = set(selected)
by_id = {t.id: t for t in tests}
out, placed = [], set()
def visit(tid, stack):
if tid in placed or tid not in want or tid not in by_id or tid in stack:
return
stack.add(tid)
for r in by_id[tid].requires:
visit(r, stack)
stack.discard(tid)
placed.add(tid)
out.append(tid)
for t in tests:
visit(t.id, set())
return out
def validate(registry=None):
"""Every `requires` names a known test and there are no cycles."""
reg = registry if registry is not None else REGISTRY
for t in reg.values():
for r in t.requires:
if r not in reg:
raise ValueError("test %s requires unknown test %s" % (t.id, r))
seen = {}
def visit(tid, stack):
if tid in stack:
raise ValueError("requires cycle: %s" % " -> ".join(stack + [tid]))
if seen.get(tid):
return
for r in reg[tid].requires:
visit(r, stack + [tid])
seen[tid] = True
for tid in reg:
visit(tid, [])
def catalog_hash(registry=None):
"""Identity of the catalog's definitions (ids, kinds, coverage,
requirements, core membership) - not of the implementations, which
the per-test fingerprints carry."""
defs = sorted((t.definition() for t in all_tests(registry)), key=lambda d: d["id"])
return _manifest.sha256_text(_manifest.canonical(defs))
def load_suite():
"""Import the suite modules (each registers its tests) and validate."""
from . import suite # noqa: F401 (registers on import)
validate()
return REGISTRY
def suite_dir():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "suite")
+72
View File
@@ -0,0 +1,72 @@
"""Coverage lint: every source path in the manifest must be selected by
some test's coverage globs, except the allowlisted non-behavioral paths,
and every coverage glob must select at least one path of its component.
Why a hard rule: under the domain model an uncovered file is worse than an
untested one - a change there leaves every inherited PASS valid when it
should have invalidated them. The lint is the floor (the file is
fingerprinted by at least one test); whether that test exercises the
change stays with the change author. An entry that selects nothing is
the same defect from the other side: the test's fingerprint ignores the
file it named.
python3 -m forgetest.coverage [--manifest PATH] [--enforce] [--json]
Exit 0 when nothing is uncovered (or when only reporting), 1 under
--enforce with uncovered paths, 2 on a usage or load error.
"""
import argparse
import json
import sys
from . import catalog as _catalog
from . import manifest as _manifest
# Non-behavioral paths need no acceptance coverage, and no fingerprint
# carries them: the one list, in the manifest module.
ALLOW = _manifest.NON_BEHAVIORAL
def run(manifest, tests, allow=ALLOW):
return _manifest.coverage_report(manifest, tests, allow)
def empty(manifest, tests):
return _manifest.empty_covers(manifest, tests)
def main(argv=None):
ap = argparse.ArgumentParser(description="forgetest coverage lint")
ap.add_argument("--manifest", default=None, help="manifest JSON (default: the running image's)")
ap.add_argument("--enforce", action="store_true", help="exit 1 when any path is uncovered")
ap.add_argument("--json", action="store_true", help="machine-readable report")
args = ap.parse_args(argv)
try:
manifest = _manifest.Manifest.load(args.manifest)
except (OSError, ValueError) as e:
print("cannot load manifest: %s" % e, file=sys.stderr)
return 2
registry = _catalog.load_suite()
tests = _catalog.all_tests(registry)
report = run(manifest, tests)
total = sum(len(v) for v in report.values())
hollow = empty(manifest, tests)
if args.json:
print(json.dumps({"uncovered": report, "total": total, "tests": len(tests),
"empty": [list(e) for e in hollow]}, indent=1, sort_keys=True))
else:
for comp in sorted(report):
print("%s: %d uncovered path(s)" % (comp, len(report[comp])))
for p in report[comp]:
print(" %s" % p)
for tid, comp, pat in hollow:
print("%s: covers (%s, %s) selects nothing" % (tid, comp, pat))
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, %d tests"
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(tests)))
if args.enforce and (total or hollow):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+387
View File
@@ -0,0 +1,387 @@
"""The bench actuator (forgefixture): the box on the bench network that
opens the lid loop, pulls the interlock loop and presses the button on
request, so an operator test can run with nobody in the room.
Configured by a bench-local file, FIXTURE_CONFIG (default
/data/forgetest/fixture.json, mode 0600, never in the repo):
{"hostname": "forgefixture", "key": "<the fixture's API key>",
"ip": null, "channels": ["lid", "interlock", "button"],
"arm_press": false}
`hostname` is resolved as <hostname>.local by the resolver here (the
image carries no mDNS resolver; the fixture answers the query itself);
`ip` overrides that. `channels` names what is wired. `arm_press` is the
one policy switch: whether the fixture may press the button to arm the
laser for a live test (a person's press by default).
The fixture does not read the machine: every action the runner asks of
it is verified through the machine's own readings afterward, exactly as
an operator's hand is (Context.act). Contract: fixture/README.md.
"""
import json
import os
import socket
import struct
import threading
import time
import urllib.error
import urllib.request
FIXTURE_CONFIG = os.environ.get("FORGETEST_FIXTURE_CONFIG", "/data/forgetest/fixture.json")
CHANNELS = ("lid", "interlock", "button")
MDNS_GROUP = "224.0.0.251"
MDNS_PORT = 5353
RESOLVE_TTL_S = 300.0 # a resolved address is trusted this long
BUTTON_GAP_S = 0.3 # the release between two presses: long enough for the controller to see it
PULSE_WAIT_S = 1.5 # a pulse in progress ends within the firmware's 500 ms clamp
class FixtureError(Exception):
pass
# ---------------------------------------------------------------- mDNS
def mdns_query(name):
"""One mDNS A query for `name` (e.g. "forgefixture.local"), as the
bytes on the wire: a standard DNS header with id 0, one question,
class IN with the unicast-response bit set so the answer comes back
to this socket rather than the group."""
labels = b"".join(struct.pack("B", len(p)) + p.encode("ascii") for p in name.rstrip(".").split("."))
header = struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0)
return header + labels + b"\x00" + struct.pack("!HH", 1, 0x8001)
def _read_name(data, off):
"""A DNS name at `off`, compression pointers followed; (name, next
offset)."""
parts = []
jumped = False
end = off
guard = 0
while True:
if off >= len(data):
raise ValueError("truncated name")
n = data[off]
if n == 0:
off += 1
break
if n & 0xC0 == 0xC0:
ptr = struct.unpack("!H", data[off:off + 2])[0] & 0x3FFF
if not jumped:
end = off + 2
jumped = True
off = ptr
guard += 1
if guard > 32:
raise ValueError("pointer loop")
continue
parts.append(data[off + 1:off + 1 + n].decode("ascii", "replace"))
off += 1 + n
if not jumped:
end = off
return ".".join(parts).lower(), end
def mdns_answers(data, name):
"""The IPv4 addresses in an mDNS response's A records for `name`
(answers and additionals; a response is anything with QR set)."""
name = name.rstrip(".").lower()
out = []
try:
_id, flags, qd, an, ns, ar = struct.unpack("!HHHHHH", data[:12])
if not flags & 0x8000:
return out
off = 12
for _ in range(qd):
_n, off = _read_name(data, off)
off += 4
for _ in range(an + ns + ar):
rname, off = _read_name(data, off)
rtype, rclass, _ttl, rdlen = struct.unpack("!HHIH", data[off:off + 10])
off += 10
rdata = data[off:off + rdlen]
off += rdlen
if rtype == 1 and rdlen == 4 and rname == name:
out.append(socket.inet_ntoa(rdata))
except (struct.error, ValueError):
pass
return out
def _local_ipv4s():
"""The interface addresses to send a multicast query from: the
default first (None), then every IPv4 address this host answers to."""
out = [None]
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET, socket.SOCK_DGRAM):
ip = info[4][0]
if ip not in out and not ip.startswith("127."):
out.append(ip)
except OSError:
pass
return out
def resolve_mdns(hostname, timeout=2.0, tries=3):
"""The IPv4 address of <hostname>.local, asked of the network
directly; None when nothing answers."""
name = hostname if hostname.endswith(".local") else hostname + ".local"
query = mdns_query(name)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
# On the mDNS port and in the group, so a responder that answers
# the group rather than the asker is heard too; a port already
# taken (a resolver daemon) means that daemon would have served
# the name, so the ephemeral port and unicast replies will do.
try:
sock.bind(("", MDNS_PORT))
except OSError:
sock.bind(("", 0))
try:
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
socket.inet_aton(MDNS_GROUP) + socket.inet_aton("0.0.0.0"))
except OSError:
pass # no group membership: unicast replies still arrive
for _ in range(tries):
# The query goes out of every local IPv4 interface that will
# take it (a host with a VPN or a VM adapter as its default
# route would otherwise never reach the bench network).
sent = False
for ifaddr in _local_ipv4s():
try:
if ifaddr is not None:
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(ifaddr))
sock.sendto(query, (MDNS_GROUP, MDNS_PORT))
sent = True
except OSError:
continue
if not sent:
return None
deadline = time.time() + timeout
while True:
left = deadline - time.time()
if left <= 0:
break
sock.settimeout(left)
try:
data, _peer = sock.recvfrom(2048)
except socket.timeout:
break
except OSError:
break
for ip in mdns_answers(data, name):
return ip
return None
finally:
sock.close()
# -------------------------------------------------------------- client
def load_config(path=None):
"""The bench's fixture config, or None when there is none (a bench
without a fixture: the runner behaves exactly as before)."""
path = path or FIXTURE_CONFIG
try:
with open(path, "r", encoding="utf-8") as f:
cfg = json.load(f)
except OSError:
return None
except ValueError as e:
raise FixtureError("%s: not JSON: %s" % (path, e))
if not isinstance(cfg, dict) or not cfg.get("key"):
raise FixtureError("%s: needs at least a key" % path)
cfg.setdefault("hostname", "forgefixture")
cfg.setdefault("ip", None)
cfg.setdefault("port", 80)
chans = cfg.get("channels")
if chans is None:
chans = list(CHANNELS)
bad = [c for c in chans if c not in CHANNELS]
if bad:
raise FixtureError("%s: unknown channel(s) %s" % (path, ", ".join(bad)))
cfg["channels"] = list(chans)
cfg["arm_press"] = bool(cfg.get("arm_press", False))
return cfg
class Fixture:
"""The actuator as the runner sees it: which channels it covers, an
action per channel, a release, its state. Every request carries the
key; the address comes from the config's ip or the mDNS lookup,
re-resolved when a request fails to connect."""
def __init__(self, cfg, timeout=3.0, resolver=resolve_mdns):
self.hostname = cfg["hostname"]
self.ip_override = cfg.get("ip") or None
self.port = int(cfg.get("port") or 80)
self.key = cfg["key"]
self.channels = tuple(cfg["channels"])
self.arm_press = bool(cfg.get("arm_press", False))
self.timeout = timeout
self._resolver = resolver
self._ip = self.ip_override
self._resolved_at = time.time() if self.ip_override else 0.0
self._lock = threading.Lock()
self.last_state = None
self._pulse_end = 0.0 # when this client's last press ends (its pulse_ms)
# -- address -------------------------------------------------------
def address(self, refresh=False):
with self._lock:
if self.ip_override:
return self.ip_override
stale = time.time() - self._resolved_at > RESOLVE_TTL_S
if self._ip is None or refresh or stale:
ip = self._resolver(self.hostname)
if ip is None:
raise FixtureError("%s.local did not answer the mDNS lookup (set ip in the config "
"to skip it)" % self.hostname)
self._ip = ip
self._resolved_at = time.time()
return self._ip
def where(self):
return "%s (%s)" % (self.hostname, self._ip or "unresolved")
# -- requests ------------------------------------------------------
def _request(self, method, path, body=None, retry=True):
ip = self.address()
data = json.dumps(body).encode() if body is not None else None
host = ip if self.port == 80 else "%s:%d" % (ip, self.port)
req = urllib.request.Request("http://%s%s" % (host, path), data=data, method=method,
headers={"X-Fixture-Key": self.key, "Host": host,
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8", "replace") or "{}")
except urllib.error.HTTPError as e:
try:
body = json.loads(e.read().decode("utf-8", "replace") or "{}")
except ValueError:
body = {}
return e.code, body
except (urllib.error.URLError, socket.timeout, OSError) as e:
if retry and not self.ip_override:
# the address may have moved: one fresh lookup, one retry
self.address(refresh=True)
return self._request(method, path, body, retry=False)
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e))
def status(self):
st, body = self._request("GET", "/")
if st == 401:
raise FixtureError("fixture %s refused the key" % self.where())
if st != 200 or body.get("device") != "forgefixture":
raise FixtureError("fixture %s: unexpected answer %s %s" % (self.where(), st, body))
self.last_state = body
return body
def covers(self, channel):
"""The runner asks this per action. The button needs the jumper
in, which the fixture reports; a channel it does not cover falls
back to the operator."""
if channel not in self.channels:
return False
if channel == "button":
st = self.last_state
return bool(st and st.get("button_enabled"))
return True
def act(self, channel, state):
if channel == "button":
if state != "press":
raise FixtureError("the button is only ever pressed")
st, body = self._press()
else:
if state not in ("open", "close"):
raise FixtureError("%s: unknown state %r" % (channel, state))
st, body = self._request("POST", "/" + channel, {"state": state})
if st != 200:
raise FixtureError("fixture %s: %s %s -> %s %s" % (self.where(), channel, state, st,
body.get("error") or body))
self.last_state = body
return body
def _press(self):
"""One press, spaced from the last one. The controller detects
the button's rising edge, so a press must follow a release it has
seen: the next press waits for the last pulse to end (the
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
progress (a press this client did not time) is waited out once
against the fixture's state, then retried."""
wait = self._pulse_end + BUTTON_GAP_S - time.time()
if wait > 0:
time.sleep(wait)
st, body = self._request("POST", "/button", {})
if st == 409 and "in progress" in str(body.get("error", "")):
deadline = time.time() + PULSE_WAIT_S
while time.time() < deadline:
time.sleep(0.1)
s, b = self._request("GET", "/")
if s == 200 and not b.get("button_pulsing"):
break
time.sleep(BUTTON_GAP_S)
st, body = self._request("POST", "/button", {})
if st == 200:
self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0
return st, body
def release(self):
st, body = self._request("POST", "/release", {})
if st != 200:
raise FixtureError("fixture %s: release -> %s %s" % (self.where(), st, body.get("error") or body))
self.last_state = body
return body
@staticmethod
def energized(state):
"""The channels a state report shows energized (a loop open, the
button pressed)."""
ch = (state or {}).get("channels") or {}
return [c for c, s in ch.items() if s in ("open", "pressed")]
def summary(self):
# The radio signal and the uptime go in the record: this actuator
# is on wifi, and a run that lost it wants both numbers to say
# whether the link faded or the box restarted. Without them a
# drop is only ever "it was gone".
st = self.last_state or {}
wifi = st.get("wifi") or {}
return {"hostname": self.hostname, "ip": self._ip, "channels": list(self.channels),
"button_enabled": bool(st.get("button_enabled")), "arm_press": self.arm_press,
"version": st.get("version"), "uptime_s": st.get("uptime_s"),
"rssi": wifi.get("rssi")}
def probe(log, path=None, resolver=resolve_mdns):
"""The bench's fixture, up and answering, or None: no config means
no fixture; a config whose fixture does not answer is logged and
treated the same, so the run goes to the operator."""
try:
cfg = load_config(path)
except FixtureError as e:
log("fixture: %s - running without it" % e)
return None
if cfg is None:
return None
fx = Fixture(cfg, resolver=resolver)
try:
st = fx.status()
except FixtureError as e:
log("fixture: %s - running without it" % e)
return None
chans = ", ".join(fx.channels)
log("fixture up: %s at %s, v%s, covers %s%s" % (
fx.hostname, fx._ip, st.get("version"), chans,
"" if st.get("button_enabled") or "button" not in fx.channels
else " (button disabled: the enable jumper is out)"))
return fx
__all__ = ["Fixture", "FixtureError", "CHANNELS", "FIXTURE_CONFIG", "load_config", "probe",
"resolve_mdns", "mdns_query", "mdns_answers"]
+521
View File
@@ -0,0 +1,521 @@
"""Hardware and service access for the suite: forgectrl's HTTP API, the
kernel module's sysfs, the init scripts, and the Grbl TCP port.
Everything is reachable through environment overrides so the suite can be
exercised against a mock on a host:
FORGECTRL_URL default http://127.0.0.1
FORGECTRL_TOKEN_FILE default /data/forgefirm/panel.token
GF_SYSFS_ROOT default /sys/glowforge/ (must end with '/')
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
FORGETEST_INITD default /etc/init.d
"""
import json
import os
import socket
import subprocess
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
class HwError(Exception):
pass
# ------------------------------------------------------------ forgectrl
# The supervisor's levers answer only when the switch is done: a mode
# switch waits for the old controller to exit, the new one to start (a
# pending liveness probe first) and its first job-state report (up to
# 15 s; the emulator's comes with its start, a real client's with its
# machine); a stop waits for the child to go. They get their own timeout,
# above the daemon's own deadlines, so a slow but honest switch is never
# read as a dead daemon.
SLOW_PATHS = ("/mode", "/controller/start", "/controller/stop")
SLOW_TIMEOUT_S = 120.0
class Forgectrl:
"""Thin client for the machine-services daemon."""
def __init__(self, base=None, token=None, timeout=10.0, slow_timeout=SLOW_TIMEOUT_S):
self.base = (base or os.environ.get("FORGECTRL_URL") or "http://127.0.0.1").rstrip("/")
self.timeout = timeout
self.slow_timeout = slow_timeout
self._token = token
def timeout_for(self, method, path):
return self.slow_timeout if method == "POST" and path in SLOW_PATHS else self.timeout
@property
def token(self):
if self._token is None:
path = os.environ.get("FORGECTRL_TOKEN_FILE") or "/data/forgefirm/panel.token"
try:
with open(path, "r", encoding="utf-8") as f:
self._token = f.read().strip()
except OSError:
self._token = ""
return self._token
def host_header(self):
return urllib.parse.urlsplit(self.base).netloc
def request(self, method, path, params=None, data=None, headers=None, auth=True, raw=False):
"""Returns (status, body). body is parsed JSON when the response
is JSON, else text (or bytes when raw=True). Never raises on an
HTTP error status - the suite asserts on codes."""
url = self.base + path
if params:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
body = None
hdrs = {"Host": self.host_header()}
if headers:
hdrs.update(headers)
if data is not None:
if isinstance(data, (dict, list)):
body = urllib.parse.urlencode(data).encode()
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded")
elif isinstance(data, str):
body = data.encode()
else:
body = data
if auth and self.token:
hdrs.setdefault("X-ForgeFIRM-Token", self.token)
req = urllib.request.Request(url, data=body, method=method, headers=hdrs)
try:
with urllib.request.urlopen(req, timeout=self.timeout_for(method, path)) as resp:
status = resp.status
content = resp.read()
ctype = resp.headers.get("Content-Type", "")
except urllib.error.HTTPError as e:
status = e.code
content = e.read()
ctype = e.headers.get("Content-Type", "") if e.headers else ""
except (urllib.error.URLError, socket.timeout, OSError) as e:
raise HwError("forgectrl %s %s: %s" % (method, path, e))
if raw:
return status, content
text = content.decode("utf-8", "replace")
if "json" in ctype:
try:
return status, json.loads(text)
except ValueError:
pass
return status, text
def get(self, path, **kw):
return self.request("GET", path, **kw)
def post(self, path, **kw):
return self.request("POST", path, **kw)
def status(self):
st, body = self.get("/status")
if st != 200 or not isinstance(body, dict):
raise HwError("forgectrl /status -> %s" % st)
return body
def settings(self):
st, body = self.get("/settings")
if st != 200 or not isinstance(body, dict):
raise HwError("forgectrl /settings -> %s" % st)
return body
def wait_idle(self, timeout=60.0, poll=0.5, abort=None):
deadline = time.time() + timeout
while time.time() < deadline:
if abort and abort():
raise HwError("aborted while waiting for idle")
try:
if self.status().get("state") == "idle":
return True
except HwError:
pass
time.sleep(poll)
return False
# ---------------------------------------------------------------- sysfs
def sysfs_root():
r = os.environ.get("GF_SYSFS_ROOT") or "/sys/glowforge/"
return r if r.endswith("/") else r + "/"
def sysfs_read(attr, default=None):
try:
with open(sysfs_root() + attr, "r") as f:
return f.read().strip()
except OSError:
return default
def sysfs_int(attr, default=None):
v = sysfs_read(attr)
if v is None or v == "":
return default
try:
return int(v.split()[0], 0)
except ValueError:
return default
def sysfs_write(attr, value):
with open(sysfs_root() + attr, "w") as f:
f.write(str(value))
# ------------------------------------------------------ head accelerometer
# The head accelerometer (LIS2HH12 on i2c-3 at 0x1e): the supervisor's
# motion-liveness witness, and the catalog's. Resolved by bus address,
# never by iio index. The raw sysfs read is slow (~150 ms), which still
# lands several samples in a one-second move; the verdict is peak-to-peak
# on X or Y, against the thresholds forgectrl's probe established (a live
# head reads 1800-2900 on its probe move, a wedged one <= 250).
HEAD_ACCEL_I2C = "3-001e"
ACCEL_P2P_MOVING = 800
def head_accel_dir():
"""The head accel's iio directory, or None. GF_IIO_ROOT overrides the
/sys/bus/iio/devices root (host tests)."""
root = os.environ.get("GF_IIO_ROOT") or "/sys/bus/iio/devices"
try:
names = sorted(os.listdir(root))
except OSError:
return None
for n in names:
d = os.path.join(root, n)
try:
target = os.readlink(d)
except OSError:
target = ""
if HEAD_ACCEL_I2C in target or HEAD_ACCEL_I2C in n:
return d
# a plain directory (host fixture): its name carries the address
try:
with open(os.path.join(d, "name")) as f:
if HEAD_ACCEL_I2C in f.read():
return d
except OSError:
pass
return None
def head_accel_read(d):
"""(x, y) raw counts, or None."""
out = []
for axis in ("x", "y"):
try:
with open(os.path.join(d, "in_accel_%s_raw" % axis)) as f:
out.append(int(f.read().strip()))
except (OSError, ValueError):
return None
return tuple(out)
class AccelSampler:
"""Samples the head accelerometer in a thread for as long as it is
running; `p2p(t0, t1)` is the peak-to-peak on X and Y over the samples
in that window and how many there were. Used as a context manager
around a motion the test wants the head to have made."""
def __init__(self, period=0.0):
self.dir = head_accel_dir()
self.period = period
self.samples = [] # (t, x, y)
self._stop = threading.Event()
self._th = None
self.errors = 0
@property
def available(self):
return self.dir is not None
def __enter__(self):
if self.dir is not None:
self._th = threading.Thread(target=self._loop, daemon=True, name="forgetest-accel")
self._th.start()
return self
def __exit__(self, *a):
self._stop.set()
if self._th is not None:
self._th.join(2.0)
return False
def _loop(self):
while not self._stop.is_set():
v = head_accel_read(self.dir)
if v is None:
self.errors += 1
else:
self.samples.append((time.time(), v[0], v[1]))
if self.period:
self._stop.wait(self.period)
def p2p(self, t0, t1=None):
"""(p2p_x, p2p_y, n) over [t0, t1]; (0, 0, 0) with no samples."""
t1 = time.time() if t1 is None else t1
xs = [x for t, x, _y in self.samples if t0 <= t <= t1]
ys = [y for t, _x, y in self.samples if t0 <= t <= t1]
if not xs:
return 0, 0, 0
return max(xs) - min(xs), max(ys) - min(ys), len(xs)
# ----------------------------------------------------------- button LEDs
BUTTON_LEDS = ("button_led_1", "button_led_2", "button_led_3")
def leds_root():
r = os.environ.get("GF_LEDS_ROOT") or "/sys/class/leds/"
return r if r.endswith("/") else r + "/"
def button_leds():
"""The three button LEDs' commanded levels (the smooth trigger's
`target`, which the controller writes; `brightness` follows it with
a fade and is read where no target exists), or None where unreadable."""
out = []
for name in BUTTON_LEDS:
val = None
for attr in ("target", "brightness"):
try:
with open(leds_root() + name + "/" + attr) as f:
val = int(f.read().strip())
break
except (OSError, ValueError):
continue
out.append(val)
return out
def button_lit():
"""True when any button LED is on, False when all three read 0, None
when none is readable."""
vals = [v for v in button_leds() if v is not None]
if not vals:
return None
return any(v > 0 for v in vals)
# --------------------------------------------------------------- init.d
def initd(service, action, timeout=60):
"""Run /etc/init.d/<service> <action>; returns (rc, output)."""
base = os.environ.get("FORGETEST_INITD") or "/etc/init.d"
script = os.path.join(base, service)
try:
p = subprocess.run([script, action], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=timeout)
except (OSError, subprocess.TimeoutExpired) as e:
return 127, str(e)
return p.returncode, p.stdout.decode("utf-8", "replace")
def pidof(comm):
"""PIDs whose /proc/<pid>/comm equals comm (15-char kernel limit applies)."""
out = []
try:
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
try:
with open("/proc/%s/comm" % pid) as f:
if f.read().strip() == comm:
out.append(int(pid))
except OSError:
pass
except OSError:
pass
return out
def pgrep_f(needle):
"""PIDs whose /proc/<pid>/cmdline contains needle (a script run by an
interpreter has the interpreter's comm, so pidof cannot see it)."""
out = []
try:
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
try:
with open("/proc/%s/cmdline" % pid, "rb") as f:
if needle.encode() in f.read():
out.append(int(pid))
except OSError:
pass
except OSError:
pass
return out
def run(cmd, timeout=60):
"""Run a command list; returns (rc, combined output)."""
try:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
except (OSError, subprocess.TimeoutExpired) as e:
return 127, str(e)
return p.returncode, p.stdout.decode("utf-8", "replace")
# ------------------------------------------------------------------ grbl
def grbl_port_open(timeout=5):
"""True when the Grbl port accepts a connection (closed again at once)."""
try:
s = socket.create_connection((os.environ.get("GRBL_HOST") or "127.0.0.1",
int(os.environ.get("GRBL_PORT") or 23)), timeout=timeout)
s.close()
return True
except OSError:
return False
class Grbl:
"""Minimal Grbl-over-TCP client. The suite is the only client while a
motion test runs; nothing here is used to poll status when a sender
may be attached (position for display comes from forgectrl)."""
def __init__(self, host=None, port=None, timeout=5.0):
self.host = host or os.environ.get("GRBL_HOST") or "127.0.0.1"
self.port = int(port or os.environ.get("GRBL_PORT") or 23)
self.timeout = timeout
self.sock = None
self.buf = b""
def __enter__(self):
self.connect()
return self
def __exit__(self, *exc):
self.close()
def connect(self):
self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
# Drain the greeting.
time.sleep(0.3)
self.drain()
def close(self):
if self.sock:
try:
self.sock.close()
except OSError:
pass
self.sock = None
def _recv(self):
"""Pull whatever is waiting into the buffer; never blocks long."""
self.sock.settimeout(0.05)
try:
while True:
d = self.sock.recv(4096)
if not d:
break
self.buf += d
except (socket.timeout, OSError):
pass
def _take_report(self):
"""Remove every complete <...> report from the buffer and return the
last one, keeping the text around them. Status reports are the only
thing consumed here: the driver's [MSG:] lines stay for drain()."""
last = None
while True:
i = self.buf.find(b"<")
j = self.buf.find(b">", i + 1) if i >= 0 else -1
if i < 0 or j <= i:
return last
last = self.buf[i + 1:j].decode("utf-8", "replace")
self.buf = self.buf[:i] + self.buf[j + 1:]
def drain(self):
self._recv()
out, self.buf = self.buf, b""
return out.decode("utf-8", "replace")
def send_raw(self, data):
self.sock.settimeout(self.timeout)
self.sock.sendall(data)
def command(self, line, timeout=None):
"""Send one line, return the response lines up to ok/error."""
self.send_raw((line.strip() + "\n").encode())
return self.wait_response(timeout or self.timeout)
def wait_response(self, timeout):
deadline = time.time() + timeout
lines = []
self.sock.settimeout(0.2)
while time.time() < deadline:
try:
d = self.sock.recv(4096)
if not d:
break
self.buf += d
except socket.timeout:
pass
while b"\n" in self.buf:
raw, self.buf = self.buf.split(b"\n", 1)
s = raw.decode("utf-8", "replace").strip()
if not s:
continue
lines.append(s)
if s == "ok" or s.startswith("error:"):
return lines
return lines
def realtime(self, byte):
self.send_raw(bytes([byte]))
def status_report(self):
"""One '?' report, parsed: {'state': 'Idle', 'MPos': (x,y,z), ...}.
The '?' is re-sent every 0.5 s until a report arrives: a soft
reset (^X) flushes the controller's read buffer and eats a '?'
that lands in it. Reports already buffered are stale and dropped -
but only the reports: anything else the controller said is left in
the buffer, so a test that polls for a state does not lose the
[MSG:] line that explains it."""
self._recv()
self._take_report() # stale: predates this '?'
self.send_raw(b"?")
deadline = time.time() + self.timeout
resend = time.time() + 0.5
self.sock.settimeout(0.2)
while time.time() < deadline:
try:
d = self.sock.recv(4096)
if d:
self.buf += d
except socket.timeout:
pass
if time.time() >= resend:
self.send_raw(b"?")
resend = time.time() + 0.5
rep = self._take_report()
if rep is not None:
return parse_report(rep)
raise HwError("no status report from grbl")
def parse_report(rep):
parts = rep.split("|")
out = {"state": parts[0]}
for p in parts[1:]:
if ":" in p:
k, v = p.split(":", 1)
if k in ("MPos", "WPos", "WCO"):
try:
out[k] = tuple(float(x) for x in v.split(","))
except ValueError:
out[k] = v
else:
out[k] = v
return out
+113
View File
@@ -0,0 +1,113 @@
"""The append-only result log (JSONL) and the export helpers.
One JSON object per line under the data directory (default
/data/forgetest/results.jsonl, override FORGETEST_DATA). Records:
campaign {"id","manifest_sha","catalog_hash","image"} a campaign opened
result {"campaign","test","result","fingerprint","manifest_sha",
"image","duration_s","evidence","answers","log","message"}
invalidate {"reason"} manual invalidate-all (full campaign required)
reset {"reason"} explicit campaign reset
export {"artifact_sha256","authorized","campaign"}
Every record carries "t" (type) and "ts" (UTC, ISO 8601, seconds).
The file is only ever appended; a corrupt line is skipped, counted, and
reported, never repaired in place.
Because the file only grows, `read` parses each line once and keeps what
it parsed; a later call reads only the bytes appended since. A result
record carries the whole run log, so the file reaches megabytes over a
campaign, and the page asks for the state every second or two.
"""
import json
import os
import threading
import time
DEFAULT_DATA_DIR = "/data/forgetest"
def now_ts():
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def data_dir():
return os.environ.get("FORGETEST_DATA") or DEFAULT_DATA_DIR
class Log:
def __init__(self, path=None):
self.path = path or os.path.join(data_dir(), "results.jsonl")
self._lock = threading.Lock()
self.corrupt = 0
self._recs = [] # every record parsed so far, in file order
self._offset = 0 # bytes of the file already consumed
self._tail = b"" # bytes past the last newline: not a record yet
def append(self, rec):
rec = dict(rec)
rec.setdefault("ts", now_ts())
line = json.dumps(rec, sort_keys=True, separators=(",", ":"))
with self._lock:
os.makedirs(os.path.dirname(self.path), exist_ok=True)
with open(self.path, "a", encoding="utf-8") as f:
f.write(line + "\n")
f.flush()
os.fsync(f.fileno())
return rec
def _forget(self):
"""Drop what was parsed: the next read starts from the top."""
self._recs = []
self._offset = 0
self._tail = b""
self.corrupt = 0
def _consume(self, chunk):
"""Parse the whole lines in `chunk`, holding back a partial tail."""
data = self._tail + chunk
cut = data.rfind(b"\n")
if cut < 0:
self._tail = data
return
self._tail = data[cut + 1:]
for line in data[:cut].split(b"\n"):
line = line.strip()
if not line:
continue
try:
rec = json.loads(line.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
self.corrupt += 1
continue
if isinstance(rec, dict) and "t" in rec:
self._recs.append(rec)
else:
self.corrupt += 1
def read(self):
"""Every record, in file order. Only the bytes appended since the
last call are parsed; a file that shrank or was replaced is read
again from the top."""
with self._lock:
try:
size = os.path.getsize(self.path)
except OSError:
self._forget()
return []
if size < self._offset:
self._forget()
if size != self._offset:
with open(self.path, "rb") as f:
f.seek(self._offset)
chunk = f.read()
self._offset += len(chunk)
self._consume(chunk)
return list(self._recs)
def raw(self):
if not os.path.exists(self.path):
return ""
with self._lock:
with open(self.path, "r", encoding="utf-8") as f:
return f.read()
+224
View File
@@ -0,0 +1,224 @@
"""The image manifest and the domain fingerprint.
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
identifies the build's inputs: for every component the pinned revision and
one [path, blob-id] pair per source file, plus the platform identity
(machine, kernel modules directory, device tree hashes, layer content
hashes). This module loads it and computes a test's *domain fingerprint*:
the hash of the source files its coverage globs select, plus the platform,
plus the test's own implementation. A recorded PASS applies to a build
exactly when the fingerprint recomputed from that build's manifest is the
same - the same code runs on the board and in the release gate.
"""
import functools
import hashlib
import json
import os
import re
DEFAULT_PATH = "/etc/forgefirm-manifest.json"
# Components that ship only on the dev image. They can never be part of a
# fingerprint (the release manifest lacks them, so the gate could not
# recompute it); the test implementation is folded in separately.
DEV_ONLY_COMPONENTS = ("forgetest",)
def canonical(obj):
"""Canonical JSON: sorted keys, no whitespace - the hashing form."""
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
def sha256_text(text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()
@functools.lru_cache(maxsize=512)
def glob_to_regex(pattern):
"""Coverage glob -> anchored regex. '**' spans directories, '*' and '?'
stay inside one path segment. Paths use '/' (git paths). Cached: the
catalog matches the same few hundred globs over and over."""
out = []
i, n = 0, len(pattern)
while i < n:
c = pattern[i]
if c == "*":
if pattern[i:i + 2] == "**":
# '**/' also matches zero directories
if pattern[i:i + 3] == "**/":
out.append("(?:.*/)?")
i += 3
continue
out.append(".*")
i += 2
continue
out.append("[^/]*")
elif c == "?":
out.append("[^/]")
else:
out.append(re.escape(c))
i += 1
return re.compile("^" + "".join(out) + "$")
def match_files(files, pattern):
"""(path, blob) pairs from a component's file list that the glob selects."""
rx = glob_to_regex(pattern)
return [(p, b) for p, b in files if rx.match(p)]
# Paths that carry no target behavior: docs, CI, the components' own unit
# tests, licenses, editor setup. Outside every fingerprint (a README edit
# re-requires nothing) and outside the coverage lint (no test has to name
# them). Reviewed with the catalog: widening this list is a change like
# any other. "*" applies to every component.
NON_BEHAVIORAL = [
("*", ".github/**"),
("*", ".gitignore"),
("*", ".gitattributes"),
("*", ".gitmodules"),
("*", "**/*.md"),
("*", "LICENSE*"),
("*", "COPYING*"),
("*", "docs/**"),
("*", "tests/**"),
("*", "graphify-out/**"),
("*", "**/.gitkeep"),
("*", ".devcontainer/**"),
("*", ".vscode/**"),
("*", ".env.example"),
("forgectrl", "tools/**"), # host-side dev tools (panel dev server)
]
def non_behavioral(comp, path, allow=NON_BEHAVIORAL):
for c, pat in allow:
if c in ("*", comp) and glob_to_regex(pat).match(path):
return True
return False
class Manifest:
def __init__(self, data):
self.data = data
self.components = data.get("components", {}) or {}
self.platform = data.get("platform", {}) or {}
self.image = data.get("image", {}) or {}
self.content_sha = data.get("content_sha256")
self._files = {}
@classmethod
def load(cls, path=None):
path = path or os.environ.get("FORGETEST_MANIFEST") or DEFAULT_PATH
with open(path, "r", encoding="utf-8") as f:
return cls(json.load(f))
@classmethod
def from_json(cls, text):
return cls(json.loads(text))
@property
def version(self):
return self.image.get("version") or "unknown"
@property
def image_name(self):
return self.image.get("name") or "unknown"
def files(self, component):
"""The (path, blob) pairs of a component, or None if the component
is not in this manifest. Built once per component: the manifest is
immutable and every coverage glob asks for the same lists."""
if component in self._files:
return self._files[component]
c = self.components.get(component)
out = None if c is None else [tuple(x) for x in c.get("files", [])]
self._files[component] = out
return out
def component_names(self):
return sorted(self.components)
def identity_sha(self):
"""sha256 of the acceptance-relevant identity: every component
except the dev-only ones, plus the platform. Informational (the
gate decides per test, by fingerprint)."""
comps = {k: v for k, v in self.components.items() if k not in DEV_ONLY_COMPONENTS}
return sha256_text(canonical({"components": comps, "platform": self.platform}))
def fingerprint(manifest, covers, extra=()):
"""The domain fingerprint of a coverage map on a manifest.
covers: iterable of (component, glob). extra: strings folded in after
the files (the test's own implementation hash). A component the
manifest lacks contributes a marker so the fingerprint is still
defined and distinct.
"""
parts = set()
for comp, pat in covers:
if comp in DEV_ONLY_COMPONENTS:
raise ValueError("coverage may not name the dev-only component %r" % comp)
files = manifest.files(comp)
if files is None:
parts.add((comp, "@missing", ""))
continue
for p, b in match_files(files, pat):
if non_behavioral(comp, p):
continue
parts.add((comp, p, b))
h = hashlib.sha256()
h.update(canonical(sorted(parts)).encode("utf-8"))
h.update(b"\n")
h.update(canonical(manifest.platform).encode("utf-8"))
for e in extra:
h.update(b"\n")
h.update(str(e).encode("utf-8"))
return h.hexdigest()
def empty_covers(manifest, tests):
"""Coverage entries that select no file of their component: a glob
that never matched (paths anchor at the repository root, so a file
under a recipe's subdirectory needs that directory in the glob), or
a component not in the manifest. Such an entry covers nothing, and
the test's fingerprint would not move with the file it meant.
Returns [(test id, component, glob)]. A glob selecting only
non-behavioral paths is hollow too: nothing it names is fingerprinted."""
out = []
for t in tests:
for comp, pat in t.covers:
files = manifest.files(comp)
if files is None or not [p for p, _b in match_files(files, pat) if not non_behavioral(comp, p)]:
out.append((t.id, comp, pat))
return out
def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
"""Which manifest paths no test covers.
tests: iterable with .covers. allow: iterable of (component, glob)
that need no coverage (docs, CI, licenses...). Returns
{component: [uncovered paths]} for the non-dev-only components.
"""
covered = {}
for t in tests:
for comp, pat in t.covers:
covered.setdefault(comp, []).append(glob_to_regex(pat))
allowed = {}
for comp, pat in allow:
allowed.setdefault(comp, []).append(glob_to_regex(pat))
report = {}
for comp in manifest.component_names():
if comp in DEV_ONLY_COMPONENTS:
continue
rxs = covered.get(comp, []) + allowed.get(comp, [])
star = allowed.get("*", [])
missing = []
for p, _b in manifest.files(comp):
if any(rx.match(p) for rx in rxs) or any(rx.match(p) for rx in star):
continue
missing.append(p)
if missing:
report[comp] = sorted(missing)
return report
+61
View File
@@ -0,0 +1,61 @@
"""The single page: acceptance tab + bench tab, assembled from ui/ into
one self-contained response (no external assets). Bootstrap and the
OpenGlow theme are inlined; theme.css and the vendor/ files are the same
bytes forgectrl's panel carries, so the two pages look like one product
and follow the same light and dark themes (scripts/check-ui-vendor.py
holds them identical).
ui/ holds index.html, theme.css, page.css, help.js, app.js and vendor/.
A plain file is read when it is there (a checkout); otherwise its .gz
sibling is, which is what the recipe installs on the dev image: the
rootfs is raw ext4, so bytes in the package are bytes on the image, and
the page is inflated once at first request. The token placeholder is
spliced in per render."""
import gzip
import os
UI_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui")
# The files index.html links, in load order: one marker tag per file.
CSS_FILES = ("vendor/bootstrap.min.css", "theme.css", "page.css")
JS_FILES = ("vendor/bootstrap.bundle.min.js", "help.js", "app.js")
TOKEN_MARK = "__TOKEN__"
def read_ui(name, ui_dir=UI_DIR):
"""A ui/ file as text: the plain file, or its gzipped install."""
path = os.path.join(ui_dir, *name.split("/"))
try:
with open(path, "rb") as f:
return f.read().decode("utf-8")
except FileNotFoundError:
with gzip.open(path + ".gz", "rb") as f:
return f.read().decode("utf-8")
def assemble(ui_dir=UI_DIR):
"""index.html with every linked stylesheet and script inlined in place
of its tag. A missing marker is a broken page, not a silent one."""
html = read_ui("index.html", ui_dir)
for name in CSS_FILES:
tag = '<link rel="stylesheet" href="%s" />' % name
if tag not in html:
raise ValueError("index.html lacks the marker %s" % tag)
html = html.replace(tag, "<style>\n" + read_ui(name, ui_dir) + "</style>", 1)
for name in JS_FILES:
tag = '<script src="%s"></script>' % name
if tag not in html:
raise ValueError("index.html lacks the marker %s" % tag)
html = html.replace(tag, "<script>\n" + read_ui(name, ui_dir) + "</script>", 1)
if html.count(TOKEN_MARK) != 1:
raise ValueError("the page must carry %s exactly once" % TOKEN_MARK)
return html
_html = None
def render(token):
global _html
if _html is None:
_html = assemble()
return _html.replace(TOKEN_MARK, token)
+143
View File
@@ -0,0 +1,143 @@
"""Pulse files for the offline cloud service: a job the machine will run
without the service having cut it.
A pulse file is a header (`\\x80GF1`, a little-endian total length, then
8-byte records of a 4-character tag and a uint32) followed by the step
stream, one byte per tick of the header's STfr (the kernel feeder
contract: bit 0 X step, bit 1 X dir, bit 2 Y step, bit 3 Y dir, bit 4
LASER, bits 5-6 Z, bit 7 marks a power byte whose low 7 bits set the
duty). The header here is the one a factory print of this machine type
carries (134 tags, MCsn 0 = not locked to a serial, PDfm 0), so the
client's header check, its job limits (the coolant window, the air
assist floor), its fan duties and its stepper settings all come from the
same place a service job's do. The stream is a square traced at a steady
feed with the laser never commanded: a leading power byte of zero, no
LASER bit anywhere. Nothing in a file from here can put energy into the
tube; the arm and the latch are exercised, the beam is not.
A file longer than the kernel ring (32 MiB of ticks) is written gzip-
compressed, whole file in the stream, as the service serves one: the
client inflates it as the ring drains, and the gzip trailer gives it the
job's length for its progress report.
"""
import gzip
import struct
STEPS_PER_MM = 53.333 # x8 microstepping: the service's scale, which cloud mode runs at
# The header of a factory print for this machine type, as captured.
PRINT_HEADER = {
"AAfc": 3632880242, "AAid": 204, "AAin": 0, "AAix": 0, "AArd": 1023, "AArn": 0,
"AArx": 64500, "AAsn": 0, "AAsx": 0, "AAwd": 1023, "AAwn": 0, "AAwx": 64500,
"BDbs": 0, "BDpe": 0, "BDps": 0, "BTcx": 12800, "BTfc": 4294711296, "BTfo": 4294942296,
"BTin": 2147483648, "BTix": 2147483647, "BTrn": 2147483648, "BTrx": 9440,
"BTwn": 2147483648, "BTwx": 8928, "BTxb": 15, "CCrp": 10000, "CCup": 1, "CCwp": 5000,
"CFrh": 1, "CMin": 10000, "CMix": 50000, "CMrn": 5000, "CMrx": 33000, "CMts": 0,
"CMwn": 7000, "CMwx": 31000, "EFid": 0, "EFin": 0, "EFix": 0, "EFrd": 65535, "EFrn": 0,
"EFrx": 0, "EFwd": 65535, "EFwn": 0, "EFwx": 0, "FTcx": 30000, "FTin": 2147483648,
"FTix": 2147483647, "FTrn": 2147483648, "FTrx": 14575, "FTwn": 2147483648, "FTwx": 14075,
"FTxb": 15, "HAai": 0, "HAar": 0, "HAsi": 2, "HAsr": 4, "HAxi": 0, "HAxr": 132, "HAyi": 0,
"HAyr": 112, "HAzi": 0, "HAzr": 0, "HIix": 12, "HIrx": 1023, "HTcx": 12800, "HTfc": 51200,
"HTfo": 5000, "HTin": 2147483648, "HTix": 2147483647, "HTrn": 2147483648, "HTrx": 9440,
"HTwn": 2147483648, "HTwx": 8928, "HTxb": 15, "IFid": 0, "IFin": 0, "IFix": 0,
"IFrd": 43278, "IFrn": 0, "IFrx": 0, "IFwd": 43278, "IFwn": 0, "IFwx": 0, "IRpd": 1000,
"IRwb": 3, "IRwc": 688, "IRwx": 275, "IRxb": 3, "IRxc": 1022, "IRxx": 374, "IRyb": 3,
"IRyc": 0, "IRyx": 0, "IRzb": 3, "IRzc": 0, "IRzx": 0, "ITcx": 19200, "ITfc": 153600,
"ITfo": 0, "ITin": 2147483648, "ITix": 2147483647, "ITrn": 2147483648, "ITrx": 15840,
"ITwn": 2147483648, "ITwx": 15328, "ITxb": 15, "LTcx": 12800, "LTfc": 51200, "LTfo": 5000,
"LTin": 2147483648, "LTix": 2147483647, "LTrn": 2147483648, "LTrx": 9440,
"LTwn": 2147483648, "LTwx": 8928, "LTxb": 15, "MCsn": 0, "PCid": 19795, "PDct": 5,
"PDfm": 0, "PTmn": 0, "PTmx": 1023, "STfr": 10000, "TRuc": 0, "XSdm": 1, "XShc": 33,
"XSmm": 8, "XSrc": 135, "YSdm": 1, "YShc": 5, "YSmm": 8, "YSrc": 22, "ZSmd": 0,
}
X_STEP, X_DIR, Y_STEP, Y_DIR, LASER = 0x01, 0x02, 0x04, 0x08, 0x10
POWER = 0x80
def header_bytes(tags=None):
"""The header record block, the template with `tags` laid over it."""
h = dict(PRINT_HEADER)
if tags:
h.update(tags)
recs = b"".join(k.encode("ascii") + struct.pack("<I", int(v)) for k, v in sorted(h.items()))
total = 8 + len(recs)
return bytes([POWER]) + b"GF1" + struct.pack("<I", total) + recs
def leg(dx_steps, dy_steps, period):
"""One straight leg: Bresenham over the major axis, one step every
`period` ticks, laser never commanded. X: DIR set = negative; Y: DIR
set = positive (the hardware convention)."""
ax, ay = abs(dx_steps), abs(dy_steps)
major = max(ax, ay)
if major == 0:
return b""
xbit = X_STEP | (X_DIR if dx_steps < 0 else 0)
ybit = Y_STEP | (Y_DIR if dy_steps > 0 else 0)
out = bytearray()
gap = bytes(period - 1)
err = 0
x_major = ax >= ay
minor = ay if x_major else ax
for _ in range(major):
b = xbit if x_major else ybit
err += minor
if err >= major:
err -= major
b |= ybit if x_major else xbit
out.append(b)
out += gap
return bytes(out)
def square_stream(side_mm=40.0, feed_mm_min=600.0, step_hz=10000, repeats=1):
"""A closed square traced `repeats` times at a steady feed: +X, +Y,
-X, -Y, the head back where it started. Leads with a power byte of
zero, as the feeder contract wants before any laser-on byte (there
is none)."""
steps = int(round(side_mm * STEPS_PER_MM))
period = max(1, int(round(step_hz / (feed_mm_min / 60.0 * STEPS_PER_MM))))
one = (leg(steps, 0, period) + leg(0, steps, period)
+ leg(-steps, 0, period) + leg(0, -steps, period))
return bytes([POWER]) + bytes(period) + one * repeats
def seconds_of(stream, step_hz=10000):
return len(stream) / float(step_hz)
def write_job(path, side_mm=40.0, feed_mm_min=600.0, seconds=None, step_hz=10000,
compress=None, tags=None):
"""Write a job to `path`: a square of `side_mm` at `feed_mm_min`,
repeated until the stream is at least `seconds` long (one square if
None). Compressed when `compress` is true, or, left None, when the
stream is longer than the kernel ring (32 MiB of ticks). Returns
(payload bytes, seconds, compressed)."""
one = square_stream(side_mm, feed_mm_min, step_hz, 1)
repeats = 1
if seconds:
per = seconds_of(one, step_hz)
repeats = max(1, int(seconds / per) + (1 if seconds % per else 0))
body = square_stream(side_mm, feed_mm_min, step_hz, repeats)
head = header_bytes(dict(tags or {}, STfr=step_hz))
if compress is None:
compress = len(body) > 32 * 1024 * 1024
data = head + body
if compress:
data = gzip.compress(data, compresslevel=6)
with open(path, "wb") as f:
f.write(data)
return len(body), seconds_of(body, step_hz), compress
def parse(data):
"""(header dict, payload) of a raw or gzip-compressed pulse file."""
if data[:2] == b"\x1f\x8b":
data = gzip.decompress(data)
if data[1:4] != b"GF1":
raise ValueError("not a GF1 pulse file")
total = struct.unpack_from("<I", data, 4)[0]
tags = {data[p:p + 4].decode("ascii"): struct.unpack_from("<I", data, p + 4)[0]
for p in range(8, total - 7, 8)}
return tags, data[total:]
File diff suppressed because it is too large Load Diff
+342
View File
@@ -0,0 +1,342 @@
"""HTTP: the page, the JSON API, and the access rules.
Access follows forgectrl's panel (auth.c): the Host header must be an
address literal or localhost (no DNS names - the rebinding vehicle), a
cross-site Sec-Fetch-Site is refused, an Origin must itself be a literal,
and every state-changing call needs the bearer token that is generated on
first start, stored 0600 under the data directory, and embedded in the
page. Read-only calls need the origin checks only.
Routes
GET / the page
GET /state campaign + per-test state + running run.
Carries an ETag; If-None-Match on an
unchanged state gets a 304, which is what an
idle page polls for.
GET /catalog test definitions (title, steps, covers...)
GET /bench bench tool listing
GET /result?test&ts one full result record (log, evidence)
GET /log the raw JSONL
GET /journal the daemon's journal (daemon.log tail)
GET /export/acceptance.json | .md the last export
POST /start {test, ack_live, ignore_requires} start an acceptance test
POST /batch {group, ack_live, ignore_requires} run everything a queue
still owes, in prerequisite order
POST /batch/stop cancel what is still queued
POST /bench/start {tool, args, ack_live}
POST /answer {prompt_id, value}
POST /abort
POST /invalidate {reason}
POST /reset {reason}
POST /export build + save the artifact, returns it
"""
import hashlib
import hmac
import json
import os
import re
import secrets
import sys
import urllib.parse
import socket
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from . import artifact as _artifact
from . import page as _page
from .log import data_dir
JOURNAL_TAIL_BYTES = 256 << 10
def journal_tail(path=None, max_bytes=JOURNAL_TAIL_BYTES):
"""The last max_bytes of the daemon's journal (daemon.log, written by
the init script from the daemon's stderr), or a note that there is
none yet."""
path = path or os.path.join(data_dir(), "daemon.log")
try:
size = os.path.getsize(path)
with open(path, "rb") as f:
if size > max_bytes:
f.seek(size - max_bytes)
f.readline()
return f.read()
except OSError:
return b"(no journal yet: %s)\n" % path.encode()
TOKEN_HEX = 32
_LITERAL_RX = re.compile(r"^[0-9.]+$")
def load_token(path=None):
path = path or os.path.join(data_dir(), "token")
try:
with open(path, "r", encoding="utf-8") as f:
tok = f.read().strip()
if len(tok) == TOKEN_HEX and all(c in "0123456789abcdef" for c in tok):
return tok
except OSError:
pass
tok = secrets.token_hex(TOKEN_HEX // 2)
os.makedirs(os.path.dirname(path), exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(tok + "\n")
return tok
def host_is_literal(h):
if not h:
return False
if h.startswith("["):
return True
host = h.split(":", 1)[0]
if host == "localhost":
return True
return bool(_LITERAL_RX.match(host))
def origin_ok(headers):
if not host_is_literal(headers.get("Host")):
return False
sfs = headers.get("Sec-Fetch-Site")
if sfs and sfs not in ("same-origin", "none"):
return False
origin = headers.get("Origin")
if origin and origin != "null":
p = urllib.parse.urlsplit(origin)
if not host_is_literal(p.netloc):
return False
return True
class App:
"""What the handler needs: the runner, the token, the export dir."""
def __init__(self, runner, token, export_dir=None):
self.runner = runner
self.token = token
self.export_dir = export_dir or os.path.join(data_dir(), "export")
def token_ok(self, given):
return bool(given) and hmac.compare_digest(given, self.token)
class Handler(BaseHTTPRequestHandler):
server_version = "forgetest"
# Keep-alive: the page polls, so a handshake and a fresh thread per
# request is pure overhead. Idle connections are dropped after the
# timeout rather than holding a thread forever.
protocol_version = "HTTP/1.1"
timeout = 30
app = None # set on the server class
# -- plumbing ---------------------------------------------------------
def log_message(self, fmt, *args):
if os.environ.get("FORGETEST_HTTP_LOG"):
sys.stderr.write("forgetest: %s - %s\n" % (self.address_string(), fmt % args))
def _send(self, status, body, ctype="application/json", extra=None):
if isinstance(body, (dict, list)):
body = json.dumps(body, sort_keys=True).encode("utf-8")
elif isinstance(body, str):
body = body.encode("utf-8")
self.responded = True
self.send_response(status)
self.send_header("Content-Type", ctype + ("; charset=utf-8" if ctype.startswith("text/") else ""))
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _send_304(self, etag):
self.responded = True
self.send_response(304)
self.send_header("ETag", etag)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", "0")
self.end_headers()
def _deny(self, status, msg):
# One response per request: on a kept-alive connection a second
# one would desynchronize the stream.
if getattr(self, "responded", False):
return
# A refused POST still has its body in the socket. Leaving it
# there would make the next read on this connection take the
# body for a request line, so the connection ends here.
# send_header sets close_connection from this.
extra = None if getattr(self, "body_read", True) else {"Connection": "close"}
self._send(status, {"error": msg}, extra=extra)
def _read_ok(self):
if not origin_ok(self.headers):
self._deny(403, "request origin refused")
return False
return True
def _write_ok(self, query):
if not origin_ok(self.headers):
self._deny(403, "request origin refused")
return False
tok = self.headers.get("X-ForgeFIRM-Token") or (query.get("token") or [None])[0]
if not self.app.token_ok(tok):
self._deny(403, "authentication required")
return False
return True
def _body_json(self):
n = int(self.headers.get("Content-Length") or 0)
if n <= 0:
self.body_read = True
return {}
if n > 1 << 20:
raise ValueError("body too large")
raw = self.rfile.read(n)
self.body_read = True
ctype = self.headers.get("Content-Type", "")
if "json" in ctype:
data = json.loads(raw.decode("utf-8"))
return data if isinstance(data, dict) else {}
# form-encoded fallback
return {k: v[0] for k, v in urllib.parse.parse_qs(raw.decode("utf-8")).items()}
# -- GET ---------------------------------------------------------------
def do_HEAD(self):
self.do_GET()
def do_GET(self):
url = urllib.parse.urlsplit(self.path)
path = url.path
query = urllib.parse.parse_qs(url.query)
r = self.app.runner
self.responded = False
if not self._read_ok():
return
try:
if path == "/":
self._send(200, _page.render(self.app.token), "text/html")
elif path == "/state":
# Conditional: an idle page polls an unchanged state, and
# a 304 spares both ends the payload and the re-render.
state, _ = r.state()
body = json.dumps(state, sort_keys=True).encode("utf-8")
etag = '"%s"' % hashlib.sha256(body).hexdigest()[:32]
if self.headers.get("If-None-Match") == etag:
self._send_304(etag)
else:
self._send(200, body, extra={"ETag": etag})
elif path == "/catalog":
self._send(200, {"tests": [t.describe() for t in r.tests()],
"catalog_hash": r.catalog_hash})
elif path == "/bench":
b = r.bench
self._send(200, {"tools": b.listing() if b else [], "tool_dir": b.tool_dir() if b else None})
elif path == "/result":
test = (query.get("test") or [""])[0]
ts = (query.get("ts") or [""])[0]
rec = None
for x in reversed(r.log.read()):
if x.get("t") == "result" and x.get("test") == test and (not ts or x.get("ts") == ts):
rec = x
break
if rec is None:
self._deny(404, "no such result")
else:
self._send(200, rec)
elif path == "/log":
self._send(200, r.log.raw(), "text/plain")
elif path == "/journal":
self._send(200, journal_tail(), "text/plain")
elif path in ("/export/acceptance.json", "/export/acceptance.md"):
fn = os.path.join(self.app.export_dir, os.path.basename(path))
if not os.path.exists(fn):
self._deny(404, "nothing exported yet")
return
with open(fn, "rb") as f:
data = f.read()
ctype = "application/json" if path.endswith(".json") else "text/markdown"
self._send(200, data, ctype, {"Content-Disposition": "attachment; filename=%s"
% os.path.basename(path)})
else:
self._deny(404, "not found")
except Exception as e: # noqa: BLE001
self._deny(500, "%s: %s" % (type(e).__name__, e))
# -- POST -----------------------------------------------------------------
def do_POST(self):
url = urllib.parse.urlsplit(self.path)
path = url.path
query = urllib.parse.parse_qs(url.query)
r = self.app.runner
self.responded = False
self.body_read = False
if not self._write_ok(query):
return
try:
body = self._body_json()
except ValueError as e:
self._deny(400, "bad body: %s" % e)
return
try:
if path == "/start":
ok, msg = r.start_test(str(body.get("test", "")), ack_live=bool(body.get("ack_live")),
ignore_requires=bool(body.get("ignore_requires")))
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/batch":
ok, msg, order = r.start_batch(str(body.get("group", "")),
ack_live=bool(body.get("ack_live")),
ignore_requires=bool(body.get("ignore_requires")))
self._send(200 if ok else 409, {"ok": ok, "message": msg, "order": order})
elif path == "/batch/stop":
ok, msg = r.stop_batch()
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/bench/start":
args = body.get("args") or {}
if not isinstance(args, dict):
self._deny(400, "args must be an object")
return
ok, msg = r.start_bench(str(body.get("tool", "")), args, ack_live=bool(body.get("ack_live")))
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/answer":
ok, msg = r.answer(str(body.get("prompt_id", "")), str(body.get("value", "")))
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/abort":
ok, msg = r.abort()
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/invalidate":
ok, msg = r.invalidate(str(body.get("reason", "")))
self._send(200 if ok else 400, {"ok": ok, "message": msg})
elif path == "/reset":
ok, msg = r.reset(str(body.get("reason", "")))
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/export":
art = r.export()
os.makedirs(self.app.export_dir, exist_ok=True)
with open(os.path.join(self.app.export_dir, "acceptance.json"), "w", encoding="utf-8") as f:
f.write(_artifact.to_json(art))
with open(os.path.join(self.app.export_dir, "acceptance.md"), "w", encoding="utf-8") as f:
f.write(_artifact.to_markdown(art))
self._send(200, {"ok": True, "authorized": art["authorized"], "sha256": art["sha256"],
"counts": art["counts"]})
else:
self._deny(404, "not found")
except Exception as e: # noqa: BLE001
self._deny(500, "%s: %s" % (type(e).__name__, e))
def make_server(app, host="::", port=8090):
handler = type("ForgetestHandler", (Handler,), {"app": app})
ThreadingHTTPServer.allow_reuse_address = True
# An IPv6 bind address (the default "::") gives one dual-stack socket
# that serves IPv4 clients too; a dotted address keeps the IPv4 family.
server_cls = ThreadingHTTPServer
if ":" in host:
server_cls = type("ForgetestServer6", (ThreadingHTTPServer,),
{"address_family": socket.AF_INET6})
srv = server_cls((host, port), handler)
srv.daemon_threads = True
return srv
+16
View File
@@ -0,0 +1,16 @@
"""The acceptance suite: one module per subsystem, imported in display
order. Each module registers its tests with @catalog.test."""
from . import image # noqa: F401,E402
from . import kernel # noqa: F401,E402
from . import forgectrl # noqa: F401,E402
from . import commission # noqa: F401,E402
from . import commission_dark # noqa: F401,E402
from . import commission_sheet # noqa: F401,E402
from . import logs # noqa: F401,E402
from . import motion # noqa: F401,E402
from . import cooling # noqa: F401,E402
from . import laser # noqa: F401,E402
from . import camera # noqa: F401,E402
from . import update # noqa: F401,E402
from . import cloud # noqa: F401,E402
+447
View File
@@ -0,0 +1,447 @@
"""camera.* - the lid camera pipeline through forgectrl."""
import json
from ..catalog import test
from .. import hw
from ..baseline import LID_LAMP_ATTR
_CAM_COVERS = [("forgectrl", "src/cam.*"), ("forgectrl", "src/camhealth.*"),
("forgectrl", "src/debayer.*"), ("forgectrl", "src/vpu_jpeg.*"),
("forgectrl", "src/vpu_h264.*"), ("forgectrl", "src/mp4mux.*"),
("forgectrl", "src/gpu_debayer.*"), ("forgectrl", "src/ipu_copy.*"),
("forgectrl", "src/main.c"),
("python3-gfhardware", "gfhardware/src/**"), ("python3-gfhardware", "gfhardware/cam*")]
# The privacy gate spans the camera path and the lid read it depends on,
# in both processes that can reach a sensor.
_PRIVACY_COVERS = _CAM_COVERS + [("forgectrl", "src/status.*"),
("python3-gfhardware", "gfhardware/switches.py"),
("forgefirm-app", "forgefirm-app/ffmachine.py")]
# The sensors the machine ships with, and the geometry each one implies.
# A machine reports exactly one of these; anything else means the sensor
# bound to a driver the capture path has no profile for.
_SENSOR_GEOMETRY = {
"OV5648": (2592, 1944),
"OV8856": (3264, 2448),
}
def _jpeg_size(data):
"""(width, height) from a JPEG's first SOFn marker, or None."""
i = 2
n = len(data)
while i + 3 < n:
if data[i] != 0xFF:
i += 1
continue
marker = data[i + 1]
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
i += 2
continue
seglen = (data[i + 2] << 8) | data[i + 3]
# SOFn, excluding the non-frame markers in the same range
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
if i + 9 > n:
return None
return ((data[i + 7] << 8) | data[i + 8],
(data[i + 5] << 8) | data[i + 6])
i += 2 + seglen
return None
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
kind="auto", est_min=2,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
steps=["Setup: lid closed."],
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
"far smaller), a second snapshot with the lid lamp switched off differs from it "
"and is smaller (the capture is live, not a frame the pipeline is re-serving, "
"and the lamp lights what the camera sees), and the MJPEG stream starts and "
"stops.")
def snapshot(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/cam/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
ev["cam_status"] = body
ctx.log("cam status: %s", body)
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
ctx.log("GET /cam/snapshot?cam=lid&res=half -> %s (%d bytes)", st, len(data) if data else 0)
ctx.check(st == 200, "snapshot -> %s %s", st, data[:120] if data else "")
ctx.check(data[:2] == b"\xff\xd8" and data[-2:] == b"\xff\xd9", "snapshot is not a complete JPEG")
ev["snapshot_bytes"] = len(data)
ctx.check(len(data) > 20000, "snapshot is only %d bytes - a dark or empty frame?", len(data))
# Live, and lit: the same frame with the lid lamp off must be a
# different and smaller picture. The lamp is put back where it was.
lamp0 = hw.sysfs_read(LID_LAMP_ATTR)
ev["lid_lamp"] = lamp0
ctx.check(lamp0 is not None, "the lid lamp (%s) is not readable", LID_LAMP_ATTR)
try:
hw.sysfs_write(LID_LAMP_ATTR, "0")
ctx.sleep(1.5)
st, dark = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
finally:
hw.sysfs_write(LID_LAMP_ATTR, lamp0)
ctx.check(st == 200 and dark and dark[:2] == b"\xff\xd8", "snapshot with the lamp off -> %s", st)
ev["snapshot_dark_bytes"] = len(dark)
ctx.log("lamp %s -> %d bytes; lamp off -> %d bytes", lamp0, len(data), len(dark))
ctx.check(dark != data, "the snapshot with the lamp off is byte-identical to the lit one: the "
"capture is not live")
ctx.check(len(dark) < len(data), "the frame with the lamp off is not smaller than the lit one "
"(%d vs %d bytes): the lamp is not lighting what the camera "
"sees", len(dark), len(data))
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "full"}, raw=True)
ctx.log("GET /cam/snapshot?cam=lid&res=full -> %s (%d bytes)", st, len(data) if data else 0)
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "full-resolution snapshot -> %s", st)
ev["snapshot_full_bytes"] = len(data)
# the stream: fetch a little of it and let it close
import urllib.request
req = urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()})
try:
with urllib.request.urlopen(req, timeout=10) as r:
ctype = r.headers.get("Content-Type", "")
chunk = r.read(65536)
except Exception as e: # noqa: BLE001
ctx.fail("stream did not open: %s", e)
ev["stream_ctype"] = ctype
ctx.log("stream: %s, first %d bytes", ctype, len(chunk))
ctx.check("multipart" in ctype and b"\xff\xd8" in chunk, "stream is not an MJPEG multipart")
ctx.sleep(2)
st, body = fc.get("/cam/status")
ev["cam_status_after"] = body
ctx.log("cam status after: %s", body)
ctx.log("PASS: half-res snapshot %d bytes, %d with the lamp off; full-res %d; stream %s",
ev["snapshot_bytes"], len(dark), len(data), ctype)
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera",
kind="auto", est_min=1,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an "
"8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it "
"actually returns are that size. A machine that reports 'unknown', or whose frames "
"do not match what it advertises, is running the wrong capture profile.")
def sensor_profile(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/cam/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
sensor = body.get("sensor")
ev["sensor"] = sensor
ctx.log("sensor: %s", sensor)
ctx.check(sensor in _SENSOR_GEOMETRY,
"sensor %r is not one this build has a capture profile for", sensor)
want_w, want_h = _SENSOR_GEOMETRY[sensor]
snap = body.get("snapshot") or {}
stream = body.get("stream") or {}
ev["geometry"] = {"snapshot": snap, "stream": stream}
ctx.log("advertised: snapshot %sx%s, stream %sx%s",
snap.get("width"), snap.get("height"), stream.get("width"), stream.get("height"))
ctx.check(snap.get("width") == want_w and snap.get("height") == want_h,
"%s should advertise %dx%d snapshots, got %sx%s",
sensor, want_w, want_h, snap.get("width"), snap.get("height"))
ctx.check(stream.get("width") == want_w // 2 and stream.get("height") == want_h // 2,
"%s should advertise %dx%d stream frames, got %sx%s",
sensor, want_w // 2, want_h // 2, stream.get("width"), stream.get("height"))
# What it advertises is what it delivers.
for res, (w, h) in (("full", (want_w, want_h)), ("half", (want_w // 2, want_h // 2))):
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": res}, raw=True)
ctx.check(st == 200 and data and data[:2] == b"\xff\xd8",
"%s snapshot -> %s", res, st)
got = _jpeg_size(data)
ev["jpeg_%s" % res] = got
ctx.log("%s snapshot: %s (%d bytes)", res, got, len(data))
ctx.check(got == (w, h), "%s snapshot is %s, not %dx%d", res, got, w, h)
@test("camera.h264-stream", title="H.264 live stream", subsystem="camera",
kind="auto", est_min=2,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
steps=["Setup: lid closed."],
description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an "
"init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few "
"seconds, and /cam/status reporting the H.264 encoder active with this client "
"counted. Also records which demosaic path (GPU or CPU) served the stream. On a "
"machine whose image lacks Mesa or whose encoder refused, the endpoint must "
"answer 503 rather than hang - that is a pass for the endpoint but is recorded "
"in the evidence.")
def h264_stream(ctx):
import urllib.error
import urllib.request
fc = ctx.forgectrl
ev = ctx.evidence
req = urllib.request.Request(fc.base + "/cam/h264?cam=lid",
headers={"Host": fc.host_header()})
try:
r = urllib.request.urlopen(req, timeout=20)
except urllib.error.HTTPError as e:
ev["status"] = e.code
ev["body"] = e.read()[:200].decode("utf-8", "replace")
ctx.check(e.code == 503, "H.264 refused with %s, not 503: %s", e.code, ev["body"])
ctx.log("H.264 unavailable on this machine (503): %s", ev["body"])
return
with r:
codec = r.headers.get("X-H264-Codec", "")
ctype = r.headers.get("Content-Type", "")
ev["codec"] = codec
ev["content_type"] = ctype
ctx.log("codec %s, content-type %s", codec, ctype)
ctx.check(ctype == "video/mp4", "content type is %r", ctype)
ctx.check(codec.startswith("avc1."), "codec header is %r", codec)
# Init segment, then at least one media fragment.
data = b""
while len(data) < 512 * 1024:
chunk = r.read(65536)
if not chunk:
break
data += chunk
if b"moof" in data and b"mdat" in data:
break
ev["bytes"] = len(data)
ctx.check(data[4:8] == b"ftyp", "stream does not start with ftyp")
ctx.check(b"moov" in data, "no moov (init segment)")
ctx.check(b"avcC" in data, "no avcC in the init segment")
ctx.check(b"moof" in data and b"mdat" in data,
"no media fragment arrived in %d bytes", len(data))
st, body = fc.get("/cam/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
ev["cam_status"] = body
ctx.log("convert=%s h264=%s", body.get("convert"), body.get("h264"))
h = body.get("h264") or {}
ctx.check(h.get("active") is True, "/cam/status does not report the H.264 encoder active: %s",
body)
@test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera",
kind="auto", est_min=1,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
description="The capture queue flags a frame errored when it is short, torn, or arrived "
"after the CSI-2 receiver lost sync; forgectrl drops those rather than "
"demosaicing them, and restarts the stream if they persist. A healthy machine "
"captures a burst with none of them. A nonzero corrupt count here is a real "
"signal - a marginal camera ribbon, a mistimed D-PHY - even though the frames "
"themselves never reach a client.")
def frame_health(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
def health():
st, body = fc.get("/cam/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
h = body.get("health")
ctx.check(isinstance(h, dict), "/cam/status carries no health block: %s", body)
return h
before = health()
ev["health_before"] = before
ctx.log("before: %s", before)
# A burst, so the count covers a run of frames rather than a single grab.
for _ in range(3):
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
ctx.check(st == 200 and data and data[:2] == b"\xff\xd8", "snapshot -> %s", st)
after = health()
ev["health_after"] = after
ctx.log("after: %s", after)
captured = (after.get("captured") or 0) - (before.get("captured") or 0)
corrupt = (after.get("corrupt") or 0) - (before.get("corrupt") or 0)
restarts = (after.get("restarts") or 0) - (before.get("restarts") or 0)
ev["captured"] = captured
ev["corrupt"] = corrupt
ev["restarts"] = restarts
ctx.log("captured %d frames, %d corrupt, %d stream restarts", captured, corrupt, restarts)
ctx.check(captured > 0, "no frames were dequeued during three snapshots")
ctx.check(corrupt == 0, "%d of %d captured frames came back errored", corrupt, captured)
ctx.check(restarts == 0, "the capture stream was restarted %d time(s)", restarts)
@test("camera.lid-privacy", title="Cameras capture only with the lid closed", subsystem="camera",
kind="operator", est_min=3,
covers=_PRIVACY_COVERS, requires=["forgectrl.panel-serves"], actions=["lid"],
steps=["Start with the lid closed; you will be told to open it, then close it again.",
"Nothing moves and the laser is not involved."],
description="The privacy gate: with the lid open neither camera captures. A running stream "
"stops within a frame or so of the lid opening, /cam/status reports capture as "
"not allowed, and both the snapshot and the stream are refused with 409 and a "
"reason naming the lid. Closing the lid restores all of it. This is what stops "
"the machine - and in cloud mode the Glowforge service - from imaging the room "
"through an open lid.")
def lid_privacy(ctx):
import urllib.error
import urllib.request
fc = ctx.forgectrl
ev = ctx.evidence
def status():
st, body = fc.get("/cam/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
return body
def snapshot():
"""(status, body) for a half-res lid snapshot."""
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
return st, data or b""
# --- lid closed: the baseline the rest is measured against ----------
ctx.act("lid", "close")
body = status()
ctx.check(body.get("capture_allowed") is True,
"with the lid closed /cam/status should allow capture, got %r",
body.get("capture_allowed"))
st, data = snapshot()
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
"snapshot with the lid closed -> %s", st)
ev["closed_snapshot_bytes"] = len(data)
ctx.log("lid closed: snapshot %d bytes", len(data))
# --- a live stream must die when the lid opens ----------------------
req = urllib.request.Request(fc.base + "/cam/stream?cam=lid",
headers={"Host": fc.host_header()})
stream = urllib.request.urlopen(req, timeout=15)
try:
first = stream.read(4096)
ctx.check(b"\xff\xd8" in first, "the stream did not start before the lid test")
ctx.act("lid", "open", text="The stream is running.")
# The engine tears the pipeline down on the next frame and the
# stream ends. Draining now returns whatever was buffered before
# the lid opened and then EOF; what must not happen is frames
# continuing to arrive indefinitely.
drained, ended = 0, False
try:
while True:
chunk = stream.read(65536)
if not chunk:
ended = True
break
drained += len(chunk)
ctx.check(drained < 8 * 1024 * 1024,
"the stream was still delivering after %d bytes with the lid open",
drained)
except Exception as e: # noqa: BLE001 - a reset connection ends it too
ended = True
ctx.log("stream ended with %s", type(e).__name__)
ev["stream_bytes_after_lid_open"] = drained
ctx.log("stream ended after %d further buffered bytes", drained)
ctx.check(ended, "the stream never ended after the lid opened")
finally:
stream.close()
# --- lid open: everything is refused, and says why ------------------
body = status()
ev["status_lid_open"] = body
ctx.check(body.get("capture_allowed") is False,
"with the lid open /cam/status should refuse capture, got %r",
body.get("capture_allowed"))
ctx.check(body.get("stopped_by_lid") is True,
"the engine should record that the lid stopped it, got %r",
body.get("stopped_by_lid"))
ctx.check(body.get("running") is False,
"the capture engine should not still be running with the lid open")
st, data = snapshot()
ev["snapshot_lid_open"] = [st, data[:120].decode("utf-8", "replace")]
ctx.log("lid open: snapshot -> %s %s", st, data[:120])
ctx.check(st == 409, "snapshot with the lid open should be refused with 409, got %s", st)
ctx.check(b"lid" in data.lower(), "the refusal should name the lid: %r", data[:120])
ctx.check(data[:2] != b"\xff\xd8", "a JPEG was returned with the lid open")
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True)
ctx.log("lid open: head snapshot -> %s", st)
ctx.check(st == 409, "the head camera should be refused too, got %s", st)
try:
with urllib.request.urlopen(req, timeout=10) as r:
ctx.fail("the stream opened with the lid open (%s)", r.status)
except urllib.error.HTTPError as e:
ctx.log("lid open: stream -> %s", e.code)
ctx.check(e.code == 409, "the stream should be refused with 409, got %s", e.code)
# --- closing the lid restores it -----------------------------------
ctx.act("lid", "close")
body = status()
ctx.check(body.get("capture_allowed") is True,
"closing the lid should allow capture again, got %r",
body.get("capture_allowed"))
st, data = snapshot()
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
"snapshot after closing the lid -> %s", st)
ctx.log("lid closed again: snapshot %d bytes", len(data))
@test("camera.key-read", title="The camera key reads without a login", subsystem="camera",
kind="auto", est_min=1,
covers=[("forgectrl", "src/camkey.*"), ("forgectrl", "src/auth.c"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/panel.js"),
("forgectrl", "src/ui/index.html"), ("forgectrl", "src/ui/help.js")],
requires=["forgectrl.auth"],
description="With the read routes closed to the network (panel_open_reads=0), a camera "
"status read from the board's LAN address is refused without the key and "
"served with it, on HTTP and on HTTPS; a wrong key is refused; the key never "
"authorizes a write; a rotation from the panel route stops the old key and "
"the new one reads. The setting is put back after.")
def key_read(ctx):
from .commission import request
from .forgectrl import lan_ip
fc = ctx.forgectrl
ev = ctx.evidence
ip = lan_ip()
ctx.check(ip, "cannot determine the board's LAN address")
http_base = "http://%s" % ip
tls_base = "https://%s" % ip
token = {"X-ForgeFIRM-Token": fc.token}
st, body, _ = request(fc.base, "GET", "/system/camera-key", headers=token)
ctx.check(st == 200, "GET /system/camera-key from loopback -> %s", st)
key = json.loads(body)["key"]
ctx.check(len(key) == 32, "the key is not 32 characters: %r", key)
ev["key_len"] = len(key)
before = fc.settings().get("panel_open_reads", "")
st, _ = fc.post("/settings", params={"panel_open_reads": "0"})
ctx.check(st == 200, "closing the reads -> %s", st)
try:
for base in (http_base, tls_base):
st, _, _ = request(base, "GET", "/cam/status")
ctx.log("GET %s/cam/status (no key) -> %s", base, st)
ctx.check(st == 403, "a closed read without the key -> %s, expected 403", st)
st, _, _ = request(base, "GET", "/cam/status?key=" + key)
ctx.log("GET %s/cam/status?key=... -> %s", base, st)
ctx.check(st == 200, "a read with the key -> %s, expected 200", st)
st, _, _ = request(base, "GET", "/cam/status", headers={"X-ForgeFIRM-Camera-Key": key})
ctx.check(st == 200, "a read with the key header -> %s, expected 200", st)
wrong = ("0" if key[0] != "0" else "1") + key[1:]
st, _, _ = request(base, "GET", "/cam/status?key=" + wrong)
ctx.check(st == 403, "a wrong key -> %s, expected 403", st)
# the key is a read credential only
st, _, _ = request(tls_base, "POST", "/settings", data={"panel_open_reads": "0"},
headers={"X-ForgeFIRM-Camera-Key": key})
ctx.log("POST /settings with the camera key -> %s", st)
ctx.check(st == 403, "the camera key authorized a write (%s)", st)
# rotation
st, body, _ = request(fc.base, "POST", "/system/camera-key?rotate=1", headers=token)
ctx.check(st == 200, "rotate -> %s", st)
new = json.loads(body)["key"]
ctx.check(new != key, "rotation kept the same key")
st, _, _ = request(http_base, "GET", "/cam/status?key=" + key)
ctx.check(st == 403, "the old key still reads after rotation (%s)", st)
st, _, _ = request(http_base, "GET", "/cam/status?key=" + new)
ctx.check(st == 200, "the new key does not read (%s)", st)
ev["rotated"] = True
finally:
fc.post("/settings", params={"panel_open_reads": before})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,369 @@
"""commission.check-* - the setup's checks (the dark wizards), driven the
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
the prompts answered from here (the bench fixture works the lid and the
button; a confirmation is answered yes once the snapshot exists), the
result judged, and every setting a check wrote put back as found. Each
check re-runs on the real record: a completed check completes again at
the same version, so the record reads as before.
"""
import json
import os
import time
from ..catalog import test
from .. import hw
from ..baseline import read_position
from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
POLL_S = 1.0
# What the machine's own press prompt says, used when it sends no text.
PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your press."
# How long the purge fan's current is given to come up after the airflow
# check switches it back on. The bench reference draws its off current
# (74) for the first seconds and settles near 630.
PURGE_SPINUP_S = 30
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
def dark(fc):
st, body = fc.get("/wiz/dark")
if st != 200 or not isinstance(body, dict):
raise hw.HwError("GET /wiz/dark -> %s" % st)
return body
def start(ctx, fc, wid):
ev = ctx.evidence
st, body = fc.post("/wiz/%s/start" % wid)
if st == 409 and dark(fc).get("id") == wid and dark(fc).get("running"):
# The same check left running by an earlier run of this test (an
# abort mid-wait): this run owns the machine, so it ends that one
# and starts its own.
ctx.log("the %s check is still running from an earlier run: aborting it", wid)
fc.post("/wiz/%s/abort" % wid)
ctx.wait_for(lambda: not dark(fc).get("running"), 30)
st, body = fc.post("/wiz/%s/start" % wid)
ctx.log("POST /wiz/%s/start -> %s %s", wid, st, body if isinstance(body, dict) else "")
ctx.check(st == 200, "the %s check did not start (%s %s)", wid, st, body)
ev["started"] = st == 200
def answer(fc, wid, prompt, value):
return fc.post("/wiz/%s/answer" % wid, data={"seq": str(prompt["seq"]), "value": value})
def run_check(ctx, wid, on_prompt, timeout_s):
"""Start `wid` and follow it to its end. on_prompt(prompt) is called
once per prompt (by sequence number) and returns the value to answer
with, or None when it handled the prompt another way (a fixture
action). Returns the final status document."""
fc = ctx.forgectrl
ev = ctx.evidence
start(ctx, fc, wid)
t0 = time.time()
seen = set()
phases = []
last = None
try:
while time.time() - t0 < timeout_s:
ctx.checkpoint()
d = dark(fc)
if d.get("id") != wid:
raise hw.HwError("another check took the slot: %s" % d.get("id"))
if d.get("phase") and (not phases or phases[-1] != d["phase"]):
phases.append(d["phase"])
ctx.log("%s: %s", wid, d["phase"])
p = d.get("prompt")
if p and p.get("seq") not in seen:
seen.add(p["seq"])
ctx.log("prompt %s (%s): %s", p.get("id"), p.get("kind"), p.get("text"))
value = on_prompt(p)
if value is not None:
st, body = answer(fc, wid, p, value)
ctx.check(st == 200, "the answer to %s -> %s %s", p.get("id"), st, body)
elif p.get("id") == "press":
# The arm press, asked for by the machine itself: the
# check opens this prompt when the button is lit and
# the tube is still dark. Nothing answers it - the
# press does, and the discharge closes it.
ctx.press_now(p.get("text") or PRESS_TEXT)
if not d.get("running"):
last = d
break
time.sleep(POLL_S)
except BaseException:
# An abort or a failure on the way out leaves no check running
# behind this test: the next run must be able to start its own.
fc.post("/wiz/%s/abort" % wid)
raise
ev["elapsed_s"] = round(time.time() - t0, 1)
ev["phases"] = phases[-12:]
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
if last is None:
fc.post("/wiz/%s/abort" % wid)
return {}
ev["log"] = last.get("log", [])[-12:]
ev["error"] = last.get("error")
ev["result"] = last.get("result")
ctx.check(not last.get("error"), "the %s check failed: %s", wid, last.get("error"))
ctx.check(isinstance(last.get("result"), dict), "the %s check ended without a result", wid)
w = wiz(fc)
ctx.check((w.get("versions") or {}).get(wid) == 1, "the record does not carry %s at version 1", wid)
return last
@test("commission.check-switches", title="The switches check follows the lid and the button",
subsystem="commission", kind="operator", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
requires=["forgectrl.auth"], actions=["lid", "button"],
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
"satisfied without a prompt. The result says lid, button, head present, and "
"whether the HV enable readback followed the lid; the record carries switches at "
"version 1.")
def check_switches(ctx):
def on_prompt(p):
pid = p.get("id", "")
if pid in ("lid-open", "lid-close-first", "lid-close"):
ctx.act("lid", "open" if pid == "lid-open" else "close",
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
text=p.get("text", ""))
elif pid == "button-press":
ctx.act("button", "press",
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("id") != "button-press",
text=p.get("text", ""))
elif pid == "button-release":
pass # the fixture's press releases on its own
elif pid.startswith("interlock-"):
ctx.act("interlock", "open" if pid == "interlock-open" else "close",
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
text=p.get("text", ""))
return None
last = run_check(ctx, "switches", on_prompt, 600)
r = last.get("result") or {}
ctx.check(r.get("lid") is True and r.get("button") is True, "lid or button not proven: %s", r)
ctx.check(r.get("head_present") is True, "the head did not read present: %s", r)
ctx.log("switches: %s", json.dumps(r))
@test("commission.check-sensors", title="The sensors check reads a plausible machine at rest",
subsystem="commission", kind="auto", est_min=2,
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
("forgectrl", "src/cool.c")],
requires=["forgectrl.auth"],
description="POST /wiz/sensors/start; ten seconds of readings, then the room-temperature "
"prompt, answered Skip so no offset is written. The result carries both coolant "
"temperatures, the chassis and SoC, the lid IR maxima, the accelerometer event "
"count, the supply power-good, the HV current, and the idle fan speeds; the "
"record carries sensors at version 1 and cool_temp_offset_c reads as before.")
def check_sensors(ctx):
with Restore(ctx, ["cool_temp_offset_c"]):
last = run_check(ctx, "sensors", lambda p: "Skip" if p.get("id") == "room-temp" else None, 120)
r = last.get("result") or {}
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
"exhaust_rpm_idle", "intake_rpm_idle"):
ctx.check(k in r, "the result lacks %s: %s", k, r)
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
r.get("lid_ir_max"), r.get("hv_current_max"))
@test("commission.check-airflow", title="The airflow check measures the fans and sets the floors",
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
"for 35 s, the purge is measured on and off, and five settings are written: the "
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
"fan must read at least 1000 rpm. The five settings are put back as found at the "
"end, the purge fan is on again and drawing over the floor the check just wrote, "
"and the controller must be running again.")
def check_airflow(ctx):
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
"cool_purge_min_current", "cool_fan_grace_s"]
with Restore(ctx, keys):
last = run_check(ctx, "airflow", lambda p: None, 240)
r = last.get("result") or {}
fans = r.get("fans") or {}
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
f = fans.get(name) or {}
ctx.check(f.get("ok") is True, "%s: %s", name, f)
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
floors = r.get("floors") or {}
for k in keys:
ctx.check(k in floors, "no floor for %s: %s", k, floors)
s = ctx.forgectrl.settings() or {}
for k in keys:
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
ctx.log("floors: %s", json.dumps(floors))
# The check switches purge air off to read its off current. Leaving
# it off costs the next job an airflow hold mid-cut, judged against
# the very floor written above, and nothing puts it back until the
# daemon restarts: the engine's idle phase never re-applies its own
# duties. Prove the machine is whole, not just measured.
purge_on = ctx.sysfs("head/purge_air")
ctx.check(purge_on == "1", "purge air reads %r after the check, expected 1 (on)", purge_on)
def purge_gate():
return ((ctx.forgectrl.get("/cool/status")[1] or {}).get("fan_gates") or {}).get("purge") or {}
# The check read its off current with the fan off, and the stand-down
# commands it back on. The current follows the command, it does not
# arrive with it: read in the same second and what comes back is the
# off current the check just measured. Wait for the draw the way the
# controller below is waited for.
took = ctx.wait_for(lambda: (purge_gate().get("reading") or 0) >= (purge_gate().get("floor") or 0),
PURGE_SPINUP_S)
gate = purge_gate()
ctx.evidence["purge_after"] = {"purge_air": purge_on, "gate": gate, "took_s": took}
ctx.log("purge after the check: commanded %s, drawing %s against the %s floor (%s)",
purge_on, gate.get("reading"), gate.get("floor"),
"after %.1f s" % took if took is not None else "never reached the floor")
ctx.check(took is not None,
"purge draws %s after %d s, under the %s floor the check just wrote: the next job "
"would be held", gate.get("reading"), PURGE_SPINUP_S, gate.get("floor"))
ok = ctx.wait_for(lambda: (ctx.forgectrl.get("/mode")[1] or {}).get("controller") == "running", 60)
ctx.check(ok is not None, "the controller did not come back after the check")
@test("commission.check-cameras", title="The cameras check captures both cameras",
subsystem="commission", kind="operator", est_min=2,
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
"head snapshot, the question. Each question is answered yes once GET /wiz/shot "
"serves the snapshot as image/jpeg. The result names the sensor and says both "
"views were accepted.")
def check_cameras(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.act("lid", "close")
shots = {}
def on_prompt(p):
pid = p.get("id", "")
if pid in ("lid-view", "head-view"):
cam = "lid" if pid == "lid-view" else "head"
st, body = fc.get("/wiz/shot", params={"cam": cam}, raw=True)
shots[cam] = {"status": st, "bytes": len(body) if body else 0,
"jpeg": bool(body) and body[:2] == b"\xff\xd8"}
ctx.check(st == 200 and shots[cam]["jpeg"], "no %s snapshot to judge: %s", cam, shots[cam])
return "Yes"
if pid == "lid-close":
ctx.act("lid", "close", until=lambda: (dark(fc).get("prompt") or {}).get("seq") != p["seq"])
return None
last = run_check(ctx, "cameras", on_prompt, 180)
ev["shots"] = shots
r = last.get("result") or {}
ctx.check(r.get("lid_ok") is True and r.get("head_ok") is True, "a view was not accepted: %s", r)
ctx.check(bool(r.get("sensor")) and r.get("sensor") != "unknown", "no sensor named: %s", r)
ctx.log("cameras: %s, lid %d bytes, head %d bytes", r.get("sensor"),
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
@test("commission.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
("forgectrl", "src/lenshome.*"),
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
requires=["forgectrl.auth", "motion.pacing"],
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
"lens takes its hall-edge reference behind it (a lens that cannot reach its "
"edge is a motion fault, and no controller starts), the lens "
"finds the hall reference in five agreeing passes, the controller comes back in "
"loopback posture, and the head jogs 50 mm each way on X and Y with the "
"accelerometer as the witness. The one prompt (the jogs are about to move) is "
"answered Continue. The result carries the probe line, the passes, and a "
"witnessed reading for each jog; the controller must be back in its normal "
"posture, and the kernel position counters must read what they read before: "
"every move played to its end (the probe's return leg under the cooling "
"engine's dead-man, the last jog under the controller stop).")
def check_motion(ctx):
fc = ctx.forgectrl
before = read_position()
ctx.notice("The head moves 50 mm each way on X and on Y. Keep the bed clear.")
last = run_check(ctx, "motion", lambda p: "Continue" if p.get("id") == "jogs" else None, 420)
ctx.clear_notice()
r = last.get("result") or {}
ctx.check(r.get("z_referenced") is True, "the lens was not referenced: %s", r)
ctx.check(len(r.get("z_passes") or []) == 5, "not five reference passes: %s", r.get("z_passes"))
moves = r.get("moves") or {}
for name in ("+X", "-X", "+Y", "-Y"):
m = moves.get(name) or {}
ctx.check(m.get("witnessed") is True, "the %s jog was not witnessed: %s", name, m)
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("local") is False
and (fc.get("/mode")[1] or {}).get("controller") == "running", 90)
ctx.check(ok is not None, "the controller is not back in its normal posture")
after = read_position()
if before is not None and after is not None:
ctx.check(all(abs(a - b) <= 2 for a, b in zip(after[:2], before[:2])),
"the check lost motion: position %s before, %s after (steps)", before, after)
ctx.log("motion: probe '%s', passes %s, rest %s, moves %s", r.get("probe"), r.get("z_passes"),
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
@test("commission.check-flow-verify", title="The flow check runs as a setup check",
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
"check runner: the diagnostic's phases show as the check's, its result becomes the "
"check's result with pass true, and the record carries cooling.flow-verify at "
"version 1. A thin margin recommends the calibration in the record's flags. The "
"air-assist offset calibration is named first among the prerequisites so a queue "
"keeps it ahead of every heater tool this check pulls forward.")
def check_flow_verify(ctx):
last = run_check(ctx, "cooling.flow-verify", lambda p: None, 600)
r = last.get("result") or {}
ctx.check(r.get("pass") is True, "the flow check did not pass: %s", r)
ctx.log("flow check: threshold %s, flow %s, no-flow %s, thin %s", r.get("threshold"),
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
@test("commission.cloud-header-capture", title="The cloud header check takes one print's envelope",
subsystem="commission", kind="operator", hardware="takeover", mode="grbl", est_min=8,
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
("python3-gfhardware", "gfhardware/machine.py"),
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
("python3-gfhardware", "forgefirm-app/ffmachine.py")],
requires=["forgectrl.auth", "cloud.mode-switch"], hands=["workstation"],
steps=["The Glowforge app open on the workstation, signed in to this machine. The test "
"turns cloud mode on itself and puts it back.",
"Once the app shows the machine online, place any small design on the bed image "
"and press Print. That is the only action: the check takes the print's header and "
"cancels the print before it arms. Do not press the machine's button.",
"The machine returns to GRBL mode on its own."],
description="POST /wiz/cloud.header/start with cloud mode on: the wizard writes the "
"client's one-start capture marker, starts cloud mode in the wizard's posture, "
"asks for a print, and takes the header the client writes to the run directory "
"before it cancels the print. The result carries the tag count, the "
"calibration-bearing tags, the limits, and the machine's own numbers; the log "
"has the tagged block; the machine is back in GRBL mode with the marker gone.")
def cloud_header_capture(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
with Restore(ctx, ["cloud_enabled", "homing_mode", "controller_mode"]):
s = fc.settings() or {}
if s.get("cloud_enabled") != "1":
st, body = fc.post("/settings", data={"cloud_enabled": "1"})
ctx.check(st == 200, "cloud_enabled=1 -> %s %s", st, body)
ctx.log("cloud mode turned on for the test")
ctx.notice("Print any small design from the Glowforge app once it shows the machine "
"online. Do not press the machine's button.")
last = run_check(ctx, "cloud.header",
lambda p: "Continue" if p.get("id") == "print" else None, 900)
ctx.clear_notice()
r = last.get("result") or {}
ctx.check(isinstance(r.get("tag_count"), int) and r["tag_count"] > 100,
"the header carried %s tags", r.get("tag_count"))
kept = r.get("calibration_tags") or {}
ctx.check(len(kept) > 10, "only %d calibration-bearing tags kept", len(kept))
ev["kept"] = sorted(kept)[:40]
ctx.check(not os.path.exists("/run/gfcloud-capture"), "the capture marker is still down")
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("mode") == "grbl"
and (fc.get("/mode")[1] or {}).get("controller") == "running", 120)
ctx.check(ok is not None, "the machine did not return to GRBL mode")
ctx.log("header: %d tags, %d kept; job %s", r.get("tag_count", 0), len(kept), r.get("job_id"))
@@ -0,0 +1,277 @@
"""commission.sheet - the setup's sheet (the live wizards) in one run,
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
polled, the prompts answered from here, each burn armed by a press, the
result judged, and every setting a wizard wrote put back as found. One
piece of wood at least 200 x 150 mm (8 x 6 in) carries the placement,
the frame, and the five cards. Each wizard re-runs on the real record,
so a completed one completes again at the same version.
The operator's part is one press on the machine at the start (the
presence check of the ready gate; with the bench actuator up it then
makes every arm press) and nothing else. The judgments a person makes
by eye on the sheet (the thinnest line, the faintest rung, the best
corner, the frame's fit) are answered here with a middle option or the
witnesses: the test proves each wizard's mechanics and the emission
witnesses, not the operator's eye, and the settings it writes are
restored. The page is where the real numbers go in.
"""
import re
from ..catalog import test
from .commission_dark import run_check, Restore
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
("forgectrl", "src/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/main.c"), ("forgectrl", "src/ui/wizard.*"),
("forgectrl", "src/super.c"),
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
("forgectrl", "src/accel.*"), ("forgectrl", "src/wizcalc.*"),
("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/glowforge_status.c"),
("grblhal-glowforge", "src/glowforge_homing.*"),
("grblhal-glowforge", "src/glowforge_io.*")]
# The live wizards in the order the sheet burns them: the wizard id, the
# answers to its prompts (a middle pick where a person would look at the
# sheet), and the keys its result must carry. The arm press is not timed
# from here: each card opens a press prompt of its own when the button is
# lit and the tube still dark, and run_check presses on that.
CARDS = [
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"]),
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"},
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
"focus_range_mm", "stops"]),
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"]),
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"]),
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"]),
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"]),
]
# Every setting a card writes, restored as found when the run ends.
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
"laser_floor_density", "laser_dose_curve", "laser_corner_gamma",
"cool_laser_heat_density", "cool_laser_heat_cw"]
# The evidence run_check and the witnesses write at the top level, moved
# under the card's own key once it is done.
CARD_EVIDENCE = ("started", "elapsed_s", "phases", "log", "error", "result",
"preview_bytes", "program_lines", "emission")
def preview_ok(ctx, wid):
"""The page's preview and the program for the wizard are served."""
fc = ctx.forgectrl
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
ctx.evidence["preview_bytes"] = len(body)
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
# The text and the frame are M4 at the mark dose; the patterns bring
# their own M3 or M4 lines. A program is one with a laser-on command.
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
"no program for %s (%s)", wid, st)
ctx.evidence["program_lines"] = body.count(b"\n")
# 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):
"""The three witnesses of the burn, from the wizard's result."""
e = (last.get("result") or {}).get("emission") or {}
ctx.evidence["emission"] = e
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
ctx.check(e.get("thermopile_delta", 0) >= 100, "the thermopile did not see the beam (%s)",
e.get("thermopile_delta"))
def file_card(ctx, wid):
"""Move the card's evidence under its own key."""
ev = ctx.evidence
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
# 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
record must carry for it."""
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
def place(ctx):
"""The placement: the lens reference, two jogs, the origin at the
head's position, the full sheet, a thickness. Nothing fires."""
jogs = ["X+10", "X-10"]
answer, want_mm = thickness_answer(ctx)
ctx.evidence["thickness_answer"] = answer
def on_prompt(p):
# Each jog answer closes the prompt and the wizard asks again with
# the next sequence number, so the pad is answered one move at a
# time until the origin is set.
if p.get("id") == "place":
return jogs.pop(0) if jogs else "Set origin"
if p.get("id") == "sheet-kind":
return "Full sheet"
if p.get("id") == "thickness":
return answer
return None
last = run_check(ctx, "sheet.place", on_prompt, 300)
r = last.get("result") or {}
ctx.check("origin_x" in r and "origin_y" in r, "no origin in the result: %s", r)
ctx.check((r.get("steps_per_mm") or {}).get("x", 0) > 0, "no steps per mm: %s", r)
ctx.check(r.get("z_referenced") is True, "the lens was not referenced")
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
ctx.check(r.get("alone") is False, "alone %s", r.get("alone"))
ctx.log("placed: datum at %s, %s; steps per mm %s", r.get("origin_x"), r.get("origin_y"),
r.get("steps_per_mm"))
file_card(ctx, "sheet.place")
def burn(ctx, wid, answers, want):
"""One live card: the preview, the burn after the press with the
prompts answered from `answers`, the witnesses, the keys of the
result in `want`. run_check makes the arm press when the card asks
for it."""
preview_ok(ctx, wid)
def on_prompt(p):
return answers.get(p.get("id"))
try:
last = run_check(ctx, wid, on_prompt, 900)
finally:
ctx.clear_notice()
emission_ok(ctx, last)
r = last.get("result") or {}
missing = [k for k in want if k not in r]
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
ctx.log("%s: %s, emission %s", wid, {k: r.get(k) for k in want}, ctx.evidence.get("emission"))
file_card(ctx, wid)
@test("commission.sheet", title="The commissioning sheet: the placement, the frame, and the five cards",
subsystem="commission", kind="live", hardware="takeover", mode="grbl", est_min=35,
covers=SHEET_COVERS,
requires=["forgectrl.auth", "commission.check-motion", "laser.emission-witness", "cooling.flow-verify"],
actions=["button"], hands=["scrap"],
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
"it goes with its top edge at the top of the cut area (the head's home corner); "
"lid closed. Eye protection on, exhaust on, extinguisher in reach.",
"Start the test and press the machine's button once. That press says you are at the "
"machine, and the bench actuator makes every arm press after it, each one when the "
"card itself asks for it. With no actuator the start is the Ready answer instead, "
"and you press the button each time the page asks: six times, the frame and the "
"five cards.",
"Nothing else: the test answers every prompt itself and puts back every setting the "
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
"tails."],
description="One run over the sheet's seven wizards on one piece of wood, 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, 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 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 "
"through M102 inside one armed job; the pick is 1.50); and the flow-load card "
"(the coolant loop settled, the box and the patch at 60 percent, the "
"downstream peak over 100 s of dark as the coefficient). Every burn must be "
"seen by the three witnesses (the tube current, the kernel's LASER_ON samples, "
"the thermopile rise); every result must carry its keys; the record must "
"carry each wizard at version 1; the seven settings the cards write are "
"restored at the end.")
def sheet(ctx):
ctx.ready("The sheet: the placement, then the frame and the five cards burn after a press "
"each. The bench presses when the button lights.")
place(ctx)
with Restore(ctx, CARD_SETTINGS):
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")
File diff suppressed because it is too large Load Diff
+414
View File
@@ -0,0 +1,414 @@
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
import json
import os
import socket
import time
from ..catalog import test
from .. import hw
# The write guard reads the account and the session store (a machine with
# an account needs a session from the LAN; this host and the dev image
# write with the token), so both are the guard's domain.
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/ui/login.js"), ("forgectrl", "src/ui/wizard.js")]
def lan_ip():
"""The board's own non-loopback IPv4 (the address a LAN client would
use), or None."""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("10.255.255.255", 9))
ip = s.getsockname()[0]
except OSError:
ip = None
finally:
s.close()
if ip and not ip.startswith("127."):
return ip
return None
@test("forgectrl.auth", title="API access control", subsystem="forgectrl", kind="auto", est_min=1,
covers=_COVERS_AUTH,
description="Every state-changing endpoint refuses an unauthenticated write (the factory "
"return, the SSH switch and the wizard's own routes included); a non-literal "
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused, while "
"the machine's own hostname passes and that name with a domain on it does not; "
"the cooling report channel accepts the loopback peer and refuses a non-loopback "
"one (over HTTP the write is sent to HTTPS first, 302; over HTTPS the route "
"answers 403 loopback only); the fuse view is two-factor "
"(token and the physical button) and refused without either; "
"the flash and factory-restore chain is refused unauthenticated; a page "
"asked for without a session is sent to the login carrying its path.")
def auth(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/status")
ctx.log("GET /status -> %s", st)
ctx.check(st == 200, "GET /status -> %s", st)
# unauthenticated writes: every one must be refused before it acts
for path, params in (("/controller/stop", None), ("/controller/start", None),
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
("/diag/flow-verify", None), ("/diag/abort", None),
("/update/apply", None), ("/boot", {"target": "a"}),
("/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),
("/wiz/complete", None)):
st, body = fc.post(path, params=params, auth=False)
ctx.log("POST %s (no token) -> %s %s", path, st, body if isinstance(body, dict) else "")
ev["noauth " + path] = st
ctx.check(st == 403, "POST %s without a token -> %s, expected 403", path, st)
ctx.check(isinstance(body, dict) and body.get("error") == "authentication required",
"POST %s without a token: unexpected body %r", path, body)
# the upload sink refuses during body parse; only the status is asserted
st, body = fc.post("/update/upload", data=b"not a firmware archive", auth=False,
headers={"Content-Type": "application/octet-stream"})
ev["noauth /update/upload"] = st
ctx.log("POST /update/upload (no token) -> %s", st)
ctx.check(st in (400, 403), "POST /update/upload without a token -> %s", st)
# An oversized body from an unauthenticated client must not be
# accumulated in memory before the token check. The framework buffers
# every POST body ahead of the callback, and its default is no limit,
# so without a ceiling this is a way to take the daemon and any job
# with it from the network, unauthenticated. The body here is far over
# the cap; what matters is that the daemon answers and is still
# serving afterwards, having refused the write.
big = b"ui_units=mm&pad=" + (b"x" * (4 * 1024 * 1024))
st, body = fc.post("/settings", data=big, auth=False,
headers={"Content-Type": "application/x-www-form-urlencoded"})
ev["oversize_body_status"] = st
ctx.log("POST /settings (no token, %d MiB body) -> %s", len(big) // (1024 * 1024), st)
ctx.check(st == 403, "an oversized unauthenticated body -> %s, expected 403", st)
st, body = fc.get("/status")
ev["alive_after_oversize"] = st
ctx.log("GET /status after the oversized body -> %s", st)
ctx.check(st == 200, "the daemon did not survive an oversized body (%s)", st)
# origin checks (read endpoint, so only the origin layer decides)
st, body = fc.get("/status", headers={"Host": "evil.example.net"})
ev["host_name"] = st
ctx.log("GET /status Host=evil.example.net -> %s", st)
ctx.check(st == 403, "a DNS-name Host was accepted (%s)", st)
# the machine's own name passes; the same name with a domain on it
# does not, because anyone can register one
own = socket.gethostname()
st, body = fc.get("/status", headers={"Host": own})
ev["host_own_name"] = st
ctx.log("GET /status Host=%s -> %s", own, st)
ctx.check(st == 200, "the machine's own hostname was refused as a Host (%s)", st)
st, body = fc.get("/status", headers={"Host": own + ".example.net"})
ev["host_own_name_domain"] = st
ctx.log("GET /status Host=%s.example.net -> %s", own, st)
ctx.check(st == 403, "a domain name built on the machine's name was accepted (%s)", st)
st, body = fc.get("/status", headers={"Origin": "http://evil.example.net"})
ev["origin_name"] = st
ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
ctx.check(st == 403, "a DNS-name Origin was accepted (%s)", st)
st, body = fc.get("/status", headers={"Sec-Fetch-Site": "cross-site"})
ev["sfs_cross"] = st
ctx.log("GET /status Sec-Fetch-Site=cross-site -> %s", st)
ctx.check(st == 403, "a cross-site fetch was accepted (%s)", st)
st, body = fc.get("/status", headers={"Sec-Fetch-Site": "same-origin", "Origin": "http://127.0.0.1"})
ctx.check(st == 200, "same-origin literal Origin refused (%s)", st)
# the fuse view is two-factor: the token AND the physical button held.
# Without the token: authentication refused; with the token and nobody
# at the button: refused with the button message. The identity itself is
# never fetched (it would land in this log).
st, body = fc.get("/fuse-identity", auth=False)
ev["fuse_noauth"] = st
ctx.log("GET /fuse-identity (no token) -> %s %s", st, body if isinstance(body, dict) else "")
ctx.check(st == 403 and isinstance(body, dict) and body.get("error") == "authentication required",
"GET /fuse-identity without token -> %s %r", st, body)
st, body = fc.get("/fuse-identity")
ev["fuse_token_no_button"] = st
ctx.log("GET /fuse-identity (token, button not held) -> %s %s", st, body if isinstance(body, dict) else "")
msg = body.get("error", "") if isinstance(body, dict) else str(body)
ctx.check(st == 403 and "button" in msg,
"GET /fuse-identity with the token but no button -> %s %r (expected the two-factor refusal)",
st, body)
# the cooling report channel: the loopback peer is accepted. An idle
# report is what the controller sends every period; the engine is idle
# here, so it changes nothing. A dual-stack listener reports this peer
# as ::ffff:127.0.0.1, which the check must recognize in full.
st, body = fc.post("/cool/state", params={"mode": "idle", "armed": "0"})
ev["cool_state_from_loopback"] = st
ctx.log("POST /cool/state from loopback -> %s %s", st, body if isinstance(body, dict) else "")
ctx.check(st == 200, "/cool/state refused the loopback peer (%s %r): the controller's "
"reports never reach the engine", st, body)
# ...and a non-loopback peer is refused, even with a token. Over HTTP
# the write is sent to HTTPS first (302, the listener's rule); over
# HTTPS the route itself refuses the peer (403 loopback only). Neither
# request follows the redirect, and the self-signed certificate is
# not verified.
from .commission import request, decode
ip = lan_ip()
ev["lan_ip"] = ip
ctx.check(ip, "cannot determine the board's LAN address")
token = {"X-ForgeFIRM-Token": fc.token}
report = {"mode": "idle", "armed": "0"}
st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token)
loc = hdrs.get("location", "")
ev["cool_state_from_lan_http"] = {"status": st, "location": loc}
ctx.log("POST http://%s/cool/state -> %s %s", ip, st, loc)
ctx.check(st == 302 and loc.startswith("https://"),
"a LAN write over HTTP -> %s %r, expected a 302 to HTTPS", st, loc)
st, body, hdrs = request("https://%s" % ip, "POST", "/cool/state", data=report, headers=token)
b = decode(body)
ev["cool_state_from_lan"] = st
ctx.log("POST https://%s/cool/state -> %s %s", ip, st, b if isinstance(b, dict) else "")
ctx.check(st == 403 and isinstance(b, dict) and b.get("error") == "loopback only",
"/cool/state accepted a non-loopback peer (%s %r)", st, b)
# the login return path: a page asked for from the LAN without a
# session is sent to the login carrying the path it asked for (with
# its query), so the login can come back to it. Before the setup has
# made the account the page is served instead (200).
st, body, hdrs = request("https://%s" % ip, "GET", "/setup?step=laser.focus")
loc = hdrs.get("location", "")
ev["setup_from_lan"] = {"status": st, "location": loc}
ctx.log("GET https://%s/setup?step=laser.focus (no session) -> %s %s", ip, st, loc)
ctx.check(st in (200, 302), "the setup page from the LAN -> %s", st)
if st == 302:
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
"the login redirect does not carry the path: %r", loc)
@test("forgectrl.settings-bounds", title="Settings validation and restore", subsystem="forgectrl",
kind="auto", est_min=1,
covers=[("forgectrl", "src/settings.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/cam.c")],
description="An over-length value and an out-of-range value are refused (400) and leave the "
"settings byte-identical; an in-range value is accepted (200). The lid lamp "
"idles at lid_lamp_idle (unset = 236), an out-of-range level is refused, a new "
"level applies to the lamp at once, and clearing it returns the default.")
def settings_bounds(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
ev["keys"] = len(before)
st, body = fc.post("/settings", data={"gf_serial": "X" * 300})
ev["overlong"] = st
ctx.log("POST /settings gf_serial=<300 chars> -> %s %s", st, body if isinstance(body, dict) else "")
ctx.check(st == 400, "over-length value -> %s, expected 400", st)
st, body = fc.post("/settings", data={"laser_disarm_s": "99999"})
ev["out_of_range"] = st
ctx.log("POST /settings laser_disarm_s=99999 -> %s", st)
ctx.check(st == 400, "out-of-range value -> %s, expected 400", st)
# The cloud download guard is bytes, so its range is far wider than the
# other numeric keys: check the far end is still a wall.
st, body = fc.post("/settings", data={"pulse_reject_threshold_bytes": "2000000000"})
ev["pulse_bytes_out_of_range"] = st
ctx.log("POST /settings pulse_reject_threshold_bytes=2000000000 -> %s", st)
ctx.check(st == 400, "out-of-range byte limit -> %s, expected 400", st)
st, body = fc.post("/settings", data={"no_such_key_forgetest": "1"})
ev["unknown_key"] = st
ctx.log("POST /settings no_such_key_forgetest=1 -> %s", st)
ctx.check(st in (400, 404), "unknown key -> %s, expected 400", st)
after = fc.settings()
ctx.check(json.dumps(after, sort_keys=True) == json.dumps(before, sort_keys=True),
"settings changed after refused writes")
ctx.log("settings unchanged after the refused writes")
# an accepted in-range write: rewrite a present key with its own value
key = None
for k in ("ui_units", "laser_disarm_s", "cool_flow_rise", "rail_settle_s"):
v = before.get(k)
if isinstance(v, str) and v != "":
key = k
break
if key is None:
key, val = "ui_units", "metric"
ctx.log("no settable key is present; writing %s=%s (recorded in evidence)", key, val)
else:
val = before[key]
st, body = fc.post("/settings", data={key: val})
ev["accepted"] = {"key": key, "value": val, "status": st}
ctx.log("POST /settings %s=%s -> %s", key, val, st)
ctx.check(st == 200, "in-range write -> %s, expected 200", st)
final = fc.settings()
others_before = {k: v for k, v in before.items() if k != key}
others_after = {k: v for k, v in final.items() if k != key}
ctx.check(others_before == others_after, "other settings changed by the write")
ctx.check(final.get(key) == val, "%s reads back %r, wrote %r", key, final.get(key), val)
# the lid lamp's idle level: resting at the setting, bounded, applied live
lamp_was = (before.get("lid_lamp_idle") or "").strip()
want = lamp_was or "236"
got = ctx.sysfs("pic/lid_led")
ev["lid_lamp"] = {"setting": lamp_was, "resting": got}
ctx.log("lid lamp: setting %r, pic/lid_led=%s (expected %s)", lamp_was, got, want)
ctx.check(got == want, "lid lamp rests at %s, lid_lamp_idle is %s", got, want)
for bad in ("256", "-1", "bright"):
st, body = fc.post("/settings", data={"lid_lamp_idle": bad})
ctx.check(st == 400, "lid_lamp_idle=%s -> %s, expected 400", bad, st)
ctx.log("lid_lamp_idle 256 / -1 / bright refused")
try_level = "100" if want != "100" else "120"
st, body = fc.post("/settings", data={"lid_lamp_idle": try_level})
ctx.check(st == 200, "lid_lamp_idle=%s -> %s, expected 200", try_level, st)
applied = None
t0 = time.time()
while time.time() - t0 < 5:
applied = ctx.sysfs("pic/lid_led")
if applied == try_level:
break
ctx.sleep(0.2)
ctx.log("lid_lamp_idle=%s -> pic/lid_led=%s after %.1f s", try_level, applied, time.time() - t0)
# an empty value clears the key: the query-string form carries it
st, body = (fc.post("/settings", params={"lid_lamp_idle": ""}) if not lamp_was
else fc.post("/settings", data={"lid_lamp_idle": lamp_was}))
ctx.check(st == 200, "restoring lid_lamp_idle=%r -> %s", lamp_was, st)
t0 = time.time()
back = None
while time.time() - t0 < 5:
back = ctx.sysfs("pic/lid_led")
if back == want:
break
ctx.sleep(0.2)
ev["lid_lamp"].update({"applied": applied, "restored": back})
ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied)
ctx.check(back == want, "lamp did not return to %s after the restore (reads %s)", want, back)
ctx.log("lid lamp follows the setting live and returns to %s", want)
@test("forgectrl.panel-serves", title="Control panel and status endpoints", subsystem="forgectrl",
kind="auto", est_min=1,
covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"),
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
("forgectrl", "src/curverec.*")],
description="The panel page is served, /status carries the machine telemetry the panel and "
"the acceptance tool read (including the sys block: CPU busy percent over the "
"interval since the previous read, memory used percent; and homed_axes, "
"the axes that carry a reference, Z alone once the lens has taken its "
"own at the controller's start), and /cam/status "
"answers. In GRBL mode with a live controller, /status also echoes the "
"controller's published state file as the grbl block (fresh age, machine "
"state, sender session, laser window and dose model, modal report), "
"GET /grbl/settings serves the published $$ view, and the controller's "
"settings store is /data/forgefirm/EEPROM-glowforge.DAT with nothing of "
"it at the top of /data.")
def panel_serves(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/", raw=True)
ev["panel_status"] = st
ctx.log("GET / -> %s (%d bytes)", st, len(body) if body else 0)
ctx.check(st == 200, "GET / -> %s", st)
text = body.decode("utf-8", "replace")
ctx.check("<html" in text.lower() and "ForgeFIRM" in text, "the panel does not look like the panel")
ctx.check(fc.token and fc.token in text, "the panel does not embed the bearer token")
ctx.check("<link " not in text and "<script src=" not in text,
"the panel references an external asset (the build did not bundle src/ui/)")
# The daemon stores the page gzipped and inflates it once at first
# request; what it serves is the plain page with the theme attribute
# the head script sets and the one save bar every settings tab shares.
ctx.check("data-bs-theme" in text, "the panel lacks the theme attribute (inflate failed?)")
ctx.check('id="savebar"' in text, "the panel lacks the save bar")
s = fc.status()
for key in ("state", "switches", "coolant", "fans"):
ctx.check(key in s, "/status lacks %r", key)
ev["state"] = s.get("state")
ev["switches"] = s.get("switches")
ctx.log("/status state=%s switches=%s", s.get("state"), s.get("switches"))
for key in ("lid", "button", "interlock_ok", "head", "hv_enable"):
ctx.check(key in (s.get("switches") or {}), "/status switches lacks %r", key)
# SoC utilization rides /status next to the temperatures. The CPU
# number is a delta over the interval since the previous read, so
# the read above primes it; after a beat both percents must be
# numbers in range.
ctx.sleep(1)
sys_ = ctx.forgectrl.status().get("sys") or {}
ev["sys"] = sys_
ctx.log("/status sys=%s", sys_)
ctx.check(isinstance(sys_.get("cpu_pct"), (int, float)) and 0.0 <= sys_["cpu_pct"] <= 100.0,
"/status sys.cpu_pct is not a percent: %s", sys_)
ctx.check(isinstance(sys_.get("mem_pct"), (int, float)) and 0.0 < sys_["mem_pct"] < 100.0,
"/status sys.mem_pct is not a percent: %s", sys_)
# The lens takes its own reference on the hall edge at every
# controller start, so with a controller running Z is referenced on
# its own while X and Y wait for a home: homed_axes names the axes
# that carry one, and Z reads inside the lens reach rather than the
# zero an unreferenced axis would show.
axes = s.get("homed_axes")
ev["homed_axes"] = axes
ctx.check(isinstance(axes, int) and 0 <= axes <= 7,
"/status homed_axes is not an axis mask: %s", axes)
ctx.check(bool(s.get("homed")) == (axes == 7),
"/status homed (%s) disagrees with homed_axes (%s)",
s.get("homed"), axes)
st, mode0 = fc.get("/mode")
if isinstance(mode0, dict) and mode0.get("controller") == "running":
lens = s.get("lens") or {}
pos = s.get("pos") or {}
ctx.log("/status homed_axes=%s pos.z=%s reach=%s..%s", axes,
pos.get("z"), lens.get("reach_min"), lens.get("reach_max"))
ctx.check(axes is not None and axes & 4,
"Z is not referenced with a controller running: %s", axes)
ctx.check(isinstance(pos.get("z"), (int, float))
and lens.get("reach_min") is not None
and lens["reach_min"] <= pos["z"] <= lens["reach_max"],
"Z %s is outside the lens reach %s..%s", pos.get("z"),
lens.get("reach_min"), lens.get("reach_max"))
st, cam = fc.get("/cam/status")
ev["cam_status"] = st
ctx.log("GET /cam/status -> %s %s", st, cam)
ctx.check(st == 200 and isinstance(cam, dict) and "running" in cam, "GET /cam/status -> %s", st)
# The controller's published state, echoed only while a live GRBL
# controller runs (glowforge_status.c -> /run/forgefirm -> /status).
st, mode = fc.get("/mode")
if isinstance(mode, dict) and mode.get("mode") == "grbl" and mode.get("controller") == "running":
g = ctx.forgectrl.status().get("grbl") or {}
ev["grbl"] = g
ctx.log("/status grbl=%s", g)
ctx.check(isinstance(g.get("age_s"), (int, float)) and g["age_s"] < 30,
"/status grbl block missing or stale: %s", g)
rep = g.get("report") or {}
for key in ("state", "sender", "laser", "modals"):
ctx.check(key in rep, "/status grbl.report lacks %r", key)
ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"),
"grbl.report.laser carries no model: %s", rep.get("laser"))
ctx.check((rep.get("laser") or {}).get("curve"),
"grbl.report.laser carries no dose curve: %s", rep.get("laser"))
st, text = fc.get("/grbl/settings", raw=True)
ev["grbl_settings_status"] = st
ctx.check(st == 200 and b"$35=" in (text or b""),
"GET /grbl/settings -> %s without the $$ view", st)
# The $-settings persist in the data directory, never loose in /data.
nvs = "/data/forgefirm/EEPROM-glowforge.DAT"
ev["grbl_nvs"] = os.path.isfile(nvs)
ctx.check(os.path.isfile(nvs), "the controller's settings store is not at %s", nvs)
ctx.check(not os.path.exists("/data/EEPROM-glowforge.DAT"),
"a settings store remains at the top of /data")
else:
ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode)
# The dose-curve recorder's read surface answers in any mode.
st, cs = fc.get("/curve/status")
ev["curve_status"] = cs
ctx.check(st == 200 and isinstance(cs, dict) and cs.get("state") in
("idle", "waiting", "recording", "done", "failed"),
"GET /curve/status -> %s %s", st, cs)
st, text = fc.get("/curve/ladder.gcode", raw=True)
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
"GET /curve/ladder.gcode -> %s without the ladder", st)
+397
View File
@@ -0,0 +1,397 @@
"""image.* - the post-flash health of the running image (always-required)."""
import glob
import gzip
import mmap
import os
import struct
import re
import stat
from ..catalog import test
from .. import hw
WDOG1_BASE = 0x020BC000 # i.MX6 WDOG1; WCR is the 16-bit word at offset 0
def _wdog1_wcr():
"""WDOG1's control register, read through /dev/mem (MMIO stays readable
under STRICT_DEVMEM), or None."""
try:
with open("/dev/mem", "r+b") as f:
m = mmap.mmap(f.fileno(), 4096, offset=WDOG1_BASE)
try:
return struct.unpack_from("<H", m, 0)[0]
finally:
m.close()
except (OSError, ValueError):
return None
def _plain(line):
"""A line without its ANSI color sequences: what a terminal shows,
counted in columns rather than in bytes."""
return re.sub(r"\x1b\[[0-9;]*m", "", line)
def _read(path, default=None):
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return default
def kernel_config():
"""The running kernel's config as a dict, from /proc/config.gz or
/boot/config-<release>. None when neither is available."""
try:
with gzip.open("/proc/config.gz", "rb") as f:
text = f.read().decode("utf-8", "replace")
except OSError:
text = None
if text is None:
rel = _read("/proc/sys/kernel/osrelease", "").strip()
text = _read("/boot/config-%s" % rel) if rel else None
if text is None:
return None
cfg = {}
for line in text.splitlines():
m = re.match(r"^(CONFIG_[A-Z0-9_]+)=(.*)$", line)
if m:
cfg[m.group(1)] = m.group(2)
m = re.match(r"^# (CONFIG_[A-Z0-9_]+) is not set$", line)
if m:
cfg[m.group(1)] = "n"
return cfg
def kernel_ident(name):
"""A kernel release or modules-directory name less its LOCALVERSION_AUTO
hash (+g<hash> or -g<hash>). The hash does not reproduce across a
re-patch of the same source; the manifest lists modules directories
without it, and the kernel's identity is its @srcrev and @config."""
return re.sub(r"[+-]g[0-9a-f]{7,}$", "", name)
def kernel_matches(release, modules_dirs):
"""True when the running release names one of the manifest's modules
directories, hashes aside (a manifest without them constrains nothing)."""
return not modules_dirs or kernel_ident(release) in {kernel_ident(m) for m in modules_dirs}
def _dt_u32(path):
"""A device-tree cell as an int (big-endian), or None."""
try:
with open(path, "rb") as f:
raw = f.read(4)
except OSError:
return None
return int.from_bytes(raw, "big") if len(raw) == 4 else None
def fds_of(pid):
out = []
try:
for fd in os.listdir("/proc/%d/fd" % pid):
try:
out.append(os.readlink("/proc/%d/fd/%s" % (pid, fd)))
except OSError:
pass
except OSError:
pass
return out
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
always=True, est_min=1,
covers=[("forgectrl", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
("linux-fslc", "**")],
description="The image that is running is the image the manifest describes, with the "
"version stamped in /etc/forgefirm-version and under the machine mark in the "
"console banner and the motd, "
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
"the daemon ownership, "
"the init ordering, the file modes the release depends on, and the mounts: the "
"rootfs read-only, /data writable, the account files and the banner rendered "
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only.")
def image_health(ctx):
ev = ctx.evidence
manifest = ctx.runner.manifest
# 1. version stamp: the machine-readable file, and the two a person
# reads - the console banner (/etc/issue, which forgefirm-banner
# renders from the image's own text) and the motd a login prints.
# Each carries the machine mark with the version on the mark's own
# last line, right-justified to the mark's last column.
ver = (_read("/etc/forgefirm-version", "") or "").strip()
ev["forgefirm_version"] = ver
ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version)
ctx.check(ver == manifest.version, "/etc/forgefirm-version %r != manifest %r", ver, manifest.version)
for path in ("/etc/issue", "/etc/motd"):
text = _read(path, "") or ""
ev[path] = text
lines = text.splitlines()
at = [i for i, line in enumerate(lines) if line.rstrip().endswith(ver)]
ctx.check(at, "%s carries no line ending in the version %r", path, ver)
if not at:
continue
i = at[0]
ctx.check(lines[i].rstrip() == lines[i], "%s pads the version line: %r", path, lines[i])
ctx.check(_plain(lines[i]).strip() != ver,
"%s puts the version on a line of its own, not on the mark's last line", path)
# Right-justified to the mark's last column: the stamped line is
# exactly as wide as the widest line above it. Only the motd is
# measured, because /etc/issue holds the mark with its
# backslashes doubled for the getty that reads it, so its bytes
# are wider than the columns a person sees.
if path == "/etc/motd":
above = [len(_plain(line)) for line in lines[:i] if line.strip()]
width = max(above) if above else 0
ev["motd_columns"] = [len(_plain(lines[i])), width]
ctx.check(len(_plain(lines[i])) == width,
"/etc/motd stamped line is %d columns, the mark above is %d",
len(_plain(lines[i])), width)
# 2. kernel options
cfg = kernel_config()
ctx.check(cfg is not None, "kernel config unavailable (/proc/config.gz, /boot/config-*)")
for opt, want in (("CONFIG_PREEMPT", ("y",)), ("CONFIG_IMX2_WDT", ("y", "m")),
("CONFIG_PANIC_ON_OOPS", ("y",))):
val = cfg.get(opt, "n")
ev[opt] = val
ctx.log("%s=%s", opt, val)
ctx.check(val in want, "%s=%s, expected one of %s", opt, val, want)
# The boot-armed hardware watchdog: U-Boot arms WDOG1 at 60 s and the
# kernel's core keeps it fed (no userspace opens it, so the sysfs state
# reads "inactive": that attribute says whether a process holds the
# device, not whether the hardware runs). The hardware's own word is
# WCR: WDE set, and WT giving the period. The sysfs view needs
# CONFIG_WATCHDOG_SYSFS; bootstatus carries the last reset's cause (32 =
# the watchdog's timeout).
wd = {k: (_read("/sys/class/watchdog/watchdog0/" + k) or "").strip()
for k in ("identity", "state", "timeout", "bootstatus")}
wcr = _wdog1_wcr()
wd["wcr"] = ("%#06x" % wcr) if wcr is not None else None
wd["wde"] = bool(wcr & 0x4) if wcr is not None else None
wd["period_s"] = ((wcr >> 8) + 1) / 2.0 if wcr is not None else None
ev["watchdog"] = wd
ctx.log("watchdog0: %s", wd)
ctx.check(wd["identity"], "watchdog0 has no sysfs view (CONFIG_WATCHDOG_SYSFS)")
ctx.check(wd["timeout"] == "60", "watchdog0 timeout %r, expected 60 s", wd["timeout"] or None)
ctx.check(wcr is not None, "WDOG1 WCR unreadable through /dev/mem")
ctx.check(wd["wde"], "WDOG1 is not enabled (WCR %s): nothing resets a hung kernel", wd["wcr"])
ctx.check(wd["period_s"] == 60.0, "WDOG1 period %s s (WCR %s), expected 60", wd["period_s"], wd["wcr"])
ctx.check(wd["state"] == "inactive", "watchdog0 is %r: a process holds the device, which the image "
"does not do", wd["state"] or None)
rel = _read("/proc/sys/kernel/osrelease", "").strip()
ev["kernel_release"] = rel
mods = manifest.platform.get("kernel_modules") or []
ctx.log("kernel release %s (manifest modules dirs: %s)", rel, ",".join(mods))
ctx.check(kernel_matches(rel, mods), "running kernel %r is not the manifest's %s", rel, mods)
# 3. the module and its sysfs
ctx.check(os.path.isdir("/sys/module/glowforge"), "glowforge.ko is not loaded")
state = hw.sysfs_read("cnc/state")
ev["cnc_state"] = state
ctx.check(state is not None, "/sys/glowforge/cnc/state unreadable")
ctx.log("cnc/state: %s", state)
free = hw.sysfs_int("cnc/free")
ev["cnc_free"] = free
ctx.check(free is not None and free > 0, "cnc/free unreadable or zero (ring not mapped?)")
ctx.log("cnc/free: %s bytes", free)
# The ring the platform ships: the DT pool is the promise, ring_mb is what
# the module took. They must agree - a pool the parameter does not use is
# no-map RAM burned for nothing, and a parameter the pool cannot back would
# have failed the probe. free can only ever be size less the 32 KiB gap.
pool = _dt_u32("/proc/device-tree/reserved-memory/cnc-pulsebuf/size")
try:
ring = int((_read("/sys/module/glowforge/parameters/ring_mb", "") or "").strip()) << 20
except ValueError:
ring = None
ev["cnc_pool_bytes"] = pool
ev["cnc_ring_bytes"] = ring
ctx.log("cnc-pulsebuf pool: %s bytes, ring_mb: %s bytes", pool, ring)
ctx.check(pool is not None, "no cnc-pulsebuf reserved-memory node (the ring fell back to CMA?)")
ctx.check(ring is not None, "/sys/module/glowforge/parameters/ring_mb unreadable")
ctx.check(pool is None or ring is None or pool == ring,
"DT pool %s and ring_mb %s disagree", pool, ring)
ctx.check(ring is None or free is None or free <= ring - 32 * 1024,
"cnc/free %s exceeds the ring less its 32 KiB gap (%s)", free, ring)
ctx.check(hw.sysfs_read("cnc/interlock_circuit") is not None, "cnc/interlock_circuit unreadable")
# The SDMA engine runs only while a channel holder keeps its ipg/ahb
# clocks enabled; imx-sdma leaves them off after probe and only a channel
# allocation turns them on. glowforge.ko takes its channel outside
# dmaengine and holds the clocks itself. With the block gated every
# channel-0 transfer is a silent no-op: the probe cannot start, and the
# ring reads back a stale bounce page (free above the ring size, a
# position counter that never moved).
clk = (_read("/sys/kernel/debug/clk/sdma/clk_enable_count", "") or "").strip()
ev["sdma_clk_enable_count"] = clk
ctx.log("sdma clk_enable_count: %s", clk or "(unreadable)")
ctx.check(clk.isdigit() and int(clk) >= 1,
"SDMA clk_enable_count %r: the engine's ipg/ahb clocks are not held", clk)
# 4. forgectrl holds /dev/glowforge and supervises the controller
pids = hw.pidof("forgectrl")
ev["forgectrl_pids"] = pids
ctx.check(pids, "forgectrl is not running")
holders = [p for p in pids if any(l == "/dev/glowforge" for l in fds_of(p))]
ev["pulse_device_holders"] = holders
ctx.log("forgectrl pids %s, holding /dev/glowforge: %s", pids, holders)
ctx.check(holders, "no forgectrl process holds /dev/glowforge")
st, mode = ctx.forgectrl.get("/mode")
ev["mode"] = mode
ctx.check(st == 200 and isinstance(mode, dict), "GET /mode -> %s", st)
ctx.log("mode: %s", mode)
ctx.check(mode.get("controller") in ("running", "standby"),
"controller is %r (expected running or standby)", mode.get("controller"))
ctx.check(mode.get("motion") != "fault", "supervisor reports a motion fault")
# 5. init ordering: controllers stop before the daemon
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*"))
ev["rc6_kill"] = k
kg = [x for x in k if "grblhal" in x]
kf = [x for x in k if x.endswith("forgectrl")]
ctx.log("rc6.d: %s", " ".join(k))
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links")
ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[0])
# 6. logging lever present, no userspace watchdog daemon
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
ev["logging_init"] = logging
ctx.check(logging, "no ForgeFIRM logging/logrotate init script")
ctx.check(not os.path.exists("/etc/init.d/watchdog"), "a userspace watchdog init script is present")
# kernel threads (the imx2_wdt kthread is expected) have an empty cmdline
user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")]
ev["userspace_watchdog_pids"] = user_wd
ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", user_wd)
# 7. file modes and space
for path in ("/data/forgefirm/panel.token", "/data/forgefirm/forgefirm.conf"):
if os.path.exists(path):
m = stat.S_IMODE(os.stat(path).st_mode)
ev[path] = "%o" % m
ctx.log("%s mode %o", path, m)
ctx.check(m == 0o600, "%s mode %o, expected 600", path, m)
if os.path.isdir("/data"):
s = os.statvfs("/data")
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
ev["data_free_mb"] = free_mb
ctx.log("/data free: %d MiB", free_mb)
ctx.check(free_mb >= 20, "/data has only %d MiB free", free_mb)
# 8. the mounts: the rootfs read-only, /data the writable partition,
# the state a read-only rootfs hands off (the read-only-rootfs image
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
# sshd host keys). The
# dev image alone mounts the factory slots under /factory.
mounts = {}
for line in (_read("/proc/mounts", "") or "").splitlines():
parts = line.split()
if len(parts) >= 4:
mounts[parts[1]] = {"source": parts[0], "type": parts[2], "opts": parts[3].split(",")}
def mount_opts(path):
return (mounts.get(path) or {}).get("opts") or []
ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd",
"/etc/hostname", "/etc/issue") if p in mounts}
ctx.log("/ mounted %s; /data %s; /var/lib %s", ",".join(mount_opts("/")) or "(absent)",
",".join(mount_opts("/data")) or "(absent)", ",".join(mount_opts("/var/lib")) or "(absent)")
ctx.check("ro" in mount_opts("/"), "the rootfs is not mounted read-only: %s", mounts.get("/"))
ctx.check("rw" in mount_opts("/data"), "/data is not mounted read-write: %s", mounts.get("/data"))
ctx.check("rw" in mount_opts("/var/lib"),
"/var/lib is not a writable copy (read-only-rootfs-hook.sh): %s", mounts.get("/var/lib"))
rcs = _read("/etc/default/rcS", "") or ""
ctx.check("ROOTFS_READ_ONLY=yes" in rcs.splitlines(), "/etc/default/rcS lacks ROOTFS_READ_ONLY=yes")
factory = sorted(p for p in mounts if p.startswith("/factory/"))
ev["factory_mounts"] = factory
dev_image = os.path.exists("/etc/forgefirm-dev")
ev["dev_image"] = dev_image
ctx.log("dev image %s; /factory mounts: %s", dev_image, factory or "none")
if dev_image:
for n in (1, 2):
if os.path.exists("/dev/mmcblk2p%d" % n):
p = "/factory/img%d" % n
ctx.check("ro" in mount_opts(p), "%s is not mounted read-only on the dev image: %s", p, mounts.get(p))
else:
ctx.check(not factory, "a release image mounts the factory slots: %s", factory)
if os.path.isfile("/data/forgefirm/users"):
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
if l.strip() and not l.startswith("#")]
passwd_names = {l.split(":")[0] for l in (_read("/etc/passwd", "") or "").splitlines()}
ev["record_accounts"] = names
for f in ("/etc/passwd", "/etc/shadow", "/etc/group"):
ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f)
for n in names:
ctx.check(n in passwd_names, "record account %r is missing from /etc/passwd", n)
ctx.check("/etc/hostname" in mounts, "/etc/hostname is not the bind-mounted name copy")
ctx.check("/etc/issue" in mounts, "/etc/issue is not the bind-mounted banner copy")
if hw.pidof("sshd"):
key = "/data/forgefirm/ssh/ssh_host_ed25519_key"
ctx.check(os.path.isfile(key), "sshd runs but %s is missing", key)
# 9. the manifest itself is coherent
ctx.check(manifest.content_sha and len(manifest.content_sha) == 64, "manifest content_sha256 missing")
ctx.check("kernel-module-glowforge" in manifest.components, "manifest lacks kernel-module-glowforge")
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components))
@test("image.license-bundle", title="The license texts travel with the image",
subsystem="image", kind="auto", est_min=1,
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/login.html")],
requires=["forgectrl.panel-serves"],
description="The image carries /usr/share/forgefirm/licenses.tar.gz, a gzip tar with "
"the license manifest (every installed package with its license) and the "
"license texts, and no loose common-licenses tree; the panel serves the "
"bundle, the manifest as text, and the Licenses page every footer links, "
"each without a login.")
def license_bundle(ctx):
import io
import tarfile
fc = ctx.forgectrl
ev = ctx.evidence
path = "/usr/share/forgefirm/licenses.tar.gz"
ctx.check(os.path.isfile(path), "%s is missing", path)
ctx.check(not os.path.isdir("/usr/share/common-licenses"),
"the loose common-licenses tree is still on the rootfs")
size = os.path.getsize(path)
ev["bundle_bytes"] = size
ctx.log("%s: %d bytes", path, size)
with tarfile.open(path, "r:gz") as t:
names = t.getnames()
ctx.check("common-licenses/license.manifest" in names, "no license.manifest in the bundle")
manifest = t.extractfile("common-licenses/license.manifest").read().decode("utf-8", "replace")
packages = manifest.count("PACKAGE NAME: ")
texts = sum(1 for n in names if "/generic_" in n and not n.endswith("/"))
ev["packages"] = packages
ev["generic_texts"] = texts
ctx.log("manifest lists %d packages; %d generic license entries", packages, texts)
ctx.check(packages >= 40, "only %d packages in the manifest", packages)
ctx.check(texts >= 5, "only %d generic license entries", texts)
for name in ("forgectrl", "grblhal-glowforge", "kernel-module-glowforge"):
ctx.check("RECIPE NAME: %s\n" % name in manifest, "%s is not in the manifest", name)
st, body = fc.get("/system/licenses/manifest", raw=True)
ctx.check(st == 200, "GET /system/licenses/manifest -> %s", st)
ctx.check(isinstance(body, (bytes, bytearray)) and b"PACKAGE NAME: " in body,
"the served manifest is not the manifest")
st, body = fc.get("/system/licenses", raw=True)
ctx.check(st == 200, "GET /system/licenses -> %s", st)
ctx.check(isinstance(body, (bytes, bytearray)) and len(body) == size,
"the served bundle is %s bytes, the file %d", len(body) if body else None, size)
with tarfile.open(fileobj=io.BytesIO(bytes(body)), mode="r:gz") as t:
ctx.check("common-licenses/license.manifest" in t.getnames(),
"the served bundle holds no manifest")
st, body = fc.get("/licenses", raw=True)
ctx.check(st == 200, "GET /licenses -> %s", st)
text = bytes(body).decode("utf-8", "replace") if body else ""
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
"the Licenses page lacks the download link or the manifest")
+919
View File
@@ -0,0 +1,919 @@
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
(K1/K2/K3) and `scripts/bench/fire_test.py` (A/B/U) with their proven
sequences kept intact; they run under a hardware takeover (forgectrl and
the controller stopped, the pulse device free) and judge the software
witnesses: cnc/state, laser_enable (the FIRE line), laser_on and
laser_on_sampled (the gated LASER_ON output), interlock_circuit bit 3
(the commanded latch), faults and underruns. Every drill forces duty to
zero before any FIRE bit, keeps motor_lock=15 (no axis moves), and
re-locks the latch on every exit path. K3 and fire B/U unlock the latch
for their run and therefore refuse to proceed unless the safety chain
holds HV off: the charge-pump watchdog dead and the pulse engine idle
(HV_ENABLE follows the pump; laser_pgood is the supply's power-good and
says nothing about HV).
"""
import errno
import os
import struct
import time
try:
import fcntl
except ImportError: # host unit tests import the suite off-target
fcntl = None
from ..catalog import test
from .. import hw
from ..runner import Failed
# interlock_circuit bits (docs.forgefirm.org, the kernel module page): bit 3 = the driven latch line, set = locked.
LATCH_BIT = 1 << 3
TICK_HZ = 10000
FIRE = b"\x10"
PAD = b"\x00"
XSTEP = b"\x01" # +X (DIR clear)
XSTEP_BACK = b"\x03" # -X (DIR set)
POWER0 = bytes([0x80]) # power byte, duty 0
_KERNEL_COVERS = [("kernel-module-glowforge", "**"), ("linux-fslc", "**")]
# ---------------------------------------------------------------- helpers
def wr(attr, val):
hw.sysfs_write(attr, val)
def rd(attr):
v = hw.sysfs_read(attr)
if v is None:
raise Failed("cannot read %s" % attr)
return v
def rd_pos():
with open(hw.sysfs_root() + "cnc/position", "rb") as f:
raw = f.read(32)
return struct.unpack("<5i", raw[:20])
def snap(ctx, tag):
line = ("%s: state=%s laser_enable=%s laser_on=%s laser_on_sampled=%s interlock=%s"
% (tag, rd("cnc/state"), rd("cnc/laser_enable"), rd("cnc/laser_on"),
rd("cnc/laser_on_sampled"), rd("cnc/interlock_circuit")))
ctx.log(line)
return line
def wait_state(ctx, want, timeout, poll=0.05):
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
s = rd("cnc/state")
if s == want:
return s
time.sleep(poll)
return rd("cnc/state")
def watch_laser_until_idle(ctx, timeout):
"""Tight-loop laser_enable/laser_on watch; returns (hits, end_state)."""
hits = []
t0 = time.time()
state = "running"
n = 0
while time.time() - t0 < timeout:
en = rd("cnc/laser_enable")
on = rd("cnc/laser_on")
if en != "0" or on != "0":
hits.append((round(time.time() - t0, 4), en, on))
state = rd("cnc/state")
if state != "running":
break
n += 1
if n % 200 == 0:
ctx.checkpoint()
return hits, state
class PulseDevice:
"""Exclusive hold of /dev/glowforge for one drill."""
def __init__(self, ctx):
self.ctx = ctx
self.fd = None
def __enter__(self):
try:
self.fd = os.open("/dev/glowforge", os.O_WRONLY)
except OSError as e:
if e.errno == errno.EBUSY:
raise Failed("/dev/glowforge is busy - the takeover did not free the pulse device")
raise
fcntl.flock(self.fd, fcntl.LOCK_EX)
return self
def write(self, data):
os.write(self.fd, data)
def rewind(self):
os.lseek(self.fd, 1, os.SEEK_SET)
def drop(self):
"""Close the device with the lock still held: the dead man's switch."""
fd, self.fd = self.fd, None
os.close(fd)
def __exit__(self, *exc):
try:
wr("cnc/laser_latch", 1) # re-lock unconditionally
finally:
if self.fd is not None:
fcntl.flock(self.fd, fcntl.LOCK_UN)
os.close(self.fd)
return False
def hv_off_reason():
"""Why the safety chain is NOT holding HV off right now, or None. The
chain asserts HV_ENABLE only while a run feeds the charge-pump
watchdog, so a dead watchdog and an idle pulse engine mean HV off.
laser_pgood is the supply's power-good, high whenever the supply is
healthy, and says nothing about HV, so it plays no part here."""
alive = rd("cnc/charge_pump_alive")
state = rd("cnc/state")
if alive is None or state is None:
return "cnc/charge_pump_alive or cnc/state unreadable"
if alive != "0" or state != "idle":
return ("charge_pump_alive=%s state=%s: the chain may be holding HV_ENABLE up" % (alive, state))
return None
def hv_off():
"""Precheck for the drills that unlock the latch with a zero-duty
stream: they run only while the chain holds HV off. At idle nothing
feeds the watchdog, so this refuses a start only on a machine that is
not idle the way it should be."""
why = hv_off_reason()
if why:
return ("%s; this drill unlocks the latch with a zero-duty stream and needs HV off "
"(let the machine go idle, then start again)" % why)
return None
HV_RELEASE_S = 3.0
def wait_hv_off(ctx, timeout_s=HV_RELEASE_S):
"""hv_off_reason() once the chain has released, or the reason that still
stands after timeout_s. A run feeds the charge-pump watchdog every
200 ms and the one-shot holds ALIVE for 0.45 s after the last feed (the
feed's soft timer can add one more feed after the state leaves
running), so a phase that follows a run finds the chain still up for
under a second. The wait covers that release and nothing more; the
time it took is logged when it was not immediate and kept in the
evidence."""
t0 = time.time()
why = hv_off_reason()
while why is not None and time.time() - t0 < timeout_s:
ctx.sleep(0.05)
why = hv_off_reason()
dt = round(time.time() - t0, 2)
ctx.evidence.setdefault("hv_release_s", []).append(dt)
if why is not None or dt >= 0.1:
ctx.log("hv off: %s after %.2f s", "released" if why is None else "still held", dt)
return why
def require_hv_off(ctx):
"""The same rule at the start of the run (the precheck ran a moment
earlier; the machine must still agree)."""
why = wait_hv_off(ctx)
ctx.evidence["charge_pump_alive"] = rd("cnc/charge_pump_alive")
ctx.evidence["kernel_state"] = rd("cnc/state")
ctx.check(why is None, "%s - refusing the latch unlock", why)
def check_hv_off(ctx):
"""The hard check right before an unlock (no prompt: forgectrl is down)."""
why = wait_hv_off(ctx)
ctx.check(why is None, "%s - refusing the latch unlock", why)
# ---------------------------------------------------------------- readbacks
@test("kernel.latch-locked-idle", title="Laser latch locked at idle", subsystem="kernel",
kind="auto", always=True, est_min=1,
covers=_KERNEL_COVERS + [("forgectrl", "src/super.c"),
("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/driver.c")],
description="With the machine idle the kernel latch reads locked, the FIRE line is not "
"driven, no LASER_ON sample is seen, and no stepper fault is pending; forgectrl "
"agrees.")
def latch_locked_idle(ctx):
ev = ctx.evidence
state = hw.sysfs_read("cnc/state")
ev["cnc_state"] = state
ctx.log("cnc/state: %s", state)
ctx.check(state is not None, "cnc/state unreadable")
ctx.check(state in ("idle", "disabled"), "machine is %r, run this test at idle", state)
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["interlock_circuit"] = ilk
ctx.check(ilk is not None, "cnc/interlock_circuit unreadable")
ctx.log("interlock_circuit: %d (0x%x)", ilk, ilk)
ctx.check(ilk & LATCH_BIT, "latch line reads unlocked at idle (bit 3 clear)")
fire = hw.sysfs_int("cnc/laser_enable")
ev["laser_enable"] = fire
ctx.log("laser_enable (FIRE line): %s", fire)
ctx.check(fire == 0, "FIRE line driven at idle (laser_enable=%s)", fire)
on = hw.sysfs_int("cnc/laser_on")
on_s = hw.sysfs_int("cnc/laser_on_sampled")
ev["laser_on"] = on
ev["laser_on_sampled"] = on_s
ctx.log("laser_on: %s, laser_on_sampled: %s", on, on_s)
ctx.check(on == 0, "LASER_ON active at idle")
ctx.check(on_s == 0, "LASER_ON samples seen at idle (%s)", on_s)
faults = hw.sysfs_int("cnc/faults")
ev["faults"] = faults
ctx.log("faults: %s", faults)
ctx.check(faults == 0, "stepper faults pending: %s", faults)
st = ctx.forgectrl.status()
ev["forgectrl_laser_locked"] = st.get("laser_locked")
ctx.log("forgectrl /status laser_locked=%s state=%s", st.get("laser_locked"), st.get("state"))
ctx.check(st.get("laser_locked") is True, "forgectrl reports the latch unlocked")
# ---------------------------------------------------------------- K1 + K2
@test("kernel.k1-k2", title="Controlled-stop floor and resume honors the locked latch",
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=2,
covers=_KERNEL_COVERS,
requires=["kernel.latch-locked-idle"],
description="K1: a controlled stop mid-run ramps the step frequency down (tens of ms), "
"never consumes the tail as a burst or hangs. K2: with the latch locked, a "
"stop inside the leading pads and a resume with a positive waypoint replays "
"a 2 s FIRE window with laser_enable/laser_on at 0 throughout. Motors locked; "
"duty zero.")
def k1_k2(ctx):
ev = ctx.evidence
with ctx.takeover():
# ---- K1
stream = POWER0 + PAD * (6 * TICK_HZ)
ctx.log("K1: %d bytes = %.1f s of pads at %d Hz, ramp 125000 Hz/s",
len(stream), len(stream) / TICK_HZ, TICK_HZ)
snap(ctx, "K1 pre")
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/ramp_rate", 125000)
wr("cnc/step_freq", TICK_HZ)
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
wr("cnc/run", 1)
ctx.sleep(1.5) # well past the accel ramp
st = rd("cnc/state")
ctx.check(st == "running", "K1: expected running before the stop, got %s", st)
t0 = time.time()
wr("cnc/stop", 1)
while time.time() - t0 < 5:
if rd("cnc/state") != "running":
break
dt = time.time() - t0
state = rd("cnc/state")
faults = rd("cnc/faults")
ev["k1"] = {"stop_to_idle_s": round(dt, 4), "state": state, "faults": faults}
ctx.log("K1 controlled stop: state=%s after %.4f s, faults=%s", state, dt, faults)
# drain the paused remainder laser-less so the device ends clean
wr("cnc/resume", 0)
wait_state(ctx, "running", 2, poll=0.005)
wait_state(ctx, "idle", 10)
ctx.check(state == "idle", "K1: state %s after the stop", state)
ctx.check(dt >= 0.02, "K1: stop consumed the tail as a burst (%.4f s) - decel floor broken", dt)
ctx.check(dt <= 3.0, "K1: stop took %.4f s", dt)
ctx.check(faults == "0", "K1: faults=%s", faults)
ctx.log("K1 PASS: decelerating tail %.4f s, no burst, no fault", dt)
# ---- K2
# 1000 X steps out and 1000 back at 2 kHz (masked by motor_lock):
# the position counters end where they started
step_sec = (XSTEP + PAD * 4) * 1000 + (XSTEP_BACK + PAD * 4) * 1000
stream = (POWER0 + PAD * TICK_HZ + step_sec + PAD * (TICK_HZ // 2)
+ FIRE * (2 * TICK_HZ) + PAD * TICK_HZ)
ctx.log("K2: %d bytes = %.1f s; latch stays LOCKED; waypoint +200; motor_lock=15",
len(stream), len(stream) / TICK_HZ)
snap(ctx, "K2 pre")
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/ramp_rate", 125000)
wr("cnc/step_freq", TICK_HZ)
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
pos_before = rd_pos()
wr("cnc/run", 1)
ctx.sleep(0.4) # inside the initial pads
wr("cnc/stop", 1)
state = wait_state(ctx, "idle", 5, poll=0.01)
ctx.check(state == "idle", "K2: controlled stop did not reach idle (state=%s)", state)
ctx.log("K2: paused inside the pads; resuming with waypoint +200 (latch LOCKED)")
wr("cnc/resume", 200)
wait_state(ctx, "running", 2, poll=0.005)
hits, state = watch_laser_until_idle(ctx, 20)
pos_after = rd_pos()
snap(ctx, "K2 post")
ev["k2"] = {"hits": hits[:10], "end_state": state, "pos_before": pos_before,
"pos_after": pos_after, "laser_on_sampled": rd("cnc/laser_on_sampled"),
"underruns": rd("cnc/underruns"), "faults": rd("cnc/faults")}
ctx.log("K2 done: state=%s pos before=%s after=%s", state, pos_before, pos_after)
ctx.check(not hits, "K2: laser asserted with the latch locked: %s", hits[:10])
ctx.check(pos_before[:3] == pos_after[:3],
"K2: position counters did not return to start (%s -> %s)", pos_before[:3], pos_after[:3])
ctx.log("K2 PASS: FIRE window replayed after the resume waypoint with "
"laser_enable/laser_on at 0 throughout")
# ---------------------------------------------------------------- dead man
@test("kernel.deadman-close", title="The final close of a running pulse device halts the engine and safes the head",
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=1,
covers=_KERNEL_COVERS,
requires=["kernel.k1-k2"],
description="The kernel dead man's switch: a pulse device closed while locked and running "
"halts the engine at once, locks the laser latch, and puts the head in its safe "
"state - lens driver disabled at low current, measure laser and UV LED off. The "
"lens driver is enabled at low current before the close, so the safe state has "
"something to undo. Motors locked; duty zero.")
def deadman_close(ctx):
ev = ctx.evidence
with ctx.takeover():
stream = POWER0 + PAD * (6 * TICK_HZ)
snap(ctx, "deadman pre")
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/ramp_rate", 125000)
wr("cnc/step_freq", TICK_HZ)
wr("head/z_current", 1)
wr("head/z_enable", 0)
before = {"z_enable": rd("head/z_enable"), "z_current": rd("head/z_current")}
ev["head_before"] = before
ctx.check(before["z_enable"] == "0", "the lens driver did not enable (z_enable %s)", before["z_enable"])
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
wr("cnc/run", 1)
ctx.sleep(1.0)
st = rd("cnc/state")
ctx.check(st == "running", "expected running before the close, got %s", st)
t0 = time.time()
dev.drop()
while time.time() - t0 < 5:
if rd("cnc/state") != "running":
break
dt = time.time() - t0
state = rd("cnc/state")
after = {"z_enable": rd("head/z_enable"), "z_current": rd("head/z_current"),
"measure_laser": rd("head/measure_laser"), "uv_led": rd("head/uv_led")}
latch = hw.sysfs_int("cnc/interlock_circuit")
ev["close"] = {"halt_s": round(dt, 4), "state": state, "faults": rd("cnc/faults"),
"interlock_circuit": latch}
ev["head_after"] = after
snap(ctx, "deadman post")
ctx.log("final close: state=%s after %.4f s; head %s", state, dt, after)
ctx.check(state == "idle", "the engine did not halt on the final close (state %s)", state)
ctx.check(dt < 1.0, "the halt took %.3f s", dt)
ctx.check(latch is not None and latch & (1 << 3), "the latch is not locked after the close")
ctx.check(after["z_enable"] == "1" and after["z_current"] == "1",
"the head was not safed: z_enable %s z_current %s (want 1, 1: driver disabled, low current)",
after["z_enable"], after["z_current"])
ctx.check(after["measure_laser"] == "0" and after["uv_led"] == "0",
"measure laser %s, UV LED %s after the close", after["measure_laser"], after["uv_led"])
wr("cnc/stop", 1)
ctx.log("PASS: the final close halted the engine in %.3f s, latch locked, head safed", dt)
# ---------------------------------------------------------------- backtrack
@test("kernel.backtrack-bounds", title="A backward run is bounded by the history the ring still holds",
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=2,
covers=_KERNEL_COVERS,
requires=["kernel.k1-k2"],
description="A pause walks the program backward to put the beam back over ground the job "
"already cut, and the ring is what remembers that ground. cnc/max_backtrack is "
"the distance still available: what has played, less the tail the deceleration "
"spends. This drills the boundary on real hardware - the readback matches the "
"bytes played, a step beyond it is refused rather than quietly shortened, and "
"the run at the boundary plays out and returns to idle. Motors locked, latch "
"locked, duty zero: nothing moves and nothing fires.")
def backtrack_bounds(ctx):
ev = ctx.evidence
tail = TICK_HZ * TICK_HZ // (2 * 125000) # v^2/2a: 400 steps at the print tick
with ctx.takeover():
stream = POWER0 + PAD * (6 * TICK_HZ)
ctx.log("%d bytes = %.1f s of pads at %d Hz; decel tail %d steps",
len(stream), len(stream) / TICK_HZ, TICK_HZ, tail)
snap(ctx, "pre")
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/ramp_rate", 125000)
wr("cnc/step_freq", TICK_HZ)
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
wr("cnc/run", 1)
ctx.sleep(1.5) # well past the accel ramp
wr("cnc/stop", 1)
state = wait_state(ctx, "idle", 5, poll=0.01)
ctx.check(state == "idle", "the controlled stop did not reach idle (state=%s)", state)
played = rd_pos()[3]
budget = int(rd("cnc/max_backtrack"))
ev["played"] = played
ev["max_backtrack"] = budget
ctx.log("played %d bytes; max_backtrack %d", played, budget)
ctx.check(budget > 0, "max_backtrack is %d after %d bytes played", budget, played)
# The two numbers come from different SDMA registers (the byte
# counter and the ring head), so allow a few bytes of skew - but
# not the whole gap, and not the whole played span.
ctx.check(abs(budget - (played - tail)) <= 8,
"max_backtrack %d is not the %d bytes played less the %d-step decel tail",
budget, played, tail)
# One step past the boundary: refused, and the device stays idle.
refused = None
try:
wr("cnc/resume", -(budget + 1))
except OSError as e:
refused = e.errno
ev["over_long_errno"] = refused
ctx.check(refused == errno.EPERM,
"a backtrack one step past the boundary was not refused with EPERM (%s)",
refused)
state = rd("cnc/state")
ctx.check(state == "idle", "the refused backtrack left the device %s", state)
# At the boundary: runs, decelerates inside genuine data, ends idle.
wr("cnc/resume", -budget)
wait_state(ctx, "running", 2, poll=0.005)
hits, state = watch_laser_until_idle(ctx, 30)
after = int(rd("cnc/max_backtrack"))
ev["after"] = {"state": state, "max_backtrack": after,
"faults": rd("cnc/faults"), "underruns": rd("cnc/underruns")}
ctx.log("backtrack of %d done: state=%s max_backtrack now %d", budget, state, after)
# Leave the program drained so the device ends where the other
# drills expect it.
wr("cnc/resume", 0)
wait_state(ctx, "running", 2, poll=0.005)
wait_state(ctx, "idle", 30)
ctx.check(not hits, "the laser asserted during the backward run: %s", hits[:10])
ctx.check(state == "idle", "the backward run ended in %s", state)
ctx.check(ev["after"]["faults"] == "0", "faults=%s after the backward run",
ev["after"]["faults"])
ctx.log("PASS: max_backtrack tracks the played history, the boundary is enforced, "
"and the run at it plays out clean")
# ---------------------------------------------------------------- K3
def _k3_phase(ctx):
"""K3: the latch unlocked during the accel ramp must not restore the FIRE
drive while the run is in flight. Runs inside the caller's takeover."""
ev = ctx.evidence
check_hv_off(ctx)
stream = POWER0 + FIRE * (3 * TICK_HZ) + PAD * (TICK_HZ // 2)
ctx.log("K3: %d bytes = %.1f s of FIRE bits; ramp_rate 10000 Hz/s (~0.9 s accel "
"window); unlock at t=+0.15 s", len(stream), len(stream) / TICK_HZ)
snap(ctx, "K3 pre")
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/step_freq", TICK_HZ)
wr("cnc/ramp_rate", 10000)
try:
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
wr("cnc/run", 1)
time.sleep(0.15) # inside the accel ramp
wr("cnc/laser_latch", 0)
ilk = rd("cnc/interlock_circuit")
ctx.log("K3: latch UNLOCKED mid-ramp; interlock=%s (bit 3 should read 0)", ilk)
hits, state = watch_laser_until_idle(ctx, 20)
snap(ctx, "K3 post")
ev["k3"] = {"interlock_after_unlock": ilk, "hits": hits[:10], "end_state": state,
"laser_on_sampled": rd("cnc/laser_on_sampled"),
"underruns": rd("cnc/underruns"), "faults": rd("cnc/faults")}
finally:
wr("cnc/laser_latch", 1)
try:
wr("cnc/ramp_rate", 125000)
except OSError:
ctx.log("WARNING: could not restore ramp_rate=125000")
ctx.check((int(ilk) & LATCH_BIT) == 0, "K3: the unlock did not drive the latch pin (interlock=%s)", ilk)
ctx.check(not hits, "K3: FIRE drive re-armed by a mid-run unlock: %s", hits[:10])
ctx.log("K3 PASS: laser_enable stayed 0 for the entire run after the mid-ramp unlock")
# ---------------------------------------------------------------- FIRE A/B/U
def _fire_stream():
return (POWER0 + # duty zero before any FIRE bit
PAD * TICK_HZ + # 1 s baseline
FIRE * (2 * TICK_HZ) + # 2.000 s FIRE window (bounded by pads)
PAD * TICK_HZ + # 1 s gap
FIRE * (2 * TICK_HZ)) # 2.000 s FIRE window ending AT end-of-data
def _fire_phase(ctx, mode):
"""One phase of fire_test.py; returns the evidence dict."""
unlock = mode in ("B", "U")
underrun_mode = mode == "U"
stream = _fire_stream()
ctx.log("fire %s: stream %d bytes = %.3f s", mode, len(stream), len(stream) / TICK_HZ)
if unlock:
check_hv_off(ctx)
snap(ctx, "fire %s pre" % mode)
wr("cnc/motor_lock", 15)
wr("cnc/step_freq", TICK_HZ)
wr("cnc/laser_latch", 1)
underruns_before = int(rd("cnc/underruns"))
mid = None
tail = None
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
pos_before = rd_pos()
if underrun_mode:
wr("cnc/streaming", 1) # end-of-data mid-run = true underrun
ctx.log("fire U: streaming=1, the terminal end-of-data will be a TRUE UNDERRUN")
try:
if unlock:
wr("cnc/laser_latch", 0)
ctx.log("fire %s: latch UNLOCKED for this run", mode)
wr("cnc/run", 1)
t0 = time.time()
state = ""
samples = []
while time.time() - t0 < 20:
ctx.checkpoint()
state = rd("cnc/state")
dt = time.time() - t0
en, on, ons = rd("cnc/laser_enable"), rd("cnc/laser_on"), rd("cnc/laser_on_sampled")
samples.append((round(dt, 2), state, en, on, ons))
if mid is None and 1.5 < dt < 3.0:
mid = {"t": round(dt, 2), "laser_enable": en, "laser_on": on,
"laser_on_sampled": ons, "interlock": rd("cnc/interlock_circuit")}
ctx.log("fire %s mid (inside FIRE window): laser_enable=%s laser_on=%s "
"laser_on_sampled=%s interlock=%s", mode, en, on, ons, mid["interlock"])
if state != "running":
break
time.sleep(0.05)
end_dt = time.time() - t0
tail = {"state": state, "after_s": round(end_dt, 2), "laser_enable": rd("cnc/laser_enable"),
"laser_on": rd("cnc/laser_on")}
ctx.log("fire %s done: state=%s after %.1f s (laser_enable=%s)", mode, state, end_dt,
tail["laser_enable"])
if underrun_mode:
if state == "underrun":
ctx.log("fire U: underrun state reached as EXPECTED; acking via stop")
else:
ctx.log("fire U: WARNING expected underrun state, got %s", state)
wr("cnc/stop", 1)
wr("cnc/streaming", 0)
tail["acked_state"] = rd("cnc/state")
ctx.log("fire U: acked: state=%s", tail["acked_state"])
finally:
wr("cnc/laser_latch", 1)
if underrun_mode:
try:
wr("cnc/streaming", 0)
except OSError:
pass
pos_after = rd_pos()
snap(ctx, "fire %s post" % mode)
ev = {"mid": mid, "tail": tail, "moved": pos_before[:3] != pos_after[:3],
"underruns_before": underruns_before, "underruns_after": int(rd("cnc/underruns")),
"faults": rd("cnc/faults"),
"any_laser_on": any(s[3] != "0" or s[4] != "0" for s in samples),
"any_fire_driven": any(s[2] != "0" for s in samples)}
ctx.log("fire %s: moved=%s underruns %d->%d faults=%s", mode, ev["moved"],
ev["underruns_before"], ev["underruns_after"], ev["faults"])
return ev
@test("kernel.fire-line", title="FIRE line: latch locked, unlocked-unarmed, true underrun, and a "
"mid-run unlock",
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=4,
covers=_KERNEL_COVERS,
requires=["kernel.k1-k2"], precheck=hv_off,
steps=["Phases B, U and K3 unlock the latch with a zero-duty stream, so the drill starts only "
"while the HV supply does not report good (true at idle; if it is refused, open the "
"lid - the safety chain holds HV off - and start it again)."],
description="Four phases behind one takeover of the pulse device, all zero duty. A: latch "
"locked, 40 000 streamed FIRE bits, nothing on the FIRE/LASER_ON nets. B: latch "
"unlocked with the chain unarmed, the FIRE line is driven mid-window and "
"LASER_ON stays off (the safety AND-gate holds), FIRE clear at end-of-data. U: "
"streaming declared, the terminal end-of-data is a true underrun, the backstop "
"drops FIRE and stop acks it. K3: the latch unlocked during the accel ramp "
"drives the latch pin but never restores the FIRE drive to a run already in "
"flight - laser_enable stays 0 for the whole run.")
def fire_line(ctx):
ev = ctx.evidence
require_hv_off(ctx)
with ctx.takeover():
try:
a = _fire_phase(ctx, "A")
ev["A"] = a
ctx.check(a["mid"] is not None, "A: no mid-window sample")
ctx.check(not a["any_fire_driven"], "A: FIRE line driven with the latch locked")
ctx.check(not a["any_laser_on"], "A: LASER_ON seen with the latch locked")
ctx.check(a["tail"]["state"] == "idle", "A: ended in %s", a["tail"]["state"])
ctx.check(not a["moved"], "A: position moved with motors locked")
ctx.log("fire A PASS: latch locked, no FIRE drive, no LASER_ON")
b = _fire_phase(ctx, "B")
ev["B"] = b
ctx.check(b["mid"] is not None, "B: no mid-window sample")
ctx.check(b["mid"]["laser_enable"] != "0",
"B: FIRE line not driven mid-window with the latch unlocked (%s)", b["mid"])
ctx.check(not b["any_laser_on"], "B: LASER_ON active with the chain unarmed - the AND-gate did not hold")
ctx.check(b["tail"]["state"] == "idle", "B: ended in %s", b["tail"]["state"])
ctx.check(b["tail"]["laser_enable"] == "0", "B: FIRE still driven after end-of-data")
ctx.check(b["underruns_after"] == b["underruns_before"], "B: underrun counted on a normal completion")
ctx.log("fire B PASS: FIRE driven mid-window, LASER_ON off, FIRE clear at end-of-data")
u = _fire_phase(ctx, "U")
ev["U"] = u
ctx.check(u["tail"]["state"] == "underrun", "U: expected the underrun state, got %s", u["tail"]["state"])
ctx.check(u["tail"]["laser_enable"] == "0", "U: FIRE still driven after the underrun")
ctx.check(not u["any_laser_on"], "U: LASER_ON active with the chain unarmed")
ctx.check(u["tail"].get("acked_state") == "idle", "U: stop did not ack the underrun (state %s)",
u["tail"].get("acked_state"))
ctx.check(u["underruns_after"] == u["underruns_before"] + 1,
"U: underrun counter %d -> %d", u["underruns_before"], u["underruns_after"])
ctx.log("fire U PASS: true underrun, backstop dropped FIRE, stop acked")
_k3_phase(ctx)
finally:
wr("cnc/laser_latch", 1)
try:
wr("cnc/disable", 1) # the script's safe state
except OSError:
pass
ctx.log("safe state restored: state=%s latch=LOCKED", rd("cnc/state"))
# ---------------------------------------------------------------- resume lead
@test("kernel.resume-lead", title="A resume's laser-off lead, and an end-of-data before the waypoint",
subsystem="kernel", kind="auto", hardware="takeover", est_min=2,
covers=_KERNEL_COVERS,
requires=["kernel.k1-k2"], precheck=hv_off,
steps=["Phase L unlocks the latch with a zero-duty stream, so the drill starts only while "
"the HV supply does not report good (true at idle; if it is refused, open the lid - "
"the safety chain holds HV off - and start it again)."],
description="Two phases behind one takeover, zero duty, motors locked, a 1 kHz tick. E: a "
"resume whose lead is longer than the data (the ring drains before the waypoint) "
"ends the run at end-of-data within a few ticks, not one re-notify period (255 "
"ticks) later: the script publishes end-of-data in its mailbox and the host "
"decodes on that, not on the waypoint it was still waiting for. L: with the "
"latch unlocked and the chain unarmed, a resume with a 1000-byte lead over a "
"stream of FIRE bits keeps the FIRE line low through the lead and drives it from "
"the waypoint byte on. The script masks the laser bits itself, so no host write "
"reaches the GPIO data register while the script runs; LASER_ON stays off (the "
"safety AND-gate holds).")
def resume_lead(ctx):
ev = ctx.evidence
require_hv_off(ctx)
tick = 1000
with ctx.takeover():
try:
# ---- E: end-of-data before the waypoint
stream = POWER0 + PAD * (tick // 2) # 0.5 s of pads
expected = len(stream) / tick
ctx.log("E: %d bytes = %.1f s of pads at %d Hz; resume with a %d-byte lead (never reached)",
len(stream), expected, tick, 10 * tick)
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/ramp_rate", 125000)
wr("cnc/step_freq", tick)
snap(ctx, "E pre")
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
underruns_before = int(rd("cnc/underruns"))
t0 = time.time()
wr("cnc/resume", 10 * tick)
state = wait_state(ctx, "running", 2, poll=0.002)
ctx.check(state == "running", "E: the resume did not start (state=%s)", state)
state = wait_state(ctx, "idle", 5, poll=0.002)
dt = time.time() - t0
ev["E"] = {"run_to_idle_s": round(dt, 3), "data_s": expected, "state": state,
"underruns_before": underruns_before,
"underruns_after": int(rd("cnc/underruns")), "faults": rd("cnc/faults")}
ctx.log("E: %s after %.3f s (the data is %.3f s; a lost end-of-data would add %.3f s)",
state, dt, expected, 255.0 / tick)
ctx.check(state == "idle", "E: state %s after the data ran out", state)
ctx.check(dt < expected + 0.15,
"E: the run ended %.3f s after its data: end-of-data decoded late", dt - expected)
ctx.check(ev["E"]["underruns_after"] == underruns_before,
"E: an underrun was counted on a normal completion")
ctx.check(ev["E"]["faults"] == "0", "E: faults=%s", ev["E"]["faults"])
ctx.log("E PASS: end-of-data before the waypoint ended the run on time")
# ---- L: the lead, latch unlocked, chain unarmed
check_hv_off(ctx)
lead = tick # 1.0 s of FIRE bits held off
lead_s = lead / tick
stream = POWER0 + FIRE * (2 * tick) + PAD * (tick // 5)
ctx.log("L: %d bytes = %.1f s (%.1f s of FIRE bits then pads); lead %d bytes = %.1f s; "
"latch UNLOCKED, chain unarmed", len(stream), len(stream) / tick,
2 * tick / tick, lead, lead_s)
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/step_freq", tick)
snap(ctx, "L pre")
samples = []
state = ""
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
try:
wr("cnc/laser_latch", 0)
t0 = time.time()
wr("cnc/resume", lead)
n = 0
while time.time() - t0 < 10:
en, on = rd("cnc/laser_enable"), rd("cnc/laser_on")
samples.append((round(time.time() - t0, 4), en, on))
state = rd("cnc/state")
if state != "running" and time.time() - t0 > 0.2:
break
n += 1
if n % 200 == 0:
ctx.checkpoint()
finally:
wr("cnc/laser_latch", 1)
snap(ctx, "L post")
driven = [s for s in samples if s[1] != "0"]
first = driven[0][0] if driven else None
last = driven[-1][0] if driven else None
inside_lead = [s for s in driven if s[0] < lead_s - 0.02]
ev["L"] = {"samples": len(samples), "driven_samples": len(driven),
"first_driven_s": first, "last_driven_s": last, "end_state": state,
"any_laser_on": any(s[2] != "0" for s in samples),
"inside_lead": inside_lead[:5], "laser_on_sampled": rd("cnc/laser_on_sampled"),
"faults": rd("cnc/faults")}
ctx.log("L: %d samples, FIRE driven in %d, first at %s s, last at %s s, end state %s",
len(samples), len(driven), first, last, state)
ctx.check(state == "idle", "L: ended in %s", state)
ctx.check(not inside_lead, "L: FIRE driven inside the lead: %s", inside_lead[:5])
ctx.check(driven, "L: FIRE never driven after the lead (the inhibit never cleared)")
ctx.check(first is not None and first < lead_s + 0.3,
"L: FIRE first driven at %s s; the lead ends at %.1f s", first, lead_s)
ctx.check(not ev["L"]["any_laser_on"],
"L: LASER_ON active with the chain unarmed - the AND-gate did not hold")
ctx.check(rd("cnc/laser_enable") == "0", "L: FIRE still driven after end-of-data")
ctx.log("L PASS: FIRE low through the %.1f s lead, driven from the waypoint on, LASER_ON off",
lead_s)
finally:
wr("cnc/laser_latch", 1)
try:
wr("cnc/ramp_rate", 125000)
wr("cnc/disable", 1) # the script's safe state
except OSError:
pass
ctx.log("safe state restored: state=%s latch=LOCKED", rd("cnc/state"))
# ---------------------------------------------------------------- PIC and the SoC's load
PIC_SETTLE_PARAM = "/sys/module/glowforge/parameters/pic_settle_us"
def _param_read(path):
try:
with open(path) as f:
return f.read().strip()
except OSError:
return None
def _param_write(path, value):
with open(path, "w") as f:
f.write("%s\n" % value)
def _level_stats(xs):
s = sorted(xs)
n = len(s)
return {"n": n, "mean": round(sum(s) / float(n), 2), "med": s[n // 2],
"iqr": s[(3 * n) // 4] - s[n // 4], "min": s[0], "max": s[-1]}
def _spin(sec):
t0 = time.perf_counter()
x = 1.0
while time.perf_counter() - t0 < sec:
x = x * 1.000001 + 0.5
return x
@test("kernel.pic-soc-load", title="A PIC reading does not depend on what the reader was doing",
subsystem="kernel", kind="auto", est_min=1,
covers=_KERNEL_COVERS + [("forgectrl", "src/diag.c")],
description="The PIC converts its sensors in a free-running loop and a read returns the "
"last conversion of that channel; the count depends on the SoC's load when the "
"conversion was made (the PIC converts against its own supply, the dividers hang "
"on the board's reference), about 6 counts between an idle and a busy CPU on the "
"coolant thermistors. The module keeps the CPU busy for pic_settle_us before every "
"transaction, longer than one PIC loop, so the value read was converted under the "
"same load whoever reads. The drill reads a coolant thermistor 200 times each after "
"3 ms of sleep and after 3 ms of spinning, with the settle off (the control, "
"reported) and on (the claim): settled, the two agree.")
def pic_soc_load(ctx):
ev = ctx.evidence
saved = _param_read(PIC_SETTLE_PARAM)
ctx.check(saved is not None, "the module has no pic_settle_us parameter (%s)", PIC_SETTLE_PARAM)
ev["pic_settle_us_before"] = saved
fd = os.open(hw.sysfs_root() + "pic/water_temp_1", os.O_RDONLY)
def reads(regime, n=200):
xs = []
for i in range(n):
if regime == "idle":
time.sleep(0.003)
else:
_spin(0.003)
xs.append(int(os.pread(fd, 32, 0).strip()))
if i % 100 == 99:
ctx.checkpoint()
return _level_stats(xs)
try:
_param_write(PIC_SETTLE_PARAM, 0)
ctx.sleep(0.05)
control = {"idle": reads("idle"), "busy": reads("busy")}
_param_write(PIC_SETTLE_PARAM, 500)
ctx.sleep(0.05)
settled = {"idle": reads("idle"), "busy": reads("busy")}
finally:
os.close(fd)
_param_write(PIC_SETTLE_PARAM, saved if saved not in (None, "0") else 500)
ev["control"] = control
ev["settled"] = settled
ev["pic_settle_us_after"] = _param_read(PIC_SETTLE_PARAM)
for name, r in (("control (settle off)", control), ("settled (500 us)", settled)):
ctx.log("%s: after sleep median %d (iqr %d, %d..%d); after spin median %d (iqr %d, %d..%d)",
name, r["idle"]["med"], r["idle"]["iqr"], r["idle"]["min"], r["idle"]["max"],
r["busy"]["med"], r["busy"]["iqr"], r["busy"]["min"], r["busy"]["max"])
ev["control_split"] = control["busy"]["med"] - control["idle"]["med"]
ev["settled_split"] = settled["busy"]["med"] - settled["idle"]["med"]
ctx.log("split busy minus idle: control %+d, settled %+d", ev["control_split"], ev["settled_split"])
ctx.check(abs(ev["settled_split"]) <= 2,
"settled reads still split by %+d counts between an idle and a busy reader", ev["settled_split"])
ctx.check(settled["idle"]["iqr"] <= 4 and settled["busy"]["iqr"] <= 4,
"settled reads spread %d / %d counts (interquartile)", settled["idle"]["iqr"], settled["busy"]["iqr"])
# The kernel's spin is its own load level, a couple of counts under a
# Python spin, so a settled reader lands above the idle regime without
# reaching the busy one. The failure this guards is a settle that
# overshoots and lets the conversion fall back to idle, which reads as
# no move at all; the split collapsing above is the primary proof.
#
# The bound is a third of the control split, not half. Measured over
# eleven runs on this bench the move is 3 counts (once 2, once 4)
# against a control split of 6 (once 7), so a half-split bound sits
# exactly on the median and decides on one count of noise: two of those
# eleven runs failed with the machine reading identically to the nine
# that passed. A third keeps the guard against no move at all and
# leaves a count of margin either way.
moved = settled["idle"]["med"] - control["idle"]["med"]
ev["settled_idle_moved"] = moved
ctx.check(ev["control_split"] <= 2 or moved * 3 >= ev["control_split"],
"a settled idle reader moved %+d counts off the idle regime, the control split is %+d: "
"the settle did not land the conversion under load", moved, ev["control_split"])
ctx.check(ev["pic_settle_us_after"] == "500", "pic_settle_us left at %s", ev["pic_settle_us_after"])
ctx.log("PASS: settled, an idle reader and a busy reader read the same (split %+d); control split %+d",
ev["settled_split"], ev["control_split"])
File diff suppressed because it is too large Load Diff
+306
View File
@@ -0,0 +1,306 @@
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
the routing path (emitters, relays, rendered rules); the level settings."""
import gzip
import io
import os
import re
import tarfile
import time
from ..catalog import test
from .. import hw
def _read(path, default=None):
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return default
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
("forgectrl", "src/main.c")]
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
kind="auto", est_min=1,
covers=_LOG_COVERS, requires=["forgectrl.auth"],
description="/logs lists the loggers with their levels and files, /logs/tail returns the "
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
"bundle that contains neither the panel token nor the camera key.")
def tree_tail_export(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/logs")
ctx.log("GET /logs -> %s", st)
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
loggers = body.get("loggers") or []
names = [l.get("name") for l in loggers] if isinstance(loggers, list) else list(loggers)
ev["loggers"] = names
ctx.log("loggers: %s", names)
ctx.check("forgectrl" in names, "/logs lacks the forgectrl logger: %s", names)
st, tail = fc.get("/logs/tail", params={"name": "forgectrl", "lines": "20"})
ctx.log("GET /logs/tail?name=forgectrl -> %s", st)
ctx.check(st == 200, "GET /logs/tail -> %s %s", st, tail if isinstance(tail, dict) else "")
st, tail = fc.get("/logs/tail", params={"name": "no-such-logger"})
ctx.check(st == 404, "unknown logger -> %s, expected 404", st)
# the sanitizer walks every log file; a full tree takes tens of
# seconds on the target, so this call gets its own client timeout
slow = hw.Forgectrl(token=fc.token, timeout=300.0)
t0 = time.time()
st, data = slow.post("/logs/export", raw=True)
ev["export_s"] = round(time.time() - t0, 1)
ctx.log("POST /logs/export -> %s (%d bytes, %.1f s)", st, len(data) if data else 0, ev["export_s"])
ctx.check(st == 200, "POST /logs/export -> %s", st)
ev["export_bytes"] = len(data)
try:
raw = gzip.decompress(data)
tf = tarfile.open(fileobj=io.BytesIO(raw))
members = tf.getnames()
except (OSError, tarfile.TarError, EOFError) as e:
ctx.fail("export is not a readable tar.gz: %s", e)
ev["members"] = len(members)
ctx.log("bundle: %d members, e.g. %s", len(members), members[:5])
ctx.check(members, "empty bundle")
ctx.check(any(m.endswith("README.txt") for m in members), "sanitized bundle lacks README.txt")
# the two secrets the sanitizer knows by value and a bundle could
# carry: the panel token and the camera key (a URL parameter that
# lands in sender logs)
secrets = {}
if fc.token:
secrets["panel token"] = fc.token
st, body = fc.get("/system/camera-key")
if st == 200 and isinstance(body, dict) and body.get("key"):
secrets["camera key"] = body["key"]
ctx.check("camera key" in secrets, "GET /system/camera-key -> %s: no key to check for", st)
contents = [(m.name, tf.extractfile(m).read()) for m in tf.getmembers() if m.isfile()]
for what, value in secrets.items():
leaked = [name for name, content in contents if value.encode() in content]
ev[what.replace(" ", "_") + "_leaks"] = leaked
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
# The routing test proves the whole path every logger takes: emitter (or
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
# logger's own file in the ff_line format. It stands for the emitters in
# every component (fflog in forgectrl and grblHAL, the SysLogHandler in the
# Python apps), the supervisor's per-controller relay, the daemon's fifo
# relay in its init script, and the render step.
_ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/super.c"),
("forgectrl", "init/**"),
("grblhal-glowforge", "src/fflog.*"), ("grblhal-glowforge", "src/main.c"),
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
("forgefirm-app", "forgefirm-app/gfhome.py")]
LOGS_ROOT = "/data/log/forgefirm"
LOGGERS = ("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
def _tail_lines(path, n=400):
try:
with open(path, "rb") as f:
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - 256 * 1024))
data = f.read()
except OSError:
return []
return data.decode("utf-8", "replace").splitlines()[-n:]
def _wait_line(path, needle, timeout=6.0):
"""Poll a log file for a line containing needle; the matched line or None."""
deadline = time.time() + timeout
while time.time() < deadline:
for line in _tail_lines(path, 200):
if needle in line:
return line
time.sleep(0.25)
return None
@test("logs.routing", title="Log routing, relays, and the rendered rules", subsystem="logs",
kind="auto", est_min=1,
covers=_ROUTING_COVERS, requires=["logs.tree-tail-export"],
description="rsyslog is the only logger (no busybox syslogd/klogd), the rules rendered from "
"the settings and the effective-levels record exist and agree with /logs, every "
"logger has its directory, the daemon's own emitter and the per-controller "
"`logger` relay both land in the right file in the ff_line format, a stray "
"program's line goes to system/ and nowhere else, kernel lines reach kernel/, "
"and nothing of ours is written outside the tree.")
def routing(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
# 1. one logger daemon
rs = hw.pidof("rsyslogd")
ev["rsyslogd_pids"] = rs
ctx.check(rs, "rsyslogd is not running")
for legacy in ("syslogd", "klogd"):
pids = hw.pidof(legacy)
ctx.check(not pids, "busybox %s is running (%s) next to rsyslog", legacy, pids)
ctx.log("rsyslogd %s; no syslogd/klogd", rs)
# 2. the render step: rules + effective levels, consistent with what
# the API reports as effective
ctx.check(os.path.isfile("/data/forgefirm/rsyslog-forgefirm.conf"),
"rendered rules missing: /data/forgefirm/rsyslog-forgefirm.conf")
rules = _read("/data/forgefirm/rsyslog-forgefirm.conf") or ""
for name in LOGGERS:
ctx.check(("# %s:" % name) in rules, "rendered rules carry no block for %s", name)
ctx.check(os.path.isfile("/var/run/forgefirm-loglevels"), "effective-levels record missing")
st, body = fc.get("/logs")
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
ctx.check(body.get("effective_known") is True, "/logs does not know the effective levels")
eff = {}
for l in body.get("loggers") or []:
eff[l["name"]] = (l.get("effective_disk"), l.get("effective_remote"))
ev["effective"] = eff
ctx.log("effective levels: %s", eff)
ctx.check(set(eff) == set(LOGGERS), "loggers reported: %s", sorted(eff))
levels = _read("/var/run/forgefirm-loglevels") or ""
for name, (disk, _remote) in eff.items():
ctx.check(("log_%s_disk=%s" % (name, disk)) in levels,
"effective record and /logs disagree on %s (%s)", name, disk)
# 3. the tree
for name in LOGGERS:
ctx.check(os.path.isdir(os.path.join(LOGS_ROOT, name)), "no directory for %s", name)
# 4. the daemon's own emitter (fflog): a line from the running pid, if
# its level admits info
pids = hw.pidof("forgectrl")
ctx.check(pids, "forgectrl is not running")
disk_rank = _SEV_RANK.get(eff.get("forgectrl", ("info", "off"))[0], 6)
fpath = os.path.join(LOGS_ROOT, "forgectrl", "forgectrl.log")
if disk_rank >= 6:
own = [l for l in _tail_lines(fpath, 2000)
if any((" forgectrl[%d] " % p) in l for p in pids)]
ev["own_lines"] = len(own)
ctx.log("forgectrl[%s] lines in its file: %d", pids, len(own))
ctx.check(own, "no line from the running daemon in %s (emitter path broken?)", fpath)
m = _LINE_RE.match(own[-1])
ctx.check(m and m.group("prog") == "forgectrl", "not in the ff_line format: %r", own[-1])
else:
ctx.log("forgectrl disk level %s: own-emitter check skipped", eff["forgectrl"][0])
# 5. the relay path: a `logger` probe under a controller's name lands
# in that controller's file with the ff_line format
nonce = "forgetest-routing-%d" % int(time.time())
for name in ("grblhal", "gfhome"):
rank = _SEV_RANK.get(eff.get(name, ("info", "off"))[0], 6)
if rank < 3:
ctx.log("%s disk level off: relay probe skipped", name)
continue
rc, out = hw.run(["logger", "-t", name, "-p", "daemon.err", "%s %s" % (nonce, name)])
ctx.check(rc == 0, "logger -t %s failed: %s", name, out)
path = os.path.join(LOGS_ROOT, name, name + ".log")
line = _wait_line(path, nonce)
ev["probe_" + name] = line
ctx.check(line, "%s probe did not reach %s", name, path)
m = _LINE_RE.match(line)
ctx.check(m and m.group("prog") == name and m.group("sev") == "ERR",
"%s probe line not in the ff_line format: %r", name, line)
ctx.log("%s: %s", name, line)
# a probe under another name must NOT leak into these files
rc, out = hw.run(["logger", "-t", "forgetest-stray", "-p", "daemon.err", "%s stray" % nonce])
time.sleep(1.5)
for name in ("grblhal", "gfhome", "forgectrl"):
path = os.path.join(LOGS_ROOT, name, name + ".log")
ctx.check(not any((nonce + " stray") in l for l in _tail_lines(path, 100)),
"a stray program's line landed in %s", path)
sys_rank = _SEV_RANK.get(eff.get("system", ("info", "off"))[0], 6)
if sys_rank >= 3:
line = _wait_line(os.path.join(LOGS_ROOT, "system", "system.log"), nonce + " stray")
ctx.check(line, "the stray program's line did not reach system/")
# 6. relay processes: the supervisor spawns one `logger` per controller
# (its stdin is the controller's output pipe); the daemon's init
# script keeps one on the fifo
st, mode = fc.get("/mode")
relays = hw.pidof("logger")
ev["logger_relays"] = relays
ev["controller"] = mode.get("controller") if isinstance(mode, dict) else None
ctx.log("logger relays: %s (controller %s)", relays, ev["controller"])
if isinstance(mode, dict) and mode.get("controller") == "running":
ctx.check(relays, "controller running but no `logger` relay process")
if os.path.exists("/var/run/forgectrl.stderr"):
ctx.check(len(relays) >= 2 or ev["controller"] != "running",
"fifo relay present but only %s logger process(es)", relays)
# 7. the kernel reaches kernel/ (unless filtered off)
if _SEV_RANK.get(eff.get("kernel", ("info", "off"))[0], 6) >= 6:
klines = _tail_lines(os.path.join(LOGS_ROOT, "kernel", "kernel.log"), 50)
ev["kernel_lines"] = len(klines)
ctx.check(klines and any(" kernel[" in l for l in klines), "no kernel lines in kernel/")
# 8. nothing outside the tree
for stray in ("/data/forgectrl.log", "/data/gfcloud.log", "/data/gfhome.log",
"/data/log/gfcloud", "/data/log/gfhome", "/var/log/messages"):
ctx.check(not os.path.exists(stray), "pre-syslog log path still present: %s", stray)
ctx.log("no ForgeFIRM log outside %s", LOGS_ROOT)
@test("logs.level-settings", title="Log level and remote-target settings", subsystem="logs",
kind="auto", est_min=1,
covers=[("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/settings.*")],
requires=["logs.tree-tail-export", "forgectrl.settings-bounds"],
description="A log level outside off..debug, a bad remote port, a bad protocol and a bad "
"server name are refused (400); a valid remote level is accepted and /logs shows "
"it as configured but not effective (pending_reboot); the setting is restored to "
"its prior value.")
def level_settings(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
for key, val in (("log_grblhal_disk", "verbose"), ("log_kernel_remote", "7"),
("syslog_port", "70000"), ("syslog_proto", "tls"),
("syslog_server", "bad host name")):
st, body = fc.post("/settings", data={key: val})
ev["refused %s=%s" % (key, val)] = st
ctx.log("POST /settings %s=%s -> %s", key, val, st)
ctx.check(st == 400, "%s=%s -> %s, expected 400", key, val, st)
st, before = fc.get("/logs")
ctx.check(st == 200 and isinstance(before, dict), "GET /logs -> %s", st)
prior = {l["name"]: l for l in before.get("loggers") or []}
ctx.check("system" in prior, "/logs lacks the system logger")
was = prior["system"].get("remote") or "off"
was_pending = bool(before.get("pending_reboot"))
new = "warning" if was != "warning" else "error"
ev["system_remote_before"] = was
ev["pending_before"] = was_pending
try:
st, body = fc.post("/settings", data={"log_system_remote": new})
ctx.log("POST /settings log_system_remote=%s -> %s", new, st)
ctx.check(st == 200, "valid level refused: %s", st)
st, after = fc.get("/logs")
now = {l["name"]: l for l in after.get("loggers") or []}
ctx.check(now["system"].get("remote") == new, "configured level reads %r", now["system"].get("remote"))
ctx.check(now["system"].get("effective_remote") == prior["system"].get("effective_remote"),
"effective level changed without a reboot")
ctx.check(after.get("pending_reboot") is True, "pending_reboot not raised by a level change")
ctx.log("configured %s, effective %s, pending_reboot %s",
now["system"].get("remote"), now["system"].get("effective_remote"), after.get("pending_reboot"))
finally:
# restore: a value that was stored goes back as a value; a default
# is restored by clearing the key (a query parameter - empty
# values never reach the form body)
stored = fc.settings().get("log_system_remote", "")
if was == "off" and stored in ("", None, new):
st, body = fc.post("/settings", params={"log_system_remote": ""})
else:
st, body = fc.post("/settings", data={"log_system_remote": was})
ctx.log("restore log_system_remote -> %s", st)
st, final = fc.get("/logs")
fin = {l["name"]: l for l in final.get("loggers") or []}
ctx.check(fin["system"].get("remote") == was, "system remote level not restored (%r)", fin["system"].get("remote"))
ctx.check(bool(final.get("pending_reboot")) == was_pending,
"pending_reboot did not return to %s after the restore", was_pending)
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
"""update.* - the A/B slot inventory and the firmware verification path."""
import os
import re
import tempfile
import time
import shutil
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", "**")]
@test("update.slots-and-signature", title="Boot slots readable, unsigned and foreign-signed archives refused",
subsystem="update", kind="auto", est_min=2,
covers=_UPDATE_COVERS, requires=["forgectrl.auth"],
description="/slots reports the A/B inventory consistent with `ffboot -l`; /update/status "
"answers; `fwup` refuses a garbage archive. A tiny archive signed with a "
"throwaway key made on the spot verifies with its own key and fails against the "
"shipped release key, and an apply of it without confirm_unsigned is refused by "
"the update job before anything is written. Nothing is written to any slot.")
def slots_and_signature(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, slots = fc.get("/slots")
ctx.log("GET /slots -> %s %s", st, slots)
ctx.check(st == 200 and isinstance(slots, dict), "GET /slots -> %s", st)
ev["slots"] = slots
rc, out = hw.run(["ffboot", "-l"])
ev["ffboot_l_rc"] = rc
ctx.log("ffboot -l -> rc %s\n%s", rc, out.strip())
ctx.check(rc == 0, "ffboot -l failed (%s)", rc)
text = str(slots).lower()
ctx.check("forgefirm" in text or "slot" in text, "/slots does not look like a slot inventory")
st, us = fc.get("/update/status")
ctx.log("GET /update/status -> %s %s", st, us)
ctx.check(st == 200 and isinstance(us, dict) and "running" in us, "GET /update/status -> %s", st)
ctx.check(not us.get("running"), "an update is running")
key = "/etc/forgefirm/keys/forgefirm-release.pub"
ctx.check(os.path.exists(key), "release key %s missing", key)
with tempfile.NamedTemporaryFile(prefix="forgetest-", suffix=".fw", delete=False) as f:
f.write(b"this is not a firmware archive" * 64)
garbage = f.name
try:
rc, out = hw.run(["fwup", "-V", "-i", garbage, "-p", key], timeout=30)
ev["fwup_garbage_rc"] = rc
ctx.log("fwup -V garbage -> rc %s: %s", rc, out.strip()[:200])
ctx.check(rc != 0, "fwup accepted a garbage archive")
finally:
os.unlink(garbage)
# A foreign signature: a throwaway key pair signs a tiny archive. The
# shipped key must refuse it, its own key must accept it, and the
# apply job must refuse it without confirm_unsigned, before it
# touches the slot.
work = tempfile.mkdtemp(prefix="forgetest-fw-")
staged = "/data/forgefirm/upload.fw"
try:
with open(os.path.join(work, "note.txt"), "w") as f:
f.write("forgetest foreign-signature drill\n")
with open(os.path.join(work, "fwup.conf"), "w") as f:
f.write('meta-product = "forgetest"\nmeta-version = "0.0.0-test"\n'
'file-resource note.txt { host-path = "note.txt" }\n'
'task complete { on-resource note.txt { raw_write(0) } }\n')
rc, out = hw.run(["sh", "-c", "cd %s && fwup -g" % work], timeout=60)
ev["fwup_gen_rc"] = rc
ctx.check(rc == 0 and os.path.exists(os.path.join(work, "fwup-key.pub")),
"fwup -g did not make a key pair (rc %s): %s", rc, out.strip()[:200])
plain, signed = os.path.join(work, "plain.fw"), os.path.join(work, "signed.fw")
rc, out = hw.run(["sh", "-c", "cd %s && fwup -c -f fwup.conf -o plain.fw && "
"fwup -S -s fwup-key.priv -i plain.fw -o signed.fw" % work], timeout=60)
ctx.check(rc == 0 and os.path.exists(signed), "could not make the signed archive (rc %s): %s",
rc, out.strip()[:200])
rc_own, _ = hw.run(["fwup", "-V", "-i", signed, "-p", os.path.join(work, "fwup-key.pub")], timeout=30)
rc_ship, out = hw.run(["fwup", "-V", "-i", signed, "-p", key], timeout=30)
ev["fwup_foreign"] = {"own_key_rc": rc_own, "shipped_key_rc": rc_ship}
ctx.log("fwup -V signed: own key rc %s, shipped key rc %s", rc_own, rc_ship)
ctx.check(rc_own == 0, "the archive does not verify with the key that signed it")
ctx.check(rc_ship != 0, "the shipped release key accepted a foreign signature")
# The apply path. The target is the slot not booted; a slot already
# selected for the next boot is refused for its own reason, which
# this drill records and steps over.
target = None
for name, si in (slots.get("slots") or {}).items():
if name in ("a", "b") and isinstance(si, dict) and not si.get("booted"):
target = name
ctx.check(target is not None, "no inactive firmware slot in /slots: %s", slots)
shutil.copyfile(signed, staged)
st, body = fc.post("/update/apply", params={"slot": target, "file": "upload"})
ev["apply"] = {"status": st, "body": body}
ctx.log("POST /update/apply slot=%s file=upload (no confirm_unsigned) -> %s %s", target, st, body)
if st == 409 and isinstance(body, dict) and "next boot" in str(body.get("error", "")):
ctx.log("slot %s is selected for the next boot: the apply is refused before the "
"signature check, which is its own guard", target)
else:
ctx.check(st == 202 and isinstance(body, dict) and body.get("started") is True,
"the apply job did not start: %s %s", st, body)
result = None
t0 = time.time()
while time.time() - t0 < 60:
ctx.sleep(1)
st, us = fc.get("/update/status")
if isinstance(us, dict) and not us.get("running"):
result = us.get("result")
break
ev["apply_result"] = result
ctx.log("apply result: %s", result)
ctx.check(isinstance(result, dict) and result.get("ok") is False
and "not signed" in str(result.get("error", "")),
"the apply of a foreign-signed archive was not refused for its signature: %s", result)
finally:
try:
os.unlink(staged)
except OSError:
pass
shutil.rmtree(work, ignore_errors=True)
_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)
+339
View File
@@ -0,0 +1,339 @@
/*
* app.js - forgetest: the acceptance page
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*
* Acceptance tab + bench tab. State comes from GET /state, polled every
* 2 s when the machine is idle and every second during a run; the catalog
* and the bench listing are fetched once per tab and refreshed after a
* run finishes.
*
* Rows, prompt buttons and tool entries are built once and thereafter
* only updated in place. A poll that rebuilt them would swallow the click
* it landed on: the button that took the mousedown would be gone before
* the mouseup, so no click event would ever be raised. Every action also
* grays its control out on the press and pulls the next poll forward, so
* the page answers the operator rather than the timer. The help popovers
* (help.js) sit on static elements only, so no rebuild ever orphans one.
*
* TOKEN is the bearer token page.py splices in when it serves the page.
*/
var TOKEN='__TOKEN__';
var state=null, catalog=null, catalogHash=null, bench=null, tab='acceptance', openDetails={};
var lastRunKey=null, stateEtag=null, curPrompt=null, promptKey=null, abortSent=false;
var pollTimer=null, polling=false, pending=0, pendingId=null, rowMsg={};
var rowEls=null, groupsKey=null, benchEls=null, benchKey=null, benchNeedsRefresh=false;
var PENDING_MS=6000;
/* A poll that never answers must not wedge the loop: it times out
and the next one is scheduled as usual. Actions carry no timeout,
because a POST the server did act on must not read as a failure. */
var POLL_TIMEOUT_MS=20000;
var ignoreReq=false;try{ignoreReq=window.localStorage.getItem('forgetest.ignoreReq')==='1'}catch(e){}
function $(id){return document.getElementById(id)}
function nowMs(){return (new Date()).getTime()}
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]})}
/* Touch the DOM only where the value really changed. Replacing a node
under the operator's finger loses the click: the button that took the
mousedown is gone before the mouseup, so no click event is ever
raised, and the press does nothing. Nothing on a poll may rebuild a
control that is only sitting there unchanged. */
function setHtml(e,h){if(e&&e.__h!==h){e.__h=h;e.innerHTML=h}}
function setText(e,t){if(e&&e.__t!==t){e.__t=t;e.textContent=t}}
function setDis(e,d){if(e&&e.disabled!==d)e.disabled=d}
function setProp(e,k,v){if(e&&e[k]!==v)e[k]=v}
function api(method,path,body,cb,hdrs,timeoutMs){var x=new XMLHttpRequest();x.open(method,path,true);
if(timeoutMs)x.timeout=timeoutMs;
x.setRequestHeader('X-ForgeFIRM-Token',TOKEN);
if(hdrs){for(var k in hdrs){if(hdrs.hasOwnProperty(k))x.setRequestHeader(k,hdrs[k])}}
if(body!==undefined&&body!==null){x.setRequestHeader('Content-Type','application/json')}
x.onreadystatechange=function(){if(x.readyState!==4)return;var d=null;try{d=JSON.parse(x.responseText)}catch(e){d={error:x.responseText}}cb(x.status,d,x)};
x.send(body===undefined||body===null?null:JSON.stringify(body))}
/* The state poll. It carries the last ETag, so an unchanged state costs
a 304 and no re-render; a run gets a faster tick, and every action
pulls the next poll forward instead of waiting out the interval. */
function pollDelay(){return (state&&(state.running||batchActive()))?1000:2000}
function schedule(ms){if(pollTimer)window.clearTimeout(pollTimer);pollTimer=window.setTimeout(poll,ms)}
function kick(){schedule(120)}
function poll(){if(polling){schedule(150);return}polling=true;
/* While an action is still in flight the state is asked for in full:
a 304 would skip the render that releases the grayed-out buttons. */
var h=(stateEtag&&!pending)?{'If-None-Match':stateEtag}:null;
api('GET','/state',null,function(s,d,x){polling=false;
if(s===200){stateEtag=x.getResponseHeader('ETag');state=d;
if(!catalog||catalogHash!==d.catalog_hash){loadCatalog(render)}else{render()}}
schedule(pollDelay())},h,POLL_TIMEOUT_MS)}
function showTab(t){tab=t;closeHelp();$('tab-acceptance').className=t==='acceptance'?'on':'';$('tab-bench').className=t==='bench'?'on':'';
$('pane-acceptance').style.display=t==='acceptance'?'':'none';$('pane-bench').style.display=t==='bench'?'':'none';
if(t==='bench'&&!bench)loadBench()}
function setMsg(id,txt,err){setHtml($(id),txt?"<div class='"+(err?'err':'msg')+"'>"+esc(txt)+"</div>":'')}
function loadCatalog(cb){api('GET','/catalog',null,function(s,d){if(s===200){catalog=d.tests;catalogHash=d.catalog_hash;if(cb)cb()}})}
function loadBench(){api('GET','/bench',null,function(s,d){if(s===200){bench=d;benchKey=null;renderBench()}})}
function setIgnoreReq(on){ignoreReq=!!on;try{window.localStorage.setItem('forgetest.ignoreReq',ignoreReq?'1':'0')}catch(e){}
$('ignreq').checked=ignoreReq;setHtml($('ignreqon'),ignoreReq?"<span class='on'>ON - prerequisites are not enforced</span>":'');
if(state&&catalog)renderGroups()}
/* A start already sent but not yet seen in the state counts as busy, so
the buttons gray out on the click rather than on the next poll. The
window is capped in case the answer never arrives. A queue holds the
machine between its tests as well as during them. */
function batchActive(){return !!(state&&state.batch&&!state.batch.finished)}
function isBusy(){if(state&&(state.running||batchActive()))return true;
return !!(pending&&(nowMs()-pending)<PENDING_MS)}
function fmtTs(t){return t?t.replace('T',' ').replace('Z',' UTC'):'-'}
function render(){if(!state||!catalog)return;
if(state.running||batchActive()){pending=0;pendingId=null}
var m=state.manifest||{};setText($('hdrver'),(m.version||'?'));setText($('hdrsub'),m.image||'');
var a=$('auth');setText(a,'Release authorized: '+(state.authorized?'YES':'NO'));setProp(a,'className','auth'+(state.authorized?' yes':''));
var c=state.campaign,cn=state.counts||{};var b='';
b+='<b>'+cn.satisfied+'</b> of <b>'+cn.total+'</b> satisfied ('+cn.inherited+' inherited), <b>'+cn.required+'</b> required<br>';
b+=c?('campaign <span class="mono">'+esc(c.id)+'</span> opened '+esc(fmtTs(c.ts))):('no open campaign'+(state.closed_by?(' (last one closed by '+esc(state.closed_by)+')'):'')+' - the first Start opens one');
b+='<br>manifest identity <span class="mono">'+esc((m.sha||'').slice(0,16))+'</span> &middot; catalog <span class="mono">'+esc((state.catalog_hash||'').slice(0,12))+'</span>';
if(state.log_corrupt)b+='<br><span class="b-bad">'+state.log_corrupt+' corrupt log line(s) skipped</span>';
setHtml($('banner'),b);
var inv=state.invalidate;setHtml($('invnote'),inv?"<div class='note'>Full campaign required since "+esc(fmtTs(inv.ts))+": "+esc(inv.reason)+"</div>":'');
renderQueue();renderGroups();renderRun();renderSteps();if(bench)renderBench()}
/* The two queues: what each would run now, and how the running one is
getting on. Built from the state, so a reload picks the queue back up
exactly where it is - the queue lives in the runner, not in this tab. */
var QUEUES=[['unattended','Unattended'],['attended','Operator and live']];
function renderFixture(){var f=state.fixture,e=$('fixture');if(!e)return;
if(!f){setHtml(e,'');return}
var ch=(f.channels||[]).filter(function(c){return c!=='button'||f.button_enabled});
setHtml(e,'Bench fixture <b>'+esc(f.hostname)+'</b> at '+esc(f.ip||'?')+' (v'+esc(f.version||'?')+') covers <b>'+
(ch.length?esc(ch.join(', ')):'nothing')+'</b>'+
((f.channels||[]).indexOf('button')>=0&&!f.button_enabled?' (button disabled: enable jumper out)':'')+
'; arm press: '+(f.arm_press?'<b>the fixture</b>':'the operator'))}
function renderQueue(){var av=state.batch_available||{},b=state.batch,busy=isBusy();
renderFixture();
QUEUES.forEach(function(p){var e=$('q-'+p[0]);if(!e)return;
var ids=av[p[0]]||[];
setText(e,ids.length?(p[1]+' ('+ids.length+')'):(p[1]+' (none left)'));
setDis(e,busy||!ids.length);
setProp(e,'title',!ids.length?('nothing left: every '+p[0]+' test is satisfied')
:(busy?'a run is in progress':('in order: '+ids.join(', '))))});
setDis($('q-stop'),!batchActive()||!!(b&&b.stopping));
if(!b){setHtml($('qstate'),'');return}
var total=b.order.length||1,ok=0,bad=0;
b.done.forEach(function(x){if(x.result==='PASS')ok++;else bad++});
function seg(cls,n){return n?("<i class='"+cls+"' style='width:"+(100*n/total)+"%'></i>"):''}
var h="<div class='qbar'>"+seg('ok',ok)+seg('bad',bad)+seg('skip',b.skipped.length)+
(b.current?seg('now',1):'')+"</div><div class='qline'><b>"+esc(b.group)+"</b> queue, opened "+
esc(fmtTs(b.ts))+" &middot; ";
h+=b.finished?('finished '+esc(fmtTs(b.finished))):(b.current?('running <b>'+esc(b.current)+'</b>'):
(b.stopping?'stopping':'starting'));
h+=" &middot; <b>"+ok+"</b> passed, <b>"+bad+"</b> not, <b>"+b.skipped.length+
"</b> skipped, <b>"+b.pending.length+"</b> waiting";
if(b.stopped)h+="<br><span class='req'>stopped: "+esc(b.stopped)+"</span>";
if(b.skipped.length)h+="<br>skipped: "+esc(b.skipped.map(function(x){return x.test+' ('+x.reason+')'}).join('; '));
if(b.pending.length)h+="<br>waiting: <span class='tid'>"+esc(b.pending.join(', '))+"</span>";
setHtml($('qstate'),h+"</div>")}
/* The rows are built once for a given catalog and then only updated in
place: a poll never rewrites the table, so a Start button survives the
press that is landing on it. */
function renderGroups(){var ids=[];catalog.forEach(function(t){ids.push(t.id)});
var sig=catalogHash+'|'+ids.join(',');
if(sig!==groupsKey){groupsKey=sig;buildGroups()}
updateGroups()}
var COLS="<colgroup><col><col class='k'><col class='s'><col class='l'><col class='a'></colgroup>";
function buildGroups(){var groups={},order=[];
catalog.forEach(function(t){if(!groups[t.subsystem]){groups[t.subsystem]=[];order.push(t.subsystem)}groups[t.subsystem].push(t)});
var h='';
order.forEach(function(g){h+="<div class='card grp'><h2>"+esc(g)+"</h2><table>"+COLS+"<tr><th>Test</th><th>Kind</th><th>Status</th><th>Last result</th><th></th></tr>";
groups[g].forEach(function(t){var d=esc(t.id);var tags='';
if(t.always)tags+="<span class='tag core'>core</span>";tags+="<span class='tag "+esc(t.kind)+"'>"+esc(t.kind)+"</span>";
if(t.hardware==='takeover')tags+="<span class='tag takeover'>takeover</span>";
if(t.mode)tags+="<span class='tag mode'>"+esc(t.mode)+"</span>";
var det="<div class='details"+(openDetails[t.id]?' on':'')+"' id='det-"+d+"'>"+
(t.description?("<div class='dsc'>"+esc(t.description)+"</div>"):'')+
(t.steps&&t.steps.length?"<b>Operator steps:</b><ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>":'')+
(t.actions&&t.actions.length?"<b>Machine actions:</b> "+esc(t.actions.join(', '))+"<br>":'')+
"<b>Requires:</b> "+esc((t.requires||[]).join(', ')||'-')+"<br><b>Covers:</b> "+esc((t.covers||[]).map(function(c){return c[0]+':'+c[1]}).join(', ')||'-')+
"<br><span id='detdyn-"+d+"'></span></div>";
h+="<tr><td><div class='tsel' title='show what this test asks of you' onclick='selectTest(\""+d+"\")'>"+esc(t.title)+"</div><div class='tid'>"+d+" <a href='#' onclick='toggleDet(\""+d+"\");return false'>details</a></div>"+
"</td><td>"+tags+"</td>"+
"<td><div id='st-"+d+"'></div><div id='unmet-"+d+"'></div><div id='note-"+d+"'></div></td>"+
"<td id='last-"+d+"'></td>"+
"<td><button class='btn btn-sm btn-primary' id='btn-"+d+"' onclick='startTest(\""+d+"\")'>Start</button></td></tr>"+
"<tr class='detrow'><td colspan='5'>"+det+"</td></tr>"});
h+="</table></div>"});
$('groups').innerHTML=h;rowEls={};
catalog.forEach(function(t){rowEls[t.id]={st:$('st-'+t.id),last:$('last-'+t.id),btn:$('btn-'+t.id),
unmet:$('unmet-'+t.id),note:$('note-'+t.id),detdyn:$('detdyn-'+t.id)}})}
function updateGroups(){if(!rowEls||!state)return;var busy=isBusy();
var queued={};if(batchActive()){(state.batch.pending||[]).forEach(function(x){queued[x]=1})}
catalog.forEach(function(t){var e=rowEls[t.id];if(!e)return;var s=state.tests[t.id]||{};
var st;
if(pendingId===t.id&&isBusy()&&!state.running){st="<span class='st running'>starting&hellip;</span>"}
else{st="<span class='st "+esc(s.status)+"'>"+esc(s.status||'none')+"</span>";
if(s.required&&s.status!=='running')st+="<br><span class='req'>required: "+esc(s.reason)+"</span>"}
if(queued[t.id])st+="<br><span class='tag queued'>queued</span>";
setHtml(e.st,st);
var last=s.last?(esc(s.last.result)+' '+esc(fmtTs(s.last.ts))):'-';
if(s.status==='inherited'&&s.origin)last+="<br><span class='tid'>from "+esc(s.origin.campaign)+" on "+esc(s.origin.image)+"</span>";
setHtml(e.last,last);
var unmet=s.requires_met===false;var can=!busy&&(!unmet||ignoreReq);
setDis(e.btn,!can);
setProp(e.btn,'title',busy?'a run is in progress':(unmet?((ignoreReq?'prerequisites overridden - needs: ':'needs: ')+(s.missing_requires||[]).join(', ')):''));
setProp(e.unmet,'className','req'+(ignoreReq?' over':''));
setHtml(e.unmet,unmet?((ignoreReq?'unmet: ':'needs: ')+esc((s.missing_requires||[]).join(', '))):'');
setHtml(e.note,rowMsg[t.id]?"<div class='err'>"+esc(rowMsg[t.id])+"</div>":'');
setHtml(e.detdyn,"<b>Fingerprint:</b> <span class='mono'>"+esc((s.fingerprint||'').slice(0,16))+"</span> &middot; est. "+esc(t.est_min)+" min"+
(s.last?"<br><a href='/result?test="+encodeURIComponent(t.id)+"&ts="+encodeURIComponent(s.last.ts)+"'>last result record</a>":''))})}
function toggleDet(id){openDetails[id]=!openDetails[id];var e=$('det-'+id);if(e)e.className='details'+(openDetails[id]?' on':'')}
function renderRun(){var r=state.running||state.last_run;var key=r?(r.kind+':'+r.id+':'+r.started):null;
if(key!==lastRunKey)abortSent=false;
document.querySelector('main').classList.toggle('running',!!state.running);
if(!r){setText($('runhead'),'idle');setText($('log'),'');$('prompt').style.display='none';$('notice').style.display='none';
curPrompt=null;promptKey=null;setDis($('abortbtn'),true);setText($('runtitle'),'Run');lastRunKey=null;return}
setText($('runtitle'),(r.kind==='bench'?'Bench: ':'Test: ')+r.title);
var hd=esc(r.id)+' &middot; started '+esc(fmtTs(r.started))+' &middot; '+r.elapsed_s+' s';
if(r.finished){hd+=' &middot; <b class="st '+(r.finished.result==='PASS'||r.finished.result==='OK'?'pass':'fail')+'">'+esc(r.finished.result)+'</b>'+(r.finished.message?' - '+esc(r.finished.message):'')}
else if(r.aborting){hd+=' &middot; <b class="st stale">aborting</b>'}
setHtml($('runhead'),hd);
var lg=$('log');var txt=(r.dropped?('... '+r.dropped+' earlier lines dropped\n'):'')+r.log.join('\n');
if(lg.__t!==txt){var atBottom=lg.scrollTop+lg.clientHeight>=lg.scrollHeight-20;
lg.__t=txt;lg.textContent=txt;if(atBottom||key!==lastRunKey)lg.scrollTop=lg.scrollHeight}
lastRunKey=key;
var nt=(r.notice&&!r.finished)?r.notice.text:'';
setText($('notice'),nt);$('notice').style.display=nt?'':'none';
/* The prompt buttons are rebuilt only when the prompt itself changes -
they are the ones the operator stares at before pressing. */
if(r.prompt&&!r.finished){var pk=r.prompt.id+'|'+r.prompt.question+'|'+r.prompt.options.length+':'+r.prompt.options.join(',');
if(pk!==promptKey){promptKey=pk;curPrompt=r.prompt;
$('promptq').textContent=r.prompt.question;var pb='';
r.prompt.options.forEach(function(o,i){pb+="<button class='btn btn-sm btn-primary' onclick='answerIdx("+i+")'>"+esc(o)+"</button>"});
$('promptb').innerHTML=pb;$('promptb').__h=pb;$('prompt').style.display=''}}
else if(promptKey!==null){promptKey=null;curPrompt=null;$('prompt').style.display='none'}
setDis($('abortbtn'),!!r.finished||abortSent||!!r.aborting);
if(r.finished&&tab==='bench'&&r.kind==='bench'&&benchNeedsRefresh){benchNeedsRefresh=false;loadBench()}}
function findTest(id){for(var i=0;i<catalog.length;i++)if(catalog[i].id===id)return catalog[i];return null}
/* What the operator will be asked to do, shown before it is asked: the
running test's steps for the whole run, the queue's attended tests
before the first one starts, or the test the operator clicked on. The
prompts and notices that follow are these steps, taken in turn. */
var selected=null;
function selectTest(id){selected=(selected===id)?null:id;renderSteps()}
function stepsOf(t,head){var h='';
var acts=(t.actions||[]);
if(head)h+="<div class='sub'>"+head+"</div>";
if(t.kind==='live')h+="<div>This test fires the laser: eye protection, fire watch, exhaust, scrap under the head.</div>";
if(acts.length)h+="<div>Machine actions: <b>"+esc(acts.join(', '))+"</b> (a standing instruction tells you when; the test watches the machine for it)</div>";
if(t.steps&&t.steps.length)h+="<ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>";
return h}
function renderSteps(){var e=$('steps');if(!e||!catalog)return;var h='',r=state.running;
if(r&&r.kind==='test'){var t=findTest(r.id);
if(t&&((t.steps&&t.steps.length)||(t.actions&&t.actions.length)||t.kind==='live'))h="<div class='sh'>What you will do</div>"+stepsOf(t,null)}
else if(batchActive()&&state.batch.pending&&state.batch.pending.length){var parts=[];
state.batch.pending.forEach(function(id){var t=findTest(id);if(t&&t.kind!=='auto')parts.push(stepsOf(t,esc(id)))});
if(parts.length)h="<div class='sh'>Coming up in this queue</div>"+parts.join('')}
else if(selected&&!r){var t=findTest(selected);
if(t)h="<div class='sh'>Before you start "+esc(t.id)+"</div>"+(t.kind==='auto'?"<div class='auto'>Nothing: this test needs nobody at the machine.</div>":stepsOf(t,null))}
setHtml(e,h);e.style.display=h?'':'none'}
function startTest(id){if(isBusy())return;var t=findTest(id);var body={test:id};
if(ignoreReq)body.ignore_requires=true;
if(t&&t.kind==='live'){if(!confirmLive())return;body.ack_live=true}
pending=nowMs();pendingId=id;rowMsg={};updateGroups();
setMsg('actmsg','starting '+id+'...');
api('POST','/start',body,function(s,d){
if(s!==200){pending=0;pendingId=null;rowMsg[id]=d.message||d.error;updateGroups()}
setMsg('actmsg',d.message||d.error,s!==200);kick()})}
/* The live-laser acknowledgment stays a blocking dialog on purpose: it
is the one thing on this page that must not be dismissed by a stray
click or a poll. */
function confirmLive(live){return window.confirm(
(live?('LIVE LASER QUEUE.\n\nThese fire the laser:\n - '+live.join('\n - ')+'\n'):'LIVE LASER TEST.\n')+
'\nConfirm before starting:\n - eye protection on, everyone in the room\n - fire watch present, extinguisher at hand\n - exhaust running, lid closed, scrap in place\n - you will press the physical button to arm when prompted\n\n'+
(live?'Start the queue?':'Start the test?'))}
/* A queue takes the machine for a long stretch, so both what it will run
and the acknowledgment it needs are put in front of the operator once,
before anything starts. */
function startBatch(group){if(isBusy())return;
var ids=(state&&state.batch_available&&state.batch_available[group])||[];
if(!ids.length)return;
var body={group:group};if(ignoreReq)body.ignore_requires=true;
var live=[];catalog.forEach(function(t){if(t.kind==='live'&&ids.indexOf(t.id)>=0)live.push(t.id)});
if(live.length){if(!confirmLive(live))return;body.ack_live=true}
else if(!window.confirm('Run these '+ids.length+' test(s), in this order?\n\n - '+ids.join('\n - ')))return;
pending=nowMs();pendingId=null;rowMsg={};updateGroups();renderQueue();
setMsg('qmsg','starting the '+group+' queue...');
api('POST','/batch',body,function(s,d){
if(s!==200){pending=0;updateGroups();renderQueue()}
setMsg('qmsg',d.message||d.error,s!==200);kick()})}
function stopBatch(){var e=$('q-stop');if(e.disabled)return;setDis(e,true);
setMsg('qmsg','stopping the queue...');
api('POST','/batch/stop',{},function(s,d){setMsg('qmsg',d.message||d.error,s!==200);kick()})}
function promptBusy(on){var b=$('promptb').getElementsByTagName('button');for(var i=0;i<b.length;i++)setDis(b[i],on)}
function answerIdx(i){if(!curPrompt)return;var p=curPrompt,v=p.options[i];
promptBusy(true);setMsg('actmsg','answer sent: '+v);
api('POST','/answer',{prompt_id:p.id,value:v},function(s,d){
if(s!==200){promptBusy(false);setMsg('actmsg',d.message||d.error,true)}kick()})}
function doAbort(){var b=$('abortbtn');if(b.disabled)return;abortSent=true;setDis(b,true);
setMsg('actmsg','aborting...');
api('POST','/abort',{},function(s,d){if(s!==200)abortSent=false;setMsg('actmsg',d.message||d.error,s!==200);kick()})}
function doExport(){var b=$('exportbtn');if(b.disabled)return;setDis(b,true);setMsg('actmsg','exporting...');
api('POST','/export',{},function(s,d){setDis(b,false);
if(s===200){setMsg('actmsg','exported: authorized='+d.authorized+', sha256 '+d.sha256.slice(0,16)+' - download below');$('dljson').style.display='';$('dlmd').style.display=''}
else setMsg('actmsg',d.message||d.error,true)})}
function toggleInv(){var f=$('invform');f.style.display=f.style.display==='none'?'':'none'}
function doInvalidate(){var r=$('invreason').value;if(!r){setMsg('actmsg','a reason is required',true);return}
api('POST','/invalidate',{reason:r},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);
if(s===200){$('invform').style.display='none';$('invreason').value=''}kick()})}
function doReset(){api('POST','/reset',{reason:'operator reset from the page'},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);kick()})}
/* Same rule as the acceptance rows: the tool list is built once and then
only updated, so a run starting never rewrites the arg fields the
operator has typed into or the button being pressed. */
function renderBench(){if(!bench)return;var ids=[];
bench.tools.forEach(function(t){ids.push(t.id+':'+(t.ported?1:0)+(t.installed?1:0))});
var sig=ids.join(',');
if(sig!==benchKey){benchKey=sig;buildBench()}
updateBench()}
function buildBench(){var groups={dry:[],takeover:[],live:[],scope:[]};
bench.tools.forEach(function(t){(groups[t.safety]||(groups[t.safety]=[])).push(t)});var h='';
['dry','takeover','scope','live'].forEach(function(g){if(!groups[g]||!groups[g].length)return;
h+="<div class='card grp'><h2>"+esc(g)+"</h2>";
groups[g].forEach(function(t){var d=esc(t.id);
h+="<div class='tool'><div><b>"+esc(t.title)+"</b> <span class='tid'>"+esc(t.script)+"</span> <span class='tag'>"+esc(t.where)+"</span>"+(t.ported?'':"<span class='tag'>unported</span>")+"</div>";
h+="<div class='hint' style='margin:2px 0 4px'>"+esc(t.desc)+"</div>";
if(t.args&&t.args.length){h+="<div class='argrow'>";t.args.forEach(function(a){var iid='arg-'+t.id+'-'+a.name;
if(a.type==='choice'){h+="<label>"+esc(a.name)+" <select class='form-select form-select-sm' id='"+iid+"'>"+a.choices.map(function(c){return "<option"+(c===a.default?' selected':'')+">"+esc(c)+"</option>"}).join('')+"</select></label>"}
else{h+="<label>"+esc(a.name)+" <input class='form-control form-control-sm' type='"+(a.type==='str'?'text':'number')+"' step='any' id='"+iid+"' value='"+esc(a.default==null?'':a.default)+"' title='"+esc(a.help)+"'></label>"}});h+="</div>"}
h+="<div class='actions'><button class='btn btn-sm btn-primary' id='tbtn-"+d+"' onclick='startTool(\""+d+"\")'>Start</button>";
h+="<span class='hint' id='tlast-"+d+"'></span></div></div>"});
h+="</div>"});
$('tools').innerHTML=h;benchEls={};
bench.tools.forEach(function(t){benchEls[t.id]={btn:$('tbtn-'+t.id),last:$('tlast-'+t.id)}})}
function updateBench(){if(!benchEls)return;var busy=isBusy();
bench.tools.forEach(function(t){var e=benchEls[t.id];if(!e)return;
var can=t.ported&&t.installed&&!busy;
setDis(e.btn,!can);
setProp(e.btn,'title',!t.ported?'not yet ported to the bench page':(!t.installed?'script not installed on this image':(busy?'a run is in progress':'')));
setHtml(e.last,t.last?("last: "+esc(t.last.result?t.last.result.result:'?')+" "+esc(fmtTs(t.last.ts))):'')})}
function startTool(id){if(isBusy())return;var t=null;bench.tools.forEach(function(x){if(x.id===id)t=x});if(!t)return;var args={};
(t.args||[]).forEach(function(a){var e=$('arg-'+id+'-'+a.name);if(e)args[a.name]=e.value});
var body={tool:id,args:args};if(t.safety==='live'){if(!confirmLive())return;body.ack_live=true}
pending=nowMs();pendingId=null;updateBench();updateGroups();
setMsg('benchmsg','starting '+id+'...');
api('POST','/bench/start',body,function(s,d){
if(s!==200){pending=0;updateBench();updateGroups()}
setMsg('benchmsg',d.message||d.error,s!==200);if(s===200)benchNeedsRefresh=true;kick()})}
/* ---- Theme ----------------------------------------------------------
Light, dark, or auto (the system preference); the choice lives in
localStorage under the key forgectrl's panel uses, so the two pages
agree. The head script in index.html applies it before first paint. */
var THEMES=['auto','light','dark'];
function themeChoice(){try{var s=localStorage.getItem('ff_theme');return s==='light'||s==='dark'?s:'auto'}catch(e){return 'auto'}}
function applyTheme(){var c=themeChoice(),t=c;
if(c==='auto')t=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';
document.documentElement.setAttribute('data-bs-theme',t);
$('themebtn').textContent=c==='auto'?'\u25d0 Auto':c==='dark'?'\u263e Dark':'\u2600 Light';
$('themebtn').title='Theme: '+c+' (click to change)'}
function cycleTheme(){var next=THEMES[(THEMES.indexOf(themeChoice())+1)%THEMES.length];
try{if(next==='auto')localStorage.removeItem('ff_theme');else localStorage.setItem('ff_theme',next)}catch(e){}
applyTheme()}
(function(){applyTheme();if(window.matchMedia){var mq=matchMedia('(prefers-color-scheme: dark)');
if(mq.addEventListener)mq.addEventListener('change',applyTheme);else if(mq.addListener)mq.addListener(applyTheme)}})();
initHelp();
setIgnoreReq(ignoreReq);
poll();
+127
View File
@@ -0,0 +1,127 @@
/*
* help.js - forgetest: the page's help text
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*
* Every explanation the page offers lives here, keyed by the data-help
* attribute of the "?" button that opens it (index.html): a title, its
* paragraphs, and the page of the documentation site it deep-links to
* (DOC_BASE + d). The buttons open Bootstrap popovers, one at a time; a
* click anywhere else, Escape, or a tab change closes the open one. The
* buttons sit on static elements only: the rows, prompts and tool entries
* are rebuilt by app.js, and a popover on one of those would be orphaned.
* What the operator must read mid-run (the steps, prompts, notices and
* the live-laser acknowledgment) stays in the page and never moves here.
*/
var DOC_BASE = 'https://docs.forgefirm.org/';
var HELP = {
campaign: {
t: 'Campaign',
d: 'developers/acceptance/',
p: [
'A release is authorized when a campaign is open on this image and every catalog test is satisfied: by a PASS in the campaign, or (never for the core) by an earlier PASS whose domain fingerprint is unchanged. A FAIL ends the campaign.',
'The manifest identity is the image, the catalog hash is the test set; either changing starts the count over.'
]
},
queues: {
t: 'Run what is left',
d: 'developers/acceptance/',
p: [
'Each queue takes every test of its kind that the campaign does not already count as satisfied, runs them one at a time in prerequisite order, and stops on the first result that is not a PASS.',
'The unattended queue needs nobody in the room; with the bench fixture up, the operator tests it can perform by itself run there too. The other one does: it prompts, and it fires the laser. Stop-the-queue cancels what is still waiting and lets the run in progress finish; Abort ends that one too.'
]
},
actions: {
t: 'Campaign actions',
d: 'developers/acceptance/',
p: [
'Export builds the release artifact the release gate reads (acceptance.json, with acceptance.md for people) from the campaign as it stands. The raw log is the append-only record; the journal is the daemon\'s own.',
'Invalidate all forces a full campaign; give the reason. Reset campaign closes the open campaign without invalidating anything: earlier passes stay inheritable under the campaign rules, and the next Start opens a new one.'
]
},
ignore: {
t: 'Ignore prerequisites',
d: 'developers/acceptance/',
p: [
'A test\'s requires list only orders the runs. With the switch on, any test starts alone and its record notes which prerequisites were unmet; the release still needs every test satisfied.'
]
},
bench: {
t: 'Bench diagnostics',
d: 'developers/bench/',
p: [
'The bench tools under scripts/bench, run on the board with their output in the Run pane. Nothing here enters a campaign. Tools not yet ported are listed for completeness.',
'Live tools need the operator acknowledgment; takeover and scope tools stop forgectrl for the duration and start it again when they finish.'
]
}
};
var helpOpen = null;
function helpContent(h) {
var d = document.createElement('div'),
i,
p,
a;
for (i = 0; i < h.p.length; i++) {
p = document.createElement('p');
p.textContent = h.p[i];
d.appendChild(p);
}
a = document.createElement('a');
a.className = 'doclink';
a.href = DOC_BASE + h.d;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Documentation \u2192';
d.appendChild(a);
return d;
}
function closeHelp() {
if (helpOpen) {
helpOpen.hide();
helpOpen = null;
}
}
function initHelp() {
var els = document.querySelectorAll('[data-help]'),
i;
for (i = 0; i < els.length; i++) {
(function (el) {
var h = HELP[el.getAttribute('data-help')];
if (!h) {
el.style.display = 'none';
return;
}
var pop = new bootstrap.Popover(el, {
trigger: 'manual',
html: true,
placement: 'auto',
title: h.t,
content: helpContent(h),
container: 'body'
});
el.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
if (helpOpen === pop) {
closeHelp();
return;
}
closeHelp();
pop.show();
helpOpen = pop;
});
el.addEventListener('hidden.bs.popover', function () {
if (helpOpen === pop) helpOpen = null;
});
})(els[i]);
}
document.addEventListener('click', function (e) {
var tip = document.querySelector('.popover.show');
if (helpOpen && !(tip && tip.contains(e.target))) closeHelp();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeHelp();
});
}
+131
View File
@@ -0,0 +1,131 @@
<!--
index.html - forgetest: the acceptance page
Copyright 2026 514 LLC d/b/a OpenGlow
Written by Scott Wiederhold
SPDX-License-Identifier: MIT
Served at "/" as one self-contained page: page.py inlines every
stylesheet and script linked below, in order, in place of its
link/script tag. No external assets. Bootstrap (vendor/) is the UI
library; theme.css is the OpenGlow theme forgectrl's panel uses, byte
for byte, in a light and a dark variant selected by the data-bs-theme
attribute the head script sets before first paint; page.css is this
page's own layout. Design notes are at the top of app.js.
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>ForgeFIRM acceptance</title>
<script>
(function () {
var t = 'light';
try {
var s = localStorage.getItem('ff_theme');
if (s === 'light' || s === 'dark') t = s;
else if (window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches)
t = 'dark';
} catch (e) {}
document.documentElement.setAttribute('data-bs-theme', t);
})();
</script>
<link rel="stylesheet" href="vendor/bootstrap.min.css" />
<link rel="stylesheet" href="theme.css" />
<link rel="stylesheet" href="page.css" />
</head>
<body>
<header>
<div class="hrow">
<span class="appname">ForgeFIRM acceptance</span><span class="sub" id="hdrsub"></span
><span class="ver" id="hdrver"></span
><button id="themebtn" type="button" onclick="cycleTheme()" title="Theme">Theme</button>
</div>
</header>
<nav>
<div class="nrow">
<a id="tab-acceptance" class="on" onclick='showTab("acceptance")'>Release acceptance</a
><a id="tab-bench" onclick='showTab("bench")'>Bench diagnostics</a>
</div>
</nav>
<main>
<div id="left">
<div id="pane-acceptance">
<div class="card">
<h2>Campaign<button class="hlp" data-help="campaign" type="button">?</button></h2>
<div class="banner">
<div class="auth" id="auth">?</div>
<div class="info" id="banner"></div>
</div>
<div id="invnote"></div>
<div class="card queue">
<h2>Run what is left<button class="hlp" data-help="queues" type="button">?</button></h2>
<div class="actions">
<button class="btn btn-sm btn-primary" id="q-unattended" onclick='startBatch("unattended")'>Unattended</button>
<button class="btn btn-sm btn-primary" id="q-attended" onclick='startBatch("attended")'>Operator and live</button>
<button class="btn btn-sm btn-danger" id="q-stop" onclick="stopBatch()" disabled>Stop the queue</button>
</div>
<div id="qstate"></div>
<div id="qmsg"></div>
<div id="fixture" class="hint"></div>
</div>
<div class="actions" style="margin-top: 10px">
<button class="btn btn-sm btn-primary" id="exportbtn" onclick="doExport()">Export release artifact</button>
<a class="btn btn-sm btn-outline-secondary" id="dljson" href="/export/acceptance.json" style="display: none">acceptance.json</a>
<a class="btn btn-sm btn-outline-secondary" id="dlmd" href="/export/acceptance.md" style="display: none">acceptance.md</a>
<a class="btn btn-sm btn-outline-secondary" href="/log">Raw log</a>
<a class="btn btn-sm btn-outline-secondary" href="/journal">Runner journal</a>
<button class="btn btn-sm btn-outline-secondary" onclick="toggleInv()">Invalidate all&hellip;</button>
<button class="btn btn-sm btn-outline-secondary" onclick="doReset()">Reset campaign</button>
<button class="hlp" data-help="actions" type="button">?</button>
</div>
<div id="invform" style="display: none">
<input type="text" class="form-control form-control-sm" id="invreason" placeholder="reason (required): what changed on the bench" />
<button class="btn btn-sm btn-danger" onclick="doInvalidate()">Invalidate all results</button>
</div>
<div id="actmsg"></div>
<div class="switch">
<input class="form-check-input" type="checkbox" id="ignreq" onchange="setIgnoreReq(this.checked)" />
<label for="ignreq">Ignore prerequisites: start any test on its own, whatever its <i>requires</i> list says</label>
<span id="ignreqon"></span>
<button class="hlp" data-help="ignore" type="button">?</button>
</div>
</div>
<div id="groups"></div>
</div>
<div id="pane-bench" style="display: none">
<div class="card">
<h2>Bench diagnostics<button class="hlp" data-help="bench" type="button">?</button></h2>
<p class="hint">
The bench tools (scripts/bench), run on the board with their output in the Run
pane. Runs here never enter a campaign; live tools need the operator
acknowledgment, and takeover tools stop forgectrl for the duration.
</p>
<div id="benchmsg"></div>
</div>
<div id="tools"></div>
</div>
</div>
<div id="right">
<div class="card">
<h2 id="runtitle">Run</h2>
<div id="runhead" class="info">idle</div>
<div id="steps" style="display: none"></div>
<div id="notice" style="display: none"></div>
<div id="prompt" style="display: none">
<div class="q" id="promptq"></div>
<div class="actions" id="promptb"></div>
</div>
<pre id="log"></pre>
<div class="actions">
<button class="btn btn-sm btn-danger" id="abortbtn" onclick="doAbort()" disabled>Abort</button
><span class="hint" id="runfoot"></span>
</div>
</div>
</div>
</main>
<script src="vendor/bootstrap.bundle.min.js"></script>
<script src="help.js"></script>
<script src="app.js"></script>
</body>
</html>
+428
View File
@@ -0,0 +1,428 @@
/*
* page.css - forgetest: the acceptance page's own styles
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*
* Everything specific to this page, on the tokens theme.css defines
* (theme.css is the same file forgectrl's panel uses, so the two look like
* one product and follow the same light and dark themes). This page is a
* wide two-pane layout, so the 900 px column theme.css gives the panel is
* lifted here.
*/
.hrow,
.nrow,
main {
max-width: none;
}
.hrow .appname {
border-left: 0;
padding-left: 0;
}
.hrow .sub {
color: rgba(255, 255, 255, 0.65);
font-size: 12.5px;
}
.hrow .ver {
margin-left: auto;
color: rgba(255, 255, 255, 0.7);
font-size: 12.5px;
font-family: ui-monospace, Consolas, monospace;
}
nav a {
cursor: pointer;
}
main {
display: flex;
gap: 14px;
padding: 12px 16px;
align-items: flex-start;
}
#left {
flex: 1 1 640px;
min-width: 0;
}
#right {
flex: 0 0 420px;
position: sticky;
top: 10px;
}
@media (max-width: 1000px) {
main {
flex-direction: column;
}
#right {
position: static;
flex: 1 1 auto;
width: 100%;
}
/* A run in progress is what the operator is watching: on a phone the
run pane goes to the top for its duration. */
main.running #right {
order: -1;
}
}
.card {
margin: 0 0 12px;
}
.banner {
display: flex;
flex-wrap: wrap;
gap: 18px;
align-items: center;
background: none;
border: 0;
color: inherit;
padding: 0;
margin: 0;
font-size: inherit;
}
.auth {
font-size: 19px;
font-weight: 700;
padding: 6px 14px;
border-radius: 6px;
color: #fff;
background: var(--og-red);
}
.auth.yes {
background: var(--og-ok);
}
.info {
color: var(--og-dim);
font-size: 12.5px;
line-height: 1.7;
}
.info b {
color: var(--og-txt);
font-weight: 600;
}
.mono {
font-size: 12px;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
font-size: 11.5px;
color: var(--og-dim);
text-align: left;
font-weight: 600;
padding: 5px 8px;
border-bottom: 1px solid var(--og-line);
}
td {
padding: 6px 8px;
border-bottom: 1px solid var(--og-line);
vertical-align: top;
}
tr:last-child td {
border-bottom: 0;
}
/* One grid for every subsystem. The groups are separate tables, so
left to themselves each sizes its own columns from its own content
and no two line up. Fixed widths put them all on the same rails. */
#groups .grp {
overflow-x: auto;
}
#groups table {
table-layout: fixed;
min-width: 700px;
}
#groups col.k {
width: 132px;
}
#groups col.s {
width: 250px;
}
#groups col.l {
width: 210px;
}
#groups col.a {
width: 96px;
}
/* Why a test stands where it does reads as one block under Status; a
requires list of three test ids would otherwise wrap into a ragged
stack under the Start button and drag the columns out of line. */
#groups .req {
overflow-wrap: break-word;
}
/* The details sit in a full-width row of their own: inside the Test
cell they would have to be read through a column, and opening one
would push that group's grid out of step with the rest. */
#groups td {
border-bottom: 0;
}
#groups tr.detrow td {
padding: 0 8px;
border-bottom: 1px solid var(--og-line);
}
#groups tr.detrow:last-child td {
border-bottom: 0;
}
#groups .details {
margin: 2px 0 8px;
}
#groups .details .dsc {
margin-bottom: 6px;
}
.tid {
color: var(--og-dim);
font-size: 11.5px;
font-family: ui-monospace, Consolas, monospace;
}
.tag {
display: inline-block;
font-size: 10.5px;
padding: 2px 6px;
border-radius: 9px;
margin-right: 4px;
background: var(--og-line-soft);
color: var(--og-txt);
font-weight: 600;
letter-spacing: 0.2px;
text-transform: uppercase;
}
.tag.live {
background: var(--og-bad-bg);
color: var(--og-red);
}
.tag.operator {
background: var(--og-note-bg);
color: var(--og-note-txt);
}
.tag.takeover {
background: var(--og-line-soft);
color: var(--og-title);
}
.tag.mode {
background: var(--og-line-soft);
color: var(--og-ok);
}
.tag.core {
background: var(--og-navy);
color: #fff;
}
.tag.queued {
background: var(--og-line-soft);
color: var(--og-action);
}
.st {
font-weight: 600;
}
.st.pass {
color: var(--og-ok);
}
.st.inherited {
color: var(--og-title);
}
.st.fail,
.st.error {
color: var(--og-red);
}
.st.stale,
.st.aborted {
color: var(--og-warn);
}
.st.none {
color: var(--og-dim);
}
.st.running {
color: var(--og-action);
}
.req {
font-size: 11.5px;
color: var(--og-warn);
}
.req.over {
color: var(--og-dim);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.actions .hint {
margin: 0;
}
pre#log {
background: var(--og-term-bg);
color: var(--og-term-txt);
font-family: ui-monospace, Consolas, monospace;
font-size: 11.5px;
padding: 10px;
border-radius: 4px;
height: 380px;
overflow: auto;
margin: 8px 0;
white-space: pre-wrap;
word-break: break-all;
}
#prompt {
background: var(--og-note-bg);
border: 1px solid var(--og-note-line);
color: var(--og-note-txt);
border-radius: 6px;
padding: 10px 12px;
margin: 8px 0;
}
#prompt .q {
font-weight: 600;
margin-bottom: 8px;
}
#notice {
background: var(--og-line-soft);
border: 1px solid var(--og-action);
border-radius: 6px;
padding: 10px 12px;
margin: 8px 0;
font-weight: 600;
}
#steps {
background: var(--og-line-soft);
border-radius: 6px;
padding: 8px 12px;
margin: 8px 0;
font-size: 12.5px;
line-height: 1.5;
}
#steps .sh {
font-weight: 600;
margin-bottom: 4px;
}
#steps .sub {
color: var(--og-dim);
margin: 6px 0 2px;
}
#steps ol {
margin: 2px 0 4px 20px;
}
#steps .auto {
color: var(--og-dim);
font-style: italic;
}
.tsel {
cursor: pointer;
}
.tsel:hover {
text-decoration: underline;
}
.details {
display: none;
background: var(--og-line-soft);
padding: 8px 10px;
border-radius: 4px;
font-size: 12.5px;
line-height: 1.55;
margin-top: 6px;
}
.details.on {
display: block;
}
.msg {
margin: 6px 0;
}
.err {
color: var(--og-red);
font-size: 12.5px;
margin: 6px 0;
}
.switch {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0 0;
font-size: 13px;
}
.switch input {
margin: 0;
}
.switch .on {
color: var(--og-warn);
font-weight: 600;
}
.grp {
margin-top: 6px;
}
.queue {
background: var(--og-line-soft);
margin: 10px 0 0;
padding: 10px 12px;
box-shadow: none;
}
.queue h2 {
margin-bottom: 8px;
}
.qline {
font-size: 12.5px;
color: var(--og-dim);
line-height: 1.7;
margin-top: 8px;
}
.qline b {
color: var(--og-txt);
}
.qbar {
display: flex;
height: 6px;
border-radius: 3px;
overflow: hidden;
background: var(--og-line);
margin: 6px 0;
}
.qbar i {
display: block;
}
.qbar i.ok {
background: var(--og-ok);
}
.qbar i.bad {
background: var(--og-red);
}
.qbar i.skip {
background: var(--og-warn);
}
.qbar i.now {
background: var(--og-action);
}
.tool {
border-bottom: 1px solid var(--og-line);
padding: 8px 0;
}
.tool:last-child {
border-bottom: 0;
}
.tool .argrow {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 6px 0;
align-items: center;
}
.tool .argrow label {
font-size: 12px;
color: var(--og-dim);
display: flex;
align-items: center;
gap: 4px;
}
.tool .argrow input,
.tool .argrow select {
width: auto;
display: inline-block;
}
.tool .argrow input {
width: 90px;
}
#invform {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-top: 8px;
}
#invform input {
flex: 1 1 320px;
}
+800
View File
@@ -0,0 +1,800 @@
/*
* theme.css - forgectrl: the OpenGlow theme on top of Bootstrap
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*
* Every color the panel uses is a token here, defined once for the light
* theme and once more for the dark one; Bootstrap's own component
* variables are pointed at the tokens, so the stock components follow the
* theme without a Sass build. The visual identity is OpenGlow's
* (community.openglow.org): navy header with the starburst wordmark, blue
* actions, laser red reserved for the brand accent and fault states. The
* header keeps its navy in both themes; the log viewers stay dark in both
* because they are terminals.
*
* Density: the panel is a machine control surface read at a glance, so
* everything runs a size tighter than Bootstrap's defaults (13.5 px body,
* small controls, compact cards).
*/
:root,
[data-bs-theme='light'] {
--og-navy: #2b2b5e;
--og-red: #e8262a;
--og-action: #0088cc;
--og-action-hover: #0077b3;
--og-ok: #3d854d;
--og-warn: #c7760a;
--og-dim: #767a82;
--og-bg: #f0f1f4;
--og-card: #ffffff;
--og-line: #dde0e6;
--og-line-soft: #eceef1;
--og-txt: #222222;
--og-field-bg: #ffffff;
--og-field-line: #c9cdd4;
--og-placeholder: #a6aab2;
--og-note-bg: #fdf3e3;
--og-note-line: #eccb90;
--og-note-txt: #7a5a12;
--og-warn-bg: #fff6e6;
--og-warn-txt: #7a4a06;
--og-bad-bg: #fdeeee;
--og-bad-txt: #7a1f1f;
--og-safety-bg: #fdeaea;
--og-safety-txt: #8a1518;
--og-cam-bg: #e8e9ec;
--og-term-bg: #23252b;
--og-term-txt: #cfd3da;
--og-title: var(--og-navy);
--og-nav-on: var(--og-navy);
}
[data-bs-theme='dark'] {
--og-navy: #2b2b5e;
--og-red: #f05a5e;
--og-action: #2ea3e0;
--og-action-hover: #46b3ec;
--og-ok: #5fb374;
--og-warn: #e09a3a;
--og-dim: #9296a2;
--og-bg: #15161c;
--og-card: #1e2028;
--og-line: #32353f;
--og-line-soft: #2a2d36;
--og-txt: #d9dce3;
--og-field-bg: #14151b;
--og-field-line: #3d404b;
--og-placeholder: #6b6f7a;
--og-note-bg: #2e2717;
--og-note-line: #6a5323;
--og-note-txt: #e3c584;
--og-warn-bg: #2f2415;
--og-warn-txt: #eab86a;
--og-bad-bg: #331c1e;
--og-bad-txt: #f0a3a6;
--og-safety-bg: #361a1c;
--og-safety-txt: #f3a7aa;
--og-cam-bg: #2a2d36;
--og-term-bg: #0f1014;
--og-term-txt: #cfd3da;
--og-title: #b9bcd8;
--og-nav-on: #e6e8f5;
}
/* ---- Bootstrap variables pointed at the tokens ---------------------- */
:root,
[data-bs-theme='light'],
[data-bs-theme='dark'] {
--bs-body-bg: var(--og-bg);
--bs-body-color: var(--og-txt);
--bs-body-font-size: 13.5px;
--bs-body-font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--bs-border-color: var(--og-line);
--bs-secondary-color: var(--og-dim);
--bs-link-color: var(--og-action);
--bs-link-hover-color: var(--og-action-hover);
--bs-link-color-rgb: 0, 136, 204;
--bs-link-hover-color-rgb: 0, 119, 179;
--bs-primary: var(--og-action);
--bs-danger: var(--og-red);
--bs-success: var(--og-ok);
--bs-warning: var(--og-warn);
--bs-font-monospace: ui-monospace, Consolas, monospace;
}
.btn-primary {
--bs-btn-bg: var(--og-action);
--bs-btn-border-color: var(--og-action);
--bs-btn-hover-bg: var(--og-action-hover);
--bs-btn-hover-border-color: var(--og-action-hover);
--bs-btn-active-bg: var(--og-action-hover);
--bs-btn-active-border-color: var(--og-action-hover);
--bs-btn-disabled-bg: var(--og-action);
--bs-btn-disabled-border-color: var(--og-action);
--bs-btn-font-weight: 600;
}
.btn-outline-secondary {
--bs-btn-color: var(--og-txt);
--bs-btn-border-color: var(--og-field-line);
--bs-btn-bg: var(--og-field-bg);
--bs-btn-hover-color: var(--og-txt);
--bs-btn-hover-bg: var(--og-field-bg);
--bs-btn-hover-border-color: var(--og-nav-on);
--bs-btn-active-color: var(--og-txt);
--bs-btn-active-bg: var(--og-line-soft);
--bs-btn-active-border-color: var(--og-nav-on);
--bs-btn-disabled-color: var(--og-txt);
--bs-btn-disabled-bg: var(--og-field-bg);
--bs-btn-disabled-border-color: var(--og-field-line);
}
.btn-danger {
--bs-btn-bg: var(--og-red);
--bs-btn-border-color: var(--og-red);
--bs-btn-font-weight: 600;
}
.btn:disabled,
.btn.disabled {
opacity: 0.55;
cursor: not-allowed;
}
.form-control,
.form-select {
--bs-body-bg: var(--og-field-bg);
--bs-border-color: var(--og-field-line);
color: var(--og-txt);
}
.form-control:focus,
.form-select:focus {
border-color: var(--og-action);
box-shadow: 0 0 0 0.15rem rgba(0, 136, 204, 0.2);
}
.form-control::placeholder {
color: var(--og-placeholder);
}
.form-control.dirty,
.form-select.dirty {
border-color: var(--og-warn);
}
.card {
--bs-card-bg: var(--og-card);
--bs-card-border-color: var(--og-line);
--bs-card-border-radius: 8px;
padding: 12px 14px;
margin: 12px 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.popover {
--bs-popover-max-width: 400px;
--bs-popover-font-size: 12.5px;
--bs-popover-bg: var(--og-card);
--bs-popover-border-color: var(--og-line);
--bs-popover-body-color: var(--og-txt);
--bs-popover-header-bg: var(--og-line-soft);
--bs-popover-header-color: var(--og-title);
--bs-popover-header-font-size: 12px;
}
.popover-header {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
}
.popover-body p {
margin: 0 0 8px;
line-height: 1.5;
}
.popover-body p:last-child {
margin-bottom: 0;
}
.popover-body .doclink {
display: inline-block;
margin-top: 6px;
font-weight: 600;
}
.modal {
--bs-modal-bg: var(--og-card);
--bs-modal-border-color: var(--og-line);
}
.toast {
--bs-toast-bg: var(--og-card);
--bs-toast-border-color: var(--og-line);
--bs-toast-color: var(--og-txt);
--bs-toast-font-size: 13px;
}
.toast.t-ok {
border-left: 4px solid var(--og-ok);
}
.toast.t-bad {
border-left: 4px solid var(--og-red);
}
/* ---- 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;
}
body.has-savebar {
padding-bottom: 64px;
}
header {
background: var(--og-navy);
}
.hrow {
max-width: 900px;
margin: 0 auto;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 14px;
}
.appname {
color: #fff;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.3px;
border-left: 1px solid rgba(255, 255, 255, 0.25);
padding-left: 14px;
}
#host {
color: rgba(255, 255, 255, 0.55);
font-size: 12.5px;
margin-left: auto;
}
#themebtn,
#logsbtn,
#logoutbtn {
background: transparent;
color: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 5px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
#themebtn:hover,
#logsbtn:hover,
#logoutbtn:hover {
color: #fff;
border-color: rgba(255, 255, 255, 0.6);
}
nav {
background: var(--og-card);
border-bottom: 1px solid var(--og-line);
}
.nrow {
max-width: 900px;
margin: 0 auto;
padding: 0 20px;
display: flex;
gap: 2px;
overflow-x: auto;
overflow-y: hidden;
}
nav a {
padding: 9px 13px;
color: var(--og-dim);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
white-space: nowrap;
}
nav a.on {
color: var(--og-nav-on);
font-weight: 600;
border-color: var(--og-red);
}
nav a:hover {
color: var(--og-nav-on);
}
main {
max-width: 900px;
margin: 0 auto;
padding: 4px 20px 12px;
}
section {
display: none;
}
section.on {
display: block;
}
footer {
max-width: 900px;
margin: 0 auto;
padding: 6px 20px 28px;
color: var(--og-dim);
font-size: 12.5px;
text-align: center;
}
footer a {
text-decoration: none;
}
/* ---- Cards and settings rows --------------------------------------- */
.card h2 {
font-size: 12px;
margin: 0 0 8px;
color: var(--og-title);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.9px;
display: flex;
align-items: center;
gap: 6px;
}
.card h3 {
font-size: 11.5px;
margin: 12px 0 4px;
color: var(--og-dim);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
display: flex;
align-items: center;
gap: 6px;
}
.card h3:first-of-type {
margin-top: 6px;
}
.grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 24px;
}
.frow {
display: grid;
grid-template-columns: 180px 1fr;
gap: 6px 10px;
align-items: center;
margin: 5px 0;
}
.frow label {
color: var(--og-txt);
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.frow .u {
color: var(--og-dim);
font-size: 11.5px;
margin-left: 2px;
}
.hint {
color: var(--og-dim);
font-size: 12.5px;
line-height: 1.5;
margin: 8px 0 0;
}
.btns {
margin-top: 10px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.msg {
font-size: 12.5px;
color: var(--og-action);
}
.mono {
font-family: ui-monospace, Consolas, monospace;
}
.brk {
word-break: break-all;
}
/* The help button: a small ring that opens the field's popover. */
.hlp {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border-radius: 50%;
border: 1px solid var(--og-dim);
background: transparent;
color: var(--og-dim);
font-size: 10.5px;
font-weight: 700;
line-height: 1;
cursor: pointer;
vertical-align: middle;
letter-spacing: 0;
text-transform: none;
flex: 0 0 auto;
}
.hlp:hover,
.hlp[aria-describedby] {
color: var(--og-action);
border-color: var(--og-action);
}
/* ---- Status widgets ------------------------------------------------ */
.kv {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 5px 2px;
border-bottom: 1px solid var(--og-line-soft);
font-size: 13px;
}
.kv:last-child {
border-bottom: none;
}
.kv > span:first-child {
color: var(--og-dim);
}
.kv .btn {
margin-left: 6px;
}
.b-ok {
color: var(--og-ok);
font-weight: 600;
}
.b-run {
color: var(--og-action);
font-weight: 600;
}
.b-bad {
color: var(--og-red);
font-weight: 600;
}
.b-dim {
color: var(--og-dim);
}
.b-warn {
color: var(--og-warn);
font-weight: 600;
}
.bigpass {
font-size: 15px;
font-weight: 700;
color: var(--og-ok);
}
.bigfail {
font-size: 15px;
font-weight: 700;
color: var(--og-red);
}
.seg {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.segbtn {
padding: 7px 20px;
border-radius: 6px;
background: var(--og-field-bg);
color: var(--og-txt);
border: 1px solid var(--og-field-line);
font: inherit;
cursor: pointer;
}
.segbtn:hover {
border-color: var(--og-nav-on);
}
.segbtn.segon {
background: var(--og-navy);
border-color: var(--og-navy);
color: #fff;
font-weight: 600;
}
.segbtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#cam,
#camvid {
width: 100%;
max-width: 520px;
border-radius: 6px;
border: 1px solid var(--og-line);
display: block;
margin: 0 auto;
background: var(--og-cam-bg);
min-height: 60px;
}
.camwrap {
text-align: center;
}
.camctl {
margin-top: 8px;
display: flex;
gap: 8px;
justify-content: center;
}
.btn.on {
background: var(--og-navy);
border-color: var(--og-navy);
color: #fff;
font-weight: 600;
}
/* ---- Banners ------------------------------------------------------- */
.banner {
background: var(--og-warn-bg);
border: 1px solid var(--og-warn);
color: var(--og-warn-txt);
border-radius: 8px;
padding: 8px 12px;
margin: 0 0 10px;
font-size: 13px;
}
.banner a {
color: inherit;
font-weight: 600;
}
.banner-bad {
border-color: var(--og-red);
background: var(--og-bad-bg);
color: var(--og-bad-txt);
}
.safety {
background: var(--og-safety-bg);
border: 1px solid var(--og-red);
color: var(--og-safety-txt);
border-radius: 8px;
padding: 7px 12px;
margin: 8px 0 10px;
font-size: 12.5px;
line-height: 1.5;
}
#locknote,
.note {
display: none;
background: var(--og-note-bg);
border: 1px solid var(--og-note-line);
color: var(--og-note-txt);
border-radius: 8px;
padding: 8px 12px;
margin: 10px 0;
font-size: 13px;
}
.note {
display: block;
margin: 8px 0 0;
font-size: 12.5px;
line-height: 1.5;
}
.gnotes {
font-size: 12.5px;
line-height: 1.5;
margin: 4px 0 6px;
}
.gnotes .gn-warn {
color: var(--og-warn);
}
.gnotes .gn-off {
color: var(--og-red);
font-weight: 600;
}
/* ---- Terminals: the diagnostic log and the log viewer -------------- */
#diaglog,
#logview {
background: var(--og-term-bg);
color: var(--og-term-txt);
border-radius: 6px;
padding: 10px 12px;
font-family: ui-monospace, Consolas, monospace;
font-size: 12px;
margin-top: 8px;
}
#diaglog {
line-height: 1.6;
max-height: 220px;
overflow-y: auto;
white-space: pre-wrap;
display: none;
}
#logview {
line-height: 1.5;
height: 380px;
overflow: auto;
white-space: pre;
}
.logtbl {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.logtbl th {
text-align: left;
color: var(--og-dim);
font-weight: 600;
font-size: 11.5px;
padding: 3px 6px;
border-bottom: 1px solid var(--og-line);
}
.logtbl td {
padding: 3px 6px;
border-bottom: 1px solid var(--og-line-soft);
}
.logtbl select {
width: auto;
display: inline-block;
}
/* ---- The fuse identity dialog -------------------------------------- */
#fusewrap {
display: none;
position: fixed;
inset: 0;
background: rgba(20, 22, 30, 0.55);
z-index: 1050;
align-items: center;
justify-content: center;
}
#fusewrap.on {
display: flex;
}
.fusebox {
background: var(--og-card);
border: 1px solid var(--og-line);
border-radius: 10px;
padding: 16px 20px;
max-width: 560px;
width: 92%;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
}
.fusebox h2 {
font-size: 12px;
margin: 0 0 8px;
color: var(--og-title);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.9px;
}
/* ---- The save bar -------------------------------------------------- */
#savebar {
display: none;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1040;
background: var(--og-card);
border-top: 1px solid var(--og-line);
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.12);
}
#savebar.on {
display: block;
}
.sbrow {
max-width: 900px;
margin: 0 auto;
padding: 9px 20px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
#sb-count {
font-weight: 600;
color: var(--og-warn);
}
#sb-msg {
font-size: 12.5px;
color: var(--og-red);
}
@media (max-width: 620px) {
.frow {
grid-template-columns: 1fr;
gap: 2px 0;
}
.grid2 {
grid-template-columns: 1fr;
}
.hrow,
.nrow,
main,
.sbrow {
padding-left: 12px;
padding-right: 12px;
}
}
/* The setup's log-bundle line, under the header: the result of the
header's Download logs, or why the machine refused it. */
.wz-logs-msg {
max-width: 1100px;
margin: 6px auto 0;
padding: 0 16px;
min-height: 16px;
text-align: right;
}
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2011-2025 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+41
View File
@@ -0,0 +1,41 @@
2026-08-17T09:44:00.417337+00:00 gfcloud[1927] INFO cnc:set_step_freq 28160
2026-08-17T09:44:00.418627+00:00 gfcloud[1927] INFO cnc:set_x_decay 1
2026-08-17T09:44:00.419858+00:00 gfcloud[1927] INFO cnc:set_x_mode 8
2026-08-17T09:44:00.421083+00:00 gfcloud[1927] INFO cnc:set_x_current 135
2026-08-17T09:44:00.422535+00:00 gfcloud[1927] INFO cnc:set_y_decay 1
2026-08-17T09:44:00.423794+00:00 gfcloud[1927] INFO cnc:set_y_mode 8
2026-08-17T09:44:00.425014+00:00 gfcloud[1927] INFO cnc:set_y_current 22
2026-08-17T09:44:00.432591+00:00 gfcloud[1927] INFO machine:_run_loop starting run
2026-08-17T09:44:00.434005+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:44:00.438277+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:44:01.785230+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:44:01.797728+00:00 gfcloud[1927] INFO machine:_run_loop finished run
2026-08-17T09:44:01.800661+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:01.808504+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (actual/expected): X (-10313/-10313), Y (-649/-649), Z (0/0)
2026-08-17T09:44:01.809326+00:00 gfcloud[1927] INFO machine:_motion_locked motion bytes actual:34952, expected: 34952
2026-08-17T09:44:01.810066+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
2026-08-17T09:44:01.811352+00:00 gfcloud[1927] INFO cnc:set_x_current 33
2026-08-17T09:44:01.812874+00:00 gfcloud[1927] INFO cnc:set_y_current 5
2026-08-17T09:44:01.820631+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (-10313, -649, 0)
2026-08-17T09:44:01.822062+00:00 gfcloud[1927] INFO machine:_motion end motion
2026-08-17T09:44:01.822829+00:00 gfcloud[1927] INFO basemachine:_finish_action motion [1576550614]: finished with event ":completed"
2026-08-17T09:44:01.824200+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:07.993647+00:00 gfcloud[1927] INFO gfuiservice:run service action request: print (ready)
2026-08-17T09:44:07.998655+00:00 gfcloud[1927] INFO machine:_motion start motion
2026-08-17T09:44:07.999643+00:00 gfcloud[1927] INFO gfuiservice:run print (ready) service action print
2026-08-17T09:44:14.199666+00:00 gfcloud[1927] INFO cnc:laser_latch 0
2026-08-17T09:44:14.208291+00:00 gfcloud[1927] INFO machine:_button_wait waiting for button
2026-08-17T09:44:21.270846+00:00 gfcloud[1927] INFO machine:_switch_event lid opened
2026-08-17T09:44:21.275703+00:00 gfcloud[1927] WARNING machine:_button_wait button wait lid opened - relocking the laser
2026-08-17T09:44:21.278615+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:21.288305+00:00 gfcloud[1927] INFO machine:_return_home start return home
2026-08-17T09:44:21.291373+00:00 gfcloud[1927] INFO machine:_return_home return home: already at the job start
2026-08-17T09:44:21.292501+00:00 gfcloud[1927] INFO machine:_motion_locked start cool down
2026-08-17T09:44:21.305578+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
2026-08-17T09:44:21.307322+00:00 gfcloud[1927] INFO cnc:set_x_current 33
2026-08-17T09:44:21.309185+00:00 gfcloud[1927] INFO cnc:set_y_current 5
2026-08-17T09:44:21.316340+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (0, 0, 0)
2026-08-17T09:44:21.318139+00:00 gfcloud[1927] INFO machine:_motion end motion
2026-08-17T09:44:21.318860+00:00 gfcloud[1927] INFO basemachine:_finish_action print [1576550621]: finished with event ":cancelled"
2026-08-17T09:44:21.320214+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:34.058320+00:00 gfcloud[1927] INFO machine:_switch_event lid closed
+207
View File
@@ -0,0 +1,207 @@
2026-08-17T09:44:35.789556+00:00 gfcloud[1927] INFO websocket:img_upload START
2026-08-17T09:44:36.105516+00:00 gfcloud[1927] INFO websocket:img_upload COMPLETE
2026-08-17T09:44:36.109421+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:38.461442+00:00 gfcloud[1927] INFO gfcloud:_shutdown shutdown requested
2026-08-17T09:44:38.910494+00:00 gfcloud[1927] INFO machine:_shutdown shutting down
2026-08-17T09:44:38.913049+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:38.950354+00:00 gfcloud[1927] INFO machine:_shutdown joining switch thread
2026-08-17T09:44:38.956909+00:00 gfcloud[1927] INFO machine:_shutdown shut down complete
2026-08-17T09:44:38.986287+00:00 gfcloud[1927] INFO websocket:_on_close RX-EVENT: closed (None, None)
2026-08-17T09:44:38.989003+00:00 gfcloud[1927] INFO websocket:run CLOSING
2026-08-17T09:44:38.992724+00:00 gfcloud[1927] INFO gfcloud:main gfcloud exit
2026-08-17T09:45:08.449553+00:00 gfcloud[2278] INFO gfuiservice:__init__ INITIALIZED
2026-08-17T09:45:08.451929+00:00 gfcloud[2278] INFO authentication:authenticate_machine START
2026-08-17T09:45:08.965893+00:00 gfcloud[2278] INFO authentication:authenticate_machine SUCCESS
2026-08-17T09:45:08.967947+00:00 gfcloud[2278] INFO websocket:ws_connect CONNECTING
2026-08-17T09:45:09.421918+00:00 gfcloud[2278] INFO _logging:info Websocket connected
2026-08-17T09:45:09.424224+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready
2026-08-17T09:45:09.973615+00:00 gfcloud[2278] INFO websocket:ws_connect ESTABLISHED
2026-08-17T09:45:09.994287+00:00 gfcloud[2278] INFO cnc:set_ignored_faults 0
2026-08-17T09:45:09.996281+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:09.998803+00:00 gfcloud[2278] INFO cnc:set_x_mode Microstep.M_8
2026-08-17T09:45:09.999688+00:00 gfcloud[2278] INFO cnc:set_y_mode Microstep.M_8
2026-08-17T09:45:10.000483+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:10.001229+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:10.002000+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:10.007267+00:00 gfcloud[2278] INFO cnc:set_y_current 33
2026-08-17T09:45:10.008695+00:00 gfcloud[2278] INFO z_axis:reset resetting z
2026-08-17T09:45:10.009124+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: False, current: ZCur.LOW, mode: Microstep.HALF
2026-08-17T09:45:10.021592+00:00 gfcloud[2278] INFO gfuiservice:run service action request: settings (ready)
2026-08-17T09:45:10.024928+00:00 gfcloud[2278] INFO settings:send_report START
2026-08-17T09:45:10.039856+00:00 gfcloud[2278] INFO settings:send_report COMPLETE
2026-08-17T09:45:10.040650+00:00 gfcloud[2278] INFO gfuiservice:run settings (ready) service action dispatched
2026-08-17T09:45:10.591813+00:00 gfcloud[2278] INFO gfuiservice:run service action request: hunt (ready)
2026-08-17T09:45:10.596213+00:00 gfcloud[2278] INFO z_axis:home starting z homing cycle
2026-08-17T09:45:10.599109+00:00 gfcloud[2278] INFO gfuiservice:run hunt (ready) service action hunt
2026-08-17T09:45:10.600433+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: True, current: ZCur.HIGH, mode: Microstep.FULL
2026-08-17T09:45:10.607559+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:11.160422+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:11.161195+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:11.714663+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:11.717907+00:00 gfcloud[2278] INFO z_axis:home pass: 1
2026-08-17T09:45:11.719134+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:12.272747+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:12.274301+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:12.829585+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:12.831305+00:00 gfcloud[2278] INFO z_axis:home pass: 2
2026-08-17T09:45:12.832786+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:13.385567+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:13.397106+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:13.947958+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:13.949754+00:00 gfcloud[2278] INFO z_axis:home pass: 3
2026-08-17T09:45:13.951244+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:14.504840+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:14.506743+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:15.060854+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:15.062536+00:00 gfcloud[2278] INFO z_axis:home pass: 4
2026-08-17T09:45:15.063941+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:15.617907+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:15.619505+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:16.175270+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:16.177421+00:00 gfcloud[2278] INFO z_axis:home pass: 5
2026-08-17T09:45:16.179808+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:16.733698+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:16.735355+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:17.289377+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:17.291109+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: None, current: ZCur.LOW, mode: Microstep.HALF
2026-08-17T09:45:17.296301+00:00 gfcloud[2278] INFO z_axis:home homing cycle complete: [0, 0, 0, 0, 0]
2026-08-17T09:45:17.298209+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:17.968494+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:17.969759+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:17.970993+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:45:17.972200+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:45:17.973616+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:17.974870+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:45:17.976077+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:45:17.983820+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:45:17.985159+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:17.989360+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:45:19.630807+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:19.636283+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:45:19.637979+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:19.651159+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (0/0), Y (0/0), Z (-4/-4)
2026-08-17T09:45:19.652103+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:15874, expected: 15874
2026-08-17T09:45:19.652852+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:45:19.653964+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:19.655305+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:45:19.664032+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (0, 0, -4)
2026-08-17T09:45:19.664853+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:19.665575+00:00 gfcloud[2278] INFO basemachine:_finish_action hunt [1576550660]: finished with event ":completed"
2026-08-17T09:45:19.667114+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:20.027338+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:20.030024+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:20.040337+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:21.195560+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:21.196529+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:21.721382+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:21.725394+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:23.732857+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:23.736044+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:23.737995+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:45:23.738941+00:00 gfcloud[2278] INFO machine:_safe_to_move lid opened, unsafe to move
2026-08-17T09:45:23.739814+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:23.740605+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550667]: finished with event ":cancelled"
2026-08-17T09:45:23.742169+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:42.629674+00:00 gfcloud[2278] INFO machine:_switch_event lid closed
2026-08-17T09:45:42.920194+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:42.924152+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:42.927934+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:43.989152+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:43.990022+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:44.312060+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:44.315952+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:46.652633+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:46.654950+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:46.657522+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:45:48.089456+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:48.090706+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:48.091970+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:45:48.093155+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:45:48.094587+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:48.095866+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:45:48.097182+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:45:48.104758+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:45:48.106128+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:48.110477+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:45:50.393091+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:50.398748+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:45:50.399629+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:50.423949+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (12939/12939), Y (7402/7402), Z (0/0)
2026-08-17T09:45:50.425534+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:22740, expected: 22740
2026-08-17T09:45:50.426258+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:45:50.427835+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:50.429387+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:45:50.437659+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (12939, 7402, 0)
2026-08-17T09:45:50.438474+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:50.439167+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550679]: finished with event ":completed"
2026-08-17T09:45:50.440481+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:50.770056+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:50.772800+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:50.782017+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:51.427848+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:51.428456+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:51.757479+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:51.760692+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:54.222123+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:54.224786+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:54.226855+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:45:54.557705+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:54.562210+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:54.571212+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:45:54.573373+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:45:54.596668+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:54.597612+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:45:54.598479+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:45:54.622488+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:45:54.624223+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:54.629307+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:45:54.835600+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:54.838848+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:45:54.839726+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:54.849011+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (159/159), Y (5/5), Z (0/0)
2026-08-17T09:45:54.849946+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:1222, expected: 1222
2026-08-17T09:45:54.850790+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:45:54.852317+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:54.854073+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:45:54.880450+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (159, 5, 0)
2026-08-17T09:45:54.881925+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:54.882641+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550683]: finished with event ":completed"
2026-08-17T09:45:54.884500+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:55.137070+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:55.139449+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:55.142346+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:55.809276+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:55.810155+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:56.158888+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:56.162644+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:58.467590+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:58.469426+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:58.471091+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:46:00.721960+00:00 gfcloud[2278] INFO cnc:set_step_freq 28160
2026-08-17T09:46:00.725688+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:46:00.727477+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:46:00.728738+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:46:00.730198+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:46:00.731506+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:46:00.732735+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:46:00.740459+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:46:00.741865+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:46:00.745983+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:46:02.491897+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:46:02.494137+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:46:02.495155+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:46:02.530565+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (-13096/-13096), Y (-7400/-7400), Z (0/0)
2026-08-17T09:46:02.531413+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:47243, expected: 47243
2026-08-17T09:46:02.538168+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:46:02.539603+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:46:02.541147+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:46:02.549578+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (-13096, -7400, 0)
2026-08-17T09:46:02.550385+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:46:02.551153+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550685]: finished with event ":completed"
2026-08-17T09:46:02.552496+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:46:03.007623+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:46:03.009488+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:46:03.037155+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:46:03.758520+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:46:03.760049+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:46:04.128126+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
+72
View File
@@ -0,0 +1,72 @@
2026-08-17T09:42:09.781500+00:00 gfcloud[1522] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:42:11.281337+00:00 gfcloud[1522] INFO cnc:set_step_freq 28160
2026-08-17T09:42:11.282627+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
2026-08-17T09:42:11.283850+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
2026-08-17T09:42:11.285121+00:00 gfcloud[1522] INFO cnc:set_x_current 135
2026-08-17T09:42:11.286711+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
2026-08-17T09:42:11.287977+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
2026-08-17T09:42:11.289235+00:00 gfcloud[1522] INFO cnc:set_y_current 22
2026-08-17T09:42:11.297425+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:11.299141+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:11.303264+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:42:12.560917+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:12.572022+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:42:12.572811+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:12.580801+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (-10313/-10313), Y (-649/-649), Z (0/0)
2026-08-17T09:42:12.581620+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:34952, expected: 34952
2026-08-17T09:42:12.582366+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
2026-08-17T09:42:12.583540+00:00 gfcloud[1522] INFO cnc:set_x_current 33
2026-08-17T09:42:12.584928+00:00 gfcloud[1522] INFO cnc:set_y_current 5
2026-08-17T09:42:12.593681+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (-10313, -649, 0)
2026-08-17T09:42:12.604394+00:00 gfcloud[1522] INFO machine:_motion end motion
2026-08-17T09:42:12.604757+00:00 gfcloud[1522] INFO basemachine:_finish_action motion [1576550503]: finished with event ":completed"
2026-08-17T09:42:12.605824+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:15.672518+00:00 gfcloud[1522] INFO gfuiservice:run service action request: print (ready)
2026-08-17T09:42:15.676330+00:00 gfcloud[1522] INFO machine:_motion start motion
2026-08-17T09:42:15.680419+00:00 gfcloud[1522] INFO gfuiservice:run print (ready) service action print
2026-08-17T09:42:21.975587+00:00 gfcloud[1522] INFO cnc:laser_latch 0
2026-08-17T09:42:22.001354+00:00 gfcloud[1522] INFO machine:_button_wait waiting for button
2026-08-17T09:42:24.774117+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
2026-08-17T09:42:24.778509+00:00 gfcloud[1522] INFO cnc:set_step_freq 10000
2026-08-17T09:42:24.781114+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
2026-08-17T09:42:24.783654+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
2026-08-17T09:42:24.786180+00:00 gfcloud[1522] INFO cnc:set_x_current 135
2026-08-17T09:42:24.789088+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
2026-08-17T09:42:24.791559+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
2026-08-17T09:42:24.794380+00:00 gfcloud[1522] INFO cnc:set_y_current 22
2026-08-17T09:42:24.815263+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:24.816687+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:24.820823+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:42:25.049489+00:00 gfcloud[1522] INFO machine:_switch_event button released
2026-08-17T09:42:30.832527+00:00 gfcloud[1522] INFO machine:_switch_event lid opened
2026-08-17T09:42:30.838627+00:00 gfcloud[1522] WARNING machine:_run_loop lid opened mid-run; stopping motion
2026-08-17T09:42:30.941553+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:30.947477+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:42:30.949099+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:30.964916+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (8454/7573), Y (684/684), Z (3/3)
2026-08-17T09:42:30.965704+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:60649, expected: 238043
2026-08-17T09:42:30.972180+00:00 gfcloud[1522] INFO machine:_return_home start return home
2026-08-17T09:42:31.847247+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:31.848617+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:31.852977+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:42:36.970521+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:36.976059+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:42:36.977738+00:00 gfcloud[1522] INFO machine:_return_home return home complete
2026-08-17T09:42:36.979696+00:00 gfcloud[1522] INFO machine:_motion_locked start cool down
2026-08-17T09:42:36.998973+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
2026-08-17T09:42:37.000314+00:00 gfcloud[1522] INFO cnc:set_x_current 33
2026-08-17T09:42:37.001774+00:00 gfcloud[1522] INFO cnc:set_y_current 5
2026-08-17T09:42:37.010089+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (0, 0, 3)
2026-08-17T09:42:37.010885+00:00 gfcloud[1522] INFO machine:_motion end motion
2026-08-17T09:42:37.011563+00:00 gfcloud[1522] INFO basemachine:_finish_action print [1576550507]: finished with event ":cancelled"
2026-08-17T09:42:37.013071+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:37.449180+00:00 gfcloud[1522] INFO gfuiservice:run service action request: hunt (ready)
2026-08-17T09:42:37.451286+00:00 gfcloud[1522] INFO z_axis:home starting z homing cycle
2026-08-17T09:42:37.452782+00:00 gfcloud[1522] INFO gfuiservice:run hunt (ready) service action hunt
2026-08-17T09:42:37.453691+00:00 gfcloud[1522] INFO z_axis:configure configuring z_enable: True, current: ZCur.HIGH, mode: Microstep.FULL
2026-08-17T09:42:37.458283+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:42:37.827610+00:00 gfcloud[1522] INFO z_axis:_step_until took -2 z_steps
2026-08-17T09:42:37.829278+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:42:38.200493+00:00 gfcloud[1522] INFO z_axis:_step_until took 2 z_steps
2026-08-17T09:42:38.202234+00:00 gfcloud[1522] INFO z_axis:home pass: 1
2026-08-17T09:42:38.203788+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: False
+54
View File
@@ -0,0 +1,54 @@
2026-08-17T09:42:15.672518+00:00 gfcloud[1522] INFO gfuiservice:run service action request: print (ready)
2026-08-17T09:42:15.676330+00:00 gfcloud[1522] INFO machine:_motion start motion
2026-08-17T09:42:15.680419+00:00 gfcloud[1522] INFO gfuiservice:run print (ready) service action print
2026-08-17T09:42:21.975587+00:00 gfcloud[1522] INFO cnc:laser_latch 0
2026-08-17T09:42:22.001354+00:00 gfcloud[1522] INFO machine:_button_wait waiting for button
2026-08-17T09:42:24.774117+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
2026-08-17T09:42:24.778509+00:00 gfcloud[1522] INFO cnc:set_step_freq 10000
2026-08-17T09:42:24.781114+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
2026-08-17T09:42:24.783654+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
2026-08-17T09:42:24.786180+00:00 gfcloud[1522] INFO cnc:set_x_current 135
2026-08-17T09:42:24.789088+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
2026-08-17T09:42:24.791559+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
2026-08-17T09:42:24.794380+00:00 gfcloud[1522] INFO cnc:set_y_current 22
2026-08-17T09:42:24.815263+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:24.816687+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:24.820823+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:42:25.049489+00:00 gfcloud[1522] INFO machine:_switch_event button released
2026-08-17T09:42:31.104201+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
2026-08-17T09:42:31.106877+00:00 gfcloud[1522] INFO machine:_run_loop button pressed mid-run; pausing
2026-08-17T09:42:31.288114+00:00 gfcloud[1522] INFO machine:_switch_event button released
2026-08-17T09:42:31.612040+00:00 gfcloud[1522] INFO machine:_run_loop paused at Position(x=AxisPosition(steps=1210, mm=22.68, inch=0.89), y=AxisPosition(steps=402, mm=7.54, inch=0.30), z=AxisPosition(steps=3, mm=0.0, inch=0.0), bytes=PulsPosition(total=238043, processed=61200))
2026-08-17T09:42:34.702311+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
2026-08-17T09:42:34.704560+00:00 gfcloud[1522] INFO machine:_run_loop button pressed while paused
2026-08-17T09:42:34.706100+00:00 gfcloud[1522] INFO machine:_resume_retraced resuming (laser lead 1950 ticks)
2026-08-17T09:42:34.901002+00:00 gfcloud[1522] INFO machine:_switch_event button released
2026-08-17T09:42:58.130807+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:58.136283+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:42:58.137979+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:58.151159+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (7573/7573), Y (684/684), Z (3/3)
2026-08-17T09:42:58.152103+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:238043, expected: 238043
2026-08-17T09:42:58.160180+00:00 gfcloud[1522] INFO machine:_return_home start return home
2026-08-17T09:42:59.047247+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:59.048617+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:59.052977+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:43:04.170521+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:43:04.176059+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:43:04.177738+00:00 gfcloud[1522] INFO machine:_return_home return home complete
2026-08-17T09:43:04.179696+00:00 gfcloud[1522] INFO machine:_motion_locked start cool down
2026-08-17T09:43:04.198973+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
2026-08-17T09:43:04.200314+00:00 gfcloud[1522] INFO cnc:set_x_current 33
2026-08-17T09:43:04.201774+00:00 gfcloud[1522] INFO cnc:set_y_current 5
2026-08-17T09:43:04.210089+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (0, 0, 3)
2026-08-17T09:43:04.210885+00:00 gfcloud[1522] INFO machine:_motion end motion
2026-08-17T09:43:04.211563+00:00 gfcloud[1522] INFO basemachine:_finish_action print [1576550507]: finished with event ":completed"
2026-08-17T09:43:04.213071+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:43:04.649180+00:00 gfcloud[1522] INFO gfuiservice:run service action request: hunt (ready)
2026-08-17T09:43:04.651286+00:00 gfcloud[1522] INFO z_axis:home starting z homing cycle
2026-08-17T09:43:04.652782+00:00 gfcloud[1522] INFO gfuiservice:run hunt (ready) service action hunt
2026-08-17T09:43:11.298209+00:00 gfcloud[1522] INFO machine:_motion start motion
2026-08-17T09:43:11.983820+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:43:13.630807+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:43:13.636283+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:43:13.664853+00:00 gfcloud[1522] INFO machine:_motion end motion
2026-08-17T09:43:13.665575+00:00 gfcloud[1522] INFO basemachine:_finish_action hunt [1576550560]: finished with event ":completed"
+276
View File
@@ -0,0 +1,276 @@
"""Shared fixtures for the forgetest unit tests: synthetic manifests and
catalog entries that never touch hardware."""
import hashlib
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(HERE))
from forgetest import catalog as catalog_mod # noqa: E402
from forgetest import manifest as manifest_mod # noqa: E402
def blob(text):
"""A git-style blob id for text (what ls-tree would report)."""
data = text.encode()
return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
def make_manifest(components=None, platform=None, version="20260101000000 (dev)", name="forgefirm-image-dev"):
comps = components if components is not None else {
"forgectrl": {"srcrev": "aaa", "files": [["src/main.c", blob("main")], ["src/ui.c", blob("ui")],
["src/auth.c", blob("auth")], ["src/cool.c", blob("cool")],
["README.md", blob("readme")]]},
"grblhal-glowforge": {"srcrev": "bbb", "files": [["src/driver.c", blob("drv")],
["src/grbl", "cccc"], ["src/grbl/core.c", blob("core")]]},
"kernel-module-glowforge": {"srcrev": "ddd", "files": [["src/cnc.c", blob("cnc")]]},
"linux-fslc": {"srcrev": "eee", "files": [["@srcrev", "eee"], ["@config", "cfg1"]]},
"forgetest": {"srcrev": None, "files": [["forgetest/x.py", blob("x")]]},
}
plat = platform if platform is not None else {
"machine": "glowforge", "kernel_modules": ["6.12.20-fslc+g0e01ec9f0d3f"],
"dtb": {"glowforge.dtb": "d" * 64},
"layers": {"meta-forgefirm": {"content_sha256": "1" * 64}, "poky": {"rev": "p" * 40}},
}
data = {"format": 1, "image": {"name": name, "version": version},
"components": comps, "platform": plat}
data["content_sha256"] = manifest_mod.sha256_text(
manifest_mod.canonical({"components": comps, "platform": plat}))
return manifest_mod.Manifest(data)
def with_file(manifest, component, path, text):
"""A copy of the manifest with one file's content changed/added."""
import copy
data = copy.deepcopy(manifest.data)
files = data["components"][component]["files"]
for f in files:
if f[0] == path:
f[1] = blob(text)
break
else:
files.append([path, blob(text)])
data["content_sha256"] = manifest_mod.sha256_text(
manifest_mod.canonical({"components": data["components"], "platform": data["platform"]}))
return manifest_mod.Manifest(data)
def with_platform(manifest, **changes):
import copy
data = copy.deepcopy(manifest.data)
data["platform"].update(changes)
data["content_sha256"] = manifest_mod.sha256_text(
manifest_mod.canonical({"components": data["components"], "platform": data["platform"]}))
return manifest_mod.Manifest(data)
def _noop(ctx):
pass
def make_test(id, covers, always=False, requires=(), kind="auto", fn=None, subsystem=None, mode=None,
actions=(), precheck=None, steps=(), hands=()):
return catalog_mod.Test(id, "Title " + id, subsystem or id.split(".")[0], kind, "api",
covers, requires, always, 1, steps, "desc", fn or _noop, mode=mode,
actions=actions, precheck=precheck, hands=hands)
def registry(*tests):
return {t.id: t for t in tests}
# ------------------------------------------------------- fake forgectrl
class FakeForgectrl:
"""A stand-in for the machine-services daemon on localhost: canned
JSON for the endpoints the suite reads, a mutable `state` the test
scripts, every POST recorded, and an optional `on_post(path, form)`
hook returning (status, body) to script the daemon's reactions.
Point the suite at it with FORGECTRL_URL (see start/stop)."""
def __init__(self):
import http.server
import json as _json
import threading as _threading
import urllib.parse as _up
self.state = {
"mode": {"mode": "grbl", "controller": "running", "pid": 100, "motion": "verified"},
"status": {"state": "idle", "homed": False, "diag": False, "laser_locked": True,
"pos": {"x": 0.0, "y": 0.0, "z": 0.0},
"switches": {"lid": True, "button": False, "interlock_ok": True,
"head": True, "hv_enable": False}},
"cool": {"phase": "idle", "armed": False, "hold": False},
"cam": {"running": False, "clients": 0},
"diag": {"running": False},
"settings": {"controller_mode": "grbl", "lid_lamp_idle": ""},
"logs_tail": {"name": "forgectrl", "text": "", "truncated": False, "exists": True},
}
self.posts = []
self.on_post = None
self.on_get = None # (path, query) -> (status, body) | (status, bytes, ctype) | None
fake = self
class H(http.server.BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, st, body):
data = _json.dumps(body).encode()
self.send_response(st)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
path, _, query = self.path.partition("?")
if fake.on_get:
r = fake.on_get(path, dict(_up.parse_qsl(query, keep_blank_values=True)))
if r is not None:
if isinstance(r[1], (bytes, bytearray)):
self.send_response(r[0])
self.send_header("Content-Type", r[2] if len(r) > 2 else "application/octet-stream")
self.send_header("Content-Length", str(len(r[1])))
self.end_headers()
self.wfile.write(r[1])
return
return self._send(r[0], r[1])
key = {"/mode": "mode", "/status": "status", "/cool/status": "cool", "/cam/status": "cam",
"/diag/status": "diag", "/settings": "settings",
"/logs/tail": "logs_tail"}.get(path)
if key is None:
return self._send(404, {"error": "no " + path})
self._send(200, fake.state[key])
def do_POST(self):
path, _, query = self.path.partition("?")
n = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(n).decode() if n else ""
form = dict(_up.parse_qsl(raw if raw else query, keep_blank_values=True))
fake.posts.append((path, form))
if fake.on_post:
r = fake.on_post(path, form)
if r is not None:
return self._send(*r)
if path == "/mode" and form.get("controller"):
fake.state["mode"] = dict(fake.state["mode"], mode=form["controller"], controller="running",
pid=fake.state["mode"].get("pid", 0) + 1)
fake.state["settings"]["controller_mode"] = form["controller"]
elif path == "/settings":
fake.state["settings"].update(form)
return self._send(200, fake.state["settings"])
self._send(200, {"ok": True})
self._srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
self._srv.daemon_threads = True
self._srv.block_on_close = False # never wait on a lingering connection
self.url = "http://127.0.0.1:%d" % self._srv.server_address[1]
self._th = _threading.Thread(target=self._srv.serve_forever, daemon=True)
def start(self):
self._th.start()
os.environ["FORGECTRL_URL"] = self.url
os.environ["FORGECTRL_TOKEN_FILE"] = os.devnull
return self
def stop(self):
self._srv.shutdown()
self._srv.server_close()
os.environ.pop("FORGECTRL_URL", None)
os.environ.pop("FORGECTRL_TOKEN_FILE", None)
# ------------------------------------------------------------ fake grbl
class FakeGrbl:
"""A Grbl-over-TCP stand-in for the controller: answers '?' with a
status report built from its mutable `state`/`mpos`, 'ok' to every
command line, and records what it was sent. Point the suite at it
with GRBL_HOST/GRBL_PORT (see start/stop)."""
def __init__(self):
import socket as _socket
import threading as _threading
self.state = "Idle"
self.mpos = [0.0, 0.0, 0.0]
self.sent = []
self.extra = b"" # text pushed to the client on the next poll
self.reset_to = None # the state a soft reset (^X) lands in, when set
self.on_command = None # called with every command line received
self._sock = _socket.socket()
self._sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
self._sock.bind(("127.0.0.1", 0))
self._sock.listen(5)
self.port = self._sock.getsockname()[1]
self._stop = False
self._th = _threading.Thread(target=self._serve, daemon=True)
def _serve(self):
import socket as _socket
import threading as _threading
self._sock.settimeout(0.2)
while not self._stop:
try:
c, _ = self._sock.accept()
except (_socket.timeout, OSError):
continue
_threading.Thread(target=self._client, args=(c,), daemon=True).start()
def _client(self, c):
import socket as _socket
c.settimeout(0.1)
buf = b""
try:
c.sendall(b"\r\nGrblHAL 1.1f ['$' for help]\r\n")
while not self._stop:
try:
d = c.recv(4096)
if not d:
break
buf += d
except _socket.timeout:
continue
except OSError:
break
out = b""
if self.extra:
out += self.extra
self.extra = b""
while buf:
if buf[0:1] == b"?":
buf = buf[1:]
out += ("<%s|MPos:%.3f,%.3f,%.3f|FS:0,0>\r\n" % (self.state, *self.mpos)).encode()
elif buf[0] in (0x18, 0x85, 0x7E, 0x21):
if buf[0] == 0x18:
self.sent.append("^X")
if self.reset_to:
self.state = self.reset_to
buf = buf[1:]
elif b"\n" in buf:
line, buf = buf.split(b"\n", 1)
self.sent.append(line.strip().decode("utf-8", "replace"))
if self.on_command:
self.on_command(self.sent[-1])
out += b"ok\r\n"
else:
break
if out:
c.sendall(out)
finally:
c.close()
def start(self):
self._th.start()
os.environ["GRBL_HOST"] = "127.0.0.1"
os.environ["GRBL_PORT"] = str(self.port)
return self
def stop(self):
self._stop = True
try:
self._sock.close()
except OSError:
pass
for k in ("GRBL_HOST", "GRBL_PORT"):
os.environ.pop(k, None)
+143
View File
@@ -0,0 +1,143 @@
import copy
import json
import unittest
import helpers
from forgetest import artifact, campaign, catalog
from forgetest import manifest as manifest_mod
from test_campaign import rec_campaign, rec_result
class ArtifactTests(unittest.TestCase):
def setUp(self):
self.man = helpers.make_manifest()
self.core = helpers.make_test("image.health", [("linux-fslc", "**")], always=True)
self.ui = helpers.make_test("forgectrl.panel", [("forgectrl", "src/ui.c")])
self.cool = helpers.make_test("cooling.flow", [("forgectrl", "src/cool.c")])
self.reg = helpers.registry(self.core, self.ui, self.cool)
self.tests = list(self.reg.values())
self.chash = catalog.catalog_hash(self.reg)
# campaign 1 on the base image: everything passes
self.recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
self.recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
# image 2: cool.c changed; campaign 2 reruns core + cool
self.man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
self.recs.append(rec_campaign("c2", self.man2, self.chash, "2026-08-21T10:00:00Z"))
self.recs.append(rec_result("c2", self.core, self.man2, "PASS", "2026-08-21T10:01:00Z"))
self.recs.append(rec_result("c2", self.cool, self.man2, "PASS", "2026-08-21T10:02:00Z"))
def build(self, man=None):
man = man or self.man2
st = campaign.compute(self.recs, self.tests, man, self.chash)
return artifact.build(st, self.tests, man, self.recs, self.chash), st
def release_manifest(self, man=None):
"""The release build's manifest: same identity, no forgetest component."""
man = man or self.man2
data = copy.deepcopy(man.data)
data["components"].pop("forgetest", None)
data["image"] = {"name": "forgefirm-image", "version": "v0.1.0"}
return manifest_mod.Manifest(data)
def test_build_and_verify(self):
art, st = self.build()
self.assertTrue(art["authorized"])
by = {t["id"]: t for t in art["tests"]}
self.assertTrue(by["forgectrl.panel"]["inherited"])
self.assertEqual(by["forgectrl.panel"]["record"]["campaign"], "c1")
self.assertFalse(by["image.health"]["inherited"])
text = artifact.to_json(art)
art2 = json.loads(text)
ok, rows, problems = artifact.verify(art2, self.release_manifest(), self.tests, self.chash,
expect_machine="glowforge")
self.assertEqual(problems, [])
self.assertTrue(ok)
md = artifact.to_markdown(art)
self.assertIn("Release authorized: YES", md)
self.assertIn("forgectrl.panel", md)
def test_tamper_detected(self):
art, _ = self.build()
art2 = json.loads(artifact.to_json(art))
art2["tests"][1]["record"]["result"] = "PASS"
art2["counts"]["required"] = 0
art2["authorized"] = True
art2["tests"][2]["record"]["ts"] = "2026-08-21T10:02:01Z"
ok, rows, problems = artifact.verify(art2, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertIn("self-hash", problems[0])
def test_release_differs_in_covered_file(self):
art, _ = self.build()
rel = self.release_manifest(helpers.with_file(self.man2, "forgectrl", "src/ui.c", "ui v3"))
ok, rows, problems = artifact.verify(art, rel, self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("forgectrl.panel" in p and "fingerprint" in p for p in problems), problems)
# an uncovered change is fine
rel2 = self.release_manifest(helpers.with_file(self.man2, "forgectrl", "README.md", "docs"))
ok, rows, problems = artifact.verify(art, rel2, self.tests, self.chash)
self.assertTrue(ok, problems)
def test_release_platform_differs(self):
art, _ = self.build()
rel = self.release_manifest(helpers.with_platform(self.man2, dtb={"glowforge.dtb": "0" * 64}))
ok, rows, problems = artifact.verify(art, rel, self.tests, self.chash)
self.assertFalse(ok)
self.assertEqual(len([r for r in rows if not r["ok"]]), 3)
def test_unauthorized_artifact(self):
self.recs.append(rec_result("c2", self.ui, self.man2, "FAIL", "2026-08-21T10:03:00Z"))
art, st = self.build()
self.assertFalse(art["authorized"])
ok, rows, problems = artifact.verify(art, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("does not claim authorization" in p for p in problems))
def test_core_inherited_refused(self):
art, _ = self.build()
# forge an artifact whose core record is marked inherited (self-hash recomputed)
body = json.loads(artifact.to_json(art))
body.pop("sha256")
for t in body["tests"]:
if t["id"] == "image.health":
t["inherited"] = True
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("always-required" in p for p in problems), problems)
def test_invalidate_epoch(self):
art, _ = self.build()
body = json.loads(artifact.to_json(art))
body.pop("sha256")
body["invalidate"] = {"t": "invalidate", "ts": "2026-08-20T12:00:00Z", "reason": "tube"}
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("predates the invalidate" in p for p in problems), problems)
def test_catalog_changed(self):
art, _ = self.build()
reg = helpers.registry(self.core, self.ui, self.cool, helpers.make_test("new.one", []))
tests = list(reg.values())
ok, rows, problems = artifact.verify(art, self.release_manifest(), tests, catalog.catalog_hash(reg))
self.assertFalse(ok)
self.assertTrue(any("catalog changed" in p for p in problems))
self.assertTrue(any("new.one" in p for p in problems))
def test_implementation_changed(self):
art, _ = self.build()
body = json.loads(artifact.to_json(art))
body.pop("sha256")
for t in body["tests"]:
if t["id"] == "cooling.flow":
t["source_sha"] = "0" * 64
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("implementation changed" in p for p in problems), problems)
if __name__ == "__main__":
unittest.main()
+611
View File
@@ -0,0 +1,611 @@
"""The baseline: fixed resting values are restored, preserved values are
handed back, every deviation is a recorded leftover. Runs against a fake
sysfs tree; forgectrl is unreachable (service-side checks skip)."""
import json
import os
import shutil
import struct
import tempfile
import unittest
from forgetest import baseline
class BaselineTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-bl-")
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
self.leds = os.path.join(self.tmp, "leds") + os.sep
for group in ("cnc", "pic", "head", "thermal"):
os.makedirs(self.sysfs + group)
for name in baseline.BUTTON_LEDS + ("lid_led",):
os.makedirs(self.leds + name)
self._led(name, "0")
# a clean machine
for attr, val in baseline.FIXED_SYSFS + baseline.IDLE_READBACKS:
self._attr(attr, val)
self._attr("cnc/interlock_circuit", "45")
self._attr("pic/lid_led", "0")
self._pos(0, 0, 0)
os.environ["GF_SYSFS_ROOT"] = self.sysfs
os.environ["GF_LEDS_ROOT"] = self.leds
os.environ["FORGECTRL_URL"] = "http://127.0.0.1:1" # nothing listens
baseline.Baseline._unreachable_until = 0.0
self.lines = []
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
for k in ("GF_SYSFS_ROOT", "GF_LEDS_ROOT", "FORGECTRL_URL"):
os.environ.pop(k, None)
def _attr(self, attr, val):
with open(self.sysfs + attr, "w") as f:
f.write(str(val))
def _read(self, attr):
with open(self.sysfs + attr) as f:
return f.read().strip()
def _led(self, name, val):
with open(self.leds + name + "/brightness", "w") as f:
f.write(val)
# the class interface writes 'target'; the fake mirrors it into brightness
# only when the test asks (see _sync_leds)
def _led2(self, name, target, brightness):
"""Both attributes of one button LED: the trigger's commanded
level and the fade that follows it."""
for a, v in (("target", target), ("brightness", brightness)):
with open(self.leds + name + "/" + a, "w") as f:
f.write(v)
def _sync_leds(self):
for name in baseline.BUTTON_LEDS:
p = self.leds + name + "/target"
if os.path.exists(p):
with open(p) as f:
v = f.read().strip()
with open(self.leds + name + "/brightness", "w") as f:
f.write(v)
def _pos(self, x, y, z):
with open(self.sysfs + "cnc/position", "wb") as f:
f.write(struct.pack("<5i", x, y, z, 0, 0))
def bl(self):
return baseline.Baseline(self.lines.append)
def test_clean_machine_has_no_leftovers(self):
left = self.bl().enforce("pre", captured=None)
self.assertEqual(left, [])
self.assertTrue(any("pre: clean" in l for l in self.lines))
def test_fixed_values_are_restored_and_recorded(self):
self._attr("cnc/motor_lock", "15")
self._attr("cnc/step_freq", "10000")
self._attr("cnc/streaming", "1")
left = self.bl().enforce("post", captured=None)
items = {x.item: x for x in left}
self.assertEqual(set(items), {"cnc/motor_lock", "cnc/step_freq", "cnc/streaming"})
for x in left:
self.assertEqual(x.action, "restored", str(x))
self.assertEqual(self._read("cnc/motor_lock"), "0")
self.assertEqual(self._read("cnc/step_freq"), "28160")
self.assertEqual(self._read("cnc/streaming"), "0")
self.assertEqual(items["cnc/motor_lock"].found, "15")
self.assertEqual(items["cnc/motor_lock"].expected, "0")
def test_unlocked_latch_is_relocked(self):
self._attr("cnc/interlock_circuit", "5") # bit 3 clear = unlocked
left = self.bl().enforce("post", captured=None)
self.assertEqual([x.item for x in left], ["laser_latch"])
self.assertEqual(self._read("cnc/laser_latch"), "1")
def test_readonly_deviation_is_unrestorable(self):
self._attr("cnc/state", "disabled")
left = self.bl().enforce("post", captured=None)
self.assertEqual([(x.item, x.action) for x in left], [("cnc/state", "unrestorable")])
def test_button_leds_are_turned_off(self):
self._led("button_led_2", "255")
left = self.bl().enforce("post", captured=None)
self.assertEqual([x.item for x in left], ["leds/button_led_2"])
self._sync_leds()
self.assertEqual(baseline.read_led("button_led_2"), "0")
def test_preserved_position(self):
b = self.bl()
cap = b.capture()
self.assertEqual(cap["position"], [0, 0, 0])
# the run shifted the counters
self._pos(1000, 0, 0)
left = b.enforce("post", captured=cap)
items = {x.item: x for x in left}
self.assertEqual(set(items), {"position"})
# no GRBL controller on the host: the head cannot be jogged back
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
self.assertEqual(items["position"].found, [1000, 0, 0])
def test_a_held_controller_is_reset_before_the_return_jog(self):
# a pause test that failed while held leaves the controller in
# Hold, which refuses a jog: the baseline resets out of it first
import helpers
fc = helpers.FakeForgectrl().start()
dev = helpers.FakeGrbl().start()
try:
dev.state = "Hold:0"
dev.reset_to = "Idle"
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
b = self.bl()
cap = b.capture()
self._pos(221, 0, 0) # 4.144 mm into the held move
left = b.enforce("post", captured=cap)
items = {x.item: x for x in left}
self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
self.assertEqual(dev.sent[0], "^X")
jogs = [l for l in dev.sent if l.startswith("$J=")]
self.assertEqual(len(jogs), 1)
self.assertIn("X-4.144", jogs[0])
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
finally:
dev.stop()
fc.stop()
for k in ("GRBL_HOST", "GRBL_PORT"):
os.environ.pop(k, None)
def _pos_bytes(self, x, y, z, processed, total):
with open(self.sysfs + "cnc/position", "wb") as f:
f.write(struct.pack("<3i2I", x, y, z, processed, total))
def test_ring_residue_is_a_leftover_and_blocks_the_return_jog(self):
b = self.bl()
cap = b.capture()
# the run left 40 unplayed bytes queued in the kernel ring and the
# head 1000 counts out: the hand-back stands the machine down to
# empty the ring, and only refuses the return jog when the bytes
# are still there afterwards (they are here: no daemon to restart
# a controller)
self._pos_bytes(1000, 0, 0, 100, 140)
left = b.enforce("post", captured=cap)
items = {x.item: x for x in left}
self.assertIn("pulse ring", items)
self.assertEqual(items["pulse ring"].found, "40 unplayed bytes")
self.assertIn("still queued", items["position"].action)
self.assertIn("after a controller restart", items["position"].action)
self.assertEqual(baseline.read_ring_residue(), 40)
def test_the_hand_back_stands_the_machine_down_for_residue(self):
"""It acts, it does not report: a run that left bytes in the ring
gets a controller restart out of the hand-back, because that is
what empties the ring."""
b = self.bl()
cap = b.capture()
called = []
b.stand_down = lambda why: called.append(why)
self._pos_bytes(1000, 0, 0, 100, 140)
b.enforce("post", captured=cap)
self.assertEqual(len(called), 1)
self.assertIn("unplayed bytes", called[0])
def test_clean_ring_reads_zero_residue(self):
self.assertEqual(baseline.read_ring_residue(), 0)
def test_lamp_needs_forgectrl(self):
# the lamp's idle level comes from forgectrl's settings: without the
# daemon there is nothing to compare against
self._attr("pic/lid_led", "77")
left = self.bl().enforce("pre", captured=None)
self.assertEqual(left, [])
self.assertEqual(self._read("pic/lid_led"), "77")
def test_no_sysfs_means_skip(self):
os.environ["GF_SYSFS_ROOT"] = os.path.join(self.tmp, "nope") + os.sep
left = self.bl().enforce("pre", captured=None)
self.assertEqual(left, [])
self.assertTrue(any("kernel sysfs not present" in l for l in self.lines))
def test_boot_reference_needs_a_recent_boot(self):
os.environ["FORGETEST_BOOT_ID"] = "test-boot"
try:
# no reference file, uptime unknown on a host without /proc/uptime,
# or too old: None, with the reason logged
ref = baseline.boot_reference(self.lines.append, self.tmp)
up = baseline.uptime_s()
if up is None or up > baseline.BOOT_MAX_AGE_S:
self.assertIsNone(ref)
self.assertTrue(any("no fresh-boot reference" in l for l in self.lines))
else:
# a young host: the reference is taken from the fake tree
self.assertIsNotNone(ref)
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
self.assertTrue(os.path.exists(os.path.join(self.tmp, "boot-test-boot.json")))
# and loaded back the second time
self.lines[:] = []
ref2 = baseline.boot_reference(self.lines.append, self.tmp)
self.assertEqual(ref2["ts"], ref["ts"])
self.assertTrue(any("reference loaded" in l for l in self.lines))
finally:
os.environ.pop("FORGETEST_BOOT_ID", None)
def test_fixed_constants_checked_against_a_dump(self):
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
diffs = baseline.check_fixed_against(ref, self.lines.append)
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=28160"])
# -- the reference is taken after the controller applied its config -----
def _probe_state(self):
# what the kernel shows between the supervisor's motion probe and
# the GRBL controller's init writes
self._attr("cnc/motor_lock", "0")
self._attr("cnc/step_freq", "10000")
self._attr("cnc/y_mode", "1")
def test_wait_configured_returns_once_the_controller_wrote_its_config(self):
self._probe_state()
calls = {"n": 0}
def sleep(_s):
calls["n"] += 1
if calls["n"] == 3: # the controller's init writes land
for attr, val in baseline.CONFIGURED_MARKERS:
self._attr(attr, val)
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
timeout=5, sleep=sleep)
self.assertTrue(ok)
self.assertTrue(any("controller configured" in l for l in self.lines))
self.assertGreaterEqual(calls["n"], 4) # 3 polls + the settle
def test_wait_configured_times_out_and_says_so(self):
self._probe_state()
t = {"now": 0.0}
real_time = baseline.time.time
baseline.time.time = lambda: t["now"]
try:
def sleep(s):
t["now"] += s
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "grbl"}, timeout=2, sleep=sleep)
finally:
baseline.time.time = real_time
self.assertFalse(ok)
self.assertTrue(any("did not apply its config" in l for l in self.lines))
def test_wait_configured_is_a_noop_outside_grbl_mode(self):
self._probe_state()
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "cloud"}, timeout=1,
sleep=lambda s: self.fail("slept in cloud mode"))
self.assertTrue(ok)
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "stopped", "mode": "grbl"}, timeout=1,
sleep=lambda s: self.fail("slept with the controller stopped"))
self.assertTrue(ok)
def test_preconfig_reference_is_recognized(self):
self.assertTrue(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"}}))
# a genuinely different single constant is a machine fact, not pre-config
self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"}}))
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
# -- the XY microstep mode drives the fixed values ------------------------
def test_the_fixed_values_follow_the_microstep_mode(self):
x8 = dict(baseline.fixed_sysfs(8))
self.assertEqual(x8, dict(baseline.FIXED_SYSFS))
x16 = dict(baseline.fixed_sysfs(16))
self.assertEqual((x16["cnc/x_mode"], x16["cnc/y_mode"], x16["cnc/step_freq"], x16["cnc/ramp_rate"]),
("16", "16", "56320", "250000"))
x32 = dict(baseline.fixed_sysfs(32))
self.assertEqual((x32["cnc/x_mode"], x32["cnc/step_freq"], x32["cnc/ramp_rate"]),
("32", "112640", "500000"))
# everything else is the same list, in the same order
self.assertEqual([a for a, _ in baseline.fixed_sysfs(32)], [a for a, _ in baseline.FIXED_SYSFS])
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
def test_the_mode_is_read_from_the_settings_with_a_default(self):
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 8)
self.assertEqual(baseline.xy_mode_of({}), 8)
self.assertEqual(baseline.xy_mode_of(None), 8)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 8)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 8)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 8)
self.assertEqual(baseline.ref_xy_mode({}), 8)
def test_fixed_constants_are_checked_at_the_reference_mode(self):
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}
self.assertEqual(baseline.check_fixed_against(ref, self.lines.append), [])
ref["forgectrl"]["/settings"]["xy_microsteps"] = "8"
diffs = baseline.check_fixed_against(ref, self.lines.append)
self.assertEqual(diffs, ["cnc/x_mode: boot=16 constant=8",
"cnc/step_freq: boot=56320 constant=28160",
"cnc/ramp_rate: boot=250000 constant=125000"])
def test_wait_configured_watches_the_mode_in_force(self):
self._probe_state()
calls = {"n": 0}
def sleep(_s):
calls["n"] += 1
if calls["n"] == 2: # an x16 controller's init writes land
self._attr("cnc/step_freq", "56320")
self._attr("cnc/y_mode", "16")
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
timeout=5, sleep=sleep, xy_mode=16)
self.assertTrue(ok)
self.assertTrue(any("controller configured" in l for l in self.lines))
# a reference taken under x16 is not pre-config
self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/step_freq": "56320", "cnc/y_mode": "16"},
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}))
self.assertTrue(baseline.reference_preconfig(
{"sysfs": {"cnc/step_freq": "10000", "cnc/y_mode": "1"},
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}))
def test_stale_preconfig_reference_is_retaken_on_a_fresh_boot(self):
os.environ["FORGETEST_BOOT_ID"] = "test-boot-2"
try:
path = os.path.join(self.tmp, "boot-test-boot-2.json")
with open(path, "w") as f:
json.dump({"ts": "old", "sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000",
"cnc/y_mode": "1"}}, f)
ref = baseline.boot_reference(self.lines.append, self.tmp)
up = baseline.uptime_s()
if up is None or up > baseline.BOOT_MAX_AGE_S:
# too old to retake: the stale reference stands, marked
self.assertTrue(any("predates the controller's config" in l for l in self.lines))
self.assertEqual(ref["ts"], "old")
else:
self.assertTrue(any("retaking" in l for l in self.lines))
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
finally:
os.environ.pop("FORGETEST_BOOT_ID", None)
if __name__ == "__main__":
unittest.main()
class TransientNotLeftoverTests(BaselineTests):
"""A leftover is what a run left behind, not the machine part-way
through its own work. Found on the bench reference, where
laser.arm-wait-lid passed every check it makes and failed its
hand-back on the post-job smoke clear."""
def test_a_fading_button_led_is_not_a_leftover(self):
# the trigger fades brightness toward target: target 0 with
# brightness still high is the fade from a level the machine ended
for name in baseline.BUTTON_LEDS:
self._led2(name, target="0", brightness="900")
left = self.bl().enforce("post", captured=None)
self.assertEqual([x for x in left if x.item.startswith("leds/")], [])
def test_a_lit_button_led_is_a_leftover(self):
# a target the run left standing is dirt, whatever the fade reads
self._led2(baseline.BUTTON_LEDS[0], target="1014", brightness="0")
left = self.bl().enforce("post", captured=None)
items = [x for x in left if x.item.startswith("leds/")]
self.assertEqual(len(items), 1)
self.assertEqual(items[0].found, "1014")
class CloudOwnedSysfsTests(unittest.TestCase):
"""In cloud mode the cloud client configures the machine from the
pulse header it is playing, the lens included: z_mode comes from
ZSmd through gfhardware's set_mode_from_puls. Handing the GRBL values
back under it would be the baseline configuring another controller's
machine. In GRBL mode the pair is checked as before."""
def test_the_z_pair_is_the_cloud_clients(self):
for attr in ("head/z_current", "head/z_mode"):
self.assertIn(attr, baseline.GRBL_CONTROLLER_SYSFS)
def test_the_z_pair_is_still_checked_in_grbl_mode(self):
fixed = dict(baseline.fixed_sysfs())
self.assertEqual(fixed.get("head/z_current"), "1")
self.assertEqual(fixed.get("head/z_mode"), "1")
class PositionDeadbandTests(unittest.TestCase):
"""The counters count steps and a controller's own return lands within
a few hundredths of a millimeter, not on the step: a difference that
small is quantization, not a leftover. Found on the bench reference,
where motion.lid-cancel-home returned the head twice, each within
0.04 mm, and failed the hand-back on the four steps left over."""
def test_a_few_steps_of_xy_are_the_quantization(self):
self.assertTrue(baseline.position_quantized([0, 0, 0], [4, 0, 0]))
self.assertTrue(baseline.position_quantized([0, 0, 0], [-4, 4, 0]))
self.assertTrue(baseline.position_quantized([10, 20, 30], [10, 20, 30]))
def test_past_the_dead_band_is_a_leftover(self):
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
def test_z_is_exact_because_the_return_never_moves_it(self):
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
def test_an_unreadable_reading_is_never_quantization(self):
self.assertFalse(baseline.position_quantized(None, [0, 0, 0]))
self.assertFalse(baseline.position_quantized([0, 0, 0], None))
class BaselineModeTests(BaselineTests):
"""The baseline against a fake forgectrl: what the mode in force
owns. Reuses the fake sysfs tree of BaselineTests; only the new
tests run here (the inherited ones are skipped)."""
def setUp(self):
super().setUp()
import helpers
self.fc = helpers.FakeForgectrl().start()
baseline.Baseline._unreachable_until = 0.0
def tearDown(self):
self.fc.stop()
super().tearDown()
def run(self, result=None):
# only this class's own tests, not the base class's
if self._testMethodName not in BaselineModeTests.__dict__:
return
return super().run(result)
def cloud(self):
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
self.fc.state["settings"]["controller_mode"] = "cloud"
def nohunt_marker(self, refuse=False):
"""The no-hunt marker under the fake tree; what each POST found.
The fake client takes the marker down as gfcloud does, first thing
at its start; with refuse the POST is refused and no client starts."""
baseline.NOHUNT_MARKER = os.path.join(self.tmp, "nohunt-marker")
self.fc.state["settings"].setdefault("cloud_enabled", "1")
seen = []
def on_post(path, form):
seen.append((path, dict(form), os.path.exists(baseline.NOHUNT_MARKER)))
if refuse:
return 409, {"error": "busy"}
if os.path.exists(baseline.NOHUNT_MARKER):
os.remove(baseline.NOHUNT_MARKER)
if path == "/mode":
self.fc.state["mode"] = dict(self.fc.state["mode"], mode=form["controller"], controller="running")
elif path == "/controller/start":
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running")
return None
self.fc.on_post = on_post
return seen
def test_a_refused_switch_leaves_no_marker_for_the_next_start(self):
seen = self.nohunt_marker(refuse=True)
ok, detail = self.bl().switch_mode("cloud")
self.assertFalse(ok)
self.assertEqual(seen, [("/mode", {"controller": "cloud"}, True)])
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
def test_a_switch_to_cloud_starts_the_client_without_the_hunt(self):
seen = self.nohunt_marker()
ok, detail = self.bl().switch_mode("cloud")
self.assertTrue(ok, detail)
self.assertEqual(seen, [("/mode", {"controller": "cloud"}, True)])
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER)) # one start, never left behind
def test_a_switch_to_cloud_turns_cloud_mode_on_when_it_is_off(self):
# A test that declares cloud mode gets it: cloud_enabled goes to 1
# first, with the typed phrase the daemon asks for and a log line,
# and only then the mode switch.
lines = []
seen = self.nohunt_marker()
self.fc.state["settings"]["cloud_enabled"] = "0"
ok, detail = baseline.Baseline(lines.append).switch_mode("cloud")
self.assertTrue(ok, detail)
self.assertEqual(seen[0], ("/settings", {"cloud_enabled": "1", "phrase": "I UNDERSTAND"}, False))
self.assertEqual(seen[1], ("/mode", {"controller": "cloud"}, True))
self.assertTrue(any("cloud mode turned on for the test" in ln for ln in lines))
def test_a_switch_to_grbl_sets_no_marker(self):
seen = self.nohunt_marker()
self.cloud()
ok, detail = self.bl().switch_mode("grbl")
self.assertFalse(ok) # the fake has no Grbl port: the switch itself happened
self.assertEqual(seen, [("/mode", {"controller": "grbl"}, False)])
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
def test_the_mode_handed_back_is_cloud_without_the_hunt(self):
seen = self.nohunt_marker()
self.cloud()
b = self.bl()
cap = b.capture() # found in cloud
self.fc.state["mode"] = dict(self.fc.state["mode"], mode="grbl") # the run left it in grbl
b.enforce("post", captured=cap)
self.assertEqual(seen[0], ("/mode", {"controller": "cloud"}, True))
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
def test_a_standby_cloud_client_is_started_without_the_hunt(self):
seen = self.nohunt_marker()
self.cloud()
b = self.bl()
cap = b.capture()
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby")
b.enforce("post", captured=cap)
self.assertEqual(seen[0], ("/controller/start", {}, True))
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
def test_grbl_mode_restores_the_controller_values_and_the_lamp(self):
self._attr("cnc/step_freq", "10000")
self._attr("pic/lid_led", "77")
left = self.bl().enforce("pre", captured=None)
self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"])
self.assertEqual(self._read("cnc/step_freq"), "28160")
self.assertEqual(self._read("pic/lid_led"), "236")
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
self.cloud()
self._attr("cnc/step_freq", "10000") # the cloud client's tick
self._attr("pic/x_step_current", "135")
self._attr("pic/lid_led", "77") # its lid-image level
b = self.bl()
cap = b.capture()
self.assertEqual(cap["mode"], "cloud")
self.assertNotIn("controller_mode", cap["settings"])
self._pos(-13096, -7400, 0) # the service re-zeroed and moved
self._attr("cnc/streaming", "1") # NOT the client's: still restored
left = b.enforce("post", captured=cap)
self.assertEqual([x.item for x in left], ["cnc/streaming"])
self.assertEqual(self._read("cnc/step_freq"), "10000")
self.assertEqual(self._read("pic/x_step_current"), "135")
self.assertEqual(self._read("pic/lid_led"), "77")
self.assertEqual(self._read("cnc/streaming"), "0")
self.assertEqual(self.fc.posts, []) # no mode switch, no settings write
def test_cloud_mode_still_relocks_the_latch(self):
self.cloud()
self._attr("cnc/interlock_circuit", "37") # bit 3 clear: unlocked
left = self.bl().enforce("post", captured=None)
self.assertEqual([x.item for x in left], ["laser_latch"])
self.assertEqual(self._read("cnc/laser_latch"), "1")
def test_undeclared_mode_change_is_handed_back_through_the_switch(self):
b = self.bl()
cap = b.capture() # found in grbl
self.assertEqual(cap["mode"], "grbl")
self.cloud() # the run left it in cloud, silently
left = b.enforce("post", captured=cap)
items = {x.item: x for x in left}
self.assertIn("mode", items)
self.assertEqual(items["mode"].action, "restored")
self.assertEqual(self.fc.posts, [("/mode", {"controller": "grbl"})])
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
def test_declared_mode_change_is_kept(self):
b = self.bl()
cap = b.capture()
self.cloud()
cap["mode"] = "cloud" # Context.mode_changed("cloud")
left = b.enforce("post", captured=cap)
self.assertNotIn("mode", [x.item for x in left])
self.assertEqual(self.fc.posts, [])
self.assertEqual(self.fc.state["mode"]["mode"], "cloud")
def test_controller_mode_setting_is_never_written_back_bare(self):
b = self.bl()
cap = b.capture()
self.assertNotIn("controller_mode", cap["settings"])
self.fc.state["settings"]["controller_mode"] = "cloud" # a switch persisted it
self.fc.state["mode"]["mode"] = "cloud"
cap["mode"] = "cloud"
b.enforce("post", captured=cap)
self.assertEqual([p for p, _ in self.fc.posts if p == "/settings"], [])
+128
View File
@@ -0,0 +1,128 @@
"""The bench registry against scripts/bench: every python tool is
registered (or named as not-a-tool), every registered script exists,
every argument spec builds a command line, every script compiles, and
the shared helper resolves host and local mode as documented."""
import glob
import os
import py_compile
import subprocess
import sys
import unittest
import helpers # noqa: F401 (sys.path)
from forgetest import bench as bench_mod
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
BENCH = os.path.join(REPO, "scripts", "bench")
class RegistryTests(unittest.TestCase):
def setUp(self):
self.bench = bench_mod.Bench(tool_dir=BENCH, index_path=os.devnull)
self.scripts = sorted(os.path.basename(p) for p in glob.glob(os.path.join(BENCH, "*.py")))
def test_every_python_tool_is_registered(self):
registered = {t["script"] for t in bench_mod.TOOLS}
missing = [s for s in self.scripts if s not in registered and s not in bench_mod.NOT_TOOLS]
self.assertEqual(missing, [], "scripts/bench python files missing from the registry")
def test_every_registered_script_exists(self):
for t in bench_mod.TOOLS:
self.assertTrue(os.path.exists(os.path.join(BENCH, t["script"])), t["script"])
def test_ids_unique_and_fields_valid(self):
ids = [t["id"] for t in bench_mod.TOOLS]
self.assertEqual(len(ids), len(set(ids)))
for t in bench_mod.TOOLS:
self.assertIn(t["safety"], ("dry", "takeover", "scope", "live"), t["id"])
self.assertIn(t["where"], ("board", "host"), t["id"])
self.assertIsInstance(t["ported"], bool, t["id"])
self.assertTrue(t["desc"], t["id"])
for a in t.get("args", []):
self.assertIn(a["type"], ("str", "int", "float", "choice"), (t["id"], a["name"]))
if a["type"] == "choice":
self.assertIn(a["default"], a["choices"], (t["id"], a["name"]))
def test_ported_tools_build_a_command_with_defaults(self):
for t in bench_mod.TOOLS:
if not t["ported"]:
continue
ok, argv, err = self.bench.command(t, {})
self.assertTrue(ok, "%s: %s" % (t["id"], err))
self.assertEqual(argv[1], os.path.join(BENCH, t["script"]))
def test_flagged_args_build_option_pairs(self):
tool = next(t for t in bench_mod.TOOLS if t["id"] == "resume-dark-lead")
ok, argv, err = self.bench.command(tool, {"run": "live", "feed": 900})
self.assertTrue(ok, err)
self.assertIn("--run", argv)
self.assertEqual(argv[argv.index("--run") + 1], "live")
self.assertEqual(argv[argv.index("--feed") + 1], "900.0")
# an empty optional with an empty default is left off entirely
self.assertNotIn("--auto", argv)
# a choice outside the list is refused before anything runs
ok, _argv, err = self.bench.command(tool, {"run": "sideways"})
self.assertFalse(ok)
self.assertIn("run must be one of", err)
def test_unported_tools_are_host_only(self):
# what stays unported is what cannot run on the machine at all
for t in bench_mod.TOOLS:
if not t["ported"]:
self.assertEqual(t["where"], "host", t["id"])
self.assertIn("not a bench-page tool", t["desc"], t["id"])
def test_scripts_compile(self):
for s in self.scripts:
py_compile.compile(os.path.join(BENCH, s), doraise=True)
class GfbenchTests(unittest.TestCase):
"""gfbench.py in a subprocess (module-level host resolution)."""
def run_snippet(self, code, env_extra):
env = dict(os.environ)
env.pop("GF_HOST", None)
env.pop("FORGETEST_BENCH_DATA", None)
env.update(env_extra)
r = subprocess.run([sys.executable, "-c", code], cwd=BENCH, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
return r.returncode, r.stdout.strip()
def test_host_mode(self):
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL, gfbench.SSH[0])",
{"GF_HOST": "192.0.2.7", "GF_SSH": "fakessh -x"})
self.assertEqual(rc, 0, out)
self.assertEqual(out, "192.0.2.7 False fakessh")
def test_local_when_named_local(self):
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL); "
"print(gfbench.board('echo hi').strip())",
{"GF_HOST": "127.0.0.1"})
self.assertEqual(rc, 0, out)
self.assertEqual(out.splitlines(), ["127.0.0.1 True", "hi"])
def test_refuses_without_a_machine(self):
if os.path.isdir("/sys/glowforge"):
self.skipTest("running on the machine")
rc, out = self.run_snippet("import gfbench", {})
self.assertNotEqual(rc, 0)
self.assertIn("GF_HOST", out)
def test_degc_and_data_dir(self):
import tempfile
d = tempfile.mkdtemp(prefix="forgetest-bench-")
rc, out = self.run_snippet(
"import gfbench, os; print(round(gfbench.degc(700), 2), gfbench.degc('x') != gfbench.degc('x'), "
"os.path.dirname(gfbench.data_path('f.json')) == os.environ['FORGETEST_BENCH_DATA'])",
{"GF_HOST": "127.0.0.1", "FORGETEST_BENCH_DATA": d})
self.assertEqual(rc, 0, out)
val, nan, indir = out.split()
# a plausible coolant reading (700 counts is room temperature territory)
self.assertTrue(15.0 < float(val) < 35.0, val)
self.assertEqual(nan, "True")
self.assertEqual(indir, "True")
if __name__ == "__main__":
unittest.main()
+201
View File
@@ -0,0 +1,201 @@
import unittest
import helpers
from forgetest import campaign
from forgetest import catalog
def rec_campaign(cid, man, chash, ts):
return {"t": "campaign", "ts": ts, "id": cid, "manifest_sha": man.content_sha,
"catalog_hash": chash, "image": man.version}
def rec_result(cid, t, man, result, ts, fp=None):
return {"t": "result", "ts": ts, "campaign": cid, "test": t.id, "result": result,
"fingerprint": fp or t.fingerprint(man), "manifest_sha": man.content_sha,
"image": man.version, "duration_s": 1, "message": ""}
class CampaignTests(unittest.TestCase):
def setUp(self):
self.man = helpers.make_manifest()
self.core = helpers.make_test("image.health", [("linux-fslc", "**")], always=True)
self.ui = helpers.make_test("forgectrl.panel", [("forgectrl", "src/ui.c")])
self.cool = helpers.make_test("cooling.flow", [("forgectrl", "src/cool.c")])
self.live = helpers.make_test("laser.witness", [("grblhal-glowforge", "src/**")],
requires=("forgectrl.panel",), kind="live")
self.reg = helpers.registry(self.core, self.ui, self.cool, self.live)
self.tests = list(self.reg.values())
self.chash = catalog.catalog_hash(self.reg)
def compute(self, records, man=None, chash=None):
return campaign.compute(records, self.tests, man or self.man, chash or self.chash)
def test_empty(self):
st = self.compute([])
self.assertIsNone(st["campaign"])
self.assertFalse(st["authorized"])
for t in self.tests:
self.assertTrue(st["tests"][t.id]["required"])
self.assertEqual(st["tests"][t.id]["reason"], "always" if t.always else "never-passed")
self.assertFalse(st["tests"][self.live.id]["requires_met"])
def test_full_campaign_authorizes(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
st = self.compute(recs)
self.assertTrue(st["authorized"])
self.assertEqual(st["counts"], {"total": 4, "satisfied": 4, "inherited": 0, "required": 0})
self.assertEqual(st["tests"][self.core.id]["status"], "pass")
def test_fail_closes_campaign(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.core, self.man, "PASS", "2026-08-20T10:01:00Z"),
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:02:00Z"),
rec_result("c1", self.cool, self.man, "FAIL", "2026-08-20T10:03:00Z")]
st = self.compute(recs)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "fail")
self.assertFalse(st["authorized"])
# the core PASS in the closed campaign no longer counts; the ui PASS is inheritable
self.assertTrue(st["tests"][self.core.id]["required"])
self.assertEqual(st["tests"][self.core.id]["reason"], "always")
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
self.assertEqual(st["tests"][self.cool.id]["status"], "fail")
self.assertEqual(st["tests"][self.cool.id]["reason"], "never-passed")
def test_a_fail_after_a_pass_blocks_inheritance(self):
# The ui test passes in c1, then fails on the same image (an
# intermittent gate): the FAIL closes c1. In c2 the older PASS must
# not be inherited over the newer FAIL: the test is required again.
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:01:00Z"),
rec_result("c1", self.ui, self.man, "FAIL", "2026-08-20T10:02:00Z"),
rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z")]
st = self.compute(recs)
t = st["tests"][self.ui.id]
self.assertEqual(t["status"], "fail")
self.assertEqual(t["reason"], "failed-since")
self.assertTrue(t["required"])
self.assertFalse(t["satisfied"])
self.assertFalse(st["authorized"])
def test_an_abort_after_a_pass_does_not_block_inheritance(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:01:00Z"),
rec_result("c1", self.ui, self.man, "ABORTED", "2026-08-20T10:02:00Z"),
rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z")]
st = self.compute(recs)
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
def test_a_pass_after_a_fail_is_inherited(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.ui, self.man, "FAIL", "2026-08-20T10:01:00Z"),
rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z"),
rec_result("c2", self.ui, self.man, "PASS", "2026-08-20T11:01:00Z"),
rec_campaign("c3", self.man, self.chash, "2026-08-20T12:00:00Z")]
st = self.compute(recs)
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
def test_error_closes_aborted_does_not(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.ui, self.man, "ABORTED", "2026-08-20T10:01:00Z")]
st = self.compute(recs)
self.assertIsNotNone(st["campaign"])
self.assertEqual(st["tests"][self.ui.id]["status"], "aborted")
recs.append(rec_result("c1", self.ui, self.man, "ERROR", "2026-08-20T10:02:00Z"))
st = self.compute(recs)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "fail")
def test_inheritance_follows_fingerprint(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
# a new image with only cool.c changed
man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
st = self.compute(recs, man=man2)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "image")
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
self.assertEqual(st["tests"][self.live.id]["status"], "inherited")
self.assertEqual(st["tests"][self.cool.id]["status"], "stale")
self.assertEqual(st["tests"][self.cool.id]["reason"], "domain-changed")
self.assertEqual(st["tests"][self.core.id]["reason"], "always")
# open a campaign on the new image, run the core + cool -> authorized
recs.append(rec_campaign("c2", man2, self.chash, "2026-08-21T10:00:00Z"))
recs.append(rec_result("c2", self.core, man2, "PASS", "2026-08-21T10:01:00Z"))
st = self.compute(recs, man=man2)
self.assertFalse(st["authorized"])
recs.append(rec_result("c2", self.cool, man2, "PASS", "2026-08-21T10:02:00Z"))
st = self.compute(recs, man=man2)
self.assertTrue(st["authorized"])
self.assertEqual(st["counts"]["inherited"], 2)
self.assertEqual(st["tests"][self.ui.id]["origin"]["campaign"], "c1")
def test_platform_change_invalidates_everything(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
man2 = helpers.with_platform(self.man, dtb={"glowforge.dtb": "f" * 64})
st = self.compute(recs, man=man2)
for t in self.tests:
self.assertTrue(st["tests"][t.id]["required"], t.id)
def test_invalidate_all(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
recs.append({"t": "invalidate", "ts": "2026-08-22T09:00:00Z", "reason": "new tube"})
st = self.compute(recs)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "invalidate")
self.assertEqual(st["invalidate"]["reason"], "new tube")
for t in self.tests:
self.assertTrue(st["tests"][t.id]["required"], t.id)
self.assertNotEqual(st["tests"][t.id]["status"], "inherited")
# after the invalidate, a new campaign's passes count and later ones inherit again
recs.append(rec_campaign("c2", self.man, self.chash, "2026-08-22T10:00:00Z"))
st = self.compute(recs)
# the campaign that started after it answers the invalidation: the
# page no longer announces it, while its epoch still holds
self.assertIsNone(st["invalidate"])
for t in self.tests:
self.assertTrue(st["tests"][t.id]["required"], t.id)
self.assertNotEqual(st["tests"][t.id]["status"], "inherited")
for i, t in enumerate(self.tests):
recs.append(rec_result("c2", t, self.man, "PASS", "2026-08-22T10:0%d:00Z" % (i + 1)))
st = self.compute(recs)
self.assertTrue(st["authorized"])
self.assertIsNone(st["invalidate"])
def test_reset_and_catalog_change(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.core, self.man, "PASS", "2026-08-20T10:01:00Z"),
{"t": "reset", "ts": "2026-08-20T11:00:00Z", "reason": "x"}]
st = self.compute(recs)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "reset")
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
st = self.compute(recs, chash="different")
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "catalog")
def test_requires_uses_satisfied(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:01:00Z")]
st = self.compute(recs)
self.assertTrue(st["tests"][self.live.id]["requires_met"])
man2 = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
st = self.compute(recs, man=man2)
self.assertFalse(st["tests"][self.live.id]["requires_met"])
self.assertEqual(st["tests"][self.live.id]["missing_requires"], ["forgectrl.panel"])
def test_running_marker(self):
st = campaign.compute([], self.tests, self.man, self.chash, running=self.ui.id)
self.assertEqual(st["tests"][self.ui.id]["status"], "running")
if __name__ == "__main__":
unittest.main()
+260
View File
@@ -0,0 +1,260 @@
"""Every phrase the cloud suite greps for in the gfcloud log must be a
phrase the pinned cloud app can actually log.
The cloud tests judge a print on the machine's own log lines. When the
app's wording moves (a resume that used to log one line logs two), a
test keeps looking for the old line and fails a print that worked. The
replay fixtures cannot catch that: they are excerpts of the old wording.
This test reads the phrases straight out of suite/cloud.py and checks
each against the logger calls in the app sources at the revisions the
recipes pin (python3-gfhardware and python3-gfutilities, from the
manifest cache the tree manifest builds, or the sibling checkouts when
no cache is at hand), placeholder-aware: "paused at" is covered by
'paused at %s', "warm up: holding" by '%s: holding %.1f s', and
"authenticate_machine SUCCESS" by logger.info('SUCCESS') inside
def authenticate_machine (the log format is module:function message).
"""
import ast
import importlib.util
import os
import re
import subprocess
import unittest
import helpers # noqa: F401 (sys.path)
HERE = os.path.dirname(os.path.abspath(__file__))
FORGETEST = os.path.dirname(HERE)
REPO = os.path.dirname(FORGETEST)
CLOUD_PY = os.path.join(FORGETEST, "forgetest", "suite", "cloud.py")
APP_COMPONENTS = ("python3-gfhardware", "python3-gfutilities")
SIBLINGS = {"python3-gfhardware": "python3-gfhardware", "python3-gfutilities": "Glowforge-Utilities"}
# Phrases cloud.py builds at run time rather than writing out (a "%s: holding"
# with the phase filled in), listed here so they are checked too.
BUILT_PHRASES = ("warm up: holding", "cool down: holding", "warm up: skipped", "cool down: skipped",
'finished with event ":cancelled"', 'finished with event ":completed"',
"motion [", "print [")
# Phrases that are not the app's: forgetest's own log, a line prefix of
# the log format rather than a message, or a line of forgectrl's log (the
# engine's effective-limits line, judged from its own file).
NOT_APP = ("PASS", "gfcloud", "[", "effective limits: coolant ceiling", "header")
PLACEHOLDER = re.compile(r"%(?:\([^)]*\))?[-+ #0]*\d*(?:\.\d+)?[sdifrxXeEgGcu]|%%")
# ------------------------------------------------------------ the needles
def needles_in(path):
"""String constants cloud.py tests against log lines: the left side of
an `x in ln`, the arguments of wait_log, endswith() arguments, and the
names those resolve to (module- or function-level constants)."""
with open(path, encoding="utf-8") as f:
tree = ast.parse(f.read())
consts = {} # name -> list of str
def strings_of(node):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return [node.value]
if isinstance(node, (ast.List, ast.Tuple)):
return [s for e in node.elts for s in strings_of(e)]
if isinstance(node, ast.Name):
return consts.get(node.id, [])
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
return strings_of(node.left) + strings_of(node.right)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "list":
return [s for a in node.args for s in strings_of(a)]
return []
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
got = strings_of(node.value)
if got:
consts[node.targets[0].id] = got
found = set()
for node in ast.walk(tree):
if isinstance(node, ast.Compare) and any(isinstance(op, ast.In) for op in node.ops):
found.update(strings_of(node.left))
elif isinstance(node, ast.Call):
fn = node.func
if isinstance(fn, ast.Name) and fn.id == "wait_log" and len(node.args) >= 3:
found.update(strings_of(node.args[2]))
elif isinstance(fn, ast.Attribute) and fn.attr == "endswith":
for a in node.args:
found.update(strings_of(a))
# the mark tuples the suite scans lines against in comprehensions
# (`any(m in ln for m in SESSION_MARKS)`), where the name is a loop variable
for name, vals in consts.items():
if name.endswith(("_MARKS", "_LINES")):
found.update(vals)
found.update(BUILT_PHRASES)
return sorted(s.strip() for s in found if s.strip() and s.strip() not in NOT_APP and " " in s or s in BUILT_PHRASES)
# ------------------------------------------------------------ the app's lines
def log_literals(source, label):
"""(function name, fragments) for every logger call in a Python source:
the message's fixed text split at its placeholders."""
out = []
try:
tree = ast.parse(source)
except SyntaxError:
return out
funcs = {}
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
for sub in ast.walk(node):
funcs.setdefault(id(sub), node.name)
for node in ast.walk(tree):
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and node.func.attr in ("debug", "info", "warning", "warn", "error", "critical", "exception")
and node.args):
continue
msg = node.args[0]
if isinstance(msg, ast.BinOp) and isinstance(msg.op, ast.Mod):
msg = msg.left
frags = None
if isinstance(msg, ast.Constant) and isinstance(msg.value, str):
frags = [p.replace("%%", "%") for p in PLACEHOLDER.split(msg.value)]
elif isinstance(msg, ast.JoinedStr):
frags = [""]
for v in msg.values:
if isinstance(v, ast.Constant):
frags[-1] += str(v.value)
else:
frags.append("")
if frags is not None:
out.append((funcs.get(id(node), ""), frags, label))
return out
def covers(frags, needle):
"""True when `needle` is a substring of a rendering of the message whose
fixed text is `frags` (the gaps between fragments are placeholders; the
rendered line is `function message`, so frags[0] carries the function).
A placeholder is a value, never the phrase itself: the needle must lie
inside one fixed fragment, or run through the fragments in order and
contain at least one complete non-empty fragment."""
n = len(frags)
if any(needle in f for f in frags if f):
return True
def in_gap(text, i, complete):
"""`text` starts in the gap before frags[i]."""
if i >= n:
return False
frag = frags[i]
if frag == "":
return complete if i == n - 1 else in_gap(text, i + 1, complete)
start = 0
while True:
j = text.find(frag, start)
if j < 0:
break
if after(text[j + len(frag):], i, True):
return True
start = j + 1
# the needle ends inside frags[i]: a proper prefix of it, which counts
# as the complete fragment the rule wants only when the gap before it
# was preceded by one, or when what is left of the fragment is a
# trailing separator (": holding " is covered by "warm up: holding")
for k in range(1, len(frag)):
if text.endswith(frag[:k]) and (complete or not frag[k:].strip()):
return True
return False
def after(text, i, complete):
"""`text` starts right after the whole of frags[i]."""
if text == "":
return complete
return in_gap(text, i + 1, complete)
for i, frag in enumerate(frags):
for off in range(len(frag)):
suffix = frag[off:]
if needle.startswith(suffix) and after(needle[len(suffix):], i, off == 0):
return True
if i >= 1 and in_gap(needle, i, False):
return True
return False
# ------------------------------------------------------------ sources
def pinned_sources():
"""{component: [(path, text)]} for the app components at their pinned
revisions, through the tree manifest's recipe parser and cache; the
sibling checkouts when the cache cannot be reached."""
spec = importlib.util.spec_from_file_location("mft", os.path.join(REPO, "scripts", "manifest-from-tree.py"))
mft = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mft)
meta = os.path.join(os.path.dirname(REPO), "meta-openglow")
cache = os.path.join(REPO, ".manifest-cache")
out = {}
for name, rel, layer in mft.RECIPES:
if name not in APP_COMPONENTS:
continue
base = REPO if layer == "forgefirm" else meta
files = []
try:
url, rev = mft.parse_recipe(os.path.join(base, rel))
repo = mft.fetch(url, rev, cache)
names = mft.git(["ls-tree", "-r", "--name-only", rev], cwd=repo).decode().split("\n")
for p in names:
if p.endswith(".py"):
files.append((p, mft.git(["show", "%s:%s" % (rev, p)], cwd=repo).decode("utf-8", "replace")))
out[name] = ("pinned %s" % rev[:12], files)
continue
except (OSError, subprocess.CalledProcessError, SystemExit):
pass
sib = os.path.join(os.path.dirname(REPO), SIBLINGS[name])
if os.path.isdir(sib):
for root, _dirs, fnames in os.walk(sib):
if ".git" in root:
continue
for fn in fnames:
if fn.endswith(".py"):
p = os.path.join(root, fn)
files.append((os.path.relpath(p, sib), open(p, encoding="utf-8", errors="replace").read()))
out[name] = ("sibling checkout", files)
return out
class CloudNeedleTests(unittest.TestCase):
def test_every_needle_is_a_line_the_pinned_app_can_log(self):
needles = needles_in(CLOUD_PY)
self.assertGreaterEqual(len(needles), 30, needles)
self.assertIn("authenticate_machine SUCCESS", needles)
self.assertIn("RX-EVENT: ready", needles)
sources = pinned_sources()
missing = [c for c in APP_COMPONENTS if c not in sources]
if missing:
if os.environ.get("CI"):
self.fail("no source for %s (run scripts/manifest-from-tree.py first)" % ", ".join(missing))
self.skipTest("no source for %s" % ", ".join(missing))
literals = []
for comp, (where, files) in sources.items():
for path, text in files:
literals.extend(log_literals(text, "%s:%s" % (comp, path)))
self.assertGreater(len(literals), 100, "too few logger calls found: is the app source complete?")
uncovered = []
for needle in needles:
if not any(covers([fn + " " + frags[0]] + frags[1:], needle) for fn, frags, _ in literals):
uncovered.append(needle)
self.assertEqual(uncovered, [], "cloud.py greps for lines the pinned app never logs (sources: %s)"
% ", ".join("%s=%s" % (c, w) for c, (w, _) in sources.items()))
def test_cover_rules(self):
self.assertTrue(covers(["_run_loop paused at ", ""], "paused at"))
self.assertTrue(covers(["_dwell ", ": holding ", " s"], "warm up: holding"))
self.assertTrue(covers(["authenticate_machine SUCCESS"], "authenticate_machine SUCCESS"))
self.assertTrue(covers(["_resume_retraced resuming (laser lead ", " ticks)"], "resuming (laser lead"))
self.assertTrue(covers(["_finish_action ", " [", "]: finished with event \"", "\""],
'finished with event ":completed"'))
self.assertFalse(covers(["_run_loop button pressed while paused"], "button pressed while paused; resuming"))
self.assertFalse(covers(["_run_loop paused at ", ""], "paused it"))
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
"""The setup's checks in the catalog: registered as automatic tests with
no precheck, the takeover ones marked so, every covers map naming the
check runner; and the check driver itself against the fake daemon: a
start, a prompt answered, the result taken, the record read at version
1, and the settings a check may write put back as found."""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(__file__))
import helpers # noqa: E402
from forgetest import catalog # noqa: E402
from forgetest.runner import Context, Run # noqa: E402
IDS = ["commission.check-switches", "commission.check-sensors", "commission.check-airflow",
"commission.check-cameras", "commission.check-motion", "commission.check-flow-verify"]
class RegistrationTests(unittest.TestCase):
def setUp(self):
self.reg = catalog.load_suite()
def test_every_check_runs_unattended_with_the_fixture(self):
# The lid and button checks name their machine actions, which the
# bench fixture performs; the rest need no hands at all.
for tid in IDS:
self.assertIn(tid, self.reg)
for tid in ("commission.check-switches", "commission.check-cameras"):
self.assertEqual(self.reg[tid].kind, "operator", tid)
self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid)
for tid in ("commission.check-sensors", "commission.check-airflow", "commission.check-motion",
"commission.check-flow-verify"):
self.assertEqual(self.reg[tid].kind, "auto", tid)
def test_no_check_asks_the_operator_to_prepare_anything(self):
for tid in IDS:
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
def test_the_checks_that_take_the_machine_say_so(self):
for tid in ("commission.check-airflow", "commission.check-motion", "commission.check-flow-verify"):
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
def test_every_check_covers_the_runner_and_the_page(self):
for tid in IDS:
covers = set(self.reg[tid].covers)
self.assertIn(("forgectrl", "src/wizdark.*"), covers, tid)
self.assertIn(("forgectrl", "src/ui/wizard.*"), covers, tid)
class SensorsCheckTests(unittest.TestCase):
"""The check driver against a scripted daemon."""
RESULT = {"coolant_down_c": 22.4, "coolant_up_c": 21.9, "chassis_c": 28.0, "soc_c": 45.2,
"lid_ir_max": [110, 120, 105, 118], "accel_events": 0, "laser_pgood": 1,
"hv_current_max": 0, "exhaust_rpm_idle": 3400, "intake_rpm_idle": 2200}
def setUp(self):
self.fake = helpers.FakeForgectrl().start()
self.fake.state["settings"].update({"cool_temp_offset_c": "0.5"})
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
"log": [], "prompt": None, "result": None, "error": "",
"shots": {"lid": False, "head": False}}
self.versions = {}
self.answers = []
def on_get(path, q):
if path == "/wiz/dark":
return 200, dict(self.dark)
if path == "/wiz":
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
return None
def on_post(path, form):
s = self.fake.state["settings"]
if path == "/wiz/sensors/start":
self.dark.update({"id": "sensors", "running": True, "phase": "sampling",
"prompt": {"seq": 7, "id": "room-temp", "kind": "number",
"text": "Optional: the room temperature.",
"options": ["Set", "Skip"]}})
return 200, {"started": True, "id": "sensors"}
if path == "/wiz/sensors/answer":
self.answers.append(dict(form))
if form.get("seq") != "7":
return 409, {"error": "no such prompt is open"}
if form.get("value") == "Skip":
self.dark.update({"running": False, "prompt": None, "progress": 100,
"result": dict(self.RESULT)})
self.versions["sensors"] = 1
return 200, {"ok": True}
if path == "/settings":
for k, v in form.items():
if v == "":
s.pop(k, None)
else:
s[k] = v
return 200, dict(s)
return None
self.fake.on_get = on_get
self.fake.on_post = on_post
def tearDown(self):
self.fake.stop()
def test_start_prompt_answer_result_and_restore(self):
t = catalog.load_suite()["commission.check-sensors"]
run = Run("test", t.id, t.title)
t.fn(Context(run, None, t))
self.assertIn(("/wiz/sensors/start", {}), [(p, f) for p, f in self.fake.posts])
self.assertEqual(self.answers, [{"seq": "7", "value": "Skip"}])
self.assertEqual(run.evidence["result"]["laser_pgood"], 1)
self.assertEqual(run.evidence["settings_before"], {"cool_temp_offset_c": "0.5"})
self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5")
if __name__ == "__main__":
unittest.main()
+380
View File
@@ -0,0 +1,380 @@
"""The setup's sheet in the catalog: one live test on the button with a
piece of wood at hand, its covers map naming the live runner, the
renderer, the sender, the page, and the driver's laser module; and the
whole run against a scripted daemon: the placement's jogs and origin,
then each card's preview and program read, its start, the arm prompt and
the pick answered, the witnesses judged, the record read at version 1,
every card's evidence filed under its own key, and the settings the cards
wrote put back as found at the end."""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(__file__))
import helpers # noqa: E402
from forgetest import catalog # noqa: E402
from forgetest.runner import Context, Run # noqa: E402
from forgetest.suite import commission_sheet # noqa: E402
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
# 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
# origin is set.
SCRIPT = {
"sheet.place": ([("place", "jog", ["X-10", "X+10", "Set origin"]),
("sheet-kind", "choice", ["Full sheet", "One card"]),
("thickness", "number", ["Set", "Skip"])],
{"origin_x": 100, "origin_y": 200, "origin_z": 0, "steps_per_mm": {"x": 40.0, "y": 40.0},
"z_referenced": True, "thickness_mm": 3.2, "alone": False}),
"sheet.frame": ([("frame-arm", "continue", ["Continue"]), ("frame-ok", "choice", ["Yes", "No", "Again"])],
{"mark_s": 400, "mark_feed": 3000, "emission": EMISSION}),
"laser.focus": ([("focus-arm", "continue", ["Continue"]),
("focus-pick", "multichoice", [str(i) for i in range(1, 13)]),
("thickness", "number", ["Keep", "Set"])],
{"pick": 11, "thickness_mm": 3.2, "pick_half_steps": -11, "edge_z_mm": 6.96,
"steps_per_mm": 2.922, "max_height_mm": 13.81,
"focus_range_mm": {"min": 2.17, "max": 13.81},
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
"contact_above": 22, "why": ""},
"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}),
"laser.dose-curve": ([("dose-arm", "continue", ["Continue"])],
{"points": [[10, 0.44], [100, 100]], "curve": "10:0.44,100:100", "emission": EMISSION}),
"laser.corner": ([("corner-arm", "continue", ["Continue"]),
("corner-pick", "choice", ["1.00", "1.25", "1.50", "1.75", "2.00"])],
{"gamma": 1.5, "emission": EMISSION}),
"cooling.flow-load": ([("load-arm", "continue", ["Continue"])],
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
"k_cw": 2.78e-5, "emission": EMISSION}),
}
# What 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"},
"laser.floor": {"laser_floor_density": "10"},
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
"laser.corner": {"laser_corner_gamma": "1.5"},
"cooling.flow-load": {"cool_laser_heat_density": "2.14e-5", "cool_laser_heat_cw": "2.78e-5"},
}
FOUND = {"lens_hall_edge_z_mm": "3.35", "lens_stop_below_steps": "10", "lens_stop_above_steps": "12",
"laser_floor_density": "12",
"laser_dose_curve": "off", "laser_corner_gamma": "2", "cool_laser_heat_density": "1e-5",
"cool_laser_heat_cw": "1e-5"}
class RegistrationTests(unittest.TestCase):
def setUp(self):
self.t = catalog.load_suite()["commission.sheet"]
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
t = self.t
self.assertEqual(t.kind, "live")
self.assertEqual(t.mode, "grbl")
self.assertEqual(t.hardware, "takeover")
self.assertIn("button", t.actions)
self.assertTrue(t.hands)
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
self.assertIsNone(getattr(t, "precheck", None))
for want in ("commission.check-motion", "laser.emission-witness", "cooling.flow-verify"):
self.assertIn(want, t.requires)
def test_no_other_sheet_test_remains(self):
ids = [i for i in catalog.load_suite() if i.startswith("commission.sheet")]
self.assertEqual(ids, ["commission.sheet"])
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
covers = set(self.t.covers)
for want in (("forgectrl", "src/wizlive.*"), ("forgectrl", "src/sheet.*"), ("forgectrl", "src/jobstream.*"),
("forgectrl", "src/curverec.*"), ("forgectrl", "src/font_hershey.*"),
("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/wizrun.h"),
("grblhal-glowforge", "src/glowforge_laser.c")):
self.assertIn(want, covers, "lacks %s" % (want,))
def test_the_steps_ask_for_one_press_and_no_page(self):
text = " ".join(self.t.steps).lower()
self.assertIn("press the machine's button once", text)
self.assertNotIn("when the page", text)
self.assertEqual(len(commission_sheet.CARDS), 6)
class ScriptedDaemon:
"""The wizard runner as the suite sees it: /wiz/<id>/start opens the
first prompt, each answer opens the next or ends the wizard with its
result and its writes, /wiz/dark reports it, GET /wiz carries the
versions, the sheet preview and program are served per card."""
def __init__(self, fake):
self.fake = fake
fake.state["settings"].update(FOUND)
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
"log": [], "prompt": None, "result": None, "error": "",
"shots": {"lid": False, "head": False}}
self.versions = {}
self.answers = {} # wid -> [values]
self.order = [] # the wizards as started
self.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
def open_next(self):
if not self.queue:
return False
pid, kind, options = self.queue[0]
self.seq += 1
self.dark["prompt"] = {"seq": self.seq, "id": pid, "kind": kind, "text": pid, "options": options}
return True
def on_get(self, path, q):
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":
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
if path == "/wiz/sheet.svg":
self.previews.append(q.get("card"))
if q.get("card") not in LIVE:
return 404, {"error": "no such card"}
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
if path == "/wiz/sheet.gcode":
# the frame and the text are M4 at the mark dose, as the daemon
# serves them; 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):
s = self.fake.state["settings"]
if path.startswith("/wiz/") and path.endswith("/start"):
wid = path[len("/wiz/"):-len("/start")]
if wid not in SCRIPT:
return 404, {"error": "no such wizard"}
self.order.append(wid)
self.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()
return 200, {"started": True, "id": wid}
if path.startswith("/wiz/") and path.endswith("/answer"):
wid = path[len("/wiz/"):-len("/answer")]
p = self.dark.get("prompt")
if not p or form.get("seq") != str(p["seq"]):
return 409, {"error": "no such prompt is open"}
self.answers.setdefault(wid, []).append(form.get("value"))
if not (p["kind"] == "jog" and form.get("value") != "Set origin"):
self.queue.pop(0)
if not self.open_next():
s.update(WRITES.get(wid, {}))
self.dark.update({"running": False, "prompt": None, "progress": 100,
"result": dict(SCRIPT[wid][1])})
self.versions[wid] = 1
return 200, {"ok": True}
if path == "/settings":
for k, v in form.items():
if v == "":
s.pop(k, None)
else:
s[k] = v
return 200, dict(s)
return None
class SheetRunTests(unittest.TestCase):
def setUp(self):
self.fake = helpers.FakeForgectrl().start()
self.daemon = ScriptedDaemon(self.fake)
def tearDown(self):
self.fake.stop()
def test_the_whole_sheet_on_one_piece(self):
t = catalog.load_suite()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True # the ready gate passes; the arm cue is a notice
t.fn(Context(run, None, t))
d = self.daemon
self.assertEqual(d.order, ["sheet.place"] + LIVE)
self.assertEqual(d.answers["sheet.place"], ["X+10", "X-10", "Set origin", "Full sheet", "3.2"])
self.assertEqual(d.answers["sheet.frame"], ["Continue", "Yes"])
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
self.assertEqual(d.previews, PREVIEWS)
cards = run.evidence["cards"]
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
for wid in LIVE:
self.assertEqual(cards[wid]["emission"]["hv_max"], 610, wid)
self.assertEqual(cards[wid]["result"], SCRIPT[wid][1], wid)
self.assertNotIn("emission", run.evidence)
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
self.assertEqual(run.evidence["settings_before"], FOUND)
# 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 commission_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 commission_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
def test_imperial_units_answer_the_thickness_in_inches(self):
# The number prompts read ui_units: on an imperial machine the
# placement's thickness is 0.125 in and the record carries 3.175 mm.
self.fake.state["settings"]["ui_units"] = "imperial"
place_prompts, place_result = SCRIPT["sheet.place"]
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=3.175))
try:
t = catalog.load_suite()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
t.fn(Context(run, None, t))
self.assertEqual(self.daemon.answers["sheet.place"][-1], "0.125")
self.assertEqual(run.evidence["thickness_answer"], "0.125")
finally:
SCRIPT["sheet.place"] = (place_prompts, place_result)
def test_a_metric_answer_on_an_imperial_machine_fails_the_placement(self):
# The defect the bench found: 3.2 read as inches is over the 30 mm
# cap and the wizard records 0; the test must not read that as a pass.
from forgetest.runner import Failed
place_prompts, place_result = SCRIPT["sheet.place"]
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=0.0))
try:
t = catalog.load_suite()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed) as cm:
t.fn(Context(run, None, t))
self.assertIn("thickness 0.0 mm", str(cm.exception))
self.assertEqual(self.daemon.order, ["sheet.place"]) # nothing burned
finally:
SCRIPT["sheet.place"] = (place_prompts, place_result)
def test_a_dark_burn_fails_the_run_and_still_restores(self):
from forgetest.runner import Failed
SCRIPT_DARK = dict(SCRIPT["laser.floor"][1])
SCRIPT_DARK["emission"] = {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0}
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], SCRIPT_DARK)
try:
t = catalog.load_suite()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed):
t.fn(Context(run, None, t))
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus", "laser.floor"])
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k)
finally:
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
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()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed) as cm:
t.fn(Context(run, None, t))
self.assertIn("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()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed) as cm:
t.fn(Context(run, None, t))
self.assertIn("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()
+423
View File
@@ -0,0 +1,423 @@
"""The commission.* suite on the host: the registration (ids, kinds, the
takeover tests, the operator tests' hands), the record builders, the
machine's name, the cookie parsing, the LED cue, the settle rule for a
gated supervisor, and the cloud-off surface test driven end to end
against the fake daemon."""
import json
import os
import shutil
import tempfile
import unittest
import helpers
from forgetest import baseline, catalog
from forgetest.runner import Context, Run
from forgetest.suite import commission
IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot",
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes",
"commission.ssh-until-reboot", "commission.cloud-disabled-surface",
"commission.factory-return", "commission.machine-name", "commission.first-run-flow",
"commission.first-run-page", "commission.what-changed", "commission.record-export",
"commission.mirror")
OPERATOR = ("commission.first-run-flow", "commission.first-run-page")
class RegistrationTests(unittest.TestCase):
def setUp(self):
self.reg = catalog.load_suite()
def test_every_test_is_registered_with_its_kind(self):
kinds = {tid: self.reg[tid].kind for tid in IDS if tid in self.reg}
self.assertEqual(sorted(kinds), sorted(IDS))
for tid in OPERATOR:
self.assertEqual(kinds[tid], "operator", tid)
for tid in IDS:
if tid not in OPERATOR:
self.assertEqual(kinds[tid], "auto", tid)
self.assertNotIn("commission.root-ssh-refused", self.reg) # folded into ssh-until-reboot
self.assertNotIn("commission.wizard-first-run", self.reg) # split into the flow and the page
def test_the_record_swapping_tests_are_takeovers(self):
for tid in ("commission.gate-blocks-controllers", "commission.advisories-rehash",
"commission.first-run-flow", "commission.first-run-page",
"commission.what-changed", "commission.mirror"):
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
self.assertEqual(self.reg["commission.record-export"].hardware, "api")
def test_the_lifecycle_tests_cover_their_files(self):
changed = set(self.reg["commission.what-changed"].covers)
export = set(self.reg["commission.record-export"].covers)
mirror = set(self.reg["commission.mirror"].covers)
self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
self.assertIn(("forgectrl", "src/recordhtml.*"), export)
self.assertIn(("forgectrl", "src/logs.*"), export)
self.assertIn(("forgectrl", "src/wizdark.*"), mirror)
self.assertIn("commission.check-sensors", self.reg["commission.mirror"].requires)
def test_flag_of_reads_the_status_document(self):
w = {"required": ["airflow", {"id": "laser.floor", "reason": "the tube was replaced"}],
"recommended": [{"id": "laser.focus", "reason": "the tray was replaced"}]}
self.assertEqual(commission.flag_of(w, "required", "laser.floor"), "the tube was replaced")
self.assertEqual(commission.flag_of(w, "recommended", "laser.focus"), "the tray was replaced")
self.assertIsNone(commission.flag_of(w, "required", "airflow")) # a plain id is the table, not a flag
self.assertIsNone(commission.flag_of(w, "required", "laser.focus"))
self.assertIsNone(commission.flag_of({}, "recommended", "x"))
def test_the_first_run_flow_runs_unattended_with_the_fixture_and_the_page_walk_does_not(self):
flow = self.reg["commission.first-run-flow"]
self.assertEqual(list(flow.actions), ["button"])
self.assertFalse(flow.hands)
self.assertTrue(flow.fixture_runnable(("button", "lid", "interlock")))
page = self.reg["commission.first-run-page"]
self.assertTrue(page.hands)
self.assertFalse(page.fixture_runnable(("button", "lid", "interlock")))
self.assertIn("commission.first-run-flow", page.requires)
def test_the_first_run_split_keeps_the_backend_and_the_page_apart(self):
flow = set(self.reg["commission.first-run-flow"].covers)
page = set(self.reg["commission.first-run-page"].covers)
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
self.assertIn(want, flow, want)
self.assertNotIn(want, page, want)
for want in (("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/md.js"), ("forgectrl", "src/tls.*")):
self.assertIn(want, page, want)
def test_the_only_attended_tests_need_a_workstation_or_the_sheet(self):
# With the bench actuator up, three commissioning tests keep a
# person: the page walk, the print from the Glowforge app, and
# the sheet. Everything else runs from the queue.
attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("commission.")
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
self.assertEqual(attended, ["commission.cloud-header-capture", "commission.first-run-page",
"commission.sheet"])
def test_the_factory_return_never_runs_the_return(self):
import inspect
t = self.reg["commission.factory-return"]
src = inspect.getsource(t.fn)
self.assertNotIn('"confirm": "1"', src) # the only argument that starts the return
self.assertIn('"confirm": "0"', src)
self.assertEqual(t.kind, "auto") # the return itself is a bench drill, not a test
self.assertFalse(t.hands)
def test_the_machine_name_covers_nothing_by_design(self):
self.assertEqual(self.reg["commission.machine-name"].covers, ())
def test_the_login_test_makes_its_own_account(self):
# No bench credentials, no precheck: the test installs a temporary
# account under a takeover and restores the real one.
t = self.reg["commission.account-login"]
self.assertIsNone(getattr(t, "precheck", None))
self.assertEqual(t.hardware, "takeover")
with open(commission.__file__) as f:
src = f.read()
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
self.assertNotIn(k, src)
class RecordTests(unittest.TestCase):
STATUS = {"sheet_id": "ABCDE-FGHIJ",
"documents": [{"id": "safety-and-risk", "hash": "a" * 64, "consent": "typed"},
{"id": "licenses", "hash": "b" * 64, "consent": "check"}],
"wizards": [{"id": "advisories", "version": 1}, {"id": "account", "version": 1},
{"id": "machine", "version": 2}]}
def test_complete_record_counts_as_commissioned(self):
rec = commission.complete_record(self.STATUS)
self.assertEqual(rec["schema"], 1)
self.assertEqual(rec["advisories"]["safety-and-risk"]["hash"], "a" * 64)
self.assertEqual(rec["advisories"]["safety-and-risk"]["method"], "typed")
self.assertIn("pressed_at", rec["acceptance"])
self.assertEqual(rec["account"]["name"], "bench")
self.assertEqual(rec["wizards"]["machine"]["version"], 2)
self.assertEqual(rec["sheet_id"], "ABCDE-FGHIJ")
self.assertTrue(rec["completed"])
json.dumps(rec)
def test_complete_record_keeps_the_base_account_and_machine(self):
base = {"account": {"name": "owner", "uid": 1000, "created": "x"}, "machine": {"model": "pro"},
"sheet_id": "KKKKK-LLLLL", "created": "then"}
rec = commission.complete_record(self.STATUS, base)
self.assertEqual(rec["account"]["name"], "owner")
self.assertEqual(rec["machine"], {"model": "pro"})
self.assertEqual(rec["sheet_id"], "KKKKK-LLLLL")
self.assertEqual(rec["created"], "then")
def test_without_wizards_keeps_consent_and_account(self):
rec = commission.complete_record(self.STATUS)
rec["flags"] = {"machine": {"level": "required", "reason": "x"}}
out = commission.record_without_wizards(rec)
self.assertEqual(out["wizards"], {})
self.assertEqual(out["flags"], {})
self.assertEqual(out["advisories"], rec["advisories"])
self.assertEqual(out["account"], rec["account"])
self.assertEqual(rec["flags"], {"machine": {"level": "required", "reason": "x"}}) # a copy
def test_write_file_and_remove(self):
tmp = tempfile.mkdtemp(prefix="forgetest-comm-")
try:
p = os.path.join(tmp, "sub", "commissioning.json")
commission.write_file(p, b"{}\n")
self.assertEqual(commission.read_file(p), b"{}\n")
if os.name == "posix": # a mode means nothing on a Windows host
self.assertEqual(oct(os.stat(p).st_mode & 0o777), oct(0o600))
commission.write_file(p, None)
self.assertIsNone(commission.read_file(p))
commission.write_file(p, None) # a second remove is silent
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_paths_follow_the_daemon_environment(self):
os.environ["FORGECTRL_DATA_DIR"] = "/tmp/ffdata"
os.environ["GF_RUN_DIR"] = "/tmp/ffrun"
try:
self.assertEqual(commission.record_path(), "/tmp/ffdata/commissioning.json")
self.assertEqual(commission.users_path(), "/tmp/ffdata/users")
self.assertEqual(commission.override_path(), "/tmp/ffrun/commissioning-override")
self.assertEqual(commission.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
finally:
os.environ.pop("FORGECTRL_DATA_DIR", None)
os.environ.pop("GF_RUN_DIR", None)
class MachineNameTests(unittest.TestCase):
def setUp(self):
self.root = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
def _net(self, **devs):
"""A /sys/class/net tree; commission.mac_suffix reads it through
commission.read_file, which takes an absolute path."""
for dev, mac in devs.items():
d = os.path.join(self.root, dev)
os.makedirs(d)
with open(os.path.join(d, "address"), "w") as f:
f.write(mac + "\n")
real = commission.read_file
def read(path):
head = "/sys/class/net/"
if path.startswith(head):
return real(os.path.join(self.root, path[len(head):]))
return real(path)
commission.read_file = read
self.addCleanup(setattr, commission, "read_file", real)
def test_the_wifi_address_names_the_machine(self):
self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
self.assertEqual(commission.mac_suffix(), "b00a")
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
self._net(eth0="00:11:22:33:44:55")
self.assertEqual(commission.mac_suffix(), "4455")
def test_an_unread_address_is_no_address(self):
self._net(wlan0="00:00:00:00:00:00")
self.assertEqual(commission.mac_suffix(), "")
def test_no_interface_is_no_address(self):
self._net()
self.assertEqual(commission.mac_suffix(), "")
class SmallHelpersTests(unittest.TestCase):
def test_cookie_parsing(self):
sid = "ab" * 32
value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid
self.assertEqual(commission.session_from_cookie(value), sid)
self.assertEqual(commission.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
self.assertIsNone(commission.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
self.assertIsNone(commission.session_from_cookie(""))
def test_the_teal_breathe_cue(self):
self.assertTrue(commission.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
"pulse_off": [0, 1400, 1400]}))
self.assertFalse(commission.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0],
"pulse_off": [0, 0, 0]})) # solid green
self.assertFalse(commission.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800],
"pulse_off": [1800] * 3})) # white
self.assertFalse(commission.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
self.assertFalse(commission.breathes_teal({}))
def test_decode(self):
self.assertEqual(commission.decode(b'{"a": 1}'), {"a": 1})
self.assertEqual(commission.decode(b"cloud mode is not enabled"), "cloud mode is not enabled")
class GatedSettleTests(unittest.TestCase):
"""A supervisor that reports the commissioning gate closed is settled:
it spawns nothing until the gate opens, so a takeover that installs
a gating record must not wait the whole settle timeout for it."""
def setUp(self):
self.fake = helpers.FakeForgectrl().start()
baseline.Baseline._unreachable_until = 0.0
def tearDown(self):
self.fake.stop()
def test_gated_returns_at_once(self):
self.fake.state["mode"] = {"mode": "grbl", "controller": "gated", "pid": 0, "motion": "unverified",
"gated": True, "why": "commissioning required: machine"}
lines = []
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
self.assertEqual(body["controller"], "gated")
self.assertTrue(any("controller=gated" in ln for ln in lines))
class FirstRunSeedTests(unittest.TestCase):
"""The record the first-run tests install: a first run behind the
checks and the sheet."""
STATUS = {"wizards": [{"id": "advisories", "version": 1, "class": "form"},
{"id": "account", "version": 1, "class": "form"},
{"id": "cloud", "version": 1, "class": "form"},
{"id": "switches", "version": 1, "class": "dark"},
{"id": "motion", "version": 2, "class": "dark"},
{"id": "sheet.frame", "version": 1, "class": "live"}]}
def test_no_consent_no_account_not_complete_and_every_check_done(self):
base = {"created": "2026-09-01T00:00:00Z", "completed": "2026-09-02T00:00:00Z",
"advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"},
"account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True},
"wizards": {"advisories": {"version": 1}, "switches": {"version": 1, "result": {"lid": True}}}}
rec = commission.first_run_record(self.STATUS, base)
self.assertNotIn("completed", rec)
self.assertNotIn("acceptance", rec)
self.assertNotIn("account", rec)
self.assertEqual(rec["advisories"], {})
self.assertEqual(rec["created"], "2026-09-01T00:00:00Z")
self.assertEqual(rec["flags"], {"flow_thin": True})
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
self.assertEqual(rec["wizards"]["switches"]["result"], {"lid": True}) # kept from the base
self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version
def test_without_a_base_record(self):
rec = commission.first_run_record(self.STATUS, None)
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
class SshdPolicyTests(unittest.TestCase):
def test_the_three_keys_out_of_sshd_t(self):
from forgetest import hw
calls = []
def fake_run(cmd, timeout=60):
calls.append(cmd)
return 0, "port 22\npermitrootlogin no\npasswordauthentication yes\npermitemptypasswords no\nx11forwarding no\n"
real = hw.run
hw.run = fake_run
try:
self.assertEqual(commission.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
"permitemptypasswords": "no"})
finally:
hw.run = real
self.assertEqual(calls, [["/usr/sbin/sshd", "-T"]])
def test_a_failing_sshd_reports_the_error(self):
from forgetest import hw
real = hw.run
hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available")
try:
self.assertEqual(commission.sshd_policy(), {"error": "sshd: no hostkeys available"})
finally:
hw.run = real
class CloudDisabledSurfaceTests(unittest.TestCase):
"""The cloud-off surface test against the fake daemon: cloud_enabled
is turned off with one write that sweeps the cloud choices, the three
cloud-pointing writes and the phrase-less cloud_enabled=1 are refused,
the settings are left alone, and the prior values come back."""
def setUp(self):
self.fake = helpers.FakeForgectrl().start()
self.fake.state["settings"].update({"controller_mode": "grbl", "homing_mode": "none",
"cloud_enabled": "1", "ui_units": "metric"})
def on_post(path, form):
s = self.fake.state["settings"]
if path == "/settings":
# as the daemon rules it: on from off takes the typed phrase
if form.get("cloud_enabled") == "1" and s.get("cloud_enabled") != "1" \
and form.get("phrase") != "I UNDERSTAND":
return 400, "type I UNDERSTAND to turn cloud mode on"
# the request's own cloud choices, as main.c checks them
enabled = form.get("cloud_enabled", s.get("cloud_enabled")) == "1"
if not enabled and form.get("controller_mode") == "cloud":
return 409, "cloud mode is not enabled on this machine"
if not enabled and form.get("homing_mode") == "gfcloud":
return 409, "cloud homing needs cloud mode enabled"
for k, v in form.items():
if k == "phrase":
continue
if v == "":
s.pop(k, None)
else:
s[k] = v
# off sweeps what pointed at the cloud, as the cloud step does
if form.get("cloud_enabled") == "0":
if "homing_mode" not in form and s.get("homing_mode") == "gfcloud":
s["homing_mode"] = "none"
if "controller_mode" not in form and s.get("controller_mode") == "cloud":
s["controller_mode"] = "grbl"
return 200, dict(s)
if path == "/mode" and form.get("controller") == "cloud" and s.get("cloud_enabled") != "1":
return 409, "cloud mode is not enabled on this machine"
return None
self.fake.on_post = on_post
def tearDown(self):
self.fake.stop()
def run_test(self):
t = catalog.load_suite()["commission.cloud-disabled-surface"]
run = Run("test", t.id, t.title)
t.fn(Context(run, None, t))
return run
def test_refusals_and_restore(self):
run = self.run_test()
s = self.fake.state["settings"]
self.assertEqual(s["cloud_enabled"], "1") # restored, with the phrase
self.assertEqual(s["controller_mode"], "grbl")
self.assertEqual(s["homing_mode"], "none")
self.assertNotIn("phrase", s)
posts = [(p, f) for p, f in self.fake.posts]
self.assertIn(("/settings", {"cloud_enabled": "0"}), posts)
self.assertIn(("/settings", {"cloud_enabled": "1"}), posts) # refused: no phrase
self.assertIn(("/settings", {"cloud_enabled": "1", "phrase": "I UNDERSTAND"}), posts)
self.assertIn(("/mode", {"controller": "cloud"}), posts)
self.assertEqual(run.evidence["mode_cloud"]["status"], 409)
self.assertEqual(run.evidence["settings cloud_enabled=1 no phrase"], 400)
self.assertEqual(run.evidence["found"]["cloud_enabled"], "1")
def test_cloud_homing_and_boot_mode_are_swept_by_the_one_write_and_go_back(self):
# As the daemon rules it: cloud_enabled=0 takes the gfcloud homing
# and the cloud boot mode down with it, so the test writes the one
# key, checks the sweep, and puts the three back, cloud mode first.
self.fake.state["settings"].update({"controller_mode": "cloud", "homing_mode": "gfcloud"})
run = self.run_test()
s = self.fake.state["settings"]
self.assertEqual((s["cloud_enabled"], s["homing_mode"], s["controller_mode"]), ("1", "gfcloud", "cloud"))
posts = [(p, f) for p, f in self.fake.posts if p == "/settings"]
order = [f for _, f in posts]
self.assertEqual(order[:1], [{"cloud_enabled": "0"}])
self.assertEqual(order[-3:], [{"cloud_enabled": "1", "phrase": "I UNDERSTAND"},
{"homing_mode": "gfcloud"}, {"controller_mode": "cloud"}])
self.assertEqual(run.evidence["found"], {"cloud_enabled": "1", "homing_mode": "gfcloud",
"controller_mode": "cloud"})
def test_an_unset_value_is_cleared_back(self):
self.fake.state["settings"].pop("cloud_enabled")
self.run_test()
self.assertNotIn("cloud_enabled", self.fake.state["settings"])
self.assertIn(("/settings", {"cloud_enabled": ""}), self.fake.posts)
if __name__ == "__main__":
unittest.main()
+19
View File
@@ -0,0 +1,19 @@
"""cooling.aa-offset-calibrate is registered before the heater tools, so
a queue runs it first: a heater trial leaves a warm slug circulating past
the coolant sensors for minutes, and the tool's edges read it as
disagreement."""
import unittest
from forgetest import catalog
class CoolingOrderTests(unittest.TestCase):
def test_aa_offset_precedes_flow_verify(self):
reg = catalog.load_suite()
tests = catalog.all_tests(reg)
order = catalog.order_by_requires(tests, [t.id for t in tests])
self.assertLess(order.index("cooling.aa-offset-calibrate"), order.index("cooling.flow-verify"))
if __name__ == "__main__":
unittest.main()
+731
View File
@@ -0,0 +1,731 @@
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
under the real runner Context against a scripted machine: a fake
forgectrl (/status fans, /cool/status phase and verdict, /settings with
the gates table, /logs/tail), a fake kernel sysfs (the fan duties), and
a fake Grbl port that answers '?' and every command.
The bench case that motivated this: the test started one second after
the engine of a previous test went idle, took the tachs still coasting
at the cooldown level as its idle reference, and then waited for the
fans to come back UP to it. The idle reference now needs the engine
idle, the idle duty applied, and tachs that have stopped changing; the
pass condition is at-or-below that level, and every sample is logged.
"""
import os
import shutil
import socket
import tempfile
import threading
import time
import unittest
import helpers
from forgetest.runner import Context, Failed, Run
from forgetest.suite import cooling
class FakeGrbl:
"""Answers '?' with an Idle report and every line with ok."""
def __init__(self):
self.sock = socket.socket()
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(2)
os.environ["GRBL_HOST"] = "127.0.0.1"
os.environ["GRBL_PORT"] = str(self.sock.getsockname()[1])
self.commands = []
self.on_command = None
self._stop = False
threading.Thread(target=self._serve, daemon=True).start()
def _serve(self):
self.sock.settimeout(0.2)
while not self._stop:
try:
c, _ = self.sock.accept()
except OSError:
continue
threading.Thread(target=self._client, args=(c,), daemon=True).start()
def _client(self, c):
c.settimeout(0.1)
buf = b""
c.sendall(b"\r\nGrbl 1.1f ['$' for help]\r\n")
while not self._stop:
try:
d = c.recv(4096)
except socket.timeout:
continue
except OSError:
break
if not d:
break
for ch in d:
if ch == 0x3F: # '?'
c.sendall(b"<Idle|MPos:0.000,0.000,0.000|FS:0,0>\r\n")
elif ch in (0x18, 0x85):
pass
else:
buf += bytes([ch])
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
line = line.decode().strip()
if line:
self.commands.append(line)
if self.on_command:
self.on_command(line)
c.sendall(b"ok\r\n")
c.close()
def close(self):
self._stop = True
self.sock.close()
for k in ("GRBL_HOST", "GRBL_PORT"):
os.environ.pop(k, None)
class FansQuietTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-cool-")
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
os.makedirs(self.sysfs + "thermal")
os.makedirs(self.sysfs + "cnc")
with open(self.sysfs + "cnc/state", "w") as f:
f.write("idle")
os.environ["GF_SYSFS_ROOT"] = self.sysfs
self.fc = helpers.FakeForgectrl().start()
self.grbl = FakeGrbl()
self.saved = (cooling.SAMPLE_S, cooling.IDLE_REF_TIMEOUT_S, cooling.COOLDOWN_TIMEOUT_S,
cooling.RESTART_IDLE_TIMEOUT_S, cooling.hw.initd)
cooling.SAMPLE_S = 0.1
cooling.IDLE_REF_TIMEOUT_S = 3
cooling.COOLDOWN_TIMEOUT_S = 4
cooling.RESTART_IDLE_TIMEOUT_S = 1
# The daemon restart of the last phase, scripted: the init script
# is a stub, and the engine that comes up applies `after_start`
# (the idle duties by default; a daemon that keeps the busy-start
# cooldown airflow applies those instead).
self.initd_calls = []
self.after_start = (0, 0)
def initd(service, action, timeout=60):
self.initd_calls.append((service, action))
if action == "start":
self.duty(*self.after_start)
return 0, ""
cooling.hw.initd = initd
self.fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
self.duty(0, 0)
self.fans(0, 736, 733)
def tearDown(self):
(cooling.SAMPLE_S, cooling.IDLE_REF_TIMEOUT_S, cooling.COOLDOWN_TIMEOUT_S,
cooling.RESTART_IDLE_TIMEOUT_S, cooling.hw.initd) = self.saved
self.grbl.close()
self.fc.stop()
os.environ.pop("GF_SYSFS_ROOT", None)
shutil.rmtree(self.tmp, ignore_errors=True)
# -- the machine -------------------------------------------------------
def duty(self, exhaust, intake):
for attr, v in (("thermal/exhaust_pwm", exhaust), ("thermal/intake_pwm", intake)):
with open(self.sysfs + attr, "w") as f:
f.write(str(v))
def fans(self, exhaust, i1, i2, air=2080):
self.fc.state["status"] = dict(self.fc.state["status"],
fans={"air_assist": air, "exhaust": exhaust, "intake_1": i1, "intake_2": i2})
def run_engine(self):
"""M8 -> run profile; M9 -> smoke phase, then idle duty, then the
tachs coast down (a few samples of spin-down), like forgectrl's
engine on the bench."""
def on_command(line):
if line == "M8":
self.duty(65535, 43278)
self.fc.state["cool"]["phase"] = "run"
self.fans(6753, 3212, 3328, air=10997)
elif line == "M9":
def cooldown():
self.fc.state["cool"]["phase"] = "smoke"
time.sleep(0.3)
self.fc.state["cool"]["phase"] = "idle"
self.duty(0, 0)
for ex, i1 in ((5030, 3319), (3100, 2200), (1200, 1300), (0, 740)):
self.fans(ex, i1, i1 + 5)
time.sleep(0.25)
threading.Thread(target=cooldown, daemon=True).start()
self.grbl.on_command = on_command
def run_test(self):
run = Run("test", "cooling.fans-quiet-after-motion", "t")
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
cooling.fans_quiet(ctx)
return run
# -- cases ------------------------------------------------------------------
def test_idle_machine_passes_and_logs_every_sample(self):
self.run_engine()
run = self.run_test()
self.assertEqual(run.evidence["before"]["exhaust"], 0)
self.assertIsNotNone(run.evidence["settle_s"])
self.assertEqual(run.evidence["duty_after"], cooling.IDLE_DUTY)
self.assertIn("M8", self.grbl.commands)
self.assertIn("M9", self.grbl.commands)
cooldown_lines = [ln for ln in run.lines if "cooldown +" in ln]
self.assertGreaterEqual(len(cooldown_lines), 2, run.lines)
def test_a_spin_down_in_progress_is_not_taken_as_the_idle_reference(self):
"""The bench case: the previous test's fans are still coasting when
this one starts. The reference waits for them to settle, and the
run passes instead of waiting for the fans to come back up."""
self.run_engine()
self.fans(5030, 3319, 3377) # coasting, engine already idle, duty 0
def coast():
for ex, i1 in ((3900, 2600), (2500, 1700), (1100, 1000), (0, 736), (0, 736)):
time.sleep(0.12)
self.fans(ex, i1, i1 + 5)
threading.Thread(target=coast, daemon=True).start()
run = self.run_test()
self.assertEqual(run.evidence["before"]["exhaust"], 0, run.evidence["before"])
self.assertGreater(run.evidence["idle_ref_s"], 0.3)
self.assertIsNotNone(run.evidence["settle_s"])
self.assertTrue(any("idle ref:" in ln for ln in run.lines))
def test_the_restart_phase_stops_disables_starts_and_sees_idle_duty(self):
self.run_engine()
run = self.run_test()
self.assertEqual(run.finished, None)
self.assertEqual(self.initd_calls, [("forgectrl", "stop"), ("forgectrl", "start")])
with open(self.sysfs + "cnc/disable") as f:
self.assertEqual(f.read(), "1")
self.assertEqual(run.evidence["duty_after_restart"], cooling.IDLE_DUTY)
self.assertIsNotNone(run.evidence["restart_idle_s"])
def test_a_daemon_that_keeps_the_busy_start_airflow_fails(self):
# The bench case: after a takeover's restart the exhaust ran at
# cooldown duty on an idle machine for good.
self.run_engine()
self.after_start = (32768, 21639)
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("kept the busy-start airflow", str(cm.exception))
self.assertIn("32768", str(cm.exception))
def test_fans_left_running_fail_with_the_reference_in_the_message(self):
def on_command(line):
if line == "M8":
self.duty(65535, 43278)
self.fc.state["cool"]["phase"] = "run"
self.fans(6753, 3212, 3328)
# M9 ignored: the run profile stays on
self.grbl.on_command = on_command
run = Run("test", "cooling.fans-quiet-after-motion", "t")
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
with self.assertRaises(Failed) as cm:
cooling.fans_quiet(ctx)
self.assertIn("did not return to the idle profile", str(cm.exception))
self.assertIn("idle reference", str(cm.exception))
self.assertIn("65535", str(cm.exception))
def test_a_reference_that_never_settles_fails_early_and_says_so(self):
def churn():
seq = (1000, 4000, 2500, 700, 3300, 1600, 4200) # aperiodic against the sampler
i = 0
while not self.grbl._stop:
v = seq[i % len(seq)]
i += 1
self.fans(v, v, v)
time.sleep(0.03)
threading.Thread(target=churn, daemon=True).start()
run = Run("test", "cooling.fans-quiet-after-motion", "t")
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
with self.assertRaises(Failed) as cm:
cooling.fans_quiet(ctx)
self.assertIn("never settled to an idle reference", str(cm.exception))
self.assertEqual(self.grbl.commands, []) # nothing was jogged
if __name__ == "__main__":
unittest.main()
class GateOffTests(unittest.TestCase):
"""cooling.gate-off against a scripted engine: the fake forgectrl
re-reads the ceiling at every M8 (as the engine reloads its tunables
at run start), trips OVERTEMP when the coolant is over it, skips the
gate and reports gates_off when the ceiling sits at its top, and
writes the run-start line the test looks for."""
TOP, BOTTOM = 60.0, 5.0
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.grbl = FakeGrbl()
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S)
cooling.VERDICT_WAIT_S = 3
cooling.SESSION_END_WAIT_S = 3
self.fc.state["status"] = dict(self.fc.state["status"],
coolant={"down_c": 22.4, "up_c": 22.3, "pump": True, "tec": False},
temps={"chassis_c": 29.0, "supply_raw": 589, "soc_c": 42.8,
"soc_throttle": 0},
gates_off=[])
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
"gates_off": []}
self.fc.state["settings"].update({"cool_temp_max": "", "cool_temp_resume": ""})
self.log_line = True # the engine writes its run-start line
self.temps_line = True # the engine writes its run-end temperature line
self.report_off = True # the engine reports the off gate
self.trips = True # the engine trips a low ceiling
self.sessions = 0 # run sessions the engine saw (M8 with the phase not run)
self._describe()
self.grbl.on_command = self._engine
self.fc.on_post = self._on_post
def tearDown(self):
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S = self.saved
self.grbl.close()
self.fc.stop()
# -- the scripted machine --------------------------------------------------
def ceiling(self):
v = self.fc.state["settings"].get("cool_temp_max") or ""
return float(v) if v else 33.0
def _describe(self):
"""/settings carries the gates table the way forgectrl's gates.c
publishes it, classified from the stored value."""
v = self.ceiling()
state = "off" if v >= self.TOP else ("ok" if 25 <= v <= 38 else "warn")
self.fc.state["settings"]["gates"] = {
"cool_temp_max": {"gate": "coolant_max", "def": 33, "lo": self.BOTTOM, "hi": self.TOP,
"band": [25, 38], "off": "high", "value": v, "state": state}}
def _on_post(self, path, form):
"""The real settings reply re-classifies the gates from the new values."""
if path != "/settings":
return None
self.fc.state["settings"].update(form)
self._describe()
return (200, self.fc.state["settings"])
def _engine(self, line):
"""M8 opens a run session: the engine re-reads the ceiling and
ticks the gate. M9 ends it a report period later (the phase
leaves run); a hold taken against the old ceiling stands until
the next session re-reads."""
self._describe()
cool = self.fc.state["cool"]
if line == "M9":
def end():
time.sleep(0.3)
cool["phase"] = "smoke"
if self.temps_line:
self.fc.state["logs_tail"]["text"] += (
"Aug 22 12:00:30 forgectrl: cool: temps this job: chassis 29.0..29.4 C, "
"soc 42.8..47.1 C, supply raw 587..592\n")
time.sleep(0.2)
cool["phase"] = "idle"
threading.Thread(target=end, daemon=True).start()
return
if line != "M8":
return
self.sessions += 1
cool["phase"] = "run"
v = self.ceiling()
off = v >= self.TOP
if off and self.log_line:
self.fc.state["logs_tail"]["text"] += (
"Aug 21 12:00:00 forgectrl: cool: gate coolant_max OFF: cool_temp_max = 60 "
"(the high end of 5 to 60; recommended 25 to 38, default 33)\n")
gates_off = ["coolant_max"] if off and self.report_off else []
if not off and self.trips and 22.3 > v:
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, gates_off=gates_off)
else:
cool.update(verdict="OK", fire_ok=True, hold=False, gates_off=gates_off)
self.fc.state["status"]["gates_off"] = gates_off
def run_test(self):
run = Run("test", "cooling.gate-off", "t")
ctx = Context(run, None, helpers.make_test("cooling.gate-off", []))
cooling.gate_off(ctx)
return run
def settings_posts(self):
return [f for p, f in self.fc.posts if p == "/settings"]
# -- cases ------------------------------------------------------------------
def test_trip_then_off_then_restored_passes(self):
run = self.run_test()
self.assertEqual(run.evidence["trip"]["verdict"], "OVERTEMP")
self.assertEqual(run.evidence["off"]["gates_off"], ["coolant_max"])
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
posts = self.settings_posts()
self.assertEqual(posts[0], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0"})
self.assertEqual(posts[1], {"cool_temp_max": "60.0", "cool_temp_resume": ""})
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
self.assertEqual(self.grbl.commands.count("M8"), 3)
self.assertEqual(self.grbl.commands.count("M9"), 3)
def test_an_engine_that_does_not_trip_fails_and_restores(self):
self.trips = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("did not trip", str(cm.exception))
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
# The restore cycles a run session so the engine re-reads the
# restored values; the bench is not left holding on the test's.
self.assertEqual(self.grbl.commands.count("M8"), 2)
self.assertEqual(self.grbl.commands.count("M9"), 2)
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
def test_every_session_waits_for_the_previous_one_to_end(self):
"""Each M8 must find the engine out of phase run, or the engine
never re-reads: the bench failure behind this case sent M9 and
the next M8 300 ms apart and the 1 Hz report pipeline swallowed
the session end."""
seen = []
inner = self._engine
def engine(line):
if line == "M8":
seen.append(self.fc.state["cool"]["phase"])
inner(line)
self.grbl.on_command = engine
self.run_test()
self.assertEqual(seen, ["idle", "idle", "idle"])
self.assertEqual(self.sessions, 3)
def test_an_engine_that_hides_the_off_gate_fails(self):
self.report_off = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("gates_off", str(cm.exception))
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
def test_a_missing_run_start_log_line_fails(self):
self.log_line = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("run-start log line", str(cm.exception))
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
def test_a_missing_run_end_temperature_line_fails(self):
self.temps_line = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("board-temperature line", str(cm.exception))
def test_a_custom_ceiling_is_restored_verbatim(self):
self.fc.state["settings"].update({"cool_temp_max": "30", "cool_temp_resume": "28"})
self._describe()
run = self.run_test()
self.assertEqual(run.evidence["orig"], {"cool_temp_max": "30", "cool_temp_resume": "28"})
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "30", "cool_temp_resume": "28"})
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "30")
class CriticalTierTests(unittest.TestCase):
"""cooling.critical-tier against a scripted engine: two tiers on the
coolant, the fail tier winning over the pause tier in a run session
and ending with it, the critical line off at its top, and the settings
cross-check refusing a critical line at or below the ceiling."""
UP = 22.3
ROWS = { # key: (gate, default, lo, hi, band_lo, band_hi, off)
"cool_temp_max": ("coolant_max", 33.0, 5.0, 60.0, 25.0, 38.0, "high"),
"cool_temp_resume": (None, 31.0, 5.0, 59.0, 20.0, 36.0, "none"),
"cool_temp_critical_c": ("coolant_critical", 38.0, 6.0, 70.0, 36.0, 45.0, "high"),
}
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.grbl = FakeGrbl()
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S)
cooling.VERDICT_WAIT_S = 3
cooling.SESSION_END_WAIT_S = 3
self.fc.state["status"] = dict(self.fc.state["status"],
coolant={"down_c": 22.4, "up_c": self.UP, "pump": True, "tec": False},
gates_off=[])
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
"gates_off": []}
for k in self.ROWS:
self.fc.state["settings"].setdefault(k, "")
self.fc.state["logs_tail"] = {"text": ""}
self.faults = True # the engine has the critical tier
self.ends_with_session = True
self.cross_checks = True # the settings API refuses crit <= max
self._describe()
self.grbl.on_command = self._engine
self.fc.on_post = self._on_post
def tearDown(self):
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S = self.saved
self.grbl.close()
self.fc.stop()
def setting(self, key):
v = self.fc.state["settings"].get(key) or ""
return float(v) if v else self.ROWS[key][1]
def _describe(self):
gates = {}
for k, (gate, d, lo, hi, blo, bhi, off) in self.ROWS.items():
v = self.setting(k)
state = "off" if (off == "high" and v >= hi) else ("ok" if blo <= v <= bhi else "warn")
gates[k] = {"gate": gate, "def": d, "lo": lo, "hi": hi, "band": [blo, bhi], "off": off,
"value": v, "state": state}
self.fc.state["settings"]["gates"] = gates
def _on_post(self, path, form):
if path != "/settings":
return None
if self.cross_checks:
merged = dict(self.fc.state["settings"])
merged.update(form)
tmax = float(merged.get("cool_temp_max") or 33.0)
tcrit = float(merged.get("cool_temp_critical_c") or 38.0)
ceiling_off = tmax >= self.ROWS["cool_temp_max"][3]
if not ceiling_off and tcrit <= tmax:
return (400, {"error": "cool_temp_critical_c must be above cool_temp_max"})
self.fc.state["settings"].update(form)
self._describe()
return (200, self.fc.state["settings"])
def _verdict_idle(self):
"""Outside a run only the ceiling's pause tier stands."""
return "OVERTEMP" if self.UP > self.setting("cool_temp_max") else "OK"
def _engine(self, line):
self._describe()
cool = self.fc.state["cool"]
if line == "M9":
def end():
time.sleep(0.3)
cool["phase"] = "idle"
if self.ends_with_session or cool["verdict"] != "CRITICAL":
v = self._verdict_idle()
cool.update(verdict=v, fire_ok=v == "OK", hold=v != "OK", reason="")
threading.Thread(target=end, daemon=True).start()
return
if line != "M8":
return
cool["phase"] = "run"
tmax, tcrit = self.setting("cool_temp_max"), self.setting("cool_temp_critical_c")
crit_off = tcrit >= self.ROWS["cool_temp_critical_c"][3]
off = ["coolant_critical"] if crit_off else []
if crit_off:
self.fc.state["logs_tail"]["text"] += (
"Aug 22 12:00:00 forgectrl: cool: gate coolant_critical OFF: cool_temp_critical_c = 70 "
"(the high end of 6 to 70; recommended 36 to 45, default 38)\n")
if self.faults and not crit_off and self.UP >= tcrit:
cool.update(verdict="CRITICAL", fire_ok=False, hold=True, resume_ok=False,
reason="CRITICAL: coolant %.1f C at or over the %.0f C critical line - hold, "
"no resume this job" % (self.UP, tcrit), gates_off=off)
elif self.UP > tmax:
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, resume_ok=False,
reason="coolant over the ceiling", gates_off=off)
else:
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off)
self.fc.state["status"]["gates_off"] = off
def run_test(self):
run = Run("test", "cooling.critical-tier", "t")
ctx = Context(run, None, helpers.make_test("cooling.critical-tier", []))
cooling.critical_tier(ctx)
return run
def settings_posts(self):
return [f for p, f in self.fc.posts if p == "/settings"]
# -- cases ------------------------------------------------------------------
def test_fault_then_off_then_restored_passes(self):
run = self.run_test()
self.assertEqual(run.evidence["cross_check"]["status"], 400)
self.assertEqual(run.evidence["critical"]["verdict"], "CRITICAL")
self.assertEqual(run.evidence["critical_after"]["verdict"], "OVERTEMP")
self.assertEqual(run.evidence["critical_off"]["gates_off"], ["coolant_critical"])
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
self.assertEqual(run.evidence["ceiling_off_accepted"], 200)
posts = self.settings_posts()
# refused cross-check, the ceiling at its off end (accepted), its undo, then the low leg
self.assertEqual(posts[1], {"cool_temp_max": "60.0"})
self.assertEqual(posts[3], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0",
"cool_temp_critical_c": "7.0"})
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": "", "cool_temp_critical_c": ""})
self.assertEqual(self.grbl.commands.count("M8"), 3)
self.assertEqual(self.grbl.commands.count("M9"), 3)
def test_an_engine_without_the_tier_fails_and_restores(self):
self.faults = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("did not fault", str(cm.exception))
self.assertEqual(self.settings_posts()[-1],
{"cool_temp_max": "", "cool_temp_resume": "", "cool_temp_critical_c": ""})
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
def test_a_fault_that_outlives_its_session_fails(self):
self.ends_with_session = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("after the faulted session", str(cm.exception))
def test_a_missing_cross_check_fails_before_any_session(self):
self.cross_checks = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("was accepted", str(cm.exception))
self.assertEqual(self.grbl.commands.count("M8"), 0)
class FanGateTests(unittest.TestCase):
"""cooling.fan-gate-trips against a scripted engine: the fake reads the
floors at every M8 (the engine reloads at run start), holds every fan
in grace for the configured seconds, then judges the bench readings
(exhaust 6753, intakes 3212/3328, air assist 10997 rpm, purge 628)
against the floors: a floor a reading cannot meet trips AIRFLOW three
ticks after the grace, a floor of zero reads off."""
READINGS = {"exhaust": 6753, "intake_1": 3212, "intake_2": 3328, "air_assist": 10997, "purge": 628}
FLOORS = {"exhaust": ("cool_tach_exhaust_min_rpm", 3700.0, 20000.0),
"intake_1": ("cool_tach_intake_min_rpm", 1800.0, 20000.0),
"intake_2": ("cool_tach_intake_min_rpm", 1800.0, 20000.0),
"air_assist": ("cool_tach_air_assist_min_rpm", 6000.0, 30000.0),
"purge": ("cool_purge_min_current", 300.0, 1023.0)}
GATE_OF = {"exhaust": "exhaust", "intake_1": "intake", "intake_2": "intake",
"air_assist": "air_assist", "purge": "purge"}
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.grbl = FakeGrbl()
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling.FAN_TRIP_WAIT_S)
cooling.VERDICT_WAIT_S = 4
cooling.SESSION_END_WAIT_S = 3
cooling.FAN_TRIP_WAIT_S = 4
self.fc.state["status"] = dict(self.fc.state["status"], gates_off=[])
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
"gates_off": [], "fan_gates": {}}
for key, _d, _h in self.FLOORS.values():
self.fc.state["settings"].setdefault(key, "")
self.fc.state["settings"].setdefault("cool_fan_grace_s", "")
self.trips = True # the engine trips an unmeetable floor
self.reports_off = True # the engine reports a zero floor as off
self.grace_scale = 0.1 # seconds of fake grace per configured second
self._describe()
self.grbl.on_command = self._engine
self.fc.on_post = self._on_post
def tearDown(self):
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling.FAN_TRIP_WAIT_S = self.saved
self.grbl.close()
self.fc.stop()
def setting(self, key, default):
v = self.fc.state["settings"].get(key) or ""
return float(v) if v else default
def _describe(self):
gates = {}
for fan, (key, default, hi) in self.FLOORS.items():
v = self.setting(key, default)
gates[key] = {"gate": self.GATE_OF[fan], "def": default, "lo": 0.0, "hi": hi,
"band": [default * 0.7, default * 1.4], "off": "low", "value": v,
"state": "off" if v <= 0 else "ok"}
g = self.setting("cool_fan_grace_s", 15.0)
gates["cool_fan_grace_s"] = {"gate": None, "def": 15.0, "lo": 0.0, "hi": 120.0, "band": [5, 30],
"off": "none", "value": g, "state": "ok"}
self.fc.state["settings"]["gates"] = gates
def _on_post(self, path, form):
if path != "/settings":
return None
self.fc.state["settings"].update(form)
self._describe()
return (200, self.fc.state["settings"])
def _engine(self, line):
self._describe()
cool = self.fc.state["cool"]
if line == "M9":
def end():
time.sleep(0.3)
cool["phase"] = "idle"
# the fan fault is the session's: it ends with it
cool.update(verdict="OK", fire_ok=True, hold=False, reason="")
for g in cool.get("fan_gates", {}).values():
g["state"] = "idle"
threading.Thread(target=end, daemon=True).start()
return
if line != "M8":
return
cool["phase"] = "run"
floors = {fan: self.setting(key, default) for fan, (key, default, _h) in self.FLOORS.items()}
off = sorted({self.GATE_OF[f] for f, v in floors.items() if v <= 0}) if self.reports_off else []
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off,
fan_gates={f: {"reading": self.READINGS[f], "floor": floors[f],
"state": "off" if floors[f] <= 0 else "grace"} for f in floors})
self.fc.state["status"]["gates_off"] = off
grace = self.setting("cool_fan_grace_s", 15.0) * self.grace_scale
def judge():
time.sleep(grace)
if cool["phase"] != "run":
return
for f in floors:
if floors[f] > 0:
cool["fan_gates"][f]["state"] = "ok" if self.READINGS[f] >= floors[f] else "under"
time.sleep(0.3)
if cool["phase"] != "run" or not self.trips:
return
for f in floors:
if 0 < floors[f] > self.READINGS[f]:
cool["fan_gates"][f]["state"] = "TRIPPED"
cool.update(verdict="AIRFLOW", fire_ok=False, hold=True, resume_ok=False,
reason="AIRFLOW: %s %d under the %d floor for 3 s - hold, no resume this job"
% (f, self.READINGS[f], floors[f]))
break
threading.Thread(target=judge, daemon=True).start()
def run_test(self):
run = Run("test", "cooling.fan-gate-trips", "t")
ctx = Context(run, None, helpers.make_test("cooling.fan-gate-trips", []))
cooling.fan_gate_trips(ctx)
return run
def settings_posts(self):
return [f for p, f in self.fc.posts if p == "/settings"]
def test_trip_purge_off_and_restore_pass(self):
run = self.run_test()
ev = run.evidence
self.assertEqual(ev["exhaust_trip"]["cool"]["verdict"], "AIRFLOW")
self.assertEqual(ev["exhaust_trip"]["gate"]["state"], "TRIPPED")
self.assertEqual(ev["purge_trip"]["gate"]["state"], "TRIPPED")
self.assertEqual(ev["exhaust_off"]["cool"]["gates_off"], ["exhaust"])
self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values()))
posts = self.settings_posts()
self.assertEqual(posts[0], {"cool_tach_exhaust_min_rpm": "20000.0", "cool_fan_grace_s": "8"})
self.assertEqual(posts[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
"cool_fan_grace_s": ""})
self.assertEqual(self.grbl.commands.count("M8"), 4)
self.assertEqual(self.grbl.commands.count("M9"), 4)
def test_an_engine_that_does_not_trip_fails_and_restores(self):
self.trips = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("did not trip", str(cm.exception))
self.assertEqual(self.settings_posts()[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
"cool_fan_grace_s": ""})
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
def test_an_engine_that_hides_an_off_floor_fails(self):
self.reports_off = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("lacks exhaust", str(cm.exception))
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
+712
View File
@@ -0,0 +1,712 @@
"""The bench actuator as the tool sees it.
The fixture is a box on the network that opens the lid loop, pulls the
interlock loop and presses the button on request. What has to hold: the
tool finds it by name without a resolver on the image, speaks its API
under the key, falls back to the operator when it cannot act, moves an
operator test it can perform alone into the unattended queue (never a
live one, never one that needs a person for anything else), refuses a
prompt during such a run rather than hanging on it, releases what the
box still holds after every run, and presses the arm button only where
the bench opted in.
"""
import json
import os
import shutil
import socket
import struct
import tempfile
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, HTTPServer
import helpers
from forgetest import fixture as fx
from forgetest import runner as runner_mod
from forgetest.log import Log
from forgetest.runner import Context, Failed, Run, Runner
KEY = "0123456789abcdef0123456789abcdef"
class FakeFixture:
"""The device's API, as fixture/README.md describes it."""
def __init__(self, button_enabled=True):
self.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
self.button_enabled = button_enabled
self.calls = []
self.presses = 0 # presses the device performed
srv = self
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _json(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _state(self):
return {"device": "forgefixture", "hostname": "forgefixture", "version": "1.0.0",
"idf": "v5.5.5", "uptime_s": 12, "channels": dict(srv.state),
"button_enabled": srv.button_enabled,
"button_pulsing": srv.state["button"] == "pressed",
"wifi": {"connected": True, "ip": "127.0.0.1", "rssi": -50}}
def _auth(self):
if self.headers.get("X-Fixture-Key") != KEY:
self._json(401, {"error": "X-Fixture-Key missing or wrong"})
return False
return True
def do_GET(self):
srv.calls.append(("GET", self.path))
if not self._auth():
return
self._json(200, self._state())
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n).decode() or "{}")
srv.calls.append(("POST", self.path, body))
if not self._auth():
return
if self.path in ("/lid", "/interlock"):
st = body.get("state")
if st not in ("open", "close", "closed"):
return self._json(400, {"error": "state must be \"open\" or \"close\""})
srv.state[self.path[1:]] = "open" if st == "open" else "closed"
return self._json(200, self._state())
if self.path == "/button":
if not srv.button_enabled:
return self._json(409, {"error": "button disabled: the enable jumper is out"})
if srv.state["button"] == "pressed":
return self._json(409, {"error": "a button pulse is in progress"})
srv.state["button"] = "pressed"
srv.presses += 1
threading.Timer(0.05, lambda: srv.state.__setitem__("button", "idle")).start()
s = self._state()
s["pulse_ms"] = 200
return self._json(200, s)
if self.path == "/release":
srv.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
return self._json(200, self._state())
self._json(404, {"error": "no such path"})
self.httpd = HTTPServer(("127.0.0.1", 0), H)
self.port = self.httpd.server_address[1]
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
def stop(self):
self.httpd.shutdown()
self.httpd.server_close()
class MdnsTests(unittest.TestCase):
def test_query_packet(self):
q = fx.mdns_query("forgefixture.local")
self.assertEqual(q[:12], struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0))
self.assertEqual(q[12:], b"\x0cforgefixture\x05local\x00" + struct.pack("!HH", 1, 0x8001))
def _response(self, name, ip, extra_name=None):
labels = b"".join(struct.pack("B", len(p)) + p.encode() for p in name.split(".")) + b"\x00"
hdr = struct.pack("!HHHHHH", 0, 0x8400, 0, 2 if extra_name else 1, 0, 0)
rr = labels + struct.pack("!HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(ip)
if extra_name:
# a second answer naming the first through a compression pointer
# to offset 12 (the first name), plus its own first label
other = struct.pack("B", len(extra_name)) + extra_name.encode() + struct.pack("!H", 0xC000 | 12)
rr += other + struct.pack("!HHIH", 1, 0x8001, 120, 4) + socket.inet_aton("10.0.0.9")
return hdr + rr
def test_answers_are_the_named_a_records(self):
data = self._response("forgefixture.local", "192.0.2.50")
self.assertEqual(fx.mdns_answers(data, "forgefixture.local"), ["192.0.2.50"])
self.assertEqual(fx.mdns_answers(data, "FORGEFIXTURE.local."), ["192.0.2.50"])
self.assertEqual(fx.mdns_answers(data, "other.local"), [])
# a query (QR clear) is never an answer
self.assertEqual(fx.mdns_answers(fx.mdns_query("forgefixture.local"), "forgefixture.local"), [])
# compression pointers are followed; the other record is not ours
data2 = self._response("forgefixture.local", "192.0.2.50", extra_name="printer")
self.assertEqual(fx.mdns_answers(data2, "forgefixture.local"), ["192.0.2.50"])
self.assertEqual(fx.mdns_answers(data2, "printer.forgefixture.local"), ["10.0.0.9"])
# garbage does not raise
self.assertEqual(fx.mdns_answers(b"\x00\x01", "forgefixture.local"), [])
class ConfigTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def write(self, obj):
p = os.path.join(self.tmp, "fixture.json")
with open(p, "w") as f:
json.dump(obj, f)
return p
def test_no_file_is_no_fixture(self):
self.assertIsNone(fx.load_config(os.path.join(self.tmp, "none.json")))
def test_defaults_and_checks(self):
cfg = fx.load_config(self.write({"key": KEY}))
self.assertEqual(cfg["hostname"], "forgefixture")
self.assertEqual(cfg["channels"], ["lid", "interlock", "button"])
self.assertFalse(cfg["arm_press"])
self.assertIsNone(cfg["ip"])
with self.assertRaises(fx.FixtureError):
fx.load_config(self.write({"hostname": "x"})) # no key
with self.assertRaises(fx.FixtureError):
fx.load_config(self.write({"key": KEY, "channels": ["lid", "laser"]}))
p = self.write({"key": KEY})
with open(p, "w") as f:
f.write("{not json")
with self.assertRaises(fx.FixtureError):
fx.load_config(p)
class ClientTests(unittest.TestCase):
def setUp(self):
self.dev = FakeFixture()
self.resolved = []
def tearDown(self):
self.dev.stop()
def resolver(self, hostname):
self.resolved.append(hostname)
return "127.0.0.1"
def client(self, **over):
cfg = {"hostname": "forgefixture", "key": KEY, "channels": ["lid", "interlock", "button"],
"port": self.dev.port}
cfg.update(over)
return fx.Fixture(cfg, resolver=self.resolver)
def test_status_covers_act_release(self):
f = self.client()
st = f.status()
self.assertEqual(st["device"], "forgefixture")
self.assertEqual(self.resolved, ["forgefixture"]) # resolved once
self.assertTrue(f.covers("lid") and f.covers("interlock") and f.covers("button"))
f.act("lid", "open")
self.assertEqual(self.dev.state["lid"], "open")
f.act("interlock", "open")
f.act("button", "press")
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {}))
time.sleep(0.15) # the fake's pulse ends
self.assertEqual(sorted(fx.Fixture.energized(f.status())), ["interlock", "lid"])
f.release()
self.assertEqual(fx.Fixture.energized(f.status()), [])
self.assertEqual(self.resolved, ["forgefixture"]) # the address is cached
with self.assertRaises(fx.FixtureError):
f.act("button", "hold")
with self.assertRaises(fx.FixtureError):
f.act("lid", "ajar")
def test_the_button_is_covered_only_with_the_jumper_in(self):
self.dev.button_enabled = False
f = self.client()
f.status()
self.assertTrue(f.covers("lid"))
self.assertFalse(f.covers("button"))
with self.assertRaises(fx.FixtureError) as cm:
f.act("button", "press")
self.assertIn("jumper", str(cm.exception))
def test_two_presses_are_spaced_so_the_controller_sees_the_release(self):
f = self.client()
f.status()
t0 = time.time()
f.act("button", "press")
f.act("button", "press")
took = time.time() - t0
posts = [c for c in self.dev.calls if c[:2] == ("POST", "/button")]
self.assertEqual(len(posts), 2) # no 409 round trip was needed
self.assertEqual(self.dev.presses, 2)
# the second waited for the first pulse (200 ms as reported) and the gap
self.assertGreaterEqual(took, 0.2 + fx.BUTTON_GAP_S - 0.05)
def test_a_pulse_in_progress_is_waited_out_then_retried(self):
f = self.client()
f.status()
self.dev.state["button"] = "pressed" # a press this client did not time
threading.Timer(0.3, lambda: self.dev.state.__setitem__("button", "idle")).start()
f.act("button", "press")
posts = [c for c in self.dev.calls if c[:2] == ("POST", "/button")]
self.assertEqual(len(posts), 2) # the 409, then the press
self.assertEqual(self.dev.presses, 1)
def test_a_pulse_that_never_ends_is_the_fixtures_error(self):
f = self.client()
f.status()
self.dev.state["button"] = "pressed"
with self.assertRaises(fx.FixtureError) as cm:
f.act("button", "press")
self.assertIn("in progress", str(cm.exception))
def test_a_wrong_key_is_refused(self):
f = self.client(key="wrong")
with self.assertRaises(fx.FixtureError) as cm:
f.status()
self.assertIn("refused the key", str(cm.exception))
def test_an_ip_override_skips_the_lookup(self):
f = self.client(ip="127.0.0.1")
f.status()
self.assertEqual(self.resolved, [])
def test_channels_not_wired_are_not_covered(self):
f = self.client(channels=["lid"])
f.status()
self.assertTrue(f.covers("lid"))
self.assertFalse(f.covers("interlock"))
def test_probe_without_a_config_is_none_and_a_silent_box_is_logged(self):
lines = []
self.assertIsNone(fx.probe(lines.append, path=os.path.join(tempfile.gettempdir(), "no-such-fixture.json")))
self.assertEqual(lines, [])
tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
try:
p = os.path.join(tmp, "fixture.json")
with open(p, "w") as f:
json.dump({"key": KEY, "ip": "127.0.0.1", "port": 1}, f)
# port 1 on localhost: nothing listens there
f2 = fx.probe(lines.append, path=p, resolver=lambda h: None)
self.assertIsNone(f2)
self.assertTrue(lines and "running without it" in lines[-1], lines)
finally:
shutil.rmtree(tmp, ignore_errors=True)
class CatalogTests(unittest.TestCase):
def test_fixture_runnable(self):
op = helpers.make_test("m.op", [], kind="operator", actions=("lid", "button"))
self.assertTrue(op.fixture_runnable(("lid", "interlock", "button")))
self.assertFalse(op.fixture_runnable(("lid",))) # the button is not covered
self.assertFalse(helpers.make_test("m.live", [], kind="live", actions=("lid",))
.fixture_runnable(("lid",))) # live never downgrades
self.assertFalse(helpers.make_test("m.app", [], kind="operator", hands=("app",))
.fixture_runnable(("lid", "interlock", "button")))
self.assertFalse(helpers.make_test("m.none", [], kind="operator")
.fixture_runnable(("lid",))) # no actions: a person does something
self.assertFalse(helpers.make_test("m.auto", []).fixture_runnable(("lid",)))
def test_an_auto_test_asks_nothing_of_a_person(self):
from forgetest import catalog
with self.assertRaises(ValueError):
catalog.test("z.auto", title="t", subsystem="z", hands=("app",))(lambda ctx: None)
class StubFixture:
"""What the runner needs of a fixture, scripted."""
def __init__(self, channels=("lid", "interlock", "button"), button_enabled=True, arm_press=False,
fail=False, fc=None):
self.fc = fc # the fake forgectrl whose switches follow the actions
self.hostname = "forgefixture"
self._ip = "127.0.0.1"
self.channels = tuple(channels)
self.button_enabled = button_enabled
self.arm_press = arm_press
self.fail = fail
self.acts = []
self.held = []
self.released = 0
def covers(self, channel):
return channel in self.channels and (channel != "button" or self.button_enabled)
def act(self, channel, state):
if self.fail:
raise fx.FixtureError("the box is off")
self.acts.append((channel, state))
if state == "open":
self.held.append(channel)
if self.fc is not None and channel in ("lid", "interlock"):
sw = self.fc.state["status"]["switches"]
sw["lid" if channel == "lid" else "interlock_ok"] = state != "open"
def status(self):
return {"channels": {c: ("open" if c in self.held else "closed") for c in ("lid", "interlock")}}
@staticmethod
def energized(state):
return fx.Fixture.energized(state)
def release(self):
self.released += 1
self.held = []
def summary(self):
return {"hostname": self.hostname, "ip": self._ip, "channels": list(self.channels),
"button_enabled": self.button_enabled, "arm_press": self.arm_press}
def t_lid(ctx):
ctx.ready("On Ready the lid opens")
ctx.act("lid", "open")
ctx.act("lid", "close")
def t_asks(ctx):
ctx.act("lid", "open")
ctx.confirm("Did it?")
class RoutingTests(unittest.TestCase):
"""The queues with a fixture up: the runner's probe is scripted."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-fixture-run-")
os.environ["FORGETEST_DATA"] = self.tmp
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
self.fc = helpers.FakeForgectrl().start()
self.man = helpers.make_manifest()
self.reg = helpers.registry(
helpers.make_test("r.auto", [("forgectrl", "src/ui.c")]),
helpers.make_test("r.lid", [("forgectrl", "src/auth.c")], kind="operator", actions=("lid",), fn=t_lid),
helpers.make_test("r.btn", [("forgectrl", "src/cool.c")], kind="operator", actions=("button",), fn=t_lid),
helpers.make_test("r.app", [("forgectrl", "src/main.c")], kind="operator", actions=("lid",),
hands=("app",), fn=t_lid),
helpers.make_test("r.live", [("grblhal-glowforge", "src/**")], kind="live", actions=("lid",)),
helpers.make_test("r.asks", [("linux-fslc", "**")], kind="operator", actions=("lid",), fn=t_asks),
)
self.log = Log(os.path.join(self.tmp, "results.jsonl"))
self.runner = Runner(self.log, self.man, self.reg)
self.saved_probe = fx.probe
self.stub = StubFixture(fc=self.fc)
fx.probe = lambda log, path=None, resolver=None: self.stub
def tearDown(self):
fx.probe = self.saved_probe
self.fc.stop()
for k in ("FORGETEST_DATA", "FORGETEST_MARKER"):
os.environ.pop(k, None)
shutil.rmtree(self.tmp, ignore_errors=True)
def selection(self):
st, _ = self.runner.state()
return st["batch_available"], st["fixture"]
def test_operator_tests_the_fixture_covers_move_to_the_unattended_queue(self):
av, summary = self.selection()
self.assertEqual(av["unattended"], ["r.auto", "r.lid", "r.btn", "r.asks"])
self.assertEqual(av["attended"], ["r.app", "r.live"]) # needs the app; live never moves
self.assertEqual(summary["hostname"], "forgefixture")
def test_without_the_jumper_the_button_tests_stay_attended(self):
self.stub.button_enabled = False
av, _ = self.selection()
self.assertIn("r.btn", av["attended"])
self.assertIn("r.lid", av["unattended"])
def test_no_fixture_is_the_old_routing(self):
fx.probe = lambda log, path=None, resolver=None: None
self.runner._fixture_probed = 0.0
av, summary = self.selection()
self.assertEqual(av["unattended"], ["r.auto"])
self.assertIsNone(summary)
def test_a_queue_started_during_a_probe_waits_for_the_fixture(self):
# The daemon just came up: nothing probed yet, a page poll starts
# the first probe (slow: an mDNS answer), and the queue start
# lands while it is in flight. The start must see the fixture.
stub = self.stub
def slow_probe(log, path=None, resolver=None):
time.sleep(0.5)
return stub
fx.probe = slow_probe
self.runner._fixture_probed = 0.0
self.runner.fixture = None
poll = threading.Thread(target=self.runner.probe_fixture)
poll.start()
time.sleep(0.1)
order = self.runner.batch_selection("unattended")
poll.join()
self.assertIn("r.lid", order)
self.assertIn("r.btn", order)
def run_queue(self, group, ack_live=False):
ok, msg, order = self.runner.start_batch(group, ack_live=ack_live)
self.assertTrue(ok, msg)
deadline = time.time() + 30
while not self.runner.batch["finished"] and time.time() < deadline:
time.sleep(0.05)
return self.runner.batch_snapshot(), order
def last_result(self, tid):
for rec in reversed(self.log.read()):
if rec.get("t") == "result" and rec["test"] == tid:
return rec
return None
def test_the_fixture_performs_the_actions_and_the_ready_gate_passes(self):
# r.asks confirms after its action: a person is asked -> a FAIL that
# says so, and the queue stops there, after r.lid and r.btn passed
b, order = self.run_queue("unattended")
self.assertEqual(order, ["r.auto", "r.lid", "r.btn", "r.asks"])
results = {x["test"]: x["result"] for x in b["done"]}
self.assertEqual(results["r.lid"], "PASS")
self.assertEqual(results["r.btn"], "PASS")
self.assertEqual(results["r.asks"], "FAIL")
rec = self.last_result("r.lid")
acts = rec["evidence"]["actions"]
self.assertEqual([(a["channel"], a["state"], a["by"]) for a in acts],
[("lid", "open", "fixture"), ("lid", "close", "fixture")])
self.assertTrue(any("READY (fixture performs the step)" in l for l in rec["log"]), rec["log"])
self.assertEqual(rec["evidence"]["fixture"]["channels"], ["lid", "interlock", "button"])
asks = self.last_result("r.asks")
self.assertIn("declare the step in hands=", asks["message"])
def test_a_failing_box_hands_the_action_to_the_operator(self):
self.stub.fail = True
run = Run("test", "r.lid", "r.lid")
ctx = Context(run, self.runner, self.reg["r.lid"])
self.runner.probe_fixture(force=True)
seen = []
ctx.wait_for = lambda cond, timeout, poll=0.25: 1.0 # the machine shows the state
run.set_notice = lambda text: seen.append(text)
ctx.act("lid", "open")
rec = run.evidence["actions"][0]
self.assertEqual(rec["by"], "operator")
self.assertIn("the box is off", rec["fixture_error"])
self.assertTrue(seen and "lid" in seen[0].lower())
def test_a_failing_box_in_an_unattended_run_ends_the_test_as_an_error(self):
# nobody is in the room: the run must not wait ACT_TIMEOUT_S for a
# hand that is not there; it ends at once, as the harness's error
self.stub.fail = True
t0 = time.time()
b, order = self.run_queue("unattended")
results = {x["test"]: x["result"] for x in b["done"]}
self.assertEqual(results["r.lid"], "ERROR")
self.assertLess(time.time() - t0, 20)
rec = self.last_result("r.lid")
self.assertIn("the fixture could not perform a step", rec["message"])
self.assertIn("the box is off", rec["message"])
act = rec["evidence"]["actions"][0]
self.assertEqual((act["by"], act["fixture_error"]), ("fixture", "the box is off"))
self.assertFalse(any("asking the operator" in l for l in rec["log"]), rec["log"])
def test_what_the_box_still_holds_is_released_after_a_run(self):
def holds(ctx):
ctx.act("lid", "open") # and never closes it
self.reg["r.hold"] = helpers.make_test("r.hold", [("forgectrl", "src/ui.c")], kind="operator",
actions=("lid",), fn=holds)
self.runner.registry = self.reg
ok, msg, run = self.runner._start_test("r.hold")
self.assertTrue(ok, msg)
while run.finished is None:
time.sleep(0.05)
self.assertEqual(self.stub.released, 1)
rec = self.last_result("r.hold")
self.assertEqual(rec["evidence"]["fixture"]["released"], ["lid"])
def press_button(self, down):
self.fc.state["status"]["switches"]["button"] = bool(down)
def test_ready_takes_a_press_on_the_machine_as_the_presence_check(self):
"""With an actuator wired to the button, the operator proves they
are at the machine by pressing it, not by clicking the page. The
actuator then owns every press in the test, so no press in a live
cut is a person's and none can go uncounted."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
asked = []
run.ask = lambda q, o: asked.append(q)
self.press_button(False)
threading.Timer(0.3, self.press_button, args=(True,)).start()
threading.Timer(0.8, self.press_button, args=(False,)).start()
ctx.ready("LIVE FIRE. Scrap under the head.")
self.assertEqual(asked, []) # no page click asked for
self.assertTrue(run.fixture_takeover)
rec = [r for r in run.evidence["actions"] if r["state"] == "presence"]
self.assertEqual(len(rec), 1)
self.assertEqual(rec[0]["by"], "operator")
self.assertTrue(any("press the button on the machine" in ln.lower() for ln in run.lines))
def test_presence_is_proved_once_per_test_not_once_per_gate(self):
"""A test with two armed halves reaches the ready gate twice. The
second one must not ask for another press: the operator proved
presence a moment earlier and the actuator has the presses. The
setup line still goes up, because the second half may want the
scrap moved."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
asked = []
run.ask = lambda q, o: asked.append(q)
self.press_button(False)
threading.Timer(0.3, self.press_button, args=(True,)).start()
threading.Timer(0.8, self.press_button, args=(False,)).start()
ctx.ready("First half.")
self.assertTrue(run.fixture_takeover)
presses = [r for r in run.evidence["actions"] if r["state"] == "presence"]
self.assertEqual(len(presses), 1)
# the second gate: returns at once, no new press, notice still shown
seen = []
run.set_notice = lambda text: seen.append(text)
t0 = time.time()
ctx.ready("Second half. Move the scrap.")
self.assertLess(time.time() - t0, 1.0)
self.assertEqual(asked, [])
self.assertEqual(len([r for r in run.evidence["actions"] if r["state"] == "presence"]), 1)
self.assertIn("Second half. Move the scrap.", seen)
self.assertTrue(any("presence already proved" in ln for ln in run.lines))
def test_the_presence_press_hands_the_arm_press_to_the_actuator(self):
"""The bench's standing opt-in is not needed once the operator has
proved presence: that press is what the opt-in existed to
establish."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
self.stub.arm_press = False
run.fixture_takeover = True
saved = runner_mod.hw.button_lit
runner_mod.hw.button_lit = lambda: True
try:
self.assertTrue(ctx.arm_press())
deadline = time.time() + 5
while ("button", "press") not in self.stub.acts and time.time() < deadline:
time.sleep(0.05)
self.assertIn(("button", "press"), self.stub.acts)
finally:
runner_mod.hw.button_lit = saved
def test_the_press_prompt_is_what_the_actuator_presses_on(self):
"""The machine's own press prompt, not a button LED read from
here: the button is lit through parts of a live check that are
not the arm, and a press then lands before the job waits for it
and is lost. press_now presses at the moment the check asks, with
no LED reading and no waiting thread."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
self.stub.arm_press = False
run.fixture_takeover = True
saved = runner_mod.hw.button_lit
runner_mod.hw.button_lit = lambda: False # never consulted
try:
self.assertTrue(ctx.press_now())
self.assertIn(("button", "press"), self.stub.acts)
finally:
runner_mod.hw.button_lit = saved
rec = [r for r in run.evidence["actions"] if r["state"] == "arm"][-1]
self.assertEqual(rec["by"], "fixture")
def test_the_press_prompt_goes_to_the_operator_without_an_actuator(self):
"""No actuator, or one that never took this test's presses: the
notice goes up for a person, who is looking at the same prompt."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
self.stub.arm_press = False
run.fixture_takeover = False
seen = []
run.set_notice = lambda text: seen.append(text)
self.assertFalse(ctx.press_now("Press it now."))
self.assertNotIn(("button", "press"), self.stub.acts)
self.assertIn("Press it now.", seen)
def test_the_arm_press_waits_as_long_as_the_card_says_for_the_light(self):
"""A card that settles the coolant before it lights the button
takes minutes; the caller names the wait, and the actuator presses
when the light comes instead of handing the press to a person at
the default 60 s (found on the bench by the flow-load card)."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
run.fixture_takeover = True
lit_at = time.time() + 0.6
saved = runner_mod.hw.button_lit
runner_mod.hw.button_lit = lambda: time.time() >= lit_at
seen = []
run.set_notice = lambda text: seen.append(text)
try:
self.assertTrue(ctx.arm_press(lit_timeout=5))
deadline = time.time() + 5
while ("button", "press") not in self.stub.acts and time.time() < deadline:
time.sleep(0.05)
self.assertIn(("button", "press"), self.stub.acts)
self.assertEqual([n for n in seen if n], []) # nobody was asked
rec = run.evidence["actions"][-1]
self.assertEqual(rec["by"], "fixture")
self.assertGreaterEqual(rec["took_s"], 0.5)
finally:
runner_mod.hw.button_lit = saved
def test_an_actuator_lost_after_the_takeover_is_said_out_loud(self):
"""Falling back to the operator without a word is how one dropped
actuator becomes a press nobody can account for afterwards."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
run.fixture_takeover = True
self.runner.fixture = None # the box drops off mid-test
self.fc.state["status"]["switches"]["lid"] = True
threading.Timer(0.3, lambda: self.fc.state["status"]["switches"].__setitem__("lid", False)).start()
ctx.act("lid", "open", timeout=5)
rec = run.evidence["actions"][-1]
self.assertTrue(rec.get("fixture_lost"))
self.assertEqual(rec["by"], "operator")
self.assertTrue(any("WARNING" in ln and "now gone" in ln for ln in run.lines))
def test_ready_still_asks_the_page_with_no_actuator(self):
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.fixture = None
asked = []
def ask(q, o):
asked.append((q, list(o)))
return "Ready"
run.ask = ask
ctx.ready("LIVE FIRE. Scrap under the head.")
self.assertEqual(asked, [("LIVE FIRE. Scrap under the head.", ["Ready", "Cannot"])])
self.assertFalse(run.fixture_takeover)
def test_the_arm_press_is_the_operators_unless_the_bench_opted_in(self):
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
seen = []
run.set_notice = lambda text: seen.append(text)
self.assertFalse(ctx.arm_press())
self.assertEqual(run.evidence["actions"][0]["by"], "operator")
self.assertTrue(seen)
# opted in: the press waits for the button to light
self.stub.arm_press = True
lit = {"v": False}
saved = runner_mod.hw.button_lit
runner_mod.hw.button_lit = lambda: lit["v"]
try:
seen[:] = []
self.assertTrue(ctx.arm_press())
time.sleep(0.3)
self.assertEqual(self.stub.acts, []) # not yet: dark button
lit["v"] = True
deadline = time.time() + 5
while not self.stub.acts and time.time() < deadline:
time.sleep(0.05)
self.assertEqual(self.stub.acts, [("button", "press")])
self.assertEqual(run.evidence["actions"][1]["by"], "fixture")
self.assertEqual(seen, []) # no notice went up
finally:
runner_mod.hw.button_lit = saved
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -0,0 +1,24 @@
import unittest
from forgetest.suite import image
class KernelIdentTests(unittest.TestCase):
def test_localversion_hash_is_stripped(self):
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc-g72a0b1431a9d"), "6.12.20-fslc-fslc")
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc+g72a0b1431a9d"), "6.12.20-fslc-fslc")
def test_plain_name_is_unchanged(self):
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc"), "6.12.20-fslc-fslc")
self.assertEqual(image.kernel_ident("6.12.20-fslc-g12"), "6.12.20-fslc-g12")
def test_release_matches_manifest_with_or_without_hash(self):
rel = "6.12.20-fslc-fslc-g72a0b1431a9d"
self.assertTrue(image.kernel_matches(rel, ["6.12.20-fslc-fslc"]))
self.assertTrue(image.kernel_matches(rel, ["6.12.20-fslc-fslc-gf52af5b522f4"]))
self.assertTrue(image.kernel_matches(rel, []))
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
if __name__ == "__main__":
unittest.main()
+67
View File
@@ -0,0 +1,67 @@
"""wait_hv_off against a scripted charge-pump readback: a chain that
releases inside the window passes, a chain held past it is the refusal,
and a chain already off costs no wait."""
import time
import unittest
from forgetest.suite import kernel
class Ctx:
def __init__(self):
self.evidence = {}
self.lines = []
def sleep(self, s):
time.sleep(s)
def log(self, fmt, *a):
self.lines.append(fmt % a)
def scripted(alive_for_s):
t0 = time.time()
def rd(path):
if path == "cnc/charge_pump_alive":
return "1" if time.time() - t0 < alive_for_s else "0"
if path == "cnc/state":
return "idle"
return None
return rd
class WaitHvOffTests(unittest.TestCase):
def setUp(self):
self._rd = kernel.rd
def tearDown(self):
kernel.rd = self._rd
def test_release_inside_the_window_passes(self):
kernel.rd = scripted(0.3)
ctx = Ctx()
t0 = time.time()
self.assertIsNone(kernel.wait_hv_off(ctx, timeout_s=2.0))
self.assertGreaterEqual(time.time() - t0, 0.25)
self.assertTrue(any("released" in l for l in ctx.lines))
self.assertGreaterEqual(ctx.evidence["hv_release_s"][0], 0.25)
def test_chain_held_past_the_window_is_the_refusal(self):
kernel.rd = scripted(99)
ctx = Ctx()
why = kernel.wait_hv_off(ctx, timeout_s=0.3)
self.assertIn("charge_pump_alive=1", why)
self.assertTrue(any("still held" in l for l in ctx.lines))
def test_chain_already_off_costs_no_wait(self):
kernel.rd = scripted(0)
ctx = Ctx()
t0 = time.time()
self.assertIsNone(kernel.wait_hv_off(ctx, timeout_s=2.0))
self.assertLess(time.time() - t0, 0.1)
self.assertEqual(ctx.lines, [])
if __name__ == "__main__":
unittest.main()
+46
View File
@@ -0,0 +1,46 @@
"""The dwell-gap verdict of laser.emission-witness over synthetic trails:
the button latch judged from the first emission through every sample
whose readback word shows the laser latch unlocked, both bits from that
word, so the relocked tail (where the relock itself sets the latch and
the emission counter still reads nonzero) is never judged."""
import unittest
from forgetest.suite import laser
def smp(emission, latch, locked=False, hv_enable=True, armed=True):
il = (laser.IL_BUTTON_LATCH if latch else 0) | (laser.IL_LASER_LATCH if locked else 0)
return {"emission": emission, "armed": armed, "il": il, "hv_enable": hv_enable}
class DwellGapTests(unittest.TestCase):
def test_the_relocked_tail_is_not_judged(self):
# the arm wait (unlocked, latch set until the press), fire, the
# gap, fire, then the relock sets the latch while the counter
# still reads nonzero
trail = [smp(0, 1), smp(0, 0), smp(10, 0), smp(20, 0, hv_enable=False),
smp(20, 0, hv_enable=False), smp(30, 0), smp(40, 0),
smp(40, 1, locked=True, armed=False), smp(40, 1, locked=True, armed=False),
smp(0, 1, locked=True, armed=False)]
g = laser.dwell_gap(trail)
self.assertEqual(g["button_latch_unlocked_max"], 0)
self.assertEqual(g["button_latch_unlocked_samples"], 5)
self.assertEqual(g["button_latch_set_at"], [])
self.assertTrue(g["hv_enable_dipped"])
self.assertTrue(g["hv_enable_back_lit"])
def test_latch_set_while_unlocked_is_the_defect(self):
trail = [smp(0, 0), smp(10, 0), smp(20, 1, hv_enable=False), smp(30, 0), smp(0, 1, locked=True)]
g = laser.dwell_gap(trail)
self.assertEqual(g["button_latch_unlocked_max"], 1)
self.assertEqual(g["button_latch_set_at"], [1])
def test_no_emission_judges_nothing(self):
g = laser.dwell_gap([smp(0, 0), smp(0, 1, locked=True)])
self.assertIsNone(g["button_latch_unlocked_max"])
self.assertEqual(g["button_latch_unlocked_samples"], 0)
self.assertFalse(g["hv_enable_dipped"])
if __name__ == "__main__":
unittest.main()
+282
View File
@@ -0,0 +1,282 @@
import os
import unittest
import helpers
from forgetest import manifest as m
from forgetest import catalog
class GlobTests(unittest.TestCase):
def test_star_and_doublestar(self):
rx = m.glob_to_regex("src/*.c")
self.assertTrue(rx.match("src/main.c"))
self.assertFalse(rx.match("src/sub/main.c"))
rx = m.glob_to_regex("src/**")
self.assertTrue(rx.match("src/main.c"))
self.assertTrue(rx.match("src/sub/deep/x.h"))
self.assertFalse(rx.match("docs/x"))
rx = m.glob_to_regex("**/*.md")
self.assertTrue(rx.match("README.md"))
self.assertTrue(rx.match("docs/a/b.md"))
self.assertFalse(rx.match("docs/a/b.txt"))
rx = m.glob_to_regex("**")
self.assertTrue(rx.match("anything/at/all"))
rx = m.glob_to_regex("src/gfcool*")
self.assertTrue(rx.match("src/gfcool_client.c"))
self.assertFalse(rx.match("src/x/gfcool.c"))
def test_question_mark_and_escaping(self):
rx = m.glob_to_regex("a?c.d")
self.assertTrue(rx.match("abc.d"))
self.assertFalse(rx.match("abcxd"))
self.assertFalse(rx.match("a/c.d"))
class FingerprintTests(unittest.TestCase):
def setUp(self):
self.man = helpers.make_manifest()
def test_stable_and_order_independent(self):
c1 = [("forgectrl", "src/ui.c"), ("forgectrl", "src/auth.c")]
c2 = list(reversed(c1))
self.assertEqual(m.fingerprint(self.man, c1), m.fingerprint(self.man, c2))
self.assertEqual(m.fingerprint(self.man, c1), m.fingerprint(self.man, c1))
def test_changes_only_when_covered_file_changes(self):
covers = [("forgectrl", "src/ui.c")]
base = m.fingerprint(self.man, covers)
other = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
self.assertEqual(base, m.fingerprint(other, covers), "an uncovered change must not move the fingerprint")
changed = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
self.assertNotEqual(base, m.fingerprint(changed, covers))
added = helpers.with_file(self.man, "forgectrl", "src/ui_extra.c", "new")
self.assertEqual(base, m.fingerprint(added, covers), "an unmatched new file does not move it")
added2 = helpers.with_file(self.man, "forgectrl", "src/x.c", "new")
self.assertNotEqual(base, m.fingerprint(added2, [("forgectrl", "src/*.c")]))
def test_platform_is_always_in(self):
covers = [("forgectrl", "src/ui.c")]
base = m.fingerprint(self.man, covers)
p2 = helpers.with_platform(self.man, dtb={"glowforge.dtb": "e" * 64})
self.assertNotEqual(base, m.fingerprint(p2, covers))
p3 = helpers.with_platform(self.man, layers={"meta-forgefirm": {"content_sha256": "2" * 64},
"poky": {"rev": "p" * 40}})
self.assertNotEqual(base, m.fingerprint(p3, covers))
def test_kernel_pseudo_files(self):
covers = [("linux-fslc", "**")]
base = m.fingerprint(self.man, covers)
k2 = helpers.with_file(self.man, "linux-fslc", "@config", "cfg2")
self.assertNotEqual(base, m.fingerprint(k2, covers))
def test_missing_component_marker(self):
f1 = m.fingerprint(self.man, [("nonexistent", "**")])
f2 = m.fingerprint(self.man, [("forgectrl", "nothing-matches-*")])
self.assertNotEqual(f1, f2)
def test_dev_only_refused(self):
with self.assertRaises(ValueError):
m.fingerprint(self.man, [("forgetest", "**")])
def test_extra_folds_in(self):
covers = [("forgectrl", "src/ui.c")]
self.assertNotEqual(m.fingerprint(self.man, covers, extra=["a"]),
m.fingerprint(self.man, covers, extra=["b"]))
def test_submodule_gitlink_and_files(self):
covers = [("grblhal-glowforge", "src/grbl/**")]
base = m.fingerprint(self.man, covers)
core = helpers.with_file(self.man, "grblhal-glowforge", "src/grbl/core.c", "core v2")
self.assertNotEqual(base, m.fingerprint(core, covers))
link = [("grblhal-glowforge", "src/grbl")]
self.assertEqual(m.fingerprint(self.man, link), m.fingerprint(core, link),
"the gitlink alone does not see a file-level change (fixture keeps the link id)")
def test_identity_sha_ignores_dev_only(self):
a = self.man.identity_sha()
b = helpers.with_file(self.man, "forgetest", "forgetest/x.py", "changed").identity_sha()
self.assertEqual(a, b)
c = helpers.with_file(self.man, "forgectrl", "src/ui.c", "changed").identity_sha()
self.assertNotEqual(a, c)
class CoverageReportTests(unittest.TestCase):
def test_report(self):
man = helpers.make_manifest()
t1 = helpers.make_test("a.one", [("forgectrl", "src/ui.c"), ("forgectrl", "src/auth.c")])
t2 = helpers.make_test("a.two", [("grblhal-glowforge", "**"), ("kernel-module-glowforge", "**"),
("linux-fslc", "**")])
rep = m.coverage_report(man, [t1, t2], allow=[("*", "**/*.md")])
self.assertEqual(set(rep), {"forgectrl"})
self.assertEqual(rep["forgectrl"], ["src/cool.c", "src/main.c"])
rep2 = m.coverage_report(man, [t1, t2, helpers.make_test("a.three", [("forgectrl", "src/**")])],
allow=[("*", "**/*.md")])
self.assertEqual(rep2, {})
self.assertNotIn("forgetest", m.coverage_report(man, [], allow=[]),
"dev-only components are outside the report")
def test_non_behavioral_paths_are_outside_every_fingerprint(self):
man = helpers.make_manifest()
t = helpers.make_test("a.one", [("forgectrl", "**")])
a = m.fingerprint(man, t.covers)
for comp, path in (("forgectrl", "README.md"), ("forgectrl", "docs/SERVICES.md"),
("forgectrl", "tests/test_x.py"), ("forgectrl", ".github/workflows/ci.yml")):
self.assertEqual(a, m.fingerprint(helpers.with_file(man, comp, path, "changed"), t.covers),
path)
self.assertNotEqual(a, m.fingerprint(helpers.with_file(man, "forgectrl", "src/ui.c", "changed"),
t.covers))
self.assertTrue(m.non_behavioral("forgectrl", "tools/devserver.py"))
self.assertFalse(m.non_behavioral("grblhal-glowforge", "tools/devserver.py"))
def test_an_entry_that_selects_nothing_is_reported(self):
man = helpers.make_manifest()
good = helpers.make_test("a.one", [("forgectrl", "src/ui.c")])
# the recipe's subdirectory left out of the glob, and an unknown component
bad = helpers.make_test("a.two", [("forgectrl", "ui.c"), ("no-such", "**")])
self.assertEqual(m.empty_covers(man, [good]), [])
self.assertEqual(m.empty_covers(man, [good, bad]),
[("a.two", "forgectrl", "ui.c"), ("a.two", "no-such", "**")])
# a glob that selects docs only names nothing any fingerprint carries
docs = helpers.make_test("a.three", [("forgectrl", "**/*.md")])
self.assertEqual(m.empty_covers(man, [docs]), [("a.three", "forgectrl", "**/*.md")])
class CatalogTests(unittest.TestCase):
def test_catalog_hash_is_definition_only(self):
r1 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")]))
r2 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")], fn=lambda ctx: 1))
self.assertEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r2))
r3 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/**")]))
self.assertNotEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r3))
r4 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")], always=True))
self.assertNotEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r4))
def test_validate(self):
r = helpers.registry(helpers.make_test("a.one", [], requires=("a.two",)))
with self.assertRaises(ValueError):
catalog.validate(r)
r = helpers.registry(helpers.make_test("a.one", [], requires=("a.two",)),
helpers.make_test("a.two", [], requires=("a.one",)))
with self.assertRaises(ValueError):
catalog.validate(r)
def test_decorator_rules(self):
with self.assertRaises(ValueError):
catalog.test("bad id", title="x", subsystem="s")(lambda c: None)
with self.assertRaises(ValueError):
catalog.test("s.x", title="x", subsystem="s", covers=[("forgetest", "**")])(lambda c: None)
def test_real_suite_loads_and_validates(self):
reg = catalog.load_suite()
self.assertIn("image.health", reg)
self.assertTrue(reg["image.health"].always)
catalog.validate(reg)
for t in catalog.all_tests(reg):
self.assertEqual(len(t.source_sha), 64)
if __name__ == "__main__":
unittest.main()
class ImplementationHashTests(unittest.TestCase):
"""A test's implementation hash is its own function plus the code its
module shares: a body edit moves one test, a helper edit moves them
all, and a file without @test functions hashes whole."""
MODULE = '''"""a suite module"""
from forgetest.catalog import test
def helper(x):
return x + 1
@test("m.one", title="one", subsystem="m")
def one(ctx):
ctx.log(helper(1))
@test("m.two", title="two", subsystem="m",
steps=["a step"])
def two(ctx):
ctx.log(helper(2))
'''
def setUp(self):
import tempfile
self.tmp = tempfile.mkdtemp(prefix="forgetest-impl-")
self.path = os.path.join(self.tmp, "mod.py")
self.write(self.MODULE)
def tearDown(self):
import shutil
shutil.rmtree(self.tmp, ignore_errors=True)
def write(self, text):
catalog._PARTS.pop(self.path, None)
with open(self.path, "w", newline="\n") as f:
f.write(text)
def shas(self):
return catalog.implementation_sha(self.path, "m.one"), catalog.implementation_sha(self.path, "m.two")
def test_a_body_edit_moves_that_test_only(self):
a1, b1 = self.shas()
self.write(self.MODULE.replace("ctx.log(helper(2))", "ctx.log(helper(3))"))
a2, b2 = self.shas()
self.assertEqual(a1, a2)
self.assertNotEqual(b1, b2)
def test_a_decorator_edit_moves_that_test_only(self):
a1, b1 = self.shas()
self.write(self.MODULE.replace('steps=["a step"]', 'steps=["another step"]'))
a2, b2 = self.shas()
self.assertEqual(a1, a2)
self.assertNotEqual(b1, b2)
def test_a_helper_edit_moves_every_test_of_the_module(self):
a1, b1 = self.shas()
self.write(self.MODULE.replace("return x + 1", "return x + 2"))
a2, b2 = self.shas()
self.assertNotEqual(a1, a2)
self.assertNotEqual(b1, b2)
def test_a_helper_edit_in_an_imported_sibling_moves_the_tests(self):
# laser.py imports its judges from motion.py: an edit to such a
# helper changes the judge, so it has to move the fingerprints of
# the tests that import it, transitively.
sib = os.path.join(self.tmp, "judge.py")
with open(sib, "w", newline="\n") as f:
f.write("def judge(x):\n return x > 1\n")
catalog._PARTS.pop(sib, None)
self.write("from .judge import judge\n" + self.MODULE)
a1, b1 = self.shas()
with open(sib, "w", newline="\n") as f:
f.write("def judge(x):\n return x > 2\n")
catalog._PARTS.pop(sib, None)
a2, b2 = self.shas()
self.assertNotEqual(a1, a2)
self.assertNotEqual(b1, b2)
# a module that is not imported changes nothing
other = os.path.join(self.tmp, "other.py")
with open(other, "w", newline="\n") as f:
f.write("def o():\n return 1\n")
self.assertEqual((a2, b2), self.shas())
def test_line_endings_do_not_count(self):
a1, b1 = self.shas()
catalog._PARTS.pop(self.path, None)
with open(self.path, "wb") as f:
f.write(self.MODULE.replace("\n", "\r\n").encode())
self.assertEqual((a1, b1), self.shas())
def test_an_unknown_id_hashes_the_whole_file(self):
self.assertEqual(catalog.implementation_sha(self.path, "m.none"), catalog.source_file_sha(self.path))
def test_every_suite_test_is_spanned(self):
import inspect
reg = catalog.load_suite()
for t in catalog.all_tests(reg):
path = inspect.getsourcefile(t.fn)
self.assertIn(t.id, catalog.module_parts(path)[1], t.id)
+216
View File
@@ -0,0 +1,216 @@
"""The controller mode a test declares: the runner puts the machine there
before the test and the baseline keeps it there afterward.
The cloud tests enter cloud mode and stay (the operator rule: no switch
back after every test), so a queue that runs on past them reaches the
motion tests with gfcloud as the controller and no grblHAL process to
find. A test that names its mode gets the switch made for it, once, in the
pre pass - after the leftovers are handled and before the preserved state
is captured, so the post pass hands back the mode the test asked for
rather than the one the run found.
"""
import os
import shutil
import socket
import tempfile
import threading
import time
import unittest
import helpers
from forgetest import baseline
from forgetest.log import Log
from forgetest.runner import Runner
class GrblPort:
"""A listening socket standing in for the Grbl port of grblHAL."""
def __init__(self):
self.sock = socket.socket()
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(5)
os.environ["GRBL_HOST"] = "127.0.0.1"
os.environ["GRBL_PORT"] = str(self.sock.getsockname()[1])
self._stop = False
self._th = threading.Thread(target=self._accept, daemon=True)
self._th.start()
def _accept(self):
self.sock.settimeout(0.2)
while not self._stop:
try:
c, _ = self.sock.accept()
c.close()
except OSError:
pass
def close(self):
self._stop = True
self.sock.close()
for k in ("GRBL_HOST", "GRBL_PORT"):
os.environ.pop(k, None)
def cloud(fc):
fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
fc.state["settings"]["controller_mode"] = "cloud"
class SwitchModeTests(unittest.TestCase):
"""Baseline.switch_mode against the fake forgectrl."""
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.port = GrblPort()
baseline.Baseline._unreachable_until = 0.0
self.lines = []
def tearDown(self):
self.port.close()
self.fc.stop()
def bl(self):
return baseline.Baseline(self.lines.append)
def test_already_in_the_mode_posts_nothing(self):
ok, detail = self.bl().switch_mode("grbl")
self.assertTrue(ok, detail)
self.assertEqual(self.fc.posts, [])
def test_the_supervisors_levers_get_the_slow_timeout(self):
from forgetest import hw
fc = hw.Forgectrl(timeout=1.0, slow_timeout=2.0)
self.assertEqual(fc.timeout_for("POST", "/mode"), 2.0)
self.assertEqual(fc.timeout_for("POST", "/controller/start"), 2.0)
self.assertEqual(fc.timeout_for("POST", "/controller/stop"), 2.0)
self.assertEqual(fc.timeout_for("GET", "/mode"), 1.0)
self.assertEqual(fc.timeout_for("POST", "/settings"), 1.0)
self.assertGreater(hw.SLOW_TIMEOUT_S, 15.0) # forgectrl's first-report wait
# a switch that answers after the ordinary timeout still lands
import time
def on_post(path, form):
time.sleep(1.5)
self.fc.state["mode"] = dict(self.fc.state["mode"], mode="cloud")
return None
self.fc.on_post = on_post
st, body = fc.post("/mode", data={"controller": "cloud"})
self.assertEqual(st, 200) # 1.5 s > timeout, < slow_timeout
self.assertEqual(self.fc.state["mode"]["mode"], "cloud")
def test_cloud_to_grbl_switches_and_waits_for_the_port(self):
cloud(self.fc)
b = self.bl()
ok, detail = b.switch_mode("grbl")
self.assertTrue(ok, detail)
self.assertEqual(self.fc.posts, [("/mode", {"controller": "grbl"})])
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
self.assertEqual(b.mode, "grbl")
def test_a_controller_that_never_comes_up_is_reported(self):
cloud(self.fc)
self.fc.on_post = lambda path, form: (200, {"ok": True}) # accepted, nothing happens
ok, detail = self.bl().switch_mode("grbl", timeout=2)
self.assertFalse(ok)
self.assertIn("did not come up", detail)
def test_a_refused_switch_is_reported(self):
cloud(self.fc)
self.fc.on_post = lambda path, form: (409, {"error": "busy"})
ok, detail = self.bl().switch_mode("grbl")
self.assertFalse(ok)
self.assertIn("409", detail)
def test_grbl_needs_the_port_open(self):
cloud(self.fc)
self.port.close() # grblHAL running, port never listening
os.environ["GRBL_HOST"], os.environ["GRBL_PORT"] = "127.0.0.1", "1"
old = baseline.GRBL_PORT_S
baseline.GRBL_PORT_S = 2
try:
ok, detail = self.bl().switch_mode("grbl")
finally:
baseline.GRBL_PORT_S = old
self.assertFalse(ok)
self.assertIn("Grbl port", detail)
class RunnerModeTests(unittest.TestCase):
"""The runner end to end: a declared mode is established before the
test function runs and kept by the post pass."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-mode-")
os.environ["FORGETEST_DATA"] = self.tmp
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
self.fc = helpers.FakeForgectrl().start()
self.port = GrblPort()
baseline.Baseline._unreachable_until = 0.0
self.seen = {}
fc, seen = self.fc, self.seen
def t_sees_mode(ctx):
seen["mode"] = fc.state["mode"]["mode"]
reg = helpers.registry(
helpers.make_test("m.grbl", [("forgectrl", "src/ui.c")], fn=t_sees_mode, mode="grbl"),
helpers.make_test("m.any", [("forgectrl", "src/ui.c")], fn=t_sees_mode),
)
self.runner = Runner(Log(os.path.join(self.tmp, "results.jsonl")), helpers.make_manifest(), reg)
def tearDown(self):
self.port.close()
self.fc.stop()
shutil.rmtree(self.tmp, ignore_errors=True)
for k in ("FORGETEST_DATA", "FORGETEST_MARKER"):
os.environ.pop(k, None)
def run_test(self, tid):
ok, msg = self.runner.start_test(tid)
self.assertTrue(ok, msg)
run = self.runner.current
deadline = time.time() + 30
while not run.finished and time.time() < deadline:
time.sleep(0.05)
self.assertTrue(run.finished, "run did not finish")
return run
def test_grbl_test_found_in_cloud_mode_gets_the_switch_and_keeps_it(self):
cloud(self.fc)
run = self.run_test("m.grbl")
self.assertEqual(run.finished["result"], "PASS", run.finished)
self.assertEqual(self.seen["mode"], "grbl")
# one switch, made before the test; the post pass keeps grbl
self.assertEqual(self.fc.posts, [("/mode", {"controller": "grbl"})])
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
self.assertEqual(run.baseline_captured["mode"], "grbl")
self.assertEqual(run.evidence["mode"]["found"], "cloud")
self.assertTrue(run.evidence["mode"]["switched"])
self.assertTrue(any("mode: test needs grbl" in ln for ln in run.lines), run.lines)
def test_grbl_test_in_grbl_mode_switches_nothing(self):
run = self.run_test("m.grbl")
self.assertEqual(run.finished["result"], "PASS", run.finished)
self.assertEqual(self.fc.posts, [])
self.assertFalse(run.evidence["mode"]["switched"])
def test_undeclared_test_runs_in_the_mode_it_finds(self):
cloud(self.fc)
run = self.run_test("m.any")
self.assertEqual(run.finished["result"], "PASS", run.finished)
self.assertEqual(self.seen["mode"], "cloud")
self.assertEqual(self.fc.posts, [])
self.assertNotIn("mode", run.evidence)
def test_a_switch_that_fails_fails_the_test_before_it_runs(self):
cloud(self.fc)
self.fc.on_post = lambda path, form: (503, {"error": "supervisor busy"})
run = self.run_test("m.grbl")
self.assertEqual(run.finished["result"], "FAIL")
self.assertIn("needs grbl mode", run.finished["message"])
self.assertNotIn("mode", self.seen)
if __name__ == "__main__":
unittest.main()
+350
View File
@@ -0,0 +1,350 @@
"""The operator channel: notices, `ready`, `act`, and the precheck.
A test asks for the operator's part in a way the page can show before
it is needed and a bench actuator can later perform: `act` names a
machine action and watches the machine for it (a standing notice for
the human, no button to race), `ready` pre-announces a timed step, and
a `precheck` refuses a start the machine cannot honor instead of
recording a FAIL. Runner-level events go to the journal, never to the
page state.
"""
import logging
import os
import shutil
import tempfile
import threading
import time
import unittest
import helpers
from forgetest import catalog
from forgetest.log import Log
from forgetest.runner import Context, Failed, Run, Runner, journal
class Catch(logging.Handler):
def __init__(self):
logging.Handler.__init__(self)
self.lines = []
def emit(self, rec):
self.lines.append(rec.getMessage())
class Answerer:
"""Answers every prompt with its first option and records it, and
records every notice it sees."""
def __init__(self, run, on_prompt=None):
self.run = run
self.on_prompt = on_prompt
self.asked = []
self.notices = []
self.stop = False
threading.Thread(target=self._loop, daemon=True).start()
def _loop(self):
seen = None
seen_n = None
while not self.stop:
n = self.run.notice
if n and n["id"] != seen_n:
seen_n = n["id"]
self.notices.append(n["text"])
p = self.run.prompt
if p and p["id"] != seen:
seen = p["id"]
self.asked.append((p["question"], list(p["options"])))
if self.on_prompt:
self.on_prompt(p)
self.run.answer(p["id"], p["options"][0])
time.sleep(0.01)
class ActTests(unittest.TestCase):
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.run = Run("test", "t.x", "t.x")
self.ctx = Context(self.run, None, helpers.make_test("t.x", [], kind="operator"))
self.ans = Answerer(self.run)
def tearDown(self):
self.ans.stop = True
self.fc.stop()
def lid(self, closed):
self.fc.state["status"]["switches"]["lid"] = bool(closed)
def interlock(self, ok):
self.fc.state["status"]["switches"]["interlock_ok"] = bool(ok)
def test_lid_open_is_a_notice_until_the_switch_shows_it(self):
threading.Timer(0.3, self.lid, args=(False,)).start()
dt = self.ctx.act("lid", "open", timeout=5)
self.assertGreater(dt, 0.2)
self.assertEqual(self.ans.asked, []) # no button to click
self.assertEqual(self.ans.notices, ["Open the lid."])
self.assertIsNone(self.run.notice) # cleared when seen
rec = self.run.evidence["actions"][0]
self.assertEqual((rec["channel"], rec["state"], rec["by"]), ("lid", "open", "operator"))
self.assertIsNotNone(rec["took_s"])
self.assertTrue(any("ACT lid open: done" in ln for ln in self.run.lines))
def test_the_tests_context_follows_the_wording(self):
self.lid(False)
self.ctx.act("lid", "close", text="Leave it closed.", until=lambda: True, timeout=1)
self.assertTrue(any("NOTICE: Close the lid. Leave it closed." in ln for ln in self.run.lines))
def test_already_done_returns_at_once(self):
self.lid(False)
dt = self.ctx.act("lid", "open", timeout=2)
self.assertLess(dt, 1.0)
def test_interlock_watches_interlock_ok(self):
threading.Timer(0.2, self.interlock, args=(False,)).start()
self.ctx.act("interlock", "open", timeout=5)
threading.Timer(0.2, self.interlock, args=(True,)).start()
self.ctx.act("interlock", "close", timeout=5)
self.assertEqual([r["state"] for r in self.run.evidence["actions"]], ["open", "close"])
def test_a_timeout_fails_the_test_and_clears_the_notice(self):
with self.assertRaises(Failed) as cm:
self.ctx.act("lid", "open", timeout=0.6)
self.assertIn("lid open was not seen", str(cm.exception))
self.assertIsNone(self.run.notice)
self.assertIsNone(self.run.evidence["actions"][0]["took_s"])
def test_a_timeout_can_be_handed_back_instead(self):
self.assertIsNone(self.ctx.act("lid", "open", timeout=0.4, fail=False))
def test_a_button_press_needs_what_it_is_expected_to_do(self):
with self.assertRaises(ValueError):
self.ctx.act("button", "press")
held = []
threading.Timer(0.2, held.append, args=(1,)).start()
self.ctx.act("button", "press", until=lambda: bool(held), timeout=5)
self.assertEqual(self.ans.notices, ["Press the button once."])
def test_unknown_actions_are_refused(self):
with self.assertRaises(ValueError):
self.ctx.act("lid", "press")
def test_a_fixture_covering_the_channel_performs_it(self):
class Fixture:
done = []
def covers(self, channel):
return channel == "lid"
def act(self, channel, state):
self.done.append((channel, state))
self_outer.lid(state != "open")
self_outer = self
class R:
fixture = Fixture()
ctx = Context(self.run, R(), helpers.make_test("t.f", [], kind="operator"))
ctx.act("lid", "open", timeout=3)
self.assertEqual(Fixture.done, [("lid", "open")])
self.assertEqual(self.ans.notices, [])
self.assertEqual(self.run.evidence["actions"][0]["by"], "fixture")
# a channel the fixture does not cover falls back to the operator
threading.Timer(0.2, self.interlock, args=(False,)).start()
ctx.act("interlock", "open", timeout=3)
self.assertEqual(self.ans.notices, [ctx.runner and "Open the remote-interlock loop: unplug the "
"Pro's interlock plug, or pull the jumper at J8 on a Basic/Plus."])
def test_ready_is_a_single_button_prompt(self):
self.ctx.ready("On Ready the head moves.")
self.assertEqual(self.ans.asked, [("On Ready the head moves.", ["Ready", "Cannot"])])
def test_cannot_on_ready_fails(self):
self.ans.stop = True
ans = Answerer(self.run, on_prompt=lambda p: None)
ans.stop = True
time.sleep(0.05)
def say_cannot():
while self.run.prompt is None:
time.sleep(0.01)
self.run.answer(self.run.prompt["id"], "Cannot")
threading.Thread(target=say_cannot, daemon=True).start()
with self.assertRaises(Failed):
self.ctx.ready("x")
def test_notice_is_logged_and_in_the_snapshot(self):
self.ctx.notice("Hold still.")
self.assertEqual(self.run.snapshot()["notice"]["text"], "Hold still.")
self.assertTrue(any("NOTICE: Hold still." in ln for ln in self.run.lines))
self.ctx.clear_notice()
self.assertIsNone(self.run.snapshot()["notice"])
class PrecheckTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-pre-")
os.environ["FORGETEST_DATA"] = self.tmp
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
self.man = helpers.make_manifest()
self.blocked = {"why": "HV reports good: open the lid"}
self.reg = helpers.registry(
helpers.make_test("p.gated", [("forgectrl", "src/main.c")],
precheck=lambda: self.blocked["why"]),
helpers.make_test("p.free", [("forgectrl", "src/ui.c")]),
)
self.log = Log(os.path.join(self.tmp, "r.jsonl"))
self.runner = Runner(self.log, self.man, self.reg)
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_a_precheck_reason_refuses_the_start_without_a_result(self):
ok, msg = self.runner.start_test("p.gated")
self.assertFalse(ok)
self.assertEqual(msg, "cannot start: HV reports good: open the lid")
self.assertEqual([r for r in self.log.read() if r["t"] == "result"], [])
# the campaign is not even opened by a refused start
self.assertEqual([r for r in self.log.read() if r["t"] == "campaign"], [])
def test_a_clear_precheck_starts(self):
self.blocked["why"] = None
ok, msg = self.runner.start_test("p.gated")
self.assertTrue(ok, msg)
while self.runner.busy():
time.sleep(0.02)
self.assertEqual(self.runner.state()[0]["tests"]["p.gated"]["status"], "pass")
def test_the_queue_skips_it_with_the_reason_and_carries_on(self):
ok, msg, order = self.runner.start_batch("unattended")
self.assertTrue(ok, msg)
deadline = time.time() + 20
while not self.runner.batch["finished"] and time.time() < deadline:
time.sleep(0.02)
b = self.runner.batch_snapshot()
self.assertEqual([x["test"] for x in b["skipped"]], ["p.gated"])
self.assertIn("cannot start: HV reports good", b["skipped"][0]["reason"])
self.assertEqual([x["test"] for x in b["done"]], ["p.free"])
def test_an_erroring_precheck_refuses_rather_than_crashes(self):
def boom():
raise RuntimeError("sysfs gone")
t = helpers.make_test("p.boom", [], precheck=boom)
self.assertIn("precheck errored: RuntimeError: sysfs gone", t.cannot_start())
def test_the_catalog_describes_actions_and_the_precheck(self):
t = helpers.make_test("p.x", [], kind="operator", actions=("lid", "button"),
precheck=lambda: None)
d = t.describe()
self.assertEqual(d["actions"], ["lid", "button"])
self.assertTrue(d["precheck"])
# neither is part of the gate-visible definition
self.assertNotIn("actions", t.definition())
def test_the_decorator_checks_actions(self):
saved = dict(catalog.REGISTRY)
try:
with self.assertRaises(ValueError):
catalog.test("x.a", title="t", subsystem="x", kind="operator", actions=("foot",))(lambda c: None)
with self.assertRaises(ValueError):
catalog.test("x.b", title="t", subsystem="x", kind="auto", actions=("lid",))(lambda c: None)
with self.assertRaises(ValueError):
catalog.test("x.c", title="t", subsystem="x", precheck="no")(lambda c: None)
catalog.test("x.d", title="t", subsystem="x", kind="operator", actions=("lid",),
precheck=lambda: None)(lambda c: None)
self.assertIn("x.d", catalog.REGISTRY)
finally:
catalog.REGISTRY.clear()
catalog.REGISTRY.update(saved)
class JournalTests(unittest.TestCase):
def test_runner_notes_go_to_the_journal_and_the_run_in_progress(self):
tmp = tempfile.mkdtemp(prefix="forgetest-j-")
os.environ["FORGETEST_DATA"] = tmp
os.environ["FORGETEST_MARKER"] = os.path.join(tmp, "marker")
h = Catch()
journal.addHandler(h)
try:
r = Runner(Log(os.path.join(tmp, "r.jsonl")), helpers.make_manifest(),
helpers.registry(helpers.make_test("j.a", [])))
r._note("queue unattended: 1 test(s) to run")
self.assertIn("queue unattended: 1 test(s) to run", h.lines)
self.assertNotIn("messages", r.state()[0])
run = Run("test", "j.a", "j.a")
r.current = run
r._note("queue unattended: stop requested")
self.assertTrue(any("stop requested" in ln for ln in run.lines))
finally:
journal.removeHandler(h)
shutil.rmtree(tmp, ignore_errors=True)
def test_journal_tail_serves_the_daemon_log(self):
from forgetest.server import journal_tail
tmp = tempfile.mkdtemp(prefix="forgetest-jt-")
try:
p = os.path.join(tmp, "daemon.log")
self.assertIn(b"no journal yet", journal_tail(p))
with open(p, "wb") as f:
for i in range(2000):
f.write(b"line %d\n" % i)
tail = journal_tail(p, max_bytes=200)
self.assertTrue(tail.endswith(b"line 1999\n"))
self.assertLess(len(tail), 200)
self.assertTrue(tail.startswith(b"line ")) # a whole line, not a fragment
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
class AccelSamplerTests(unittest.TestCase):
"""The head accelerometer sampler over a fake iio tree."""
def setUp(self):
from forgetest import hw
self.hw = hw
self.tmp = tempfile.mkdtemp(prefix="forgetest-iio-")
d = os.path.join(self.tmp, "iio_device1") # no colon: the host may be Windows
os.makedirs(d)
with open(os.path.join(d, "name"), "w") as f:
f.write("lis2hh12 3-001e\n")
self.dir = d
self.write(100, 200)
os.environ["GF_IIO_ROOT"] = self.tmp
def tearDown(self):
os.environ.pop("GF_IIO_ROOT", None)
shutil.rmtree(self.tmp, ignore_errors=True)
def write(self, x, y):
for axis, v in (("x", x), ("y", y)):
with open(os.path.join(self.dir, "in_accel_%s_raw" % axis), "w") as f:
f.write("%d\n" % v)
def test_found_by_bus_address_and_sampled(self):
self.assertEqual(self.hw.head_accel_dir(), self.dir)
s = self.hw.AccelSampler(period=0.02)
self.assertTrue(s.available)
with s:
t0 = time.time()
time.sleep(0.1)
self.write(1100, 200)
time.sleep(0.1)
self.write(100, 200)
time.sleep(0.1)
t1 = time.time()
p2px, p2py, n = s.p2p(t0, t1)
self.assertGreaterEqual(n, 5)
self.assertEqual((p2px, p2py), (1000, 0))
# a window with no samples reads as nothing, not as an error
self.assertEqual(s.p2p(t1 + 10, t1 + 20), (0, 0, 0))
def test_absent_device_is_unavailable(self):
os.environ["GF_IIO_ROOT"] = os.path.join(self.tmp, "nowhere")
s = self.hw.AccelSampler()
self.assertFalse(s.available)
with s:
pass
self.assertEqual(s.p2p(0), (0, 0, 0))
+66
View File
@@ -0,0 +1,66 @@
"""The page assembly: the ui/ files inlined in order, the gzipped install
(what the dev image carries) assembling to the same bytes as a checkout,
the token placeholder carried exactly once, and a missing marker refused
rather than served."""
import gzip
import os
import shutil
import tempfile
import unittest
from forgetest import page
def gzipped_copy(src_dir):
"""A copy of src_dir with every file gzipped in place, the way the
recipe installs ui/."""
tmp = tempfile.mkdtemp(prefix="forgetest-ui-")
for root, _, files in os.walk(src_dir):
rel = os.path.relpath(root, src_dir)
dst_dir = os.path.normpath(os.path.join(tmp, rel))
os.makedirs(dst_dir, exist_ok=True)
for f in files:
with open(os.path.join(root, f), "rb") as i, \
gzip.open(os.path.join(dst_dir, f + ".gz"), "wb") as o:
o.write(i.read())
return tmp
class PageTests(unittest.TestCase):
def test_checkout_and_gzipped_install_agree(self):
plain = page.assemble()
tmp = gzipped_copy(page.UI_DIR)
try:
self.assertFalse(os.path.exists(os.path.join(tmp, "index.html")))
self.assertEqual(page.assemble(tmp), plain)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_page_is_self_contained(self):
html = page.assemble()
self.assertNotIn("<link ", html)
self.assertNotIn("<script src=", html)
self.assertEqual(html.count(page.TOKEN_MARK), 1)
self.assertIn("data-bs-theme", html)
for name in page.CSS_FILES + page.JS_FILES:
self.assertNotIn('href="%s"' % name, html)
self.assertNotIn('src="%s"' % name, html)
def test_render_substitutes_the_token(self):
out = page.render("deadbeef0123")
self.assertEqual(out.count("deadbeef0123"), 1)
self.assertNotIn(page.TOKEN_MARK, out)
def test_missing_marker_is_refused(self):
tmp = tempfile.mkdtemp(prefix="forgetest-ui-")
try:
with open(os.path.join(tmp, "index.html"), "w", encoding="utf-8") as f:
f.write("<html><head></head><body>no markers</body></html>")
with self.assertRaises(ValueError):
page.assemble(tmp)
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
+368
View File
@@ -0,0 +1,368 @@
"""The two queues: what they select, the order they run in, and where
they stop.
A campaign is mostly waiting, so the page offers to run what is still
owed in one go: the unattended tests for an empty room, the attended ones
for an operator at the machine. The rules that matter here are that a
queue takes exactly the unsatisfied tests of its kinds, runs a
prerequisite before the test that names it, stops on the first result
that is not a PASS (a FAIL closes the campaign, so going on would open a
second one behind the operator), and never fires the laser without the
acknowledgment.
"""
import json
import os
import shutil
import tempfile
import threading
import time
import unittest
import urllib.error
import urllib.request
import helpers
from forgetest import catalog, server
from forgetest.log import Log
from forgetest.runner import BATCH_GROUPS, Failed, Runner
def t_pass(ctx):
ctx.log("ok")
def t_fail(ctx):
ctx.check(False, "deliberate")
def t_prompt(ctx):
ctx.prompt("Ready?", ("Continue",))
def t_slow(ctx):
ctx.sleep(30)
class OrderTests(unittest.TestCase):
"""order_by_requires is a pure function over the requires graph."""
def order(self, tests, selected):
return catalog.order_by_requires(tests, selected)
def test_prerequisite_first(self):
a = helpers.make_test("s.a", [], fn=t_pass)
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
# selection order must not matter; registration order breaks ties
self.assertEqual(self.order([b, a], ["s.b", "s.a"]), ["s.a", "s.b"])
self.assertEqual(self.order([a, b], ["s.b", "s.a"]), ["s.a", "s.b"])
def test_chain(self):
c = helpers.make_test("s.c", [], requires=("s.b",), fn=t_pass)
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
a = helpers.make_test("s.a", [], fn=t_pass)
self.assertEqual(self.order([c, b, a], ["s.c", "s.b", "s.a"]), ["s.a", "s.b", "s.c"])
def test_registration_order_otherwise(self):
ts = [helpers.make_test("s.%d" % i, [], fn=t_pass) for i in range(4)]
self.assertEqual(self.order(ts, [t.id for t in ts]), ["s.0", "s.1", "s.2", "s.3"])
def test_prerequisite_outside_the_selection_places_nothing(self):
a = helpers.make_test("s.a", [], fn=t_pass)
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
self.assertEqual(self.order([a, b], ["s.b"]), ["s.b"])
def test_unknown_and_empty(self):
a = helpers.make_test("s.a", [], fn=t_pass)
self.assertEqual(self.order([a], []), [])
self.assertEqual(self.order([a], ["s.nope"]), [])
def test_a_cycle_does_not_recurse_forever(self):
"""validate() rejects cycles; a malformed registry must still not
hang the runner."""
a = helpers.make_test("s.a", [], requires=("s.b",), fn=t_pass)
b = helpers.make_test("s.b", [], requires=("s.a",), fn=t_pass)
out = self.order([a, b], ["s.a", "s.b"])
self.assertEqual(sorted(out), ["s.a", "s.b"])
class RunClockTests(unittest.TestCase):
"""The page shows the last run until the next one starts, so a
finished run's elapsed figure must be the time it took, not the time
since. Found on the bench reference, where a failed test went on
counting past 5000 s and read as still running."""
def test_a_finished_runs_clock_stops(self):
from forgetest.runner import Run
run = Run("test", "t.x", "t.x")
run.started = time.time() - 30
self.assertGreaterEqual(run.snapshot()["elapsed_s"], 30)
run.finished = {"result": "FAIL", "message": "", "duration_s": 31}
first = run.snapshot()["elapsed_s"]
time.sleep(1.1)
self.assertEqual(first, 31)
self.assertEqual(run.snapshot()["elapsed_s"], 31)
def test_a_running_runs_clock_ticks(self):
from forgetest.runner import Run
run = Run("test", "t.x", "t.x")
run.started = time.time() - 5
first = run.snapshot()["elapsed_s"]
time.sleep(1.1)
self.assertGreater(run.snapshot()["elapsed_s"], first)
class QueueTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.mkdtemp(prefix="forgetest-queue-")
os.environ["FORGETEST_DATA"] = cls.tmp
os.environ["FORGETEST_MARKER"] = os.path.join(cls.tmp, "marker")
cls.man = helpers.make_manifest()
cls.reg = helpers.registry(
# auto: b requires a, so a queue must run a first even though
# b registers earlier
helpers.make_test("q.b", [("forgectrl", "src/ui.c")], requires=("q.a",), fn=t_pass),
helpers.make_test("q.a", [("forgectrl", "src/auth.c")], fn=t_pass),
helpers.make_test("q.c", [("forgectrl", "src/cool.c")], fn=t_pass),
# attended
helpers.make_test("q.op", [("forgectrl", "src/main.c")], kind="operator", fn=t_prompt),
helpers.make_test("q.live", [("grblhal-glowforge", "src/**")], kind="live", fn=t_pass),
)
cls.log = Log(os.path.join(cls.tmp, "results.jsonl"))
cls.runner = Runner(cls.log, cls.man, cls.reg)
cls.token = server.load_token(os.path.join(cls.tmp, "token"))
cls.app = server.App(cls.runner, cls.token, export_dir=os.path.join(cls.tmp, "export"))
cls.srv = server.make_server(cls.app, "127.0.0.1", 0)
cls.port = cls.srv.server_address[1]
threading.Thread(target=cls.srv.serve_forever, daemon=True).start()
@classmethod
def tearDownClass(cls):
cls.srv.shutdown()
cls.srv.server_close()
shutil.rmtree(cls.tmp, ignore_errors=True)
def setUp(self):
# A clean history per test: a PASS left by an earlier one would be
# inherited and drop that test out of the selection. Truncating is
# also the shrunk-file path Log.read has to notice.
open(self.log.path, "w").close()
self.assertEqual(self.log.read(), [])
self.runner.batch = None
def call(self, method, path, body=None):
url = "http://127.0.0.1:%d%s" % (self.port, path)
hdrs = {"Host": "127.0.0.1:%d" % self.port, "X-ForgeFIRM-Token": self.token}
data = None
if body is not None:
data = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read().decode())
def wait_queue(self, timeout=30):
deadline = time.time() + timeout
while time.time() < deadline:
st, d = self.call("GET", "/state")
if d["batch"] and d["batch"]["finished"]:
return d
time.sleep(0.05)
self.fail("the queue did not finish")
# -- selection ---------------------------------------------------------
def test_groups_split_the_catalog_by_who_has_to_be_there(self):
self.assertEqual(BATCH_GROUPS["unattended"], ("auto",))
self.assertEqual(sorted(BATCH_GROUPS["attended"]), ["live", "operator"])
st, d = self.call("GET", "/state")
self.assertEqual(d["batch_available"]["unattended"], ["q.a", "q.b", "q.c"])
self.assertEqual(d["batch_available"]["attended"], ["q.op", "q.live"])
def test_selection_runs_prerequisites_first(self):
"""q.b registers before q.a but requires it."""
st, d = self.call("GET", "/state")
order = d["batch_available"]["unattended"]
self.assertLess(order.index("q.a"), order.index("q.b"))
def test_satisfied_tests_drop_out(self):
st, d = self.call("POST", "/start", {"test": "q.c"})
self.assertEqual(st, 200)
self.wait_idle()
st, d = self.call("GET", "/state")
self.assertEqual(d["tests"]["q.c"]["status"], "pass")
self.assertNotIn("q.c", d["batch_available"]["unattended"])
self.assertEqual(d["batch_available"]["unattended"], ["q.a", "q.b"])
def wait_idle(self, timeout=20):
deadline = time.time() + timeout
while time.time() < deadline:
st, d = self.call("GET", "/state")
if not d["running"]:
return d
time.sleep(0.05)
self.fail("run did not finish")
# -- running -----------------------------------------------------------
def test_unattended_queue_runs_everything_in_order(self):
st, d = self.call("POST", "/batch", {"group": "unattended"})
self.assertEqual(st, 200)
self.assertEqual(d["order"], ["q.a", "q.b", "q.c"])
d = self.wait_queue()
b = d["batch"]
self.assertEqual([x["test"] for x in b["done"]], ["q.a", "q.b", "q.c"])
self.assertEqual(set(x["result"] for x in b["done"]), {"PASS"})
self.assertEqual(b["skipped"], [])
self.assertIsNone(b["stopped"])
for tid in ("q.a", "q.b", "q.c"):
self.assertEqual(d["tests"][tid]["status"], "pass", tid)
# and the run records say which queue put them there
st, rec = self.call("GET", "/result?test=q.b")
self.assertEqual(rec["evidence"]["batch"]["group"], "unattended")
def test_an_empty_queue_is_refused_not_started(self):
self.call("POST", "/batch", {"group": "unattended"})
self.wait_queue()
st, d = self.call("POST", "/batch", {"group": "unattended"})
self.assertEqual(st, 409)
self.assertIn("already satisfied", d["message"])
def test_a_failure_stops_the_queue(self):
reg = helpers.registry(
helpers.make_test("f.a", [("forgectrl", "src/ui.c")], fn=t_pass),
helpers.make_test("f.b", [("forgectrl", "src/auth.c")], fn=t_fail),
helpers.make_test("f.c", [("forgectrl", "src/cool.c")], fn=t_pass),
)
tmp = tempfile.mkdtemp(prefix="forgetest-fail-")
try:
r = Runner(Log(os.path.join(tmp, "r.jsonl")), self.man, reg)
ok, msg, order = r.start_batch("unattended")
self.assertTrue(ok, msg)
self.assertEqual(order, ["f.a", "f.b", "f.c"])
deadline = time.time() + 20
while not r.batch["finished"] and time.time() < deadline:
time.sleep(0.05)
b = r.batch_snapshot()
self.assertEqual([x["test"] for x in b["done"]], ["f.a", "f.b"])
self.assertEqual(b["done"][1]["result"], "FAIL")
self.assertEqual(b["stopped"], "FAIL on f.b")
self.assertEqual(b["pending"], ["f.c"], "f.c ran after a FAIL closed the campaign")
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_a_test_that_cannot_start_is_skipped_not_silently_dropped(self):
"""An unmet prerequisite outside the queue refuses the start; the
queue records why and carries on."""
reg = helpers.registry(
helpers.make_test("k.op", [("forgectrl", "src/main.c")], kind="operator", fn=t_pass),
helpers.make_test("k.a", [("forgectrl", "src/ui.c")], requires=("k.op",), fn=t_pass),
helpers.make_test("k.b", [("forgectrl", "src/auth.c")], fn=t_pass),
)
tmp = tempfile.mkdtemp(prefix="forgetest-skip-")
try:
r = Runner(Log(os.path.join(tmp, "r.jsonl")), self.man, reg)
ok, msg, order = r.start_batch("unattended")
self.assertEqual(order, ["k.a", "k.b"])
deadline = time.time() + 20
while not r.batch["finished"] and time.time() < deadline:
time.sleep(0.05)
b = r.batch_snapshot()
self.assertEqual([x["test"] for x in b["skipped"]], ["k.a"])
self.assertIn("prerequisites not satisfied", b["skipped"][0]["reason"])
self.assertEqual([x["test"] for x in b["done"]], ["k.b"])
self.assertIsNone(b["stopped"])
finally:
shutil.rmtree(tmp, ignore_errors=True)
# -- safety and exclusion ----------------------------------------------
def test_an_attended_queue_with_a_live_test_needs_the_acknowledgment(self):
st, d = self.call("POST", "/batch", {"group": "attended"})
self.assertEqual(st, 409)
self.assertIn("fires the laser", d["message"])
self.assertIn("q.live", d["message"])
st, d = self.call("GET", "/state")
self.assertIsNone(d["batch"], "a refused queue must not exist")
def test_the_acknowledgment_reaches_every_live_run(self):
st, d = self.call("POST", "/batch", {"group": "attended", "ack_live": True})
self.assertEqual(st, 200)
# the operator test prompts; answer it so the queue can reach the live one
deadline = time.time() + 20
while time.time() < deadline:
st, s = self.call("GET", "/state")
if s["running"] and s["running"]["prompt"]:
self.call("POST", "/answer", {"prompt_id": s["running"]["prompt"]["id"],
"value": "Continue"})
break
if s["batch"] and s["batch"]["finished"]:
break
time.sleep(0.05)
d = self.wait_queue()
self.assertEqual([x["test"] for x in d["batch"]["done"]], ["q.op", "q.live"])
st, rec = self.call("GET", "/result?test=q.live")
self.assertTrue(rec["evidence"]["operator"]["ack_live"])
def test_a_single_start_is_refused_while_a_queue_runs(self):
reg = helpers.registry(
helpers.make_test("s.slow", [("forgectrl", "src/ui.c")], fn=t_slow),
helpers.make_test("s.other", [("forgectrl", "src/auth.c")], fn=t_pass),
)
tmp = tempfile.mkdtemp(prefix="forgetest-excl-")
try:
r = Runner(Log(os.path.join(tmp, "r.jsonl")), self.man, reg)
r.start_batch("unattended")
deadline = time.time() + 10
while r.current is None and time.time() < deadline:
time.sleep(0.02)
ok, msg = r.start_test("s.other")
self.assertFalse(ok)
self.assertEqual(msg, "a queue is running")
ok, msg = r.start_bench("anything")
self.assertFalse(ok)
self.assertEqual(msg, "a queue is running")
ok, msg, _ = r.start_batch("unattended")
self.assertFalse(ok)
self.assertIn("already running", msg)
r.stop_batch()
r.abort()
deadline = time.time() + 20
while not r.batch["finished"] and time.time() < deadline:
time.sleep(0.05)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_stop_cancels_what_is_waiting(self):
reg = helpers.registry(
helpers.make_test("p.slow", [("forgectrl", "src/ui.c")], fn=t_slow),
helpers.make_test("p.next", [("forgectrl", "src/auth.c")], fn=t_pass),
)
tmp = tempfile.mkdtemp(prefix="forgetest-stop-")
try:
r = Runner(Log(os.path.join(tmp, "r.jsonl")), self.man, reg)
r.start_batch("unattended")
deadline = time.time() + 10
while r.current is None and time.time() < deadline:
time.sleep(0.02)
ok, msg = r.stop_batch()
self.assertTrue(ok, msg)
self.assertTrue(r.batch_snapshot()["stopping"])
r.abort() # the run in progress needs its own lever
deadline = time.time() + 20
while not r.batch["finished"] and time.time() < deadline:
time.sleep(0.05)
b = r.batch_snapshot()
self.assertNotIn("p.next", [x["test"] for x in b["done"]])
ok, msg = r.stop_batch()
self.assertFalse(ok)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_unknown_group(self):
st, d = self.call("POST", "/batch", {"group": "everything"})
self.assertEqual(st, 409)
self.assertEqual(d["message"], "unknown queue")
if __name__ == "__main__":
unittest.main()
+252
View File
@@ -0,0 +1,252 @@
"""What keeps the page answering the operator instead of the timer.
Two things went wrong on the bench and are pinned here:
- the state cost. Every poll re-read and re-parsed the whole result log,
and recomputed every test's domain fingerprint. A result record carries
its run log, so the file reaches megabytes over a campaign and the poll
grew with it. Both are now parsed and computed once.
- the wasted payload. An idle page polls an unchanged state; it now gets
a 304 instead of the whole thing.
The click-swallowing defect these were found with lives in the page's
JavaScript and is not reachable from here: rows, prompt buttons and tool
entries are built once and afterwards only updated in place, because a
poll that rebuilt them removed the button between the operator's mousedown
and mouseup, and no click event was ever raised. `test_page_never_rebuilds`
holds the shape of that rule.
"""
import json
import os
import re
import shutil
import tempfile
import unittest
import helpers
from forgetest import catalog, manifest as manifest_mod, page
from forgetest.log import Log
def t_noop(ctx):
pass
class LogCacheTests(unittest.TestCase):
"""The log is append-only, so a read parses each line exactly once."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-log-")
self.path = os.path.join(self.tmp, "results.jsonl")
self.log = Log(self.path)
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def write(self, *lines):
with open(self.path, "a", encoding="utf-8") as f:
for line in lines:
f.write(line + "\n")
def test_missing_file_reads_empty(self):
self.assertEqual(self.log.read(), [])
def test_appends_are_picked_up(self):
self.log.append({"t": "campaign", "id": "c1"})
self.assertEqual([r["id"] for r in self.log.read()], ["c1"])
self.log.append({"t": "result", "test": "a.b"})
self.log.append({"t": "result", "test": "c.d"})
recs = self.log.read()
self.assertEqual([r["t"] for r in recs], ["campaign", "result", "result"])
self.assertEqual(recs[2]["test"], "c.d")
def test_only_new_bytes_are_parsed(self):
for i in range(5):
self.log.append({"t": "result", "test": "t%d" % i})
self.log.read()
# a second read must not touch the file at all
real_open = open
opened = []
def counting_open(*a, **kw):
opened.append(a[0])
return real_open(*a, **kw)
import builtins
builtins.open = counting_open
try:
recs = self.log.read()
finally:
builtins.open = real_open
self.assertEqual(opened, [], "an unchanged log was re-opened")
self.assertEqual(len(recs), 5)
def test_read_returns_a_fresh_list(self):
"""Callers filter and sort the result; the cache must not be theirs."""
self.log.append({"t": "result", "test": "a.b"})
first = self.log.read()
first.append({"t": "bogus"})
self.assertEqual(len(self.log.read()), 1)
def test_partial_trailing_line_is_held_not_counted_corrupt(self):
self.write('{"t":"result","test":"a.b"}')
with open(self.path, "a", encoding="utf-8") as f:
f.write('{"t":"result","te') # a line still being written
recs = self.log.read()
self.assertEqual(len(recs), 1)
self.assertEqual(self.log.corrupt, 0)
with open(self.path, "a", encoding="utf-8") as f:
f.write('st":"c.d"}\n') # ... now finished
recs = self.log.read()
self.assertEqual([r["test"] for r in recs], ["a.b", "c.d"])
self.assertEqual(self.log.corrupt, 0)
def test_corrupt_lines_counted_once_not_per_read(self):
self.write('{"t":"result","test":"a.b"}', "not json at all", '["not","a","record"]',
'{"t":"result","test":"c.d"}')
recs = self.log.read()
self.assertEqual([r["test"] for r in recs], ["a.b", "c.d"])
self.assertEqual(self.log.corrupt, 2)
self.log.read()
self.log.read()
self.assertEqual(self.log.corrupt, 2, "corrupt lines re-counted on every read")
def test_replaced_file_is_read_again(self):
self.write('{"t":"result","test":"a.b"}', '{"t":"result","test":"c.d"}')
self.assertEqual(len(self.log.read()), 2)
with open(self.path, "w", encoding="utf-8") as f:
f.write('{"t":"campaign","id":"c9"}\n')
recs = self.log.read()
self.assertEqual([r.get("id") for r in recs], ["c9"])
def test_blank_lines_are_not_corrupt(self):
self.write('{"t":"result","test":"a.b"}', "", " ", '{"t":"result","test":"c.d"}')
self.assertEqual(len(self.log.read()), 2)
self.assertEqual(self.log.corrupt, 0)
class FingerprintCacheTests(unittest.TestCase):
"""Memoized per manifest: the page recomputes every test's fingerprint
on every poll, and a manifest never changes under a running tool."""
def setUp(self):
self.man = helpers.make_manifest()
self.t = helpers.make_test("fake.fp", [("forgectrl", "src/ui.c")], fn=t_noop)
def test_repeat_calls_agree(self):
first = self.t.fingerprint(self.man)
self.assertEqual(self.t.fingerprint(self.man), first)
self.assertEqual(self.t.fingerprint(self.man), first)
def test_a_changed_manifest_is_not_served_from_the_cache(self):
first = self.t.fingerprint(self.man)
man2 = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
self.assertNotEqual(self.t.fingerprint(man2), first)
# and back again: the cache must not have latched the new one either
self.assertEqual(self.t.fingerprint(self.man), first)
def test_a_file_outside_the_coverage_does_not_move_it(self):
first = self.t.fingerprint(self.man)
man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
self.assertEqual(self.t.fingerprint(man2), first)
def test_manifest_without_a_content_hash_is_not_cached(self):
"""The cache is keyed by the manifest's content hash. A manifest
that has none must still fingerprint correctly, not collide."""
a = manifest_mod.Manifest({"components": {"forgectrl": {"files": [["src/ui.c", "aaa"]]}},
"platform": {}})
b = manifest_mod.Manifest({"components": {"forgectrl": {"files": [["src/ui.c", "bbb"]]}},
"platform": {}})
self.assertIsNone(a.content_sha)
self.assertNotEqual(self.t.fingerprint(a), self.t.fingerprint(b))
def test_component_file_list_is_stable_and_read_only(self):
files = self.man.files("forgectrl")
self.assertEqual(files, self.man.files("forgectrl"))
self.assertIsNone(self.man.files("no-such-component"))
class PageTests(unittest.TestCase):
def test_page_never_rebuilds_what_the_operator_may_be_pressing(self):
"""A poll must update rows, prompt buttons and tool entries in
place. Assigning innerHTML to their containers on every poll is
what swallowed the clicks; only the one-time build may do it."""
html = page.render("0" * 32)
for container, builder in (("groups", "buildGroups"), ("tools", "buildBench")):
self.assertIn("function %s(" % builder, html,
"no one-time builder for #%s" % container)
assigns = html.count("$('%s').innerHTML=" % container)
self.assertEqual(assigns, 1,
"#%s is assigned innerHTML %d times; it belongs to the "
"builder alone" % (container, assigns))
# the per-poll path writes through the guarded setters only
self.assertIn("function setHtml(e,h){if(e&&e.__h!==h)", html)
self.assertIn("function updateGroups()", html)
self.assertIn("function updateBench()", html)
# the prompt buttons are rebuilt only when the prompt changes
self.assertIn("if(pk!==promptKey)", html)
# the queue controls are static markup: a poll relabels them and
# flips disabled, it never replaces the node
for bid in ("q-unattended", "q-attended", "q-stop"):
self.assertIn('id="%s"' % bid, html)
self.assertNotIn("id='" + bid + "-", html)
self.assertNotIn('id="' + bid + "-", html)
self.assertIn("function renderQueue()", html)
# the help popovers sit on static markup only: a rebuilt row or
# tool entry would orphan one
self.assertNotIn("data-help", html[html.index("function buildGroups("):])
def test_every_group_is_built_on_the_same_grid(self):
"""The subsystems are separate tables. Left to size themselves
from their own content no two line up, which is what made the
page look busy, so they share one colgroup and a fixed layout."""
html = page.render("0" * 32)
self.assertRegex(html, r"#groups table\s*\{\s*table-layout:\s*fixed")
# one colgroup definition, emitted into every group's table
self.assertEqual(html.count('var COLS="<colgroup>'), 1)
self.assertEqual(html.count('<table>"+COLS+"'), 1,
"a group table built without the shared colgroup")
colgroup = re.search(r'var COLS="(.*?)";', html).group(1)
heads = ["Test", "Kind", "Status", "Last result"]
for h in heads:
self.assertIn("<th>%s</th>" % h, html)
self.assertEqual(len(re.findall(r"<col(?:>| )", colgroup)), len(heads) + 1,
"a column per header, the action column included")
# every fixed column carries a width, or the grid is only a wish
for cls in re.findall(r"<col class='(\w)'", colgroup):
self.assertRegex(html, r"#groups col\.%s\s*\{\s*width:\s*\d+px" % cls)
def test_details_and_the_requires_note_stay_out_of_the_columns(self):
"""Both are long enough to stretch a cell. In a column they would
pull one group's grid out of step with the rest: the details get a
full-width row, and the note sits under Status, which is sized for
it."""
html = page.render("0" * 32)
self.assertIn("<tr class='detrow'><td colspan='5'>", html)
# the status cell carries the status, the unmet note and the row
# message; the action cell carries the button and nothing else
self.assertIn("<td><div id='st-\"+d+\"'></div><div id='unmet-\"+d+"
"\"'></div><div id='note-\"+d+\"'></div></td>", html)
self.assertIn("<td><button class='btn btn-sm btn-primary' id='btn-\"+d+\"' "
"onclick='startTest(\\\"\"+d+\"\\\")'>Start</button></td>", html)
def test_page_is_self_contained_ascii(self):
html = page.render("0" * 32)
self.assertNotIn("__TOKEN__", html)
# our own sources stay ASCII (no stray typography in an embedded
# page); the vendored Bootstrap carries its own glyphs
for name in ("index.html",) + page.CSS_FILES + page.JS_FILES:
if name.startswith("vendor/"):
continue
page.read_ui(name).encode("ascii")
stray = sorted(set(hex(ord(c)) for c in html if ord(c) < 32 and c != "\n"))
self.assertEqual(stray, [], "control characters in the page source")
# nothing fetched from anywhere: the documentation links open a
# site, they are not assets
for remote in ("<link ", "<script src=", "@import", "url(http", "url(//",
'src="http', "src='http", "//cdn"):
self.assertNotIn(remote, html)
if __name__ == "__main__":
unittest.main()
+431
View File
@@ -0,0 +1,431 @@
"""Runner + HTTP API end to end on localhost with a fake catalog and a
fake bench tool. No hardware, no forgectrl."""
import json
import os
import shutil
import sys
import tempfile
import threading
import time
import unittest
import urllib.error
import urllib.request
import helpers
from forgetest import bench as bench_mod
from forgetest import catalog, server
from forgetest.log import Log
from forgetest.runner import Failed, Runner
def t_pass(ctx):
ctx.log("hello")
ctx.evidence["k"] = 1
def t_prompt(ctx):
ans = ctx.prompt("Did the light blink?", ("Yes", "No"))
if ans != "Yes":
raise Failed("operator said no")
def t_fail(ctx):
ctx.check(False, "deliberate")
def t_slow(ctx):
ctx.sleep(30)
def t_error(ctx):
raise RuntimeError("boom")
class ServerTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.mkdtemp(prefix="forgetest-")
os.environ["FORGETEST_DATA"] = cls.tmp
os.environ["FORGETEST_MARKER"] = os.path.join(cls.tmp, "marker")
cls.man = helpers.make_manifest()
cls.reg = helpers.registry(
helpers.make_test("fake.pass", [("forgectrl", "src/ui.c")], always=True, fn=t_pass),
helpers.make_test("fake.prompt", [("forgectrl", "src/auth.c")], fn=t_prompt, kind="operator"),
helpers.make_test("fake.fail", [("forgectrl", "src/cool.c")], fn=t_fail),
helpers.make_test("fake.slow", [("forgectrl", "src/main.c")], fn=t_slow),
helpers.make_test("fake.error", [("forgectrl", "src/main.c")], fn=t_error),
helpers.make_test("fake.live", [("grblhal-glowforge", "src/**")], fn=t_pass, kind="live"),
helpers.make_test("fake.needs", [("kernel-module-glowforge", "**")], fn=t_pass,
requires=("fake.prompt",)),
)
# a fake bench tool
cls.tooldir = os.path.join(cls.tmp, "bench")
os.makedirs(cls.tooldir)
with open(os.path.join(cls.tooldir, "echo_tool.py"), "w") as f:
f.write("import os, sys, time\nprint('args', sys.argv[1:])\n"
"print('env', os.environ.get('GF_HOST'), os.environ.get('FORGETEST_BENCH_DATA'))\n"
"sys.stdout.flush()\n"
"if 'slow' in sys.argv: time.sleep(30)\nsys.exit(0 if 'fail' not in sys.argv else 3)\n")
tools = [{"id": "echo", "title": "Echo", "script": "echo_tool.py", "safety": "dry", "where": "board",
"ported": True, "desc": "echo",
"args": [{"name": "word", "type": "str", "default": "hi", "help": ""},
{"name": "n", "type": "int", "default": 2, "help": ""}]},
{"id": "unported", "title": "U", "script": "nope.py", "safety": "dry", "where": "host",
"ported": False, "desc": "", "args": []},
{"id": "hot", "title": "H", "script": "echo_tool.py", "safety": "live", "where": "board",
"ported": True, "desc": "", "args": []},
{"id": "tk", "title": "T", "script": "echo_tool.py", "safety": "takeover", "where": "board",
"ported": True, "desc": "", "args": []},
{"id": "sc", "title": "S", "script": "echo_tool.py", "safety": "scope", "where": "board",
"ported": True, "desc": "", "args": []}]
cls.bench = bench_mod.Bench(tools, tool_dir=cls.tooldir,
index_path=os.path.join(cls.tmp, "bench.jsonl"))
cls.log = Log(os.path.join(cls.tmp, "results.jsonl"))
cls.runner = Runner(cls.log, cls.man, cls.reg, cls.bench)
cls.token = server.load_token(os.path.join(cls.tmp, "token"))
cls.app = server.App(cls.runner, cls.token, export_dir=os.path.join(cls.tmp, "export"))
cls.srv = server.make_server(cls.app, "127.0.0.1", 0)
cls.port = cls.srv.server_address[1]
cls.th = threading.Thread(target=cls.srv.serve_forever, daemon=True)
cls.th.start()
@classmethod
def tearDownClass(cls):
cls.srv.shutdown()
cls.srv.server_close()
shutil.rmtree(cls.tmp, ignore_errors=True)
# -- helpers ----------------------------------------------------------
def call(self, method, path, body=None, token=True, headers=None):
st, payload, _ = self.call_h(method, path, body, token, headers)
return st, payload
def call_h(self, method, path, body=None, token=True, headers=None):
"""As call, plus the response headers."""
url = "http://127.0.0.1:%d%s" % (self.port, path)
hdrs = {"Host": "127.0.0.1:%d" % self.port}
if token:
hdrs["X-ForgeFIRM-Token"] = self.token
data = None
if body is not None:
data = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
if headers:
hdrs.update(headers)
req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
try:
with urllib.request.urlopen(req, timeout=10) as r:
raw = r.read()
st = r.status
rh = r.headers
except urllib.error.HTTPError as e:
raw = e.read()
st = e.code
rh = e.headers
ct = rh.get("Content-Type", "")
if "json" in ct:
return st, json.loads(raw.decode()), rh
return st, raw, rh
def wait_idle(self, timeout=10):
deadline = time.time() + timeout
while time.time() < deadline:
st, d = self.call("GET", "/state")
if not d["running"]:
return d
time.sleep(0.1)
self.fail("run did not finish")
def wait_prompt(self, timeout=10):
deadline = time.time() + timeout
while time.time() < deadline:
st, d = self.call("GET", "/state")
if d["running"] and d["running"]["prompt"]:
return d["running"]["prompt"]
time.sleep(0.05)
self.fail("no prompt appeared")
# -- tests -----------------------------------------------------------------
def test_01_auth_and_page(self):
st, d = self.call("GET", "/state", token=False)
self.assertEqual(st, 200)
st, d = self.call("GET", "/", token=False)
self.assertEqual(st, 200)
self.assertIn(self.token.encode(), d)
page = d.decode("utf-8")
# one self-contained response: nothing linked, the token once,
# the theme attribute the head script sets
self.assertEqual(page.count(self.token), 1)
self.assertNotIn("<link ", page)
self.assertNotIn("<script src=", page)
self.assertIn("data-bs-theme", page)
st, d = self.call("GET", "/state", headers={"Host": "evil.example.net"})
self.assertEqual(st, 403)
st, d = self.call("GET", "/state", headers={"Origin": "http://evil.example.net"})
self.assertEqual(st, 403)
st, d = self.call("GET", "/state", headers={"Sec-Fetch-Site": "cross-site"})
self.assertEqual(st, 403)
st, d = self.call("POST", "/start", {"test": "fake.pass"}, token=False)
self.assertEqual(st, 403)
self.assertEqual(d["error"], "authentication required")
st, d = self.call("GET", "/catalog")
self.assertEqual(st, 200)
self.assertEqual(len(d["tests"]), 7)
st, d = self.call("GET", "/nope")
self.assertEqual(st, 404)
def test_01c_connection_is_kept_alive(self):
"""A poll per second over a fresh TCP connection each time is
waste the board does not need to pay."""
import http.client
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
for _ in range(3):
c.request("GET", "/state", headers={"Host": "127.0.0.1:%d" % self.port})
r = c.getresponse()
r.read()
self.assertEqual(r.status, 200)
self.assertEqual(r.version, 11)
self.assertNotEqual((r.getheader("Connection") or "").lower(), "close")
self.assertIsNotNone(r.getheader("Content-Length"))
finally:
c.close()
def test_01d_refused_post_does_not_poison_the_connection(self):
"""A POST refused before its body is read leaves that body in the
socket. On a kept-alive connection the next read would take it for
a request line, so a refusal must end the connection instead."""
import http.client
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
body = json.dumps({"test": "fake.pass"}).encode()
c.request("POST", "/start", body=body,
headers={"Host": "127.0.0.1:%d" % self.port,
"Content-Type": "application/json"}) # no token
r = c.getresponse()
payload = json.loads(r.read().decode())
self.assertEqual(r.status, 403)
self.assertEqual(payload["error"], "authentication required")
self.assertEqual((r.getheader("Connection") or "").lower(), "close",
"an unread body was left on a connection kept alive")
finally:
c.close()
# the server is still healthy, and the body was never taken for a request
st, d = self.call("GET", "/state")
self.assertEqual(st, 200)
self.assertIsNone(d["running"])
def test_02_run_pass_opens_campaign(self):
st, d = self.call("GET", "/state")
self.assertIsNone(d["campaign"])
st, d = self.call("POST", "/start", {"test": "fake.pass"})
self.assertEqual(st, 200, d)
state = self.wait_idle()
self.assertIsNotNone(state["campaign"])
self.assertEqual(state["tests"]["fake.pass"]["status"], "pass")
self.assertEqual(state["last_run"]["finished"]["result"], "PASS")
st, rec = self.call("GET", "/result?test=fake.pass")
self.assertEqual(st, 200)
self.assertEqual(rec["evidence"]["k"], 1)
# the baseline passes ran (no machine on the host: nothing to restore)
self.assertEqual(rec["evidence"]["baseline"], {"pre": [], "post": []})
self.assertTrue(any("hello" in l for l in rec["log"]))
def test_03_requires_and_live_gate(self):
st, d = self.call("POST", "/start", {"test": "fake.needs"})
self.assertEqual(st, 409)
self.assertIn("prerequisites", d["message"])
# the operator's override: the test runs alone and the record says so
st, d = self.call("POST", "/start", {"test": "fake.needs", "ignore_requires": True})
self.assertEqual(st, 200)
state = self.wait_idle()
self.assertEqual(state["tests"]["fake.needs"]["status"], "pass")
st, rec = self.call("GET", "/result?test=fake.needs")
self.assertEqual(rec["evidence"]["prerequisites"]["missing"], ["fake.prompt"])
self.assertTrue(rec["evidence"]["prerequisites"]["overridden"])
self.assertTrue(any("prerequisites overridden" in l for l in rec["log"]))
# its prerequisite is still required for the release
self.assertFalse(state["tests"]["fake.prompt"]["satisfied"])
self.assertFalse(state["authorized"])
st, d = self.call("POST", "/start", {"test": "fake.live"})
self.assertEqual(st, 409)
self.assertIn("live", d["message"])
st, d = self.call("POST", "/start", {"test": "fake.live", "ack_live": True})
self.assertEqual(st, 200)
state = self.wait_idle()
self.assertEqual(state["tests"]["fake.live"]["status"], "pass")
st, rec = self.call("GET", "/result?test=fake.live")
self.assertTrue(rec["evidence"]["operator"]["ack_live"])
def test_04_prompt_flow(self):
st, d = self.call("POST", "/start", {"test": "fake.prompt"})
self.assertEqual(st, 200)
p = self.wait_prompt()
self.assertEqual(p["options"], ["Yes", "No"])
st, d = self.call("POST", "/answer", {"prompt_id": p["id"], "value": "Maybe"})
self.assertEqual(st, 409)
st, d = self.call("POST", "/answer", {"prompt_id": p["id"], "value": "Yes"})
self.assertEqual(st, 200)
state = self.wait_idle()
self.assertEqual(state["tests"]["fake.prompt"]["status"], "pass")
st, rec = self.call("GET", "/result?test=fake.prompt")
self.assertEqual(rec["answers"][0]["answer"], "Yes")
# now the dependent test may start
st, d = self.call("POST", "/start", {"test": "fake.needs"})
self.assertEqual(st, 200)
self.wait_idle()
def test_05_busy_abort(self):
st, d = self.call("POST", "/start", {"test": "fake.slow"})
self.assertEqual(st, 200)
st, d = self.call("POST", "/start", {"test": "fake.pass"})
self.assertEqual(st, 409)
st, d = self.call("POST", "/abort")
self.assertEqual(st, 200)
state = self.wait_idle()
self.assertEqual(state["tests"]["fake.slow"]["status"], "aborted")
self.assertIsNotNone(state["campaign"], "an abort does not close the campaign")
def test_06_error_and_fail_close_campaign(self):
st, d = self.call("GET", "/state")
cid = d["campaign"]["id"]
st, d = self.call("POST", "/start", {"test": "fake.error"})
state = self.wait_idle()
self.assertEqual(state["tests"]["fake.error"]["status"], "error")
self.assertIsNone(state["campaign"])
self.assertEqual(state["closed_by"], "fail")
# inherited passes survive; the core is required again
self.assertEqual(state["tests"]["fake.prompt"]["status"], "inherited")
self.assertTrue(state["tests"]["fake.pass"]["required"])
# a new start opens a new campaign
st, d = self.call("POST", "/start", {"test": "fake.pass"})
state = self.wait_idle()
self.assertIsNotNone(state["campaign"])
self.assertNotEqual(state["campaign"]["id"], cid)
def test_07_export_and_invalidate(self):
st, d = self.call("POST", "/export")
self.assertEqual(st, 200)
self.assertFalse(d["authorized"])
st, raw = self.call("GET", "/export/acceptance.json")
self.assertEqual(st, 200)
art = raw if isinstance(raw, dict) else json.loads(raw)
self.assertEqual(art["manifest_sha"], self.man.content_sha)
st, raw = self.call("GET", "/export/acceptance.md")
self.assertEqual(st, 200)
self.assertIn(b"Release authorized: NO", raw)
st, d = self.call("POST", "/invalidate", {"reason": ""})
self.assertEqual(st, 400)
st, d = self.call("POST", "/invalidate", {"reason": "new tube"})
self.assertEqual(st, 200)
st, d = self.call("GET", "/state")
self.assertEqual(d["invalidate"]["reason"], "new tube")
self.assertIsNone(d["campaign"])
for tid, ts in d["tests"].items():
self.assertNotEqual(ts["status"], "inherited", tid)
st, raw = self.call("GET", "/log", token=False)
self.assertEqual(st, 200)
self.assertIn(b'"t": "invalidate"'.replace(b" ", b""), raw.replace(b" ", b""))
def test_08_bench(self):
st, d = self.call("GET", "/bench")
self.assertEqual(st, 200)
ids = [t["id"] for t in d["tools"]]
self.assertEqual(ids, ["echo", "unported", "hot", "tk", "sc"])
st, d = self.call("POST", "/bench/start", {"tool": "unported"})
self.assertEqual(st, 409)
st, d = self.call("POST", "/bench/start", {"tool": "hot"})
self.assertEqual(st, 409)
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "yo", "n": "x"}})
self.assertEqual(st, 409)
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "yo", "n": 5}})
self.assertEqual(st, 200, d)
state = self.wait_idle()
self.assertEqual(state["last_run"]["kind"], "bench")
self.assertEqual(state["last_run"]["finished"]["result"], "OK")
self.assertTrue(any("['yo', '5']" in l for l in state["last_run"]["log"]))
# the bench environment: the machine is local, data under <data>/bench
self.assertTrue(any(" env 127.0.0.1 " in l and l.endswith("bench")
for l in state["last_run"]["log"]), state["last_run"]["log"])
st, d = self.call("GET", "/bench")
self.assertEqual(d["tools"][0]["last"]["result"]["result"], "OK")
# a failing tool and an aborted one
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "fail", "n": 1}})
state = self.wait_idle()
self.assertEqual(state["last_run"]["finished"]["result"], "EXIT 3")
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "slow", "n": 1}})
self.assertEqual(st, 200)
time.sleep(0.5)
st, d = self.call("POST", "/abort")
state = self.wait_idle()
self.assertEqual(state["last_run"]["finished"]["result"], "ABORTED")
# a takeover tool runs inside the takeover wrapper (init.d is absent on the host: rc 127)
st, d = self.call("POST", "/bench/start", {"tool": "tk"})
self.assertEqual(st, 200, d)
state = self.wait_idle(timeout=40) # two unreachable-forgectrl settle waits
log = "\n".join(state["last_run"]["log"])
self.assertIn("takeover: pulse device free", log)
self.assertIn("takeover: forgectrl start", log)
self.assertIn("baseline: forgectrl unreachable for 10 s", log)
self.assertFalse(os.path.exists(os.environ["FORGETEST_MARKER"]))
# a scope tool is a takeover too
st, d = self.call("POST", "/bench/start", {"tool": "sc"})
self.assertEqual(st, 200, d)
state = self.wait_idle(timeout=40)
log = "\n".join(state["last_run"]["log"])
self.assertIn("takeover: pulse device free", log)
self.assertIn("takeover: forgectrl start", log)
# bench runs never touched the acceptance log
recs = self.log.read()
self.assertFalse(any(r.get("t") == "result" and r.get("test") == "echo" for r in recs))
def test_09_recovery_marker(self):
marker = os.environ["FORGETEST_MARKER"]
with open(marker, "w") as f:
f.write("x fake.slow\n")
import logging
from forgetest.runner import journal
seen = []
class Catch(logging.Handler):
def emit(self, rec):
seen.append(rec.getMessage())
h = Catch()
journal.addHandler(h)
try:
r = Runner(self.log, self.man, self.reg, self.bench)
finally:
journal.removeHandler(h)
# the recovery is a journal line, not a page message
self.assertTrue(any("recovered" in m for m in seen), seen)
self.assertNotIn("messages", r.state()[0])
self.assertFalse(os.path.exists(marker))
def test_10_state_is_conditional(self):
"""The page polls; an unchanged state must cost a 304, and the
ETag must move as soon as the state does.
Runs last because it invalidates: timestamps are whole seconds, and
a PASS stamped in the same second as an invalidate is deliberately
not inheritable, so an invalidate early in this class would decide
the inheritance the earlier tests are checking.
"""
st, d, hdrs = self.call_h("GET", "/state")
self.assertEqual(st, 200)
etag = hdrs.get("ETag")
self.assertTrue(etag and etag.startswith('"'), "no ETag on /state")
st, _, hdrs2 = self.call_h("GET", "/state", headers={"If-None-Match": etag})
self.assertEqual(st, 304)
self.assertEqual(hdrs2.get("ETag"), etag)
# a stale validator must not be honored
st, _, _ = self.call_h("GET", "/state", headers={"If-None-Match": '"stale"'})
self.assertEqual(st, 200)
# and the validator moves with the state
self.call("POST", "/invalidate", {"reason": "etag check"})
st, _, hdrs3 = self.call_h("GET", "/state", headers={"If-None-Match": etag})
self.assertEqual(st, 200)
self.assertNotEqual(hdrs3.get("ETag"), etag)
if __name__ == "__main__":
unittest.main()
+225
View File
@@ -0,0 +1,225 @@
"""scripts/source-bundle.py - the source that a release publishes.
The bundle must hold the source of every recipe of the image whose license
makes source necessary. The list comes from the license manifests that the
build writes, so a package cannot reach a machine with its source left
behind. These tests hold the decision (which recipe needs source), the
choice of archive (the version of the image, not an older build), and the
refusal that stops a release with source missing."""
import importlib.util
import json
import os
import tarfile
import unittest
import helpers # noqa: F401 (sys.path)
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
SCRIPT = os.path.join(REPO, "scripts", "source-bundle.py")
META_OPENGLOW = os.path.join(REPO, "..", "meta-openglow")
IMAGE = "forgefirm-image-glowforge.rootfs-20260101000000"
def load_script():
spec = importlib.util.spec_from_file_location("source_bundle", SCRIPT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def package_block(recipe, version, license_, package=None):
return ("PACKAGE NAME: %s\nPACKAGE VERSION: %s\nRECIPE NAME: %s\nLICENSE: %s\n\n"
% (package or recipe, version, recipe, license_))
def write(path, text):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(text)
def make_deploy(root, packages, archives, image_packages=()):
"""A deploy directory with the license manifests of one image and the
archive directories of the archiver pass."""
licdir = os.path.join(root, "licenses", "glowforge", IMAGE)
# The bundle answers for one image: its license manifests and its build
# identity must be in the deploy directory.
write(os.path.join(root, "images", "glowforge",
IMAGE + ".forgefirm-manifest.json"),
'{"content_sha256": "0"}\n')
write(os.path.join(licdir, "license.manifest"),
"".join(package_block(*p) for p in packages))
write(os.path.join(licdir, "image_license.manifest"),
"".join("RECIPE NAME: %s\nVERSION: %s\nLICENSE: %s\nFILES: x\n\n" % p
for p in image_packages))
for name in archives:
write(os.path.join(root, "sources", "arm-fslc-linux-gnueabi", name,
"%s.tar.gz" % name), "source of %s\n" % name)
return licdir
class Policy(unittest.TestCase):
"""The archiver pass and the check read one license list."""
def setUp(self):
self.mod = load_script()
self.policy = self.mod.read_policy(self.mod.KAS_OVERLAY)
def test_the_overlay_carries_the_filter(self):
self.assertIn("GPL*", self.policy["COPYLEFT_LICENSE_INCLUDE"])
self.assertIn("LGPL*", self.policy["COPYLEFT_LICENSE_INCLUDE"])
self.assertIn("Proprietary", self.policy["COPYLEFT_LICENSE_EXCLUDE"])
# The ForgeFIRM components are MIT and travel with the release too.
self.assertIn("forgectrl", self.policy["COPYLEFT_PN_INCLUDE"])
self.assertIn("gfcloud", self.policy["COPYLEFT_PN_INCLUDE"])
def test_a_copyleft_license_needs_source(self):
for expr in ("GPL-2.0-only",
"LGPL-2.1-or-later",
"GPL-2.0-only & bzip2-1.0.4",
"AFL-2.1 | GPL-2.0-or-later",
"(GPL-2.0-or-later | LGPL-3.0-only) & Unicode-DFS-2016",
"MIT ; LGPL-2.1-or-later",
"GPL-3.0-with-GCC-exception"):
self.assertTrue(self.mod.source_needed(expr, self.policy), expr)
def test_a_permissive_license_does_not(self):
for expr in ("MIT", "BSD-3-Clause", "Apache-2.0", "ISC", "PSF-2.0",
"Proprietary", "Firmware-imx-sdma_firmware", "PD"):
self.assertFalse(self.mod.source_needed(expr, self.policy), expr)
class Manifests(unittest.TestCase):
def setUp(self):
self.mod = load_script()
def test_a_recipe_collects_the_licenses_of_its_packages(self):
path = os.path.join(self.tmp(), "license.manifest")
write(path, package_block("avahi", "0.8", "LGPL-2.1-or-later", "avahi-daemon")
+ package_block("avahi", "0.8", "GPL-2.0-or-later & LGPL-2.1-or-later",
"avahi-locale-en-gb"))
recipes = self.mod.parse_manifest(path, "PACKAGE VERSION")
self.assertEqual(list(recipes), ["avahi"])
self.assertEqual(recipes["avahi"]["version"], "0.8")
self.assertEqual(len(recipes["avahi"]["licenses"]), 2)
def test_the_archive_of_the_image_version_wins(self):
index = {"linux-fslc": [("6.11.0", "/old"), ("6.12.20+git0+844aa34", "/new")],
"busybox": [("1.36.1", "/b1"), ("1.35.0", "/b0")],
"forgectrl": [("1_0.1.4", "/fc")]}
# An exact version first.
self.assertEqual(self.mod.select_archives(index, "busybox", "1.36.1"), ["/b1"])
# Then a version that starts with it: the kernel carries the
# revision of its git source in PV, and the manifest does not.
self.assertEqual(self.mod.select_archives(index, "linux-fslc", "6.12.20+git"),
["/new"])
# The name of an archive carries the epoch of the recipe, and a
# license manifest does not.
self.assertEqual(self.mod.select_archives(index, "forgectrl", "0.1.4"), ["/fc"])
self.assertEqual(self.mod.select_archives(index, "curl", "8.7.1"), [])
def test_a_directory_belongs_to_the_longest_recipe_name(self):
root = self.tmp()
make_deploy(root, [], ["python3-3.12.13-r0", "python3-certifi-2024.2.2-r0"])
index = self.mod.index_archives(os.path.join(root, "sources"),
{"python3", "python3-certifi"})
self.assertEqual([v for v, _ in index["python3"]], ["3.12.13"])
self.assertEqual([v for v, _ in index["python3-certifi"]], ["2024.2.2"])
def tmp(self):
import tempfile
d = tempfile.mkdtemp()
self.addCleanup(__import__("shutil").rmtree, d, True)
return d
@unittest.skipUnless(os.path.isdir(META_OPENGLOW),
"the meta-openglow sibling checkout is not here")
class Pack(unittest.TestCase):
"""The bundle end to end, on a deploy directory of made-up recipes."""
def setUp(self):
import tempfile
self.mod = load_script()
self.root = tempfile.mkdtemp()
self.addCleanup(__import__("shutil").rmtree, self.root, True)
self.deploy = os.path.join(self.root, "deploy")
self.out = os.path.join(self.root, "out")
def run_main(self, packages, archives, image_packages=()):
make_deploy(self.deploy, packages, archives, image_packages)
import sys
argv = sys.argv
sys.argv = ["source-bundle.py", "0.0.1", "--deploy", self.deploy,
"--image-name", IMAGE, "--out", self.out]
try:
return self.mod.main()
finally:
sys.argv = argv
def test_the_bundle_holds_the_source_and_the_accounting(self):
self.run_main(
packages=[("busybox", "1.36.1", "GPL-2.0-only"),
("jansson", "2.14", "MIT"),
("forgectrl", "0.1.4", "MIT")],
archives=["busybox-1.36.1-r0", "forgectrl-0.1.4-r0", "jansson-2.14-r0",
"u-boot-2020.01-r0"],
image_packages=[("u-boot", "2020.01", "GPL-2.0-or-later")])
path = os.path.join(self.out, "forgefirm-source-v0.0.1.tar.gz")
self.assertTrue(os.path.isfile(path))
with tarfile.open(path) as tar:
names = tar.getnames()
manifest = json.loads(tar.extractfile(
"forgefirm-source-v0.0.1/MANIFEST.json").read().decode())
sums = tar.extractfile(
"forgefirm-source-v0.0.1/sha256sums.txt").read().decode()
# The copyleft recipe and the ForgeFIRM component are in, the
# permissive third party is not.
self.assertIn("forgefirm-source-v0.0.1/sources/busybox-1.36.1-r0/busybox-1.36.1-r0.tar.gz",
names)
self.assertIn("forgefirm-source-v0.0.1/sources/forgectrl-0.1.4-r0/forgectrl-0.1.4-r0.tar.gz",
names)
self.assertNotIn("forgefirm-source-v0.0.1/sources/jansson-2.14-r0/jansson-2.14-r0.tar.gz",
names)
# The accounting travels with the source.
for name in ("README.md", "SOURCES.txt", "MANIFEST.json",
"licenses/license.manifest", "licenses/image_license.manifest",
"metadata/LAYERS.txt", "metadata/meta-forgefirm.tar.gz",
"metadata/kas/source-bundle.yml"):
self.assertIn("forgefirm-source-v0.0.1/" + name, names)
self.assertEqual(manifest["release"], "v0.0.1")
self.assertEqual(sorted(r["recipe"] for r in manifest["recipes"]),
["busybox", "forgectrl", "u-boot"])
self.assertEqual(manifest["recipes_without_source"],
[{"recipe": "jansson", "version": "2.14", "license": "MIT"}])
# Every file of the bundle is in the checksums, and only files that
# the bundle holds.
listed = sorted(line.split(" ", 1)[1] for line in sums.splitlines())
held = sorted(n.split("/", 1)[1] for n in names
if n != "forgefirm-source-v0.0.1/sha256sums.txt")
self.assertEqual(listed, held)
def test_a_recipe_with_no_source_stops_the_release(self):
with self.assertRaises(SystemExit) as caught:
self.run_main(packages=[("busybox", "1.36.1", "GPL-2.0-only"),
("gnutls", "3.8.4", "LGPL-2.1-or-later")],
archives=["busybox-1.36.1-r0"])
self.assertEqual(caught.exception.code, 1)
def test_the_source_of_gcc_covers_libgcc(self):
self.run_main(
packages=[("libgcc", "13.4.0", "GPL-3.0-with-GCC-exception"),
("glibc", "2.39+git", "GPL-2.0-only & LGPL-2.1-or-later"),
("glibc-locale", "2.39+git", "GPL-2.0-only & LGPL-2.1-or-later")],
archives=["gcc-source-13.4.0-13.4.0-r0", "glibc-2.39+git-r0"])
with tarfile.open(os.path.join(self.out, "forgefirm-source-v0.0.1.tar.gz")) as tar:
manifest = json.loads(tar.extractfile(
"forgefirm-source-v0.0.1/MANIFEST.json").read().decode())
holders = {r["recipe"]: r["holder"] for r in manifest["recipes"]}
self.assertEqual(holders["libgcc"], "gcc-source-13.4.0")
self.assertEqual(holders["glibc-locale"], "glibc")
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
"""scripts/manifest-from-tree.py - the workstation/CI mirror of the image
manifest: recipe pins are read through their pin files, and a layer's
content hash leaves the pin files out (a component bump is not a platform
change; a recipe-body change is)."""
import importlib.util
import os
import shutil
import subprocess
import tempfile
import unittest
import helpers # noqa: F401 (sys.path)
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
SCRIPT = os.path.join(REPO, "scripts", "manifest-from-tree.py")
def load_script():
spec = importlib.util.spec_from_file_location("manifest_from_tree", SCRIPT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def git_ok():
try:
subprocess.run(["git", "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
return True
except (OSError, subprocess.CalledProcessError):
return False
RECIPE = '''DESCRIPTION = "component"
LICENSE = "MIT"
SRC_URI = "git://github.com/example/comp.git;protocol=https;branch=main"
# SRCREV and PV live in the pin file.
require comp-pin.inc
S = "${WORKDIR}/git"
'''
PIN_A = 'SRCREV = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\nPV = "1.0"\n'
PIN_B = 'SRCREV = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\nPV = "1.1"\n'
@unittest.skipUnless(git_ok(), "git not available")
class TreeManifestTests(unittest.TestCase):
def setUp(self):
self.mod = load_script()
self.tmp = tempfile.mkdtemp(prefix="forgetest-tree-")
self.layer = os.path.join(self.tmp, "meta-x")
self.rdir = os.path.join(self.layer, "recipes-x", "comp")
os.makedirs(self.rdir)
subprocess.run(["git", "init", "-q"], cwd=self.tmp, check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.write("comp.bb", RECIPE)
self.write("comp-pin.inc", PIN_A)
self.write("README.md", "# docs\n")
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def write(self, name, text):
with open(os.path.join(self.rdir, name), "w", encoding="utf-8", newline="\n") as f:
f.write(text)
def test_parse_recipe_follows_the_pin_file(self):
url, rev = self.mod.parse_recipe(os.path.join(self.rdir, "comp.bb"))
self.assertEqual(url, "https://github.com/example/comp.git")
self.assertEqual(rev, "a" * 40)
self.write("comp-pin.inc", PIN_B)
self.assertEqual(self.mod.parse_recipe(os.path.join(self.rdir, "comp.bb"))[1], "b" * 40)
def test_parse_recipe_inline_pin_still_works(self):
self.write("comp.bb", RECIPE.replace("require comp-pin.inc\n", PIN_A))
os.remove(os.path.join(self.rdir, "comp-pin.inc"))
self.assertEqual(self.mod.parse_recipe(os.path.join(self.rdir, "comp.bb"))[1], "a" * 40)
def test_layer_hash_ignores_pin_bumps_and_docs(self):
base = self.mod.layer_content(self.layer)
self.assertIsNotNone(base)
self.write("comp-pin.inc", PIN_B)
self.assertEqual(self.mod.layer_content(self.layer), base, "a pin bump is not layer content")
self.write("README.md", "# docs, revised\n")
self.assertEqual(self.mod.layer_content(self.layer), base, "documentation is not layer content")
def test_layer_hash_sees_recipe_body_changes(self):
base = self.mod.layer_content(self.layer)
self.write("comp.bb", RECIPE + 'EXTRA_OEMAKE += "KCFLAGS=-Werror"\n')
self.assertNotEqual(self.mod.layer_content(self.layer), base)
def test_layer_hash_sees_a_pin_written_into_the_recipe(self):
# The safe direction: a pin that bypasses its pin file still hashes.
base = self.mod.layer_content(self.layer)
self.write("comp.bb", RECIPE + PIN_B)
self.assertNotEqual(self.mod.layer_content(self.layer), base)
def test_layer_hash_matches_the_bbclass_rule(self):
# The same skip list as *.md plus FORGEFIRM_MANIFEST_PIN_SUFFIX and
# FORGEFIRM_MANIFEST_VERSION_SUFFIX in
# forgefirm-image-manifest.bbclass. The two implementations compute
# the same identity - one on the build host, one on a workstation -
# so a suffix added to either without the other is the failure this
# guards.
bbclass = os.path.join(REPO, "meta-forgefirm", "classes", "forgefirm-image-manifest.bbclass")
with open(bbclass, encoding="utf-8") as f:
text = f.read()
self.assertIn('FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"', text)
self.assertIn('FORGEFIRM_MANIFEST_VERSION_SUFFIX ?= "forgefirm-release.inc"', text)
self.assertEqual(self.mod.LAYER_SKIP_SUFFIXES,
(".md", "-pin.inc", "forgefirm-release.inc"))
def test_layer_hash_ignores_the_release_version(self):
# The version is metadata: setting the release number must not move
# the layer hash, or the bump would invalidate the campaign that
# authorizes the release.
self.write("forgefirm-release.inc",'FORGEFIRM_RELEASE ?= "0.0.1"\n')
base = self.mod.layer_content(self.layer)
self.write("forgefirm-release.inc",'FORGEFIRM_RELEASE ?= "0.0.2"\n')
self.assertEqual(self.mod.layer_content(self.layer), base)
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More