22 Commits
Author SHA1 Message Date
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
40 changed files with 43789 additions and 416 deletions
+143 -13
View File
@@ -65,6 +65,21 @@ FIXED_SYSFS = [
("head/white_led", "0"), ("head/white_led", "0"),
("thermal/heater_pwm", "0"), ("thermal/heater_pwm", "0"),
("thermal/tec_on", "0"), ("thermal/tec_on", "0"),
# Purge air runs continuously, as on the factory machine: the engine
# turns it on at start and only a listening (the quiet hold) switches
# it off, which puts it back on release. A check that switches it off
# to measure it and forgets leaves the machine one job away from an
# airflow hold mid-cut, judged against the floor that same check just
# wrote, and nothing notices until the daemon restarts: the engine's
# idle phase never re-applies its own duties. The airflow check did
# exactly that, and it reached an operator's first fire.
("head/purge_air", "1"),
# The lens motor at rest: hold current, half step. The lens checks and
# the sheet cards take it to the run current in full or half step and
# must hand it back, or the motor sits hot and the next reference
# starts from a state nobody chose.
("head/z_current", "1"),
("head/z_mode", "1"),
] ]
# The subset the GRBL controller writes at its start: checked and restored # The subset the GRBL controller writes at its start: checked and restored
@@ -72,9 +87,14 @@ FIXED_SYSFS = [
# every pulse header (step_freq 10 kHz, the run currents), runs at the # every pulse header (step_freq 10 kHz, the run currents), runs at the
# service's own x8 with the module's ramp whatever xy_microsteps says, and # service's own x8 with the module's ramp whatever xy_microsteps says, and
# hands the hold currents back at idle; forcing the GRBL values under it # hands the hold currents back at idle; forcing the GRBL values under it
# would be the baseline configuring another controller's machine. # would be the baseline configuring another controller's machine. The Z
# pair belongs with them: the cloud client takes the lens microstep mode
# from the pulse header it is playing (ZSmd, gfhardware z_axis
# set_mode_from_puls) and the current with it, so a job whose header asks
# for full step leaves z_mode 0 behind by doing what it was told.
GRBL_CONTROLLER_SYSFS = ("cnc/motor_lock", "cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay", GRBL_CONTROLLER_SYSFS = ("cnc/motor_lock", "cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
"cnc/step_freq", "cnc/ramp_rate", "pic/x_step_current", "pic/y_step_current") "cnc/step_freq", "cnc/ramp_rate", "pic/x_step_current", "pic/y_step_current",
"head/z_current", "head/z_mode")
# Settings the baseline never hands back as bare settings: controller_mode # Settings the baseline never hands back as bare settings: controller_mode
# is the persisted mirror of the live mode (the mode item restores it # is the persisted mirror of the live mode (the mode item restores it
@@ -118,6 +138,13 @@ GRBL_PORT_S = 30 # the Grbl port after the supervisor reports
XY_STEPS_PER_MM = 53.333 # boards/glowforge.h (x8 microstepping) XY_STEPS_PER_MM = 53.333 # boards/glowforge.h (x8 microstepping)
RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most this far RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most this far
# The counters count steps; the controller's own returns land within a
# tenth of a millimeter of where they started, not on the step. A
# difference under this is that quantization, not a leftover: the head is
# still put back, so nothing accumulates over a campaign, but the test
# that made it does not fail for it. X and Y only - Z is the lens, which
# the return never moves, so any Z difference is still a leftover.
POSITION_DEADBAND_MM = 0.1
# The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset = # The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset =
# 8). The GRBL controller reads it at its start and derives its scale, # 8). The GRBL controller reads it at its start and derives its scale,
@@ -160,11 +187,18 @@ def leds_root():
return r if r.endswith("/") else r + "/" return r if r.endswith("/") else r + "/"
def read_led(name): def read_led(name, attr="target"):
"""A button LED's commanded level. The trigger fades brightness toward
target, so brightness is where the fade has reached and target is what
the machine was told: a run that left the button lit left a target, and
a run that ended a moment ago may still be fading from one. Falls back
to brightness where no target exists."""
for a in (attr, "brightness"):
try: try:
with open(leds_root() + name + "/brightness") as f: with open(leds_root() + name + "/" + a) as f:
return f.read().strip() return f.read().strip()
except OSError: except OSError:
continue
return None return None
@@ -183,6 +217,19 @@ def read_position():
return None return None
def position_quantized(was, now):
"""True when two step-counter readings differ by no more than the
dead band on X and Y and not at all on Z: the step quantization of a
move that landed where it meant to, rather than a leftover. A cancel
that returns the head to the job start lands within a few hundredths
of a millimeter, which is a step or four, and whether that rounds to
the same integer is chance."""
if was is None or now is None or now[2] != was[2]:
return False
return (abs(now[0] - was[0]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM and
abs(now[1] - was[1]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM)
def read_ring_residue(): def read_ring_residue():
"""Unplayed bytes queued in the kernel pulse ring: total written minus """Unplayed bytes queued in the kernel pulse ring: total written minus
processed (the cnc/position byte counters), or None when unreadable. processed (the cnc/position byte counters), or None when unreadable.
@@ -421,6 +468,37 @@ class Baseline:
time.sleep(1.0) time.sleep(1.0)
return None return None
def stand_down(self, why):
"""End whatever the machine is still doing, and prove it ended.
A run that dies mid-job leaves the job alive behind it: the
cooling engine armed and holding for a job nothing will finish,
the fans at run duty, and the rest of the program still queued in
the pulse ring. Waiting does not end any of that - the engine is
holding correctly, for a job that is never coming back.
Stopping the controller ends it. The supervisor brings the
controller back, the arm and the hold go with the job, and the
ring is empty on the way in. This is what the ring-residue
message has always told an operator to do; the hand-back does it
instead of saying it.
Returns the /mode body the machine settled on, or None."""
self.log("baseline: standing the machine down (%s): stopping the controller" % why)
st, _b = self.fc_post("/controller/stop")
if st != 200:
self.log("baseline: stand-down: /controller/stop -> %s" % st)
deadline = time.time() + 30
while time.time() < deadline and not self.abort():
body = self.fc_get("/mode")[1] or {}
if body.get("controller") != "running":
break
time.sleep(1.0)
st, _b = self.fc_post("/controller/start")
if st != 200:
self.log("baseline: stand-down: /controller/start -> %s" % st)
return self.wait_settled()
def cloud_mode(self): def cloud_mode(self):
return self.mode == "cloud" return self.mode == "cloud"
@@ -479,7 +557,14 @@ class Baseline:
before = (mode.get("controller"), mode.get("motion")) before = (mode.get("controller"), mode.get("motion"))
mode = self.wait_settled() or mode mode = self.wait_settled() or mode
if mode.get("controller") == "running" and mode.get("motion") == "verified": if mode.get("controller") == "running" and mode.get("motion") == "verified":
left.append(Leftover("controller", "%s/%s" % before, "running/verified", "waited")) # The supervisor's own work: a takeover ends by starting
# forgectrl again, and the respawn runs the liveness probe
# and the lens reference before the controller is up and
# motion is verified. Waiting for that is right; calling
# it a leftover is not, because the run did put it back -
# the machine was still doing what it was asked.
self.log("controller: %s/%s settled to running/verified on its own; "
"the supervisor's own start, not a leftover" % before)
else: else:
left.append(Leftover("controller", "%s/%s" % before, "running/verified", left.append(Leftover("controller", "%s/%s" % before, "running/verified",
"failed: %s/%s" % (mode.get("controller"), mode.get("motion")))) "failed: %s/%s" % (mode.get("controller"), mode.get("motion"))))
@@ -487,12 +572,21 @@ class Baseline:
st, s = self.fc_get("/status") st, s = self.fc_get("/status")
if st == 200 and isinstance(s, dict): if st == 200 and isinstance(s, dict):
if s.get("state") != "idle": if s.get("state") != "idle":
if s.get("state") == "underrun": # An underrun is the run's: the engine ran out of bytes and
# the fault is acknowledged here. Any other state is the
# machine finishing what it was given - the ring draining
# to the end of a job - and it reaches idle on its own.
underrun = s.get("state") == "underrun"
if underrun:
try: try:
hw.sysfs_write("cnc/stop", "1") # ack hw.sysfs_write("cnc/stop", "1") # ack
except OSError: except OSError:
pass pass
w = self._wait("idle", lambda: (self.fc_get("/status")[1] or {}).get("state") == "idle", IDLE_S) w = self._wait("idle", lambda: (self.fc_get("/status")[1] or {}).get("state") == "idle", IDLE_S)
if w is not None and not underrun:
self.log("state: %s reached idle on its own after %.0f s; the machine "
"finishing, not a leftover" % (s.get("state"), w))
else:
left.append(Leftover("state", s.get("state"), "idle", left.append(Leftover("state", s.get("state"), "idle",
"waited" if w is not None else "failed: not idle")) "waited" if w is not None else "failed: not idle"))
if s.get("laser_locked") is False: if s.get("laser_locked") is False:
@@ -507,11 +601,31 @@ class Baseline:
if st == 200 and isinstance(c, dict): if st == 200 and isinstance(c, dict):
if c.get("phase") != "idle" or c.get("armed") or c.get("hold"): if c.get("phase") != "idle" or c.get("armed") or c.get("hold"):
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold")) found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
w = self._wait("cool idle", lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {}), and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
COOL_IDLE_S) # Armed, or holding, is the run's doing. A phase alone is
# not: the engine clears smoke at run duty after an armed
# session and cools down after a hot one, both timed and
# both ending on their own. Waiting for that is right;
# calling it a leftover is not, because nothing was left -
# the machine was still finishing.
dirt = bool(c.get("armed") or c.get("hold"))
w = self._wait("cool idle", idle, COOL_IDLE_S)
if w is not None and not dirt:
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
"work, not a leftover" % (found, w))
else:
# The engine holds for a job. A run that ended without
# ending its job leaves one alive, and no amount of
# waiting ends it: stand the machine down and let the
# supervisor bring the controller back clean.
act = "waited"
if w is None:
self.stand_down("the cooling engine is still %s" % found)
w = self._wait("cool idle", idle, COOL_IDLE_S)
act = "restored (stood the machine down)"
left.append(Leftover("cool", found, "idle/unarmed/no hold", left.append(Leftover("cool", found, "idle/unarmed/no hold",
"waited" if w is not None else "failed: still %s" % found)) act if w is not None else "failed: still %s" % found))
def _lamp_side(self, left): def _lamp_side(self, left):
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting). """The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
@@ -575,6 +689,9 @@ class Baseline:
except OSError as e: except OSError as e:
act = "failed: %s" % e act = "failed: %s" % e
left.append(Leftover("leds/" + name, got, "0", act)) left.append(Leftover("leds/" + name, got, "0", act))
elif (read_led(name, "brightness") or "0") != "0":
self.log("leds/%s: target 0, brightness %s; the fade from a level the machine "
"itself ended, not a leftover" % (name, read_led(name, "brightness")))
def _return_head(self, was, now): def _return_head(self, was, now):
"""Jog the head back along its own path by the kernel-measured X/Y """Jog the head back along its own path by the kernel-measured X/Y
@@ -590,10 +707,18 @@ class Baseline:
# Never jog on top of stale bytes: a run started now would replay # Never jog on top of stale bytes: a run started now would replay
# whatever the ring still holds before the jog, in a direction and # whatever the ring still holds before the jog, in a direction and
# for a distance nobody asked for. Report and leave the head. # for a distance nobody asked for. Report and leave the head.
# Stale bytes in the ring are the last job's, and a jog now would
# replay them before its own. The ring empties when the
# controller restarts, so the hand-back restarts it rather than
# leaving the head where it is and the bytes where they are.
residue = read_ring_residue() residue = read_ring_residue()
if residue: if residue:
return ("unrestorable: %d unplayed bytes queued in the kernel ring - a jog would " self.log("baseline: %d unplayed bytes in the kernel ring before the return" % residue)
"replay them; clear the ring (controller restart) before moving" % residue) self.stand_down("%d unplayed bytes in the kernel ring" % residue)
residue = read_ring_residue()
if residue:
return ("unrestorable: %d unplayed bytes still queued in the kernel ring after a "
"controller restart - a jog would replay them" % residue)
# a controller may be inside a respawn backoff (seconds): wait for it # a controller may be inside a respawn backoff (seconds): wait for it
mode = None mode = None
deadline = time.time() + 30 deadline = time.time() + 30
@@ -662,7 +787,12 @@ class Baseline:
was = captured.get("position") was = captured.get("position")
now = read_position() now = read_position()
if was is not None and now is not None and now != was and not self.cloud_mode(): if was is not None and now is not None and now != was and not self.cloud_mode():
left.append(Leftover("position", now, was, self._return_head(was, now))) act = self._return_head(was, now)
if position_quantized(was, now):
self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, "
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
else:
left.append(Leftover("position", now, was, act))
was = captured.get("settings") was = captured.get("settings")
if was: if was:
st, body = self.fc_get("/settings") st, body = self.fc_get("/settings")
+5 -2
View File
@@ -247,8 +247,11 @@ TOOLS = [
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py", {"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [], "safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the " "desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the "
"lens stands, and neither a $20 nor a $132 write frees it. A CI harness (the grblHAL repo): needs " "lens stands, and neither a $20 nor a $132 write frees it. A referenced lens (forgectrl's marker "
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."}, "and the lens settings) opens it to the window the settings hold, the fallback or the focus "
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
"bench-page tool."},
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py", {"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
"safety": "dry", "where": "board", "ported": True, "safety": "dry", "where": "board", "ported": True,
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])], "args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
+60 -2
View File
@@ -164,7 +164,12 @@ class Run:
notice = dict(self.notice) if self.notice else None notice = dict(self.notice) if self.notice else None
return { return {
"kind": self.kind, "id": self.id, "title": self.title, "kind": self.kind, "id": self.id, "title": self.title,
"started": self.started_ts, "elapsed_s": int(time.time() - self.started), # A finished run's clock stops: the page shows the last run
# until the next one starts, and a live figure there counts
# the time since, not the time it took.
"started": self.started_ts,
"elapsed_s": (self.finished["duration_s"] if self.finished
else int(time.time() - self.started)),
"log": lines, "dropped": self.dropped, "prompt": prompt, "notice": notice, "log": lines, "dropped": self.dropped, "prompt": prompt, "notice": notice,
"finished": self.finished, "aborting": self.aborted.is_set(), "finished": self.finished, "aborting": self.aborted.is_set(),
} }
@@ -424,6 +429,45 @@ class Context:
self.log("ACT %s %s: done after %.1f s", channel, state, dt) self.log("ACT %s %s: done after %.1f s", channel, state, dt)
return dt return dt
def press_now(self, text="The button is lit white. Press it now: the laser fires after your press."):
"""The arm press, at the moment the machine says it is waiting.
A check that runs a job opens a `press` prompt of its own when
it sees the button lit and the tube still dark, which is the
machine's own word that the press it wants is this one. Pressing
on that is exact; pressing on a button LED read from here is
not, because the button is lit through parts of a check that are
not the arm (the lens reference, the program on its way to the
controller), and a press then lands before the job waits for it
and is lost.
The fixture presses where the bench took this test's presses (or
opted in standing); otherwise the notice goes up for a person,
who is looking at the same prompt. Returns True when the fixture
pressed."""
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
self.evidence.setdefault("actions", []).append(rec)
opted_in = fixture is not None and fixture.covers("button") and \
(fixture.arm_press or self.run.fixture_takeover)
if not opted_in:
if self.run.fixture_takeover:
rec["fixture_lost"] = True
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
"gone - asking the operator for the arm press")
self.notice(text)
return False
try:
fixture.act("button", "press")
except _fixture.FixtureError as e:
rec["fixture_error"] = str(e)
self.log("ARM press: fixture failed (%s) - asking the operator", e)
self.notice(text)
return False
rec["by"] = "fixture"
self.log("ARM press by the fixture, at the machine's own press prompt")
return True
def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.", def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.",
lit_timeout=60): lit_timeout=60):
"""The arm cue of a live test. A person's press by default: a """The arm cue of a live test. A person's press by default: a
@@ -994,7 +1038,21 @@ class Runner:
run.log(traceback.format_exc().rstrip()) run.log(traceback.format_exc().rstrip())
self.fixture_release(run) self.fixture_release(run)
try: try:
self._baseline_post(run, captured) left = self._baseline_post(run, captured)
# A test hands the machine back as it found it. Anything left
# behind was recorded here and nothing more, so a check that
# measured correctly and walked away with the machine in a
# state nobody chose still passed: that is how the airflow
# check came to leave the purge fan off, and an operator met
# it at their first fire instead of the bench meeting it here.
# The pass is now conditional on the machine being whole. A
# leftover the baseline put back still fails: the restore is
# the bench cleaning up after a defect, not the defect's
# absence.
if left and result == _campaign.PASS:
result = _campaign.FAIL
message = ("the machine was not handed back as found: %s"
% "; ".join(str(x) for x in left))
except Exception as e: # noqa: BLE001 - never lose the result over the cleanup except Exception as e: # noqa: BLE001 - never lose the result over the cleanup
run.log("baseline: post pass errored: %s: %s" % (type(e).__name__, e)) run.log("baseline: post pass errored: %s: %s" % (type(e).__name__, e))
duration = int(time.time() - run.started) duration = int(time.time() - run.started)
+7 -1
View File
@@ -372,7 +372,13 @@ def mode_switch(ctx):
gfhome_homing(ctx, ev, g) gfhome_homing(ctx, ev, g)
finally: finally:
if ev["homing_mode"] != "gfcloud": if ev["homing_mode"] != "gfcloud":
st, body = fc.post("/settings", data={"homing_mode": ev["homing_mode"] or "none"}) # Back to exactly what the machine had: unset is the empty
# string, and clearing a key needs the query-string form.
# "none" is a value, and writing it where the machine had
# nothing is a leftover the hand-back reports.
st, body = (fc.post("/settings", params={"homing_mode": ""})
if not ev["homing_mode"]
else fc.post("/settings", data={"homing_mode": ev["homing_mode"]}))
ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st) ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st)
ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> " ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> "
"grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"]) "grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
+88 -157
View File
@@ -1,6 +1,7 @@
"""commission.* - the first-run commissioning: the record and the """commission.* - the first-run commissioning: the record and the
controller gate, the advisories, the account and its login, the HTTPS controller gate, the advisories, the account and its login, the HTTPS
boundary, SSH, the cloud switch, the mDNS name, and the factory return. boundary, SSH, the cloud switch, the machine's name, and the factory
return.
The daemon reads the commissioning record (commissioning.json in the The daemon reads the commissioning record (commissioning.json in the
data directory) and the account record (users) once, at its start, and data directory) and the account record (users) once, at its start, and
@@ -11,7 +12,7 @@ never saves a test record over the real one, and a restore is complete
when the daemon is up again. when the daemon is up again.
The layer content of this work (the account replay init script, the The layer content of this work (the account replay init script, the
sshd policy, the console banner, the avahi service) is part of the sshd policy, the hostname and console banner scripts) is part of the
platform identity that every fingerprint carries, not of a component. platform identity that every fingerprint carries, not of a component.
No coverage map names it: a change there makes every test necessary No coverage map names it: a change there makes every test necessary
again. again.
@@ -23,10 +24,9 @@ import http.client
import json import json
import os import os
import random import random
import select import re
import socket import socket
import ssl import ssl
import struct
import time import time
import urllib.parse import urllib.parse
@@ -46,9 +46,6 @@ ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service")
SAFETY_PHRASE = "I UNDERSTAND" SAFETY_PHRASE = "I UNDERSTAND"
LOGIN_FAILS = 5 LOGIN_FAILS = 5
LOGIN_LOCK_S = 30 LOGIN_LOCK_S = 30
MDNS_NAME = "forgefirm.local"
MDNS_GROUP = "224.0.0.251"
MDNS_PORT = 5353
# ----------------------------------------------------------------- files # ----------------------------------------------------------------- files
@@ -777,7 +774,7 @@ def https_only_writes(ctx):
# ------------------------------------------------------------------ ssh # ------------------------------------------------------------------ ssh
def sshd_policy(): def sshd_policy():
"""The three ForgeFIRM keys of the effective sshd policy (sshd -T, """The ForgeFIRM keys of the effective sshd policy (sshd -T,
lowercase keys), or {"error": ...} when sshd cannot report it.""" lowercase keys), or {"error": ...} when sshd cannot report it."""
for exe in ("/usr/sbin/sshd", "sshd"): for exe in ("/usr/sbin/sshd", "sshd"):
rc, out = hw.run([exe, "-T"], timeout=15) rc, out = hw.run([exe, "-T"], timeout=15)
@@ -789,7 +786,7 @@ def sshd_policy():
for line in out.splitlines(): for line in out.splitlines():
parts = line.split(None, 1) parts = line.split(None, 1)
if len(parts) == 2 and parts[0] in ("permitrootlogin", "permitemptypasswords", if len(parts) == 2 and parts[0] in ("permitrootlogin", "permitemptypasswords",
"passwordauthentication"): "passwordauthentication", "banner"):
policy[parts[0]] = parts[1].strip() policy[parts[0]] = parts[1].strip()
return policy return policy
return {"error": "no sshd binary"} return {"error": "no sshd binary"}
@@ -814,7 +811,9 @@ def restore_ssh(fc, before):
"sshd listens on 22, with the effective policy (sshd -T) at PasswordAuthentication " "sshd listens on 22, with the effective policy (sshd -T) at PasswordAuthentication "
"yes, and on a release image PermitRootLogin no and PermitEmptyPasswords no (the " "yes, and on a release image PermitRootLogin no and PermitEmptyPasswords no (the "
"dev image turns root over SSH back on for the bench; the release gate checks " "dev image turns root over SSH back on for the bench; the release gate checks "
"the release image's sshd_config as built). An enable outside 0 and 1 is refused (400). On a release " "the release image's sshd_config as built), and with no pre-authentication "
"banner, so the machine says nothing to a client that has not logged in. "
"An enable outside 0 and 1 is refused (400). On a release "
"image POST enable=0 removes the flag and stops sshd; on the dev image the boot " "image POST enable=0 removes the flag and stops sshd; on the dev image the boot "
"rule keeps sshd up and the stop is never gated, so the test removes the flag " "rule keeps sshd up and the stop is never gated, so the test removes the flag "
"by hand (as a reboot does, the flag is on tmpfs) and sshd stays. The prior " "by hand (as a reboot does, the flag is on tmpfs) and sshd stays. The prior "
@@ -848,6 +847,13 @@ def ssh_until_reboot(ctx):
ctx.log("sshd -T: %s", policy) ctx.log("sshd -T: %s", policy)
ctx.check(policy.get("passwordauthentication") == "yes", ctx.check(policy.get("passwordauthentication") == "yes",
"PasswordAuthentication is %r: the account cannot log in", policy.get("passwordauthentication")) "PasswordAuthentication is %r: the account cannot log in", policy.get("passwordauthentication"))
# No pre-authentication banner: the machine says nothing to a
# client that has not logged in. The mark and the version go in
# the motd, which a login prints (image.health).
ctx.check(policy.get("banner") in (None, "none"),
"sshd sends a pre-authentication banner (%r)", policy.get("banner"))
if not before["dev_image"]: if not before["dev_image"]:
ctx.check(policy.get("permitrootlogin") == "no" and policy.get("permitemptypasswords") == "no", ctx.check(policy.get("permitrootlogin") == "no" and policy.get("permitemptypasswords") == "no",
"a release image runs sshd with PermitRootLogin %r, PermitEmptyPasswords %r", "a release image runs sshd with PermitRootLogin %r, PermitEmptyPasswords %r",
@@ -1002,165 +1008,89 @@ def factory_return(ctx):
ctx.log("PASS: the return is refused without confirm=1; the page confirms before it calls") ctx.log("PASS: the return is refused without confirm=1; the page confirms before it calls")
# ----------------------------------------------------------------- mDNS # --------------------------------------------------- the machine's name
def mdns_query(name, qid=None): def mac_suffix():
"""A DNS query packet for the A record of name with the unicast- """The last four hex digits of the MAC address the hostname is built
response bit set (RFC 6762 5.4), so the responder answers this from: wlan0, or eth0 on a machine with no WiFi."""
socket directly.""" for dev in ("wlan0", "eth0"):
qid = random.randrange(1, 65536) if qid is None else qid raw = read_file("/sys/class/net/%s/address" % dev)
labels = b"".join(struct.pack("B", len(p)) + p.encode("ascii") for p in name.strip(".").split(".")) mac = (raw or b"").decode("ascii", "replace").strip().replace(":", "").lower()
return struct.pack(">HHHHHH", qid, 0, 1, 0, 0, 0) + labels + b"\x00" + struct.pack(">HH", 1, 0x8001) if mac and mac != "0" * 12:
return mac[-4:]
return ""
def _dns_name(pkt, off): def cmdlines():
"""(name, offset after the name); follows compression pointers.""" """The command line of every process on the machine, one string
parts = [] each."""
jumped = False
end = off
hops = 0
while True:
if off >= len(pkt):
raise ValueError("truncated name")
n = pkt[off]
if n == 0:
off += 1
break
if n & 0xC0 == 0xC0:
if off + 1 >= len(pkt):
raise ValueError("truncated pointer")
ptr = ((n & 0x3F) << 8) | pkt[off + 1]
if not jumped:
end = off + 2
jumped = True
off = ptr
hops += 1
if hops > 32:
raise ValueError("pointer loop")
continue
off += 1
parts.append(pkt[off:off + n].decode("ascii", "replace"))
off += n
if not jumped:
end = off
return ".".join(parts), end
def mdns_answers(pkt, qid=None):
"""The A records of a DNS response: [(name, address)]. A qid, when
given, must match the packet's id; the answer flag (QR) must be set."""
if len(pkt) < 12:
return []
pid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", pkt[:12])
if qid is not None and pid != qid:
return []
if not flags & 0x8000:
return []
off = 12
try:
for _ in range(qd):
_name, off = _dns_name(pkt, off)
off += 4
out = [] out = []
for _ in range(an + ns + ar): for name in os.listdir("/proc"):
name, off = _dns_name(pkt, off) if not name.isdigit():
if off + 10 > len(pkt):
break
rtype, rclass, _ttl, rdlen = struct.unpack(">HHIH", pkt[off:off + 10])
off += 10
rdata = pkt[off:off + rdlen]
off += rdlen
if rtype == 1 and rdlen == 4:
out.append((name.lower(), socket.inet_ntoa(rdata)))
return out
except ValueError:
return []
def mdns_resolve(ip, name=MDNS_NAME, timeout=3.0, tries=3):
"""Ask the LAN for the A record of name over mDNS from the interface
that holds ip, and collect the answers: {address}. The query goes to
the multicast group; the responder on this machine answers the
unicast-response bit directly (a legacy query from a port that is
not 5353 is answered the same way), and a listener on 5353 catches
a multicast answer too."""
found = set()
qid = random.randrange(1, 65536)
q = mdns_query(name, qid)
tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rx = None
try:
tx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
tx.bind((ip, 0))
tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(ip))
tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1)
try:
rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"):
rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
rx.bind(("", MDNS_PORT))
rx.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
socket.inet_aton(MDNS_GROUP) + socket.inet_aton(ip))
except OSError:
rx = None
socks = [s for s in (tx, rx) if s is not None]
for _ in range(tries):
tx.sendto(q, (MDNS_GROUP, MDNS_PORT))
deadline = time.time() + timeout
while time.time() < deadline:
ready, _w, _x = select.select(socks, [], [], max(0.05, deadline - time.time()))
for s in ready:
try:
pkt, _peer = s.recvfrom(4096)
except OSError:
continue continue
# a multicast answer carries id 0; the direct one echoes the query's raw = read_file("/proc/%s/cmdline" % name)
for n, addr in mdns_answers(pkt, qid if s is tx else None): if raw:
if n == name.lower(): out.append(raw.decode("utf-8", "replace").replace("\0", " ").strip())
found.add(addr) return out
if found:
return found
finally:
tx.close()
if rx is not None:
rx.close()
return found
@test("commission.mdns-announce", title="The machine answers forgefirm.local", subsystem="commission", @test("commission.machine-name", title="The machine names itself from its MAC address",
kind="auto", est_min=1, subsystem="commission", kind="auto", est_min=1,
requires=["forgectrl.auth"], requires=["forgectrl.auth"],
description="avahi-daemon runs, its configuration names the host forgefirm on the WiFi and " description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex "
"wired links, and the service file advertises the panel on 443 and 80. An mDNS " "digits of its WiFi MAC address, so two machines on one network answer to "
"query for the A record of forgefirm.local, sent to the multicast group from " "different names and no name carries a serial number. The live name, "
"the board's own LAN interface, is answered with the board's LAN address; that " "/etc/hostname (a bind-mounted copy, because the rootfs is read-only) and the "
"is what `ping forgefirm.local` on a workstation resolves. No component " "MAC address agree; the DHCP client sends the name as the hostname option, "
"covers this: the avahi files are layer content, in the platform identity of " "which is what a network with dynamic DNS publishes; the console banner offers "
"the machine's addresses and nothing else, with no marker line and no .local "
"name; and no mDNS responder is on the image. No component covers this: the "
"hostname and banner scripts are layer content, in the platform identity of "
"every fingerprint.") "every fingerprint.")
def mdns_announce(ctx): def machine_name(ctx):
ev = ctx.evidence ev = ctx.evidence
pids = hw.pidof("avahi-daemon")
ev["avahi_pids"] = pids # 1. the name, the file and the MAC address agree
ctx.check(pids, "avahi-daemon is not running") suffix = mac_suffix()
conf = read_file("/etc/avahi/avahi-daemon.conf") or b"" ev["mac_suffix"] = suffix
ctx.check(b"host-name=forgefirm" in conf, "avahi-daemon.conf does not name the host forgefirm") ctx.check(suffix, "no MAC address to build a name from")
ctx.check(b"allow-interfaces=wlan0,eth0" in conf, "avahi-daemon.conf does not restrict the interfaces") live = socket.gethostname()
svc = read_file("/etc/avahi/services/forgefirm.service") or b"" on_file = ((read_file("/etc/hostname") or b"").decode("utf-8", "replace")).strip()
ev["service_file_bytes"] = len(svc) ev["hostname"] = live
ctx.check(b"_https._tcp" in svc and b"<port>443</port>" in svc, "the service file lacks HTTPS 443") ev["hostname_file"] = on_file
ctx.check(b"_http._tcp" in svc and b"<port>80</port>" in svc, "the service file lacks HTTP 80") ctx.log("hostname %r (MAC suffix %s)", live, suffix or "none")
ctx.check(live == "forgefirm-" + suffix,
"the hostname is %r, expected forgefirm-%s", live, suffix)
ctx.check(on_file == live, "/etc/hostname holds %r, the live name is %r", on_file, live)
# 2. the DHCP client sends it (option 12), so dynamic DNS can publish it
dhcp = [c for c in cmdlines() if c.split(" ")[0].split("/")[-1] == "udhcpc"]
ev["udhcpc"] = dhcp
ctx.check(dhcp, "no DHCP client is running")
ctx.check(any("-x hostname:" + live in c for c in dhcp),
"the DHCP client does not send the machine's name: %s", dhcp)
# 3. the console banner: the addresses, and nothing that is not the banner
issue = (read_file("/etc/issue") or b"").decode("utf-8", "replace")
ev["issue"] = issue
ip = lan_ip() ip = lan_ip()
ev["lan_ip"] = ip ev["lan_ip"] = ip
ctx.check(ip, "cannot determine the board's LAN address") ctx.check(ip, "cannot determine the board's LAN address")
t0 = time.time() ctx.check("Control panel:" in issue, "the console banner names no control panel")
found = mdns_resolve(ip) ctx.check("https://%s/" % ip in issue,
ev["answers"] = sorted(found) "the console banner does not carry the LAN address %s", ip)
ev["resolve_s"] = round(time.time() - t0, 2) for junk in ("# end", "# ForgeFIRM addresses", ".local"):
ctx.log("%s -> %s (%.1f s)", MDNS_NAME, sorted(found) or "no answer", ev["resolve_s"]) ctx.check(junk not in issue, "the console banner shows %r", junk)
ctx.check(found, "no mDNS answer for %s from the LAN interface (%s)", MDNS_NAME, ip) urls = re.findall(r"https://(\S+)/", issue)
ctx.check(ip in found, "%s resolves to %s, not the LAN address %s", MDNS_NAME, sorted(found), ip) ev["banner_urls"] = urls
for u in urls:
literal = u.startswith("[") or all(c.isdigit() or c == "." for c in u)
ctx.check(literal, "the console banner offers %r, which is not an address", u)
# 4. no mDNS on the image; the name is the only name the machine has
ev["avahi_pids"] = hw.pidof("avahi-daemon")
ctx.check(not ev["avahi_pids"], "an mDNS responder is running: %s", ev["avahi_pids"])
ctx.check(not os.path.exists("/etc/avahi"), "/etc/avahi is on the image")
# --------------------------------------------------------- the first run # --------------------------------------------------------- the first run
@@ -1458,7 +1388,8 @@ def cert_page(ctx):
ctx.check(st == 200, "GET %s/cert -> %s, expected 200", base, st) ctx.check(st == 200, "GET %s/cert -> %s, expected 200", base, st)
ctx.check("location" not in hdrs, "GET %s/cert redirected", base) ctx.check("location" not in hdrs, "GET %s/cert redirected", base)
ctx.check(fp in text, "the fingerprint is not on the page from %s", base) ctx.check(fp in text, "the fingerprint is not on the page from %s", base)
ctx.check("forgefirm.local" in text, "forgefirm.local is not among the names on the page") ctx.check(socket.gethostname() in text,
"the machine's hostname is not among the names on the page")
st, body, hdrs = request(base, "GET", "/cert.pem") st, body, hdrs = request(base, "GET", "/cert.pem")
ctx.check(st == 200 and body.startswith(b"-----BEGIN CERTIFICATE-----"), ctx.check(st == 200 and body.startswith(b"-----BEGIN CERTIFICATE-----"),
"GET %s/cert.pem -> %s, not a PEM certificate", base, st) "GET %s/cert.pem -> %s, not a PEM certificate", base, st)
+41 -2
View File
@@ -15,6 +15,12 @@ from ..baseline import read_position
from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
POLL_S = 1.0 POLL_S = 1.0
# What the machine's own press prompt says, used when it sends no text.
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.*"), DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")] ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
@@ -76,6 +82,12 @@ def run_check(ctx, wid, on_prompt, timeout_s):
if value is not None: if value is not None:
st, body = answer(fc, wid, p, value) st, body = answer(fc, wid, p, value)
ctx.check(st == 200, "the answer to %s -> %s %s", p.get("id"), st, body) 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"): if not d.get("running"):
last = d last = d
break break
@@ -167,7 +179,8 @@ def check_sensors(ctx):
"for 35 s, the purge is measured on and off, and five settings are written: the " "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 " "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 " "fan must read at least 1000 rpm. The five settings are put back as found at the "
"end and the controller must be running again.") "end, the purge fan is on again and drawing over the floor the check just wrote, "
"and the controller must be running again.")
def check_airflow(ctx): def check_airflow(ctx):
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm", 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"] "cool_purge_min_current", "cool_fan_grace_s"]
@@ -186,6 +199,32 @@ def check_airflow(ctx):
for k in keys: 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.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)) 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) 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") ctx.check(ok is not None, "the controller did not come back after the check")
@@ -228,7 +267,7 @@ def check_cameras(ctx):
@test("commission.check-motion", title="The motion check proves the rail, the lens reference, and the jogs", @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, subsystem="commission", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"), covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
("forgectrl", "src/lenshome.c"), ("forgectrl", "src/lenshome.*"),
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")], ("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
requires=["forgectrl.auth", "motion.pacing"], requires=["forgectrl.auth", "motion.pacing"],
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the " description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
+105 -24
View File
@@ -21,7 +21,7 @@ from ..catalog import test
from .commission_dark import run_check, Restore from .commission_dark import run_check, Restore
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"), SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
("forgectrl", "src/sheet.*"), ("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"), ("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"), ("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
@@ -36,19 +36,18 @@ SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
# The live wizards in the order the sheet burns them: the wizard id, the # 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 # answers to its prompts (a middle pick where a person would look at the
# sheet), the keys its result must carry, and how long the bench actuator # sheet), and the keys its result must carry. The arm press is not timed
# waits for the button to light before the arm press goes to a person. # from here: each card opens a press prompt of its own when the button is
# The flow-load card settles the coolant loop before it lights the button # lit and the tube still dark, and run_check presses on that.
# (60 to 240 s); the others light within seconds of the lens reference.
CARDS = [ CARDS = [
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"], 120), ("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"]),
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"}, ("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", ["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
"focus_range_mm", "stops"], 120), "focus_range_mm", "stops"]),
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"], 120), ("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"]),
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"], 120), ("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"]),
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"], 120), ("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"], 420), ("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. # 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", CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
@@ -72,6 +71,22 @@ def preview_ok(ctx, wid):
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None, ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
"no program for %s (%s)", wid, st) "no program for %s (%s)", wid, st)
ctx.evidence["program_lines"] = body.count(b"\n") ctx.evidence["program_lines"] = body.count(b"\n")
# Every Z the program commands is a height the head reaches in the
# window the machine holds now: the focus card's own numbers once it
# has them, and the conservative fallback on a head whose travel is
# not known yet. The controller refuses a Z outside it with ALARM:2,
# and it refuses it while the body is still burning, so the card dies
# where the parser is and not where the bad line is.
lens = (fc.status() or {}).get("lens") or {}
lo, hi = lens.get("reach_min"), lens.get("reach_max")
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
ctx.evidence.setdefault("program_z", {})[wid] = {
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
if lo is not None and hi is not None:
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
wid, lo, hi, out)
def emission_ok(ctx, last): def emission_ok(ctx, last):
@@ -90,6 +105,61 @@ def file_card(ctx, wid):
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev} ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
# The lens settings: the head's numbers, which a fresh machine does not
# have. Cleared before the cards so the run is a fresh machine's.
LENS_SETTINGS = CARD_SETTINGS[:3]
FALLBACK_WINDOW = (10, 12)
def fresh_lens(ctx):
"""The lens as a fresh machine has it: the head's numbers unknown, so
the frame runs in the fallback window and the focus card has to find
the stops and write them before its ladder burns. The machine's own
numbers come back with the restore at the end of the run."""
fc = ctx.forgectrl
for k in LENS_SETTINGS:
st, _ = fc.post("/settings", params={k: ""})
ctx.check(st == 200, "clearing %s -> %s", k, st)
lens = (fc.status() or {}).get("lens") or {}
ctx.evidence["lens_fresh"] = lens
ctx.check(lens.get("stops_found") is False and (lens.get("below"), lens.get("above")) == FALLBACK_WINDOW,
"the lens window is not the fallback after the clear: %s", lens)
def focus_window_ok(ctx):
"""The window the focus card burned its ladder in is the one it wrote
before its controller started, and the one the machine holds now: the
result's window is the stops it found (or the fallback), the settings
hold that window, every ladder height lies in its reach, and the
program served now agrees with /status."""
fc = ctx.forgectrl
r = ((ctx.evidence.get("cards") or {}).get("laser.focus") or {}).get("result") or {}
w, stops = r.get("window") or {}, r.get("stops") or {}
s = fc.settings() or {}
held = (str(s.get("lens_stop_below_steps")), str(s.get("lens_stop_above_steps")))
ctx.check(held == (str(w.get("below")), str(w.get("above"))),
"the settings hold the window %s, the ladder ran in %s/%s", held, w.get("below"), w.get("above"))
if stops.get("found"):
ctx.check((w.get("below"), w.get("above")) == (stops.get("below"), stops.get("above")),
"the ladder's window %s is not the stops found %s", w, stops)
else:
ctx.check((w.get("below"), w.get("above")) == FALLBACK_WINDOW,
"stops not found (%s), but the ladder's window is %s", stops.get("why"), w)
zs = r.get("ladder_z") or []
lo, hi = w.get("reach_min"), w.get("reach_max")
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
ev = ctx.evidence
before = dict(ev.get("program_z") or {}).get("laser.focus")
preview_ok(ctx, "laser.focus")
ev["focus_window"] = {"window": w, "stops": stops, "ladder_z": zs,
"settings": {k: s.get(k) for k in LENS_SETTINGS},
"program_z_before": before,
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
"preview_bytes": ev.pop("preview_bytes", None),
"program_lines": ev.pop("program_lines", None)}
def thickness_answer(ctx): def thickness_answer(ctx):
"""The thickness the placement is answered with, in the machine's """The thickness the placement is answered with, in the machine's
units (the number prompts read ui_units), and the millimeters the units (the number prompts read ui_units), and the millimeters the
@@ -130,12 +200,12 @@ def place(ctx):
file_card(ctx, "sheet.place") file_card(ctx, "sheet.place")
def burn(ctx, wid, answers, want, lit_s): def burn(ctx, wid, answers, want):
"""One live card: the preview, the burn after the press with the """One live card: the preview, the burn after the press with the
prompts answered from `answers`, the witnesses, the keys of the prompts answered from `answers`, the witnesses, the keys of the
result in `want`. The actuator waits `lit_s` for the button to light.""" result in `want`. run_check makes the arm press when the card asks
for it."""
preview_ok(ctx, wid) preview_ok(ctx, wid)
ctx.arm_press(lit_timeout=lit_s)
def on_prompt(p): def on_prompt(p):
return answers.get(p.get("id")) return answers.get(p.get("id"))
@@ -161,21 +231,29 @@ def burn(ctx, wid, answers, want, lit_s):
"it goes with its top edge at the top of the cut area (the head's home corner); " "it goes with its top edge at the top of the cut area (the head's home corner); "
"lid closed. Eye protection on, exhaust on, extinguisher in reach.", "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 " "Start the test and press the machine's button once. That press says you are at the "
"machine, and the bench actuator makes every arm press after it. With no actuator " "machine, and the bench actuator makes every arm press after it, each one when the "
"the start is the Ready answer instead, and you press the button each time it lights " "card itself asks for it. With no actuator the start is the Ready answer instead, "
"white: six times, the frame and the five cards.", "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 " "Nothing else: the test answers every prompt itself and puts back every setting the "
"cards write. It runs about half an hour, mostly the coolant settle and the dark " "cards write. It runs about half an hour, mostly the coolant settle and the dark "
"tails."], "tails."],
description="One run over the sheet's seven wizards on one piece of wood. " description="One run over the sheet's seven wizards on one piece of wood, as a fresh "
"machine runs them: the three lens settings are cleared first, so the frame "
"runs in the fallback window and the focus card has to find the stops and write "
"them before its ladder burns. "
"POST /wiz/sheet.place/start references the lens, takes the controller in " "POST /wiz/sheet.place/start references the lens, takes the controller in "
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head " "loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or " "(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
"0.125 in). Then, each after the press and each with its preview " "0.125 in). Then, each after the press and each with its preview "
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode: the frame " "and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the "
"program commands inside the reach /status reports: the frame "
"(180 x 130 mm) and the header band at the mark dose; the focus card (the " "(180 x 130 mm) and the header band at the mark dose; the focus card (the "
"lens homed on its bottom stop, the hall edge counted, twelve lines down the " "lens on its hall edge, the stops found by the head accelerometer and written "
"travel; the pick is line 11, the thickness kept); the floor card (twelve " "as the window before the controller starts, twelve lines from the top of the "
"window to the bottom; the pick is line 11, the thickness kept; after it the "
"settings, the result's window, and the served program must hold the window "
"the ladder ran in); the floor card (twelve "
"rungs from 2 to 24 percent with the floor and the curve off for the job; the " "rungs from 2 to 24 percent with the floor and the curve off for the job; the "
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at " "pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded " "25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
@@ -191,6 +269,9 @@ def sheet(ctx):
"each. The bench presses when the button lights.") "each. The bench presses when the button lights.")
place(ctx) place(ctx)
with Restore(ctx, CARD_SETTINGS): with Restore(ctx, CARD_SETTINGS):
for wid, answers, want, lit_s in CARDS: fresh_lens(ctx)
burn(ctx, wid, answers, want, lit_s) for wid, answers, want in CARDS:
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored") 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")
+14 -2
View File
@@ -35,8 +35,9 @@ def lan_ip():
covers=_COVERS_AUTH, covers=_COVERS_AUTH,
description="Every state-changing endpoint refuses an unauthenticated write (the factory " 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 " "return, the SSH switch and the wizard's own routes included); a non-literal "
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused; the " "Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused, while "
"cooling report channel accepts the loopback peer and refuses a non-loopback " "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 " "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 " "answers 403 loopback only); the fuse view is two-factor "
"(token and the physical button) and refused without either; " "(token and the physical button) and refused without either; "
@@ -96,6 +97,17 @@ def auth(ctx):
ev["host_name"] = st ev["host_name"] = st
ctx.log("GET /status Host=evil.example.net -> %s", st) ctx.log("GET /status Host=evil.example.net -> %s", st)
ctx.check(st == 403, "a DNS-name Host was accepted (%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"}) st, body = fc.get("/status", headers={"Origin": "http://evil.example.net"})
ev["origin_name"] = st ev["origin_name"] = st
ctx.log("GET /status Origin=http://evil.example.net -> %s", st) ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
+43 -4
View File
@@ -28,6 +28,12 @@ def _wdog1_wcr():
return None 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): def _read(path, default=None):
try: try:
with open(path, "r", encoding="utf-8", errors="replace") as f: with open(path, "r", encoding="utf-8", errors="replace") as f:
@@ -104,7 +110,9 @@ def fds_of(pid):
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"), ("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
("linux-fslc", "**")], ("linux-fslc", "**")],
description="The image that is running is the image the manifest describes, with the " description="The image that is running is the image the manifest describes, with the "
"kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, " "version stamped in /etc/forgefirm-version and under the machine mark in the "
"console banner and the motd, "
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
"the daemon ownership, " "the daemon ownership, "
"the init ordering, the file modes the release depends on, and the mounts: the " "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 " "rootfs read-only, /data writable, the account files and the banner rendered "
@@ -113,11 +121,39 @@ def image_health(ctx):
ev = ctx.evidence ev = ctx.evidence
manifest = ctx.runner.manifest manifest = ctx.runner.manifest
# 1. version stamp # 1. version stamp: the machine-readable file, and the two a person
# reads - the console banner (/etc/issue, which forgefirm-banner
# renders from the image's own text) and the motd a login prints.
# Each carries the machine mark with the version on the mark's own
# last line, right-justified to the mark's last column.
ver = (_read("/etc/forgefirm-version", "") or "").strip() ver = (_read("/etc/forgefirm-version", "") or "").strip()
ev["forgefirm_version"] = ver ev["forgefirm_version"] = ver
ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version) ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version)
ctx.check(ver == manifest.version, "/etc/forgefirm-version %r != manifest %r", 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 # 2. kernel options
cfg = kernel_config() cfg = kernel_config()
@@ -251,7 +287,8 @@ def image_health(ctx):
# 8. the mounts: the rootfs read-only, /data the writable partition, # 8. the mounts: the rootfs read-only, /data the writable partition,
# the state a read-only rootfs hands off (the read-only-rootfs image # the state a read-only rootfs hands off (the read-only-rootfs image
# feature, forgefirm-users, forgefirm-banner, the sshd host keys). The # feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
# sshd host keys). The
# dev image alone mounts the factory slots under /factory. # dev image alone mounts the factory slots under /factory.
mounts = {} mounts = {}
for line in (_read("/proc/mounts", "") or "").splitlines(): for line in (_read("/proc/mounts", "") or "").splitlines():
@@ -262,7 +299,8 @@ def image_health(ctx):
def mount_opts(path): def mount_opts(path):
return (mounts.get(path) or {}).get("opts") or [] return (mounts.get(path) or {}).get("opts") or []
ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd", "/etc/issue") if p in mounts} ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd",
"/etc/hostname", "/etc/issue") if p in mounts}
ctx.log("/ mounted %s; /data %s; /var/lib %s", ",".join(mount_opts("/")) or "(absent)", 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)") ",".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("ro" in mount_opts("/"), "the rootfs is not mounted read-only: %s", mounts.get("/"))
@@ -292,6 +330,7 @@ def image_health(ctx):
ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f) ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f)
for n in names: for n in names:
ctx.check(n in passwd_names, "record account %r is missing from /etc/passwd", n) 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") ctx.check("/etc/issue" in mounts, "/etc/issue is not the bind-mounted banner copy")
if hw.pidof("sshd"): if hw.pidof("sshd"):
key = "/data/forgefirm/ssh/ssh_host_ed25519_key" key = "/data/forgefirm/ssh/ssh_host_ed25519_key"
+23
View File
@@ -566,11 +566,19 @@ def m5_rapid_dark(ctx):
ctx.check(base, "forgectrl /status or /cool/status unavailable") ctx.check(base, "forgectrl /status or /cool/status unavailable")
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"]) ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
ctx.ready(ARM_CUE % "20 mm +X") ctx.ready(ARM_CUE % "20 mm +X")
# +20 out on the cut, then the two rapids the test is about, then
# a third that puts the head back where the job found it: the
# first two net to +20 and a job that walks the head 20 mm and
# leaves it there is dirt the hand-back has to jog out. The
# return is inside the sampling window and after the M5, so it is
# one more rapid that must be dark, which is what the test asks
# of the other two.
job = ["G91", "G21", "M3", "S400", job = ["G91", "G21", "M3", "S400",
"G1 X20 F600", "G1 X20 F600",
"M5", "G4 P2.5", "M5", "G4 P2.5",
"G0 X-20", "G4 P2.5", "G0 X-20", "G4 P2.5",
"G0 X20", "G4 P2.5", "G0 X20", "G4 P2.5",
"G0 X-20", "G4 P0.5",
"G90", "M2"] "G90", "M2"]
ctx.arm_press() ctx.arm_press()
try: try:
@@ -632,6 +640,7 @@ def disarm_in_hold(ctx):
ev = ctx.evidence ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g): with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g) prepare(ctx, g)
k0 = kernel_start(ctx)
ctx.ready(ARM_CUE % "40 mm +X") ctx.ready(ARM_CUE % "40 mm +X")
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"]) stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
ctx.log("armed; waiting for motion to start (arm + your button press)...") ctx.log("armed; waiting for motion to start (arm + your button press)...")
@@ -674,6 +683,20 @@ def disarm_in_hold(ctx):
ctx.sleep(1) ctx.sleep(1)
if "Alarm" in g.status_report()["state"]: if "Alarm" in g.status_report()["state"]:
g.command("$X") g.command("$X")
# The hold parks the head part-way through the move and the reset
# abandons the rest of it, so the job ends wherever the feed hold
# caught it - about 11 mm in, on the bench reference. That is the
# test's own doing and it goes back: the kernel counters say how
# far the head actually went, which is what a jog has to undo.
wait_idle(ctx, g, 15)
dx, dy = kernel_xy_mm(ctx)
back_x, back_y = k0[0] - dx, k0[1] - dy
ev["left_at_mm"] = [round(dx - k0[0], 3), round(dy - k0[1], 3)]
if abs(back_x) >= 0.02 or abs(back_y) >= 0.02:
ctx.log("returning the head: %+.3f/%+.3f mm", back_x, back_y)
g.command("$J=G91X%.3fY%.3fF1200" % (back_x, back_y))
wait_idle(ctx, g, 30)
ev["returned_mm"] = list(kernel_xy_mm(ctx))
ctx.check(disarmed_at is not None, "still armed after 120 s in Hold") ctx.check(disarmed_at is not None, "still armed after 120 s in Hold")
ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold) ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold)
check_button_dark(ctx, ev) check_button_dark(ctx, ev)
+12 -3
View File
@@ -1285,8 +1285,13 @@ def interlock_cancel_home(ctx):
def lid_policy_hold(ctx): def lid_policy_hold(ctx):
ev = ctx.evidence ev = ctx.evidence
fc = ctx.forgectrl fc = ctx.forgectrl
was = (fc.settings() or {}).get("lid_policy") or "cancel" # What the machine had, exactly: an unset lid_policy reads as the empty
# string and behaves as cancel, and writing the word back where the
# machine had nothing is a leftover the hand-back reports. The default
# is for reading the value, never for restoring it.
was = (fc.settings() or {}).get("lid_policy", "")
ev["lid_policy_before"] = was ev["lid_policy_before"] = was
ev["lid_policy_in_force"] = was or "cancel"
st, _b = fc.post("/settings", data={"lid_policy": "hold"}) st, _b = fc.post("/settings", data={"lid_policy": "hold"})
ctx.check(st == 200, "could not set lid_policy=hold (%s)", st) ctx.check(st == 200, "could not set lid_policy=hold (%s)", st)
ctx.check(((fc.settings() or {}).get("lid_policy")) == "hold", "lid_policy did not take") ctx.check(((fc.settings() or {}).get("lid_policy")) == "hold", "lid_policy did not take")
@@ -1332,8 +1337,12 @@ def lid_policy_hold(ctx):
g.command("G90") g.command("G90")
machine_idle(ctx) machine_idle(ctx)
finally: finally:
st, _b = fc.post("/settings", data={"lid_policy": was}) # An empty value clears the key, and only the query-string form
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy") # carries one: an empty form field never reaches the request, and
# the write is refused with "no known setting in request".
st, _b = (fc.post("/settings", params={"lid_policy": ""}) if not was
else fc.post("/settings", data={"lid_policy": was}))
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy", "")
ctx.log("lid_policy restored to %s", ev["lid_policy_restored"]) ctx.log("lid_policy restored to %s", ev["lid_policy_restored"])
ctx.check(ev["lid_policy_restored"] == was, "lid_policy was not restored to %r", was) ctx.check(ev["lid_policy_restored"] == was, "lid_policy was not restored to %r", was)
ctx.log("PASS: lid_policy=hold parked the job in Door and the cycle start finished it (%.3f mm)", ctx.log("PASS: lid_policy=hold parked the job in Door and the cycle start finished it (%.3f mm)",
+12
View File
@@ -238,6 +238,7 @@ header {
margin-left: auto; margin-left: auto;
} }
#themebtn, #themebtn,
#logsbtn,
#logoutbtn { #logoutbtn {
background: transparent; background: transparent;
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.75);
@@ -249,6 +250,7 @@ header {
white-space: nowrap; white-space: nowrap;
} }
#themebtn:hover, #themebtn:hover,
#logsbtn:hover,
#logoutbtn:hover { #logoutbtn:hover {
color: #fff; color: #fff;
border-color: rgba(255, 255, 255, 0.6); border-color: rgba(255, 255, 255, 0.6);
@@ -692,3 +694,13 @@ footer a {
padding-right: 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;
}
+91 -3
View File
@@ -52,6 +52,13 @@ class BaselineTests(unittest.TestCase):
# the class interface writes 'target'; the fake mirrors it into brightness # the class interface writes 'target'; the fake mirrors it into brightness
# only when the test asks (see _sync_leds) # 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): def _sync_leds(self):
for name in baseline.BUTTON_LEDS: for name in baseline.BUTTON_LEDS:
p = self.leds + name + "/target" p = self.leds + name + "/target"
@@ -154,16 +161,32 @@ class BaselineTests(unittest.TestCase):
b = self.bl() b = self.bl()
cap = b.capture() cap = b.capture()
# the run left 40 unplayed bytes queued in the kernel ring and the # the run left 40 unplayed bytes queued in the kernel ring and the
# head 1000 counts out: the residue is reported, and the return jog # head 1000 counts out: the hand-back stands the machine down to
# is refused (it would replay the residue first) # 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) self._pos_bytes(1000, 0, 0, 100, 140)
left = b.enforce("post", captured=cap) left = b.enforce("post", captured=cap)
items = {x.item: x for x in left} items = {x.item: x for x in left}
self.assertIn("pulse ring", items) self.assertIn("pulse ring", items)
self.assertEqual(items["pulse ring"].found, "40 unplayed bytes") self.assertEqual(items["pulse ring"].found, "40 unplayed bytes")
self.assertIn("unplayed bytes queued", items["position"].action) self.assertIn("still queued", items["position"].action)
self.assertIn("after a controller restart", items["position"].action)
self.assertEqual(baseline.read_ring_residue(), 40) 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): def test_clean_ring_reads_zero_residue(self):
self.assertEqual(baseline.read_ring_residue(), 0) self.assertEqual(baseline.read_ring_residue(), 0)
@@ -354,6 +377,71 @@ if __name__ == "__main__":
unittest.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): class BaselineModeTests(BaselineTests):
"""The baseline against a fake forgectrl: what the mode in force """The baseline against a fake forgectrl: what the mode in force
owns. Reuses the fake sysfs tree of BaselineTests; only the new owns. Reuses the fake sysfs tree of BaselineTests; only the new
+121 -7
View File
@@ -17,7 +17,44 @@ from forgetest.runner import Context, Run # noqa: E402
from forgetest.suite import commission_sheet # 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"] LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
# The focus card's program is read again after it ran: the served ladder
# must then lie in the window the card wrote.
PREVIEWS = ["sheet.frame", "laser.focus", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner",
"cooling.flow-load"]
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0} EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
SPM = 2.922 # the lens screw, half-steps per mm ($102)
EDGE_DEFAULT = 3.35
def lens_block(s):
"""The /status lens block as the daemon builds it from the settings: a
stop count from 1 to 40 stands, anything else is the fallback."""
def count(key, fallback):
try:
n = float(s.get(key, ""))
except ValueError:
return fallback, False
return (int(n), True) if 1 <= n <= 40 else (fallback, False)
try:
edge = float(s.get("lens_hall_edge_z_mm", ""))
except ValueError:
edge = EDGE_DEFAULT
below, a = count("lens_stop_below_steps", 10)
above, b = count("lens_stop_above_steps", 12)
return {"edge_z": round(edge, 2), "below": below, "above": above, "stops_found": a and b,
"reach_min": round(edge - below / SPM, 2), "reach_max": round(edge + above / SPM, 2)}
def ladder_z(edge, below, above):
"""The focus ladder the daemon builds: twelve whole half-steps from
the top of the window to the bottom."""
return [round(edge + round(above - (above + below) * i / 11) / SPM, 2) for i in range(12)]
# The window the focus card finds and writes before its controller starts.
STOPS = {"lens_stop_below_steps": "14", "lens_stop_above_steps": "20"}
FOCUS_WINDOW = {"below": 14, "above": 20, "reach_min": round(EDGE_DEFAULT - 14 / SPM, 2),
"reach_max": round(EDGE_DEFAULT + 20 / SPM, 2)}
# Each wizard as the daemon runs it: its prompts in order (id, kind, # Each wizard as the daemon runs it: its prompts in order (id, kind,
# options) and the result it ends with. A "jog" prompt repeats until the # options) and the result it ends with. A "jog" prompt repeats until the
@@ -37,7 +74,9 @@ SCRIPT = {
"steps_per_mm": 2.922, "max_height_mm": 13.81, "steps_per_mm": 2.922, "max_height_mm": 13.81,
"focus_range_mm": {"min": 2.17, "max": 13.81}, "focus_range_mm": {"min": 2.17, "max": 13.81},
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16, "stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
"contact_above": 22, "why": ""}, "emission": EMISSION}), "contact_above": 22, "why": ""},
"window": dict(FOCUS_WINDOW), "ladder_z": ladder_z(EDGE_DEFAULT, 14, 20),
"emission": EMISSION}),
"laser.floor": ([("floor-arm", "continue", ["Continue"]), "laser.floor": ([("floor-arm", "continue", ["Continue"]),
("floor-pick", "choice", ["2", "4", "6", "8", "None"])], ("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}), {"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
@@ -50,10 +89,11 @@ SCRIPT = {
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5, {"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
"k_cw": 2.78e-5, "emission": EMISSION}), "k_cw": 2.78e-5, "emission": EMISSION}),
} }
# What each card writes when it ends. # What a card writes when it starts (the focus card's window, before its
# controller starts) and when it ends.
START_WRITES = {"laser.focus": dict(STOPS)}
WRITES = { WRITES = {
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14", "laser.focus": {"lens_hall_edge_z_mm": "6.96"},
"lens_stop_above_steps": "20"},
"laser.floor": {"laser_floor_density": "10"}, "laser.floor": {"laser_floor_density": "10"},
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"}, "laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
"laser.corner": {"laser_corner_gamma": "1.5"}, "laser.corner": {"laser_corner_gamma": "1.5"},
@@ -115,9 +155,11 @@ class ScriptedDaemon:
self.versions = {} self.versions = {}
self.answers = {} # wid -> [values] self.answers = {} # wid -> [values]
self.order = [] # the wizards as started self.order = [] # the wizards as started
self.settings_at_start = {} # wid -> the settings as the wizard found them
self.previews = [] self.previews = []
self.queue = [] self.queue = []
self.seq = 0 self.seq = 0
self.stray_z = None # a Z the served program commands beyond the reach, when set
fake.on_get = self.on_get fake.on_get = self.on_get
fake.on_post = self.on_post fake.on_post = self.on_post
@@ -130,6 +172,10 @@ class ScriptedDaemon:
return True return True
def on_get(self, path, q): def on_get(self, path, q):
s = self.fake.state["settings"]
if path == "/status":
# the lens block follows the settings, as the daemon's does
return 200, dict(self.fake.state["status"], lens=lens_block(s))
if path == "/wiz/dark": if path == "/wiz/dark":
return 200, dict(self.dark) return 200, dict(self.dark)
if path == "/wiz": if path == "/wiz":
@@ -140,8 +186,18 @@ class ScriptedDaemon:
return 404, {"error": "no such card"} return 404, {"error": "no such card"}
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml" return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
if path == "/wiz/sheet.gcode": if path == "/wiz/sheet.gcode":
# the frame and the text are M4 at the mark dose, as the daemon serves them # the frame and the text are M4 at the mark dose, as the daemon
return 200, b"; mock\nM4 S400\nG0 X60 Y42\nG1 X120 Y42 F3000\nM5\n", "text/plain" # serves them; the head's Z is the edge, the tail goes back to
# it, and the focus card's ladder spans the window the settings
# hold now
lens = lens_block(s)
lines = ["; mock", "G0 Z%.2f" % lens["edge_z"], "M4 S400", "G0 X60 Y42", "G1 X120 Y42 F3000"]
if q.get("card") == "laser.focus":
lines += ["G0 Z%.2f" % z for z in ladder_z(lens["edge_z"], lens["below"], lens["above"])]
if self.stray_z is not None:
lines.append("G0 Z%.2f" % self.stray_z)
lines += ["M5", "G0 Z%.2f" % lens["edge_z"], "G0 X0 Y0", "M2"]
return 200, ("\n".join(lines) + "\n").encode(), "text/plain"
return None return None
def on_post(self, path, form): def on_post(self, path, form):
@@ -151,6 +207,8 @@ class ScriptedDaemon:
if wid not in SCRIPT: if wid not in SCRIPT:
return 404, {"error": "no such wizard"} return 404, {"error": "no such wizard"}
self.order.append(wid) self.order.append(wid)
self.settings_at_start[wid] = dict(s)
s.update(START_WRITES.get(wid, {}))
self.queue = list(SCRIPT[wid][0]) self.queue = list(SCRIPT[wid][0])
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""}) self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
self.open_next() self.open_next()
@@ -199,7 +257,7 @@ class SheetRunTests(unittest.TestCase):
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"]) self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"]) self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"]) self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
self.assertEqual(d.previews, LIVE) self.assertEqual(d.previews, PREVIEWS)
cards = run.evidence["cards"] cards = run.evidence["cards"]
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE)) self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
for wid in LIVE: for wid in LIVE:
@@ -208,6 +266,24 @@ class SheetRunTests(unittest.TestCase):
self.assertNotIn("emission", run.evidence) self.assertNotIn("emission", run.evidence)
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100) self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
self.assertEqual(run.evidence["settings_before"], FOUND) self.assertEqual(run.evidence["settings_before"], FOUND)
# A fresh machine's run: the lens settings were gone before the
# frame, the frame's program ran in the fallback window, the focus
# card's window was in the settings when the floor card started,
# and the window check filed what it compared.
for k in 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(): for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k) # restored self.assertEqual(self.fake.state["settings"][k], v, k) # restored
@@ -261,6 +337,44 @@ class SheetRunTests(unittest.TestCase):
finally: finally:
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION)) SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
def test_a_focus_window_the_settings_do_not_hold_fails_the_run(self):
# The defect the bench found: the focus card burned its ladder over
# the stops it found while the controller's Z limit stood on the
# settings, which did not hold them. A daemon whose focus result
# names a window the settings do not hold fails the run before the
# floor card, and the settings still go back.
from forgetest.runner import Failed
saved = dict(START_WRITES["laser.focus"])
START_WRITES["laser.focus"] = {}
try:
t = catalog.load_suite()["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__": if __name__ == "__main__":
unittest.main() unittest.main()
+38 -30
View File
@@ -1,13 +1,11 @@
"""The commission.* suite on the host: the registration (ids, kinds, the """The commission.* suite on the host: the registration (ids, kinds, the
takeover tests, the operator tests' hands), the record builders, the takeover tests, the operator tests' hands), the record builders, the
mDNS packet code, the cookie parsing, the LED cue, the settle rule for a machine's name, the cookie parsing, the LED cue, the settle rule for a
gated supervisor, and the cloud-off surface test driven end to end gated supervisor, and the cloud-off surface test driven end to end
against the fake daemon.""" against the fake daemon."""
import json import json
import os import os
import shutil import shutil
import socket
import struct
import tempfile import tempfile
import unittest import unittest
@@ -19,7 +17,7 @@ from forgetest.suite import commission
IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot", IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot",
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes", "commission.advisories-rehash", "commission.account-login", "commission.https-only-writes",
"commission.ssh-until-reboot", "commission.cloud-disabled-surface", "commission.ssh-until-reboot", "commission.cloud-disabled-surface",
"commission.factory-return", "commission.mdns-announce", "commission.first-run-flow", "commission.factory-return", "commission.machine-name", "commission.first-run-flow",
"commission.first-run-page", "commission.what-changed", "commission.record-export", "commission.first-run-page", "commission.what-changed", "commission.record-export",
"commission.mirror") "commission.mirror")
OPERATOR = ("commission.first-run-flow", "commission.first-run-page") OPERATOR = ("commission.first-run-flow", "commission.first-run-page")
@@ -104,8 +102,8 @@ class RegistrationTests(unittest.TestCase):
self.assertEqual(t.kind, "auto") # the return itself is a bench drill, not a test self.assertEqual(t.kind, "auto") # the return itself is a bench drill, not a test
self.assertFalse(t.hands) self.assertFalse(t.hands)
def test_mdns_covers_nothing_by_design(self): def test_the_machine_name_covers_nothing_by_design(self):
self.assertEqual(self.reg["commission.mdns-announce"].covers, ()) self.assertEqual(self.reg["commission.machine-name"].covers, ())
def test_the_login_test_makes_its_own_account(self): def test_the_login_test_makes_its_own_account(self):
# No bench credentials, no precheck: the test installs a temporary # No bench credentials, no precheck: the test installs a temporary
@@ -184,35 +182,45 @@ class RecordTests(unittest.TestCase):
os.environ.pop("GF_RUN_DIR", None) os.environ.pop("GF_RUN_DIR", None)
class MdnsTests(unittest.TestCase): class MachineNameTests(unittest.TestCase):
def test_query_is_a_unicast_response_question(self): def setUp(self):
q = commission.mdns_query("forgefirm.local", qid=0x1234) self.root = tempfile.mkdtemp()
qid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", q[:12]) self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
self.assertEqual((qid, flags, qd, an, ns, ar), (0x1234, 0, 1, 0, 0, 0))
self.assertEqual(q[12:], b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 0x8001))
def _response(self, qid, name_bytes, addr, flags=0x8400, extra=b""): def _net(self, **devs):
q = b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 1) """A /sys/class/net tree; commission.mac_suffix reads it through
rr = name_bytes + struct.pack(">HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(addr) commission.read_file, which takes an absolute path."""
return struct.pack(">HHHHHH", qid, flags, 1, 1, 0, 0) + q + rr + extra 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 test_answers_follow_a_compression_pointer(self): def read(path):
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9") head = "/sys/class/net/"
self.assertEqual(commission.mdns_answers(pkt, 7), [("forgefirm.local", "192.168.1.9")]) if path.startswith(head):
return real(os.path.join(self.root, path[len(head):]))
return real(path)
def test_answers_with_the_name_spelled_out(self): commission.read_file = read
pkt = self._response(0, b"\x09forgefirm\x05local\x00", "10.0.0.5") self.addCleanup(setattr, commission, "read_file", real)
self.assertEqual(commission.mdns_answers(pkt), [("forgefirm.local", "10.0.0.5")])
def test_wrong_id_or_a_query_yields_nothing(self): def test_the_wifi_address_names_the_machine(self):
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9") self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
self.assertEqual(commission.mdns_answers(pkt, 8), []) self.assertEqual(commission.mac_suffix(), "b00a")
self.assertEqual(commission.mdns_answers(self._response(7, b"\xc0\x0c", "1.2.3.4", flags=0), 7), [])
self.assertEqual(commission.mdns_answers(b"\x00" * 5), [])
def test_a_truncated_packet_yields_nothing(self): def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9") self._net(eth0="00:11:22:33:44:55")
self.assertEqual(commission.mdns_answers(pkt[:20], 7), []) 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): class SmallHelpersTests(unittest.TestCase):
+35
View File
@@ -586,6 +586,41 @@ class RoutingTests(unittest.TestCase):
finally: finally:
runner_mod.hw.button_lit = saved 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): 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 """A card that settles the coolant before it lights the button
takes minutes; the caller names the wait, and the actuator presses takes minutes; the caller names the wait, and the actuator presses
+26
View File
@@ -84,6 +84,32 @@ class OrderTests(unittest.TestCase):
self.assertEqual(sorted(out), ["s.a", "s.b"]) 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): class QueueTests(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
+18 -3
View File
@@ -94,13 +94,28 @@ class TreeManifestTests(unittest.TestCase):
self.assertNotEqual(self.mod.layer_content(self.layer), base) self.assertNotEqual(self.mod.layer_content(self.layer), base)
def test_layer_hash_matches_the_bbclass_rule(self): def test_layer_hash_matches_the_bbclass_rule(self):
# The same skip list as FORGEFIRM_MANIFEST_PIN_SUFFIX + *.md in # The same skip list as *.md plus FORGEFIRM_MANIFEST_PIN_SUFFIX and
# forgefirm-image-manifest.bbclass. # 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") bbclass = os.path.join(REPO, "meta-forgefirm", "classes", "forgefirm-image-manifest.bbclass")
with open(bbclass, encoding="utf-8") as f: with open(bbclass, encoding="utf-8") as f:
text = f.read() text = f.read()
self.assertIn('FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"', text) self.assertIn('FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"', text)
self.assertEqual(self.mod.LAYER_SKIP_SUFFIXES, (".md", "-pin.inc")) self.assertIn('FORGEFIRM_MANIFEST_VERSION_SUFFIX ?= "forgefirm-release.inc"', text)
self.assertEqual(self.mod.LAYER_SKIP_SUFFIXES,
(".md", "-pin.inc", "forgefirm-release.inc"))
def test_layer_hash_ignores_the_release_version(self):
# The version is metadata: setting the release number must not move
# the layer hash, or the bump would invalidate the campaign that
# authorizes the release.
self.write("forgefirm-release.inc",'FORGEFIRM_RELEASE ?= "0.0.1"\n')
base = self.mod.layer_content(self.layer)
self.write("forgefirm-release.inc",'FORGEFIRM_RELEASE ?= "0.0.2"\n')
self.assertEqual(self.mod.layer_content(self.layer), base)
if __name__ == "__main__": if __name__ == "__main__":
+1 -1
View File
@@ -11,4 +11,4 @@ overrides:
meta-freescale-distro: meta-freescale-distro:
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345 commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
meta-openglow: meta-openglow:
commit: b7ad6d9377fbb4215dc4d898837906b06b47e5e9 commit: ced2af274ba270fd8fc9a6601bbd3fc6247110a6
@@ -44,6 +44,13 @@
FORGEFIRM_MANIFEST_DIR ?= "${sysconfdir}/forgefirm-manifest.d" FORGEFIRM_MANIFEST_DIR ?= "${sysconfdir}/forgefirm-manifest.d"
FORGEFIRM_MANIFEST_CONTENT_LAYERS ?= "meta-forgefirm meta-glowforge-bsp meta-openglow-core" FORGEFIRM_MANIFEST_CONTENT_LAYERS ?= "meta-forgefirm meta-glowforge-bsp meta-openglow-core"
FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc" FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"
# The release version file. Like a pin file it is metadata, not platform
# content: it carries FORGEFIRM_RELEASE and nothing else, the version
# string is already outside the identity hash (below), and the release
# gate proves the number against the rootfs stamp, the .fw meta-version
# and the tag. Hashing it would make every version bump a platform
# change and invalidate the campaign that authorizes the release.
FORGEFIRM_MANIFEST_VERSION_SUFFIX ?= "forgefirm-release.inc"
do_rootfs[depends] += "virtual/kernel:do_deploy kernel-module-glowforge:do_deploy" do_rootfs[depends] += "virtual/kernel:do_deploy kernel-module-glowforge:do_deploy"
@@ -88,7 +95,8 @@ def forgefirm_manifest_layers(d):
and dirty flag of every layer checkout (informational).""" and dirty flag of every layer checkout (informational)."""
import os, subprocess import os, subprocess
content_layers = (d.getVar('FORGEFIRM_MANIFEST_CONTENT_LAYERS') or '').split() content_layers = (d.getVar('FORGEFIRM_MANIFEST_CONTENT_LAYERS') or '').split()
skip = ('.md',) + tuple((d.getVar('FORGEFIRM_MANIFEST_PIN_SUFFIX') or '').split()) skip = (('.md',) + tuple((d.getVar('FORGEFIRM_MANIFEST_PIN_SUFFIX') or '').split())
+ tuple((d.getVar('FORGEFIRM_MANIFEST_VERSION_SUFFIX') or '').split()))
identity, build = {}, {} identity, build = {}, {}
for layer in (d.getVar('BBLAYERS') or '').split(): for layer in (d.getVar('BBLAYERS') or '').split():
name = os.path.basename(layer.rstrip('/')) name = os.path.basename(layer.rstrip('/'))
+13 -13
View File
@@ -8,19 +8,19 @@ DISTRO_FEATURES:remove = " \
3g alsa avahi bluetooth bluez5 ext2 irda nfc nfs pci pcmcia \ 3g alsa avahi bluetooth bluez5 ext2 irda nfc nfs pci pcmcia \
pulseaudio vulkan wayland x11 zeroconf " pulseaudio vulkan wayland x11 zeroconf "
# mDNS: the image installs avahi-daemon by name (forgefirm-image.bb), so # The name the image ships in /etc/hostname. forgefirm-hostname replaces
# the control panel answers at forgefirm.local. The avahi and zeroconf # it at S38 in rcS with forgefirm-<xxxx>, from the machine's MAC address;
# features stay removed above: zeroconf would install # this is what the few seconds before that show, and the fallback on a
# packagegroup-base-zeroconf (the daemon plus libnss-mdns), and avahi # machine whose MAC address cannot be read.
# would switch other recipes' avahi options on. The build is trimmed to hostname:pn-base-files = "forgefirm"
# the daemon: no D-Bus (so no libavahi-client, no avahi-utils, no bus
# activation; the daemon reads /etc/avahi/services itself), no GTK or Qt # No mDNS. The panel is reached at the machine's address, which every
# front ends, no libdns_sd compatibility library, no libevent binding. # network resolves; the machine's own name (forgefirm-<xxxx>, from
# Python bindings are off in the recipe itself. # forgefirm-hostname) goes out in the DHCP request, so a network with
PACKAGECONFIG:pn-avahi = "" # dynamic DNS publishes it as well. The avahi and zeroconf features stay
# libnss-mdns is what avahi-daemon recommends: an NSS module so the # removed above: zeroconf would install packagegroup-base-zeroconf (the
# machine itself resolves .local names. Nothing on the machine does. # daemon plus libnss-mdns), and avahi would switch other recipes' avahi
BAD_RECOMMENDATIONS += "libnss-mdns" # options on.
# TLS: forgectrl serves HTTPS on 443 with a self-signed certificate # TLS: forgectrl serves HTTPS on 443 with a self-signed certificate
# through libmicrohttpd and ulfius, and links GnuTLS itself. GnuTLS is # through libmicrohttpd and ulfius, and links GnuTLS itself. GnuTLS is
@@ -1,33 +0,0 @@
# ForgeFIRM avahi-daemon configuration (avahi-daemon.conf(5)).
# The machine answers forgefirm.local on the WiFi link (and on eth0 when
# the machine has one), over IPv4 and IPv6. It publishes its addresses
# and the services in /etc/avahi/services, nothing else: no workstation
# record, no host information, no wide-area lookups, no reflector.
[server]
host-name=forgefirm
use-ipv4=yes
use-ipv6=yes
allow-interfaces=wlan0,eth0
ratelimit-interval-usec=1000000
ratelimit-burst=1000
[wide-area]
enable-wide-area=no
[publish]
publish-hinfo=no
publish-workstation=no
publish-addresses=yes
publish-domain=yes
[reflector]
enable-reflector=no
[rlimits]
rlimit-core=0
rlimit-data=8388608
rlimit-fsize=0
rlimit-nofile=768
rlimit-stack=8388608
rlimit-nproc=3
@@ -1,21 +0,0 @@
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<!-- ForgeFIRM control panel: HTTPS on 443 and HTTP on 80, served by
forgectrl. %h is the host name (avahi.service(5)). -->
<service-group>
<name replace-wildcards="yes">ForgeFIRM on %h</name>
<service>
<type>_https._tcp</type>
<port>443</port>
</service>
<service>
<type>_http._tcp</type>
<port>80</port>
</service>
</service-group>
@@ -1,17 +0,0 @@
FILESEXTRAPATHS:prepend := "${THISDIR}/${BPN}:"
# mDNS for the control panel: the machine answers forgefirm.local and
# advertises the panel on HTTPS 443 and HTTP 80. Only avahi-daemon is
# installed (forgefirm-image.bb); the build options that keep it to the
# daemon are in conf/distro/forgefirm.conf. The daemon reads the service
# file itself: no D-Bus is involved.
SRC_URI += " \
file://avahi-daemon.conf \
file://forgefirm.service \
"
do_install:append() {
install -m 0644 ${WORKDIR}/avahi-daemon.conf ${D}${sysconfdir}/avahi/avahi-daemon.conf
install -d ${D}${sysconfdir}/avahi/services
install -m 0644 ${WORKDIR}/forgefirm.service ${D}${sysconfdir}/avahi/services/forgefirm.service
}
@@ -2,5 +2,5 @@
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the # only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the # layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass). # pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "468ee215dd1148b92c979decacecc41aa6a75ccf" SRCREV = "864b8daea94b833ac05eda80170c54f550d54293"
PV = "0.1.12" PV = "0.1.20"
@@ -1,24 +1,23 @@
#!/bin/sh #!/bin/sh
# Rewrites the address block of /etc/issue, the serial-console login # Rewrites /etc/issue, the serial-console login banner: the lines the
# banner: the control panel by mDNS name, then one https:// URL per # image build wrote, then one https:// URL per global address of wlan0
# global address of wlan0 (and eth0 when the machine has one). Every # (and eth0 when the machine has one). The address is the only way in
# other line of /etc/issue stays as the image build wrote it. The block # that the banner gives, because it is the one that works on every
# sits between the marker lines "# ForgeFIRM addresses" and "# end" and # network. Called by the init script at boot and by the udhcpc hook on
# is appended when absent. Called by the init script at boot and by the # every lease event. Idempotent: the file is written only when the text
# udhcpc hook on every lease event. Idempotent: the file is written only # changes.
# when the block changes.
# #
# The rootfs is read-only: at the first change after boot the file is # The rootfs is read-only: at the first change after boot the file is
# bind-mounted from a copy under /run/forgefirm (tmpfs) and the block is # bind-mounted from a copy under /run/forgefirm (tmpfs) and the banner
# written through the mount. Before that the image's own file shows. # is written through the mount. Before that the image's own file shows.
# The image's own text is kept at that moment in a second copy, so the
# banner needs no marker line in the file and shows nothing but itself.
PATH=/sbin:/usr/sbin:/bin:/usr/bin PATH=/sbin:/usr/sbin:/bin:/usr/bin
ISSUE=/etc/issue ISSUE=/etc/issue
STATE=/run/forgefirm/issue STATE=/run/forgefirm/issue
MARK_BEGIN='# ForgeFIRM addresses' BASE=/run/forgefirm/issue.base
MARK_END='# end'
PANEL='Control panel: https://forgefirm.local/'
# One URL per global address; an IPv6 address gets its URL brackets. # One URL per global address; an IPv6 address gets its URL brackets.
# Tentative, deprecated and temporary addresses are left out. # Tentative, deprecated and temporary addresses are left out.
@@ -32,45 +31,40 @@ addresses () {
a = $2 a = $2
sub(/\/.*/, "", a) sub(/\/.*/, "", a)
if ($1 == "inet6") a = "[" a "]" if ($1 == "inet6") a = "[" a "]"
print "https://" a "/" print " https://" a "/"
}' }'
done done
} }
block () { # A mount at the file, read from /proc/mounts (mountpoint(1) judges a
echo "$MARK_BEGIN" # file by its device numbers alone).
echo "$PANEL" is_mounted () {
addrs=$(addresses) awk -v t="$1" '$2 == t { f = 1 } END { exit !f }' /proc/mounts
if [ -n "$addrs" ]; then
echo "$addrs"
else
echo "no network address yet"
fi
echo "$MARK_END"
} }
[ -f "$ISSUE" ] || exit 0 [ -f "$ISSUE" ] || exit 0
new=$(block) # The image's own text: the file as it is before the first write of the
old=$(awk -v b="$MARK_BEGIN" -v e="$MARK_END" \ # boot. /run is a tmpfs, so the copy is made once per boot, and the
'$0 == b { p = 1 } p { print } $0 == e { p = 0 }' "$ISSUE") # first caller is the one that finds the file untouched.
[ "$new" = "$old" ] && exit 0 mkdir -p "${STATE%/*}" || exit 1
[ -f "$BASE" ] || cp -p "$ISSUE" "$BASE" || exit 1
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a if ! is_mounted "$ISSUE"; then
# file by its device numbers alone). cp -p "$BASE" "$STATE" \
if ! awk -v t="$ISSUE" '$2 == t { f = 1 } END { exit !f }' /proc/mounts; then
mkdir -p "${STATE%/*}" \
&& cp -p "$ISSUE" "$STATE" \
&& mount --bind "$STATE" "$ISSUE" || exit 1 && mount --bind "$STATE" "$ISSUE" || exit 1
fi fi
base=$(cat "$BASE")
addrs=$(addresses)
if [ -n "$addrs" ]; then
new=$(printf '%s\n\nControl panel:\n%s\n' "$base" "$addrs")
else
new=$(printf '%s\n\nControl panel: no network address yet\n' "$base")
fi
[ "$new" = "$(cat "$ISSUE")" ] && exit 0
tmp="$STATE.tmp.$$" tmp="$STATE.tmp.$$"
awk -v b="$MARK_BEGIN" -v e="$MARK_END" -v blk="$new" ' printf '%s\n\n' "$new" > "$tmp" || { rm -f "$tmp"; exit 1; }
$0 == b { print blk; seen = 1; skip = 1; next }
$0 == e && skip { skip = 0; next }
!skip { print }
END { if (!seen) print blk }
' "$ISSUE" > "$tmp" || { rm -f "$tmp"; exit 1; }
cat "$tmp" > "$ISSUE" cat "$tmp" > "$ISSUE"
rm -f "$tmp" rm -f "$tmp"
exit 0 exit 0
@@ -1,7 +1,7 @@
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue" SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
DESCRIPTION = "Keeps an address block in the serial-console login banner \ DESCRIPTION = "Keeps an address block in the serial-console login banner \
(/etc/issue): the control panel by mDNS name and by every global address \ (/etc/issue): one control panel URL per global address of wlan0 and eth0. \
of wlan0 and eth0. Refreshed at boot and on every DHCP lease event." Refreshed at boot and on every DHCP lease event."
LICENSE = "MIT" LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302" LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
@@ -0,0 +1,68 @@
#!/bin/sh
# Sets the machine's hostname to forgefirm-<xxxx>, where xxxx is the
# last four hex digits of the wlan0 MAC address (eth0 on a machine with
# no WiFi). The name is the same at every boot, two machines on one
# network answer to different names, and the name carries no serial
# number. The DHCP client sends it as the hostname option
# (/etc/network/interfaces), so a network with dynamic DNS resolves it.
#
# The rootfs is read-only: /etc/hostname shows a copy under
# /run/forgefirm (tmpfs), bind-mounted, and the name is written through
# the mount. The init script calls this at boot, before the network
# starts and before anything reads the name.
PATH=/sbin:/usr/sbin:/bin:/usr/bin
FILE=/etc/hostname
STATE=/run/forgefirm/hostname
PREFIX=forgefirm
WAIT_S=10
# The MAC address of the first interface that has one. An interface
# before its driver reads the address reports all zeros.
mac () {
for dev in wlan0 eth0; do
a=$(cat "/sys/class/net/$dev/address" 2>/dev/null) || continue
case "$a" in
''|00:00:00:00:00:00) continue ;;
esac
printf '%s\n' "$a"
return 0
done
return 1
}
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
# file by its device numbers alone).
is_mounted () {
awk -v t="$1" '$2 == t { f = 1 } END { exit !f }' /proc/mounts
}
# An interface registers when its driver probes, which udev does earlier
# in rcS. The wait is for a slow probe and ends at once in the normal
# case.
n=0
while [ "$n" -lt "$WAIT_S" ] && ! mac >/dev/null; do
sleep 1
n=$((n + 1))
done
suffix=$(mac | tr -d ':' | tr 'A-Z' 'a-z' | cut -c9-12)
if [ -n "$suffix" ]; then
name="$PREFIX-$suffix"
else
name="$PREFIX"
fi
[ "$(hostname)" = "$name" ] || hostname "$name"
# The file follows the live name, so every reader agrees.
[ -f "$FILE" ] || exit 0
[ "$(cat "$FILE" 2>/dev/null)" = "$name" ] && exit 0
if ! is_mounted "$FILE"; then
mkdir -p "${STATE%/*}" \
&& cp -p "$FILE" "$STATE" \
&& mount --bind "$STATE" "$FILE" || exit 1
fi
printf '%s\n' "$name" > "$FILE" || exit 1
exit 0
@@ -0,0 +1,31 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: forgefirm-hostname
# Required-Start: $local_fs
# Required-Stop:
# Default-Start: S
# Default-Stop:
# Short-Description: ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address
### END INIT INFO
# Writes the hostname before the network starts. At boot this runs at
# S38 in rcS: /run is mounted (mountall, S03), udev has probed the
# network drivers (S04), and poky's own hostname.sh (S39) reads the file
# this leaves.
case "$1" in
start|restart|reload|force-reload)
/usr/sbin/forgefirm-hostname
;;
stop)
;;
status)
hostname
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
exit 0
@@ -0,0 +1,28 @@
SUMMARY = "ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address"
DESCRIPTION = "Names the machine forgefirm-<xxxx>, where xxxx is the last \
four hex digits of the wlan0 MAC address (eth0 on a machine with no WiFi). \
The name is the same at every boot, two machines on one network answer to \
different names, and the name carries no serial number. The DHCP client \
sends it as the hostname option, so a network with dynamic DNS resolves it."
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
SRC_URI = " \
file://forgefirm-hostname \
file://forgefirm-hostname.init \
"
S = "${WORKDIR}"
inherit update-rc.d
INITSCRIPT_NAME = "forgefirm-hostname"
# 38 in rcS: after udev (S04) has probed the network drivers, before
# poky's hostname.sh (S39) reads /etc/hostname and before the network
# starts (rc5 S01).
INITSCRIPT_PARAMS = "start 38 S ."
do_install() {
install -Dm 0755 ${WORKDIR}/forgefirm-hostname ${D}${sbindir}/forgefirm-hostname
install -Dm 0755 ${WORKDIR}/forgefirm-hostname.init ${D}${sysconfdir}/init.d/forgefirm-hostname
}
@@ -2,5 +2,5 @@
# changes; keep only SRCREV and PV here - the image manifest leaves # changes; keep only SRCREV and PV here - the image manifest leaves
# *-pin.inc out of the layer content hash because the component entry # *-pin.inc out of the layer content hash because the component entry
# already identifies the pinned source (forgefirm-image-manifest.bbclass). # already identifies the pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "9ee624bbc1d1fd929f1f5587b6fc6acaa13a4561" SRCREV = "1f2ea9ca83aa0035260c9db6b21c4fe0873796e0"
PV = "0.1.10" PV = "0.1.11"
@@ -48,15 +48,12 @@ IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome gfcloud v4l-utils fw
# forgefirm-users: renders the operator account record # forgefirm-users: renders the operator account record
# (/data/forgefirm/users, written by forgectrl) into the system account # (/data/forgefirm/users, written by forgectrl) into the system account
# files at boot, before sshd, and on reload; also installs the warning an # files at boot, before sshd, and on reload; also installs the warning an
# interactive root shell prints. forgefirm-banner: keeps the control # interactive root shell prints. forgefirm-hostname: names the machine
# panel addresses in the serial-console banner (/etc/issue). # forgefirm-<xxxx> from its MAC address, before the network starts.
# forgefirm-banner: keeps the control panel addresses in the
# serial-console banner (/etc/issue).
# forgefirm-persist: the boot timestamp and the random seed on /data. # forgefirm-persist: the boot timestamp and the random seed on /data.
# avahi-daemon: mDNS, so the panel answers at https://forgefirm.local/ IMAGE_INSTALL:append = " forgefirm-users forgefirm-hostname forgefirm-banner forgefirm-persist"
# and shows up in service browsers. The daemon is installed by name (the
# zeroconf distro feature stays off: it would bring libnss-mdns); the
# build options and the configuration are in conf/distro/forgefirm.conf
# and recipes-connectivity/avahi.
IMAGE_INSTALL:append = " forgefirm-users forgefirm-banner forgefirm-persist avahi-daemon"
# The rootfs mounts read-only on both images; /data (p3) is the writable # The rootfs mounts read-only on both images; /data (p3) is the writable
# partition. read-only-rootfs is poky's feature for it: the root line of # partition. read-only-rootfs is poky's feature for it: the root line of
@@ -67,8 +64,9 @@ IMAGE_INSTALL:append = " forgefirm-users forgefirm-banner forgefirm-persist avah
# package whose post-install must run on the machine, and the removal of # package whose post-install must run on the machine, and the removal of
# the packages a read-only rootfs cannot use (shadow, base-passwd, # the packages a read-only rootfs cannot use (shadow, base-passwd,
# update-rc.d, update-alternatives; the account files stay). What must # update-rc.d, update-alternatives; the account files stay). What must
# last or change at run time is handled file by file: the account files # last or change at run time is handled file by file: the account files,
# and /etc/issue (forgefirm-users, forgefirm-banner), the sshd host keys # /etc/hostname and /etc/issue (forgefirm-users, forgefirm-hostname,
# forgefirm-banner), the sshd host keys
# (recipes-connectivity/openssh), the timestamp and the random seed # (recipes-connectivity/openssh), the timestamp and the random seed
# (forgefirm-persist). The facts are on the docs site, # (forgefirm-persist). The facts are on the docs site,
# technical/forgefirm/image-and-bsp; scripts/release.sh checks the built # technical/forgefirm/image-and-bsp; scripts/release.sh checks the built
@@ -108,19 +106,81 @@ IMAGE_OVERHEAD_FACTOR = "1.0"
IMAGE_ROOTFS_EXTRA_SPACE = "40960" IMAGE_ROOTFS_EXTRA_SPACE = "40960"
IMAGE_ROOTFS_MAXSIZE = "204800" IMAGE_ROOTFS_MAXSIZE = "204800"
# Version stamp: /etc/forgefirm-version (machine-readable), echoed on the # The ForgeFIRM mark and the version stamp:
# serial-console login prompt (/etc/issue) and at SSH login (motd). # /etc/forgefirm-version (machine-readable), and, under the OpenGlow mark
# the base image carries (base-files, meta-openglow), the ForgeFIRM mark
# with the version on its last line, right-justified to the mark's last
# column, in the two files a person reads - the serial-console login
# prompt (/etc/issue) and the motd, which every login prints, the network
# ones included. The mark names the firmware, so the version stands
# alone.
#
# The mark is written once here and rendered for each reader, because the
# two files are read by different programs:
# /etc/issue busybox getty parses it, and both a backslash and a
# percent sign start an escape (libbb/login.c,
# print_login_issue). An unrecognized escape prints the
# character and swallows the backslash, so the art goes in
# with every backslash doubled.
# /etc/motd a login writes it out as it is: nothing is doubled.
# Both keep their color: a getty passes an escape character through, and
# so does a login writing the motd. Only the ssh client escapes one, and
# it does that to a banner alone.
# The pre-authentication banner (/etc/issue.net) stays unused: the ssh
# client prints a control character in a banner as an octal escape, and
# the machine tells a client that has not logged in nothing anyway.
#
# Release images carry the release version; the dev image overrides the # Release images carry the release version; the dev image overrides the
# string with the build timestamp (the same DATETIME as the artifact # string with the build timestamp (the same DATETIME as the artifact
# name) plus a dev tag. # name) plus a dev tag.
FORGEFIRM_RELEASE ?= "0.0.1" #
# FORGEFIRM_RELEASE lives in its own file, which the manifest leaves out
# of the layer content hash: the version is metadata, and a bump must not
# read as a platform change and invalidate a campaign
# (forgefirm-release.inc).
require forgefirm-release.inc
FORGEFIRM_VERSION_STRING ?= "v${FORGEFIRM_RELEASE}" FORGEFIRM_VERSION_STRING ?= "v${FORGEFIRM_RELEASE}"
write_forgefirm_version() { write_forgefirm_version() {
echo "${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/forgefirm-version echo "${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/forgefirm-version
echo "ForgeFIRM ${FORGEFIRM_VERSION_STRING}" >> ${IMAGE_ROOTFS}${sysconfdir}/issue
mark=${WORKDIR}/forgefirm-mark
cat > $mark <<'MARK'
___ ___ ___ ___ __ __
| __|__ _ _ __ _ ___| __|_ _| _ \ \/ |
| _/ _ \ '_/ _` / -_) _| | || / |\/| |
|_|\___/_| \__, \___|_| |___|_|_\_| |_|
|___/
MARK
# The version rides the mark's own last line, its last character on
# the mark's last column: under the FIRM half, in the room the
# descender of the Forge half leaves. The mark carries the name, so
# the version stands alone. Measured in columns, not in bytes: the
# color sequences take no room on the screen, and the doubling below
# is undone by the getty that reads it. A version with no room left
# keeps one space and runs past the mark rather than being cut.
stamped=${WORKDIR}/forgefirm-mark-stamped
awk -v v="${FORGEFIRM_VERSION_STRING}" '
{ line[NR] = $0
bare = $0
gsub(/\033\[[0-9;]*m/, "", bare)
col[NR] = length(bare)
if (col[NR] > w) w = col[NR] }
END { for (i = 1; i < NR; i++) print line[i]
pad = w - col[NR] - length(v)
if (pad < 1) pad = 1
gap = ""
while (length(gap) < pad) gap = gap " "
print line[NR] gap v }' $mark > $stamped
sed 's|\\|\\\\|g' $stamped >> ${IMAGE_ROOTFS}${sysconfdir}/issue
echo "" >> ${IMAGE_ROOTFS}${sysconfdir}/issue echo "" >> ${IMAGE_ROOTFS}${sysconfdir}/issue
echo "ForgeFIRM ${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/motd
cat $stamped >> ${IMAGE_ROOTFS}${sysconfdir}/motd
echo "" >> ${IMAGE_ROOTFS}${sysconfdir}/motd
rm -f $mark $stamped
} }
write_forgefirm_version[vardepsexclude] += "DATETIME" write_forgefirm_version[vardepsexclude] += "DATETIME"
# No semicolon after a function name here or below (the vardeps rule in # No semicolon after a function name here or below (the vardeps rule in
@@ -0,0 +1,18 @@
# The release version, and nothing else.
#
# This file is metadata, not platform content: the image manifest leaves
# it out of the layer content hash the way it leaves out the component
# pin files (forgefirm-image-manifest.bbclass,
# FORGEFIRM_MANIFEST_VERSION_SUFFIX). A version bump therefore changes
# the version and invalidates nothing, so the number can be set at the
# moment a release is cut rather than before the campaign that authorizes
# it. Setting it inside forgefirm-image.bb instead would hash as layer
# content and invalidate every acceptance result, which is what it did
# until v0.0.1.
#
# Nothing else belongs here. The version is still proven end to end:
# scripts/release.sh reads FORGEFIRM_RELEASE from this file and requires
# it to equal the rootfs stamp, the .fw meta-version and the release tag,
# and the acceptance test image.health compares the stamp on the running
# machine with the manifest's.
FORGEFIRM_RELEASE ?= "0.0.3"
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
# ForgeFIRM acceptance - 20260910181308 (dev)
- Image: `20260910181308 (dev)` (forgefirm-image-dev)
- Manifest identity: `168e0307cef8f58f6bb871102f98ee56f9661b852e738d054891c5f072fd33b9`
- Catalog: `da9da478f3df1e5ad87319fd9d831ee224a3c88e5d3896162a09b4de14aad6b1`
- Campaign: `c-20260910181641-11a2` opened 2026-09-10T18:16:41Z
- Exported: 2026-09-10T18:32:07Z
- **Release authorized: YES**
- Tests: 83 total, 83 satisfied (57 inherited), 0 required
| Test | Kind | Result | Run at | Campaign | Inherited |
|---|---|---|---|---|---|
| `image.health` | auto, core | PASS | 2026-09-10T18:23:17Z | `c-20260910181641-11a2` | no |
| `image.license-bundle` | auto | PASS | 2026-09-10T13:11:39Z | `c-20260910131134-5cb0` | yes |
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-10T18:23:17Z | `c-20260910181641-11a2` | no |
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-10T18:23:39Z | `c-20260910181641-11a2` | no |
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-10T18:23:50Z | `c-20260910181641-11a2` | no |
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-10T18:24:08Z | `c-20260910181641-11a2` | no |
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-10T18:24:51Z | `c-20260910181641-11a2` | no |
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-10T13:13:35Z | `c-20260910131134-5cb0` | yes |
| `kernel.pic-soc-load` | auto | PASS | 2026-09-10T13:13:39Z | `c-20260910131134-5cb0` | yes |
| `forgectrl.auth` | auto | PASS | 2026-09-10T13:13:40Z | `c-20260910131134-5cb0` | yes |
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-10T13:13:41Z | `c-20260910131134-5cb0` | yes |
| `forgectrl.panel-serves` | auto | PASS | 2026-09-10T13:11:36Z | `c-20260910131134-5cb0` | yes |
| `commission.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-10T13:14:00Z | `c-20260910131134-5cb0` | yes |
| `commission.override-until-reboot` | auto | PASS | 2026-09-10T13:14:00Z | `c-20260910131134-5cb0` | yes |
| `commission.advisories-rehash` | auto, takeover | PASS | 2026-09-10T13:14:09Z | `c-20260910131134-5cb0` | yes |
| `commission.account-login` | auto, takeover | PASS | 2026-09-10T13:14:59Z | `c-20260910131134-5cb0` | yes |
| `commission.https-only-writes` | auto | PASS | 2026-09-10T13:15:00Z | `c-20260910131134-5cb0` | yes |
| `commission.ssh-until-reboot` | auto | PASS | 2026-09-10T13:15:00Z | `c-20260910131134-5cb0` | yes |
| `commission.cloud-disabled-surface` | auto | PASS | 2026-09-10T13:15:01Z | `c-20260910131134-5cb0` | yes |
| `commission.factory-return` | auto | PASS | 2026-09-10T13:15:04Z | `c-20260910131134-5cb0` | yes |
| `commission.machine-name` | auto | PASS | 2026-09-10T13:15:04Z | `c-20260910131134-5cb0` | yes |
| `commission.first-run-flow` | operator, takeover | PASS | 2026-09-10T13:15:26Z | `c-20260910131134-5cb0` | yes |
| `commission.first-run-page` | operator, takeover | PASS | 2026-09-10T17:25:08Z | `c-20260910171338-9149` | yes |
| `commission.cert-page` | auto | PASS | 2026-09-10T13:15:26Z | `c-20260910131134-5cb0` | yes |
| `commission.what-changed` | auto, takeover | PASS | 2026-09-10T13:15:36Z | `c-20260910131134-5cb0` | yes |
| `commission.record-export` | auto | PASS | 2026-09-10T13:16:52Z | `c-20260910131134-5cb0` | yes |
| `commission.mirror` | auto, takeover | PASS | 2026-09-10T13:17:25Z | `c-20260910131134-5cb0` | yes |
| `commission.check-switches` | operator | PASS | 2026-09-10T16:06:37Z | `c-20260910160456-619b` | yes |
| `commission.check-sensors` | auto | PASS | 2026-09-10T16:06:50Z | `c-20260910160456-619b` | yes |
| `commission.check-airflow` | auto, takeover | PASS | 2026-09-10T16:08:24Z | `c-20260910160456-619b` | yes |
| `commission.check-cameras` | operator | PASS | 2026-09-10T16:08:39Z | `c-20260910160456-619b` | yes |
| `commission.check-motion` | auto, takeover | PASS | 2026-09-10T16:09:05Z | `c-20260910160456-619b` | yes |
| `commission.check-flow-verify` | auto, takeover | PASS | 2026-09-10T16:16:37Z | `c-20260910160456-619b` | yes |
| `commission.cloud-header-capture` | operator, takeover | PASS | 2026-09-10T17:26:21Z | `c-20260910171338-9149` | yes |
| `commission.sheet` | live, takeover | PASS | 2026-09-10T17:39:28Z | `c-20260910171338-9149` | yes |
| `logs.tree-tail-export` | auto | PASS | 2026-09-10T13:16:14Z | `c-20260910131134-5cb0` | yes |
| `logs.routing` | auto | PASS | 2026-09-10T13:27:51Z | `c-20260910131134-5cb0` | yes |
| `logs.level-settings` | auto | PASS | 2026-09-10T13:27:52Z | `c-20260910131134-5cb0` | yes |
| `motion.pacing` | auto | PASS | 2026-09-10T18:25:07Z | `c-20260910181641-11a2` | no |
| `motion.jog-roundtrip` | auto | PASS | 2026-09-10T18:25:21Z | `c-20260910181641-11a2` | no |
| `motion.microstep-modes` | auto | PASS | 2026-09-10T18:25:46Z | `c-20260910181641-11a2` | no |
| `motion.liveness-probe` | auto | PASS | 2026-09-10T13:28:39Z | `c-20260910131134-5cb0` | yes |
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-10T13:28:56Z | `c-20260910131134-5cb0` | yes |
| `motion.cancel-abort` | auto | PASS | 2026-09-10T18:25:53Z | `c-20260910181641-11a2` | no |
| `motion.deadman` | auto | PASS | 2026-09-10T18:26:22Z | `c-20260910181641-11a2` | no |
| `motion.button-hold-resume` | operator | PASS | 2026-09-10T18:26:33Z | `c-20260910181641-11a2` | no |
| `motion.lid-cancel-home` | operator | PASS | 2026-09-10T18:26:49Z | `c-20260910181641-11a2` | no |
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-10T18:26:58Z | `c-20260910181641-11a2` | no |
| `motion.lid-policy-hold` | operator | PASS | 2026-09-10T18:27:12Z | `c-20260910181641-11a2` | no |
| `motion.step-timing-under-load` | auto | PASS | 2026-09-10T18:27:42Z | `c-20260910181641-11a2` | no |
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-10T16:10:38Z | `c-20260910160456-619b` | yes |
| `cooling.flow-verify` | auto | PASS | 2026-09-10T16:13:21Z | `c-20260910160456-619b` | yes |
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-10T16:17:38Z | `c-20260910160456-619b` | yes |
| `cooling.gate-off` | auto | PASS | 2026-09-10T16:06:57Z | `c-20260910160456-619b` | yes |
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-10T16:19:05Z | `c-20260910160456-619b` | yes |
| `cooling.tec-drive` | auto | PASS | 2026-09-10T16:19:13Z | `c-20260910160456-619b` | yes |
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-10T16:19:29Z | `c-20260910160456-619b` | yes |
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-10T16:19:36Z | `c-20260910160456-619b` | yes |
| `cooling.critical-tier` | auto | PASS | 2026-09-10T16:19:44Z | `c-20260910160456-619b` | yes |
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-10T16:07:43Z | `c-20260910160456-619b` | yes |
| `cooling.flow-under-load` | live | PASS | 2026-09-10T17:41:03Z | `c-20260910171338-9149` | yes |
| `laser.power-floor` | auto | PASS | 2026-09-10T18:27:43Z | `c-20260910181641-11a2` | no |
| `laser.emission-witness` | live, core | PASS | 2026-09-10T18:17:19Z | `c-20260910181641-11a2` | no |
| `laser.m5-rapid-dark` | live | PASS | 2026-09-10T18:18:01Z | `c-20260910181641-11a2` | no |
| `laser.disarm-in-hold` | live | PASS | 2026-09-10T18:19:29Z | `c-20260910181641-11a2` | no |
| `laser.armed-kill` | live | PASS | 2026-09-10T18:20:12Z | `c-20260910181641-11a2` | no |
| `laser.arm-wait-lid` | operator | PASS | 2026-09-10T18:28:02Z | `c-20260910181641-11a2` | no |
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-10T18:20:41Z | `c-20260910181641-11a2` | no |
| `camera.snapshot` | auto | PASS | 2026-09-10T13:19:21Z | `c-20260910131134-5cb0` | yes |
| `camera.sensor-profile` | auto | PASS | 2026-09-10T14:55:23Z | `c-20260910144945-3072` | yes |
| `camera.h264-stream` | auto | PASS | 2026-09-10T14:55:37Z | `c-20260910144945-3072` | yes |
| `camera.frame-health` | auto | PASS | 2026-09-10T14:55:50Z | `c-20260910144945-3072` | yes |
| `camera.lid-privacy` | operator | PASS | 2026-09-10T14:56:05Z | `c-20260910144945-3072` | yes |
| `camera.key-read` | auto | PASS | 2026-09-10T14:56:06Z | `c-20260910144945-3072` | yes |
| `update.slots-and-signature` | auto | PASS | 2026-09-10T13:15:03Z | `c-20260910131134-5cb0` | yes |
| `cloud.mode-switch` | operator | PASS | 2026-09-10T18:29:38Z | `c-20260910181641-11a2` | no |
| `cloud.service-protocol` | operator | PASS | 2026-09-10T18:21:52Z | `c-20260910181641-11a2` | no |
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-10T17:19:59Z | `c-20260910171338-9149` | yes |
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-10T14:58:32Z | `c-20260910144945-3072` | yes |
| `cloud.verdict-hold` | operator | PASS | 2026-09-10T17:18:09Z | `c-20260910171338-9149` | yes |
| `cloud.pause-resume` | live | PASS | 2026-09-10T18:23:07Z | `c-20260910181641-11a2` | no |
| `cloud.oversize-stream` | live | PASS | 2026-09-10T17:22:39Z | `c-20260910171338-9149` | yes |
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-10T17:18:57Z | `c-20260910171338-9149` | yes |
Artifact sha256: `191f416666d40efd92c77973fa392f38ca3d5ca160533ff83b4475d47b5fa6e3`
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
# ForgeFIRM acceptance - 20260911172215 (dev)
- Image: `20260911172215 (dev)` (forgefirm-image-dev)
- Manifest identity: `fb24c3f4508ae54592f8d0b98ed0b2a07a933274980d8c1e4e5a967618ef9bd8`
- Catalog: `ceabc830345fc4cac4832de691c0571e93a5c4e155666ce91601c844c40942fc`
- Campaign: `c-20260911182256-078f` opened 2026-09-11T18:22:56Z
- Exported: 2026-09-11T18:54:20Z
- **Release authorized: YES**
- Tests: 83 total, 83 satisfied (57 inherited), 0 required
| Test | Kind | Result | Run at | Campaign | Inherited |
|---|---|---|---|---|---|
| `image.health` | auto, core | PASS | 2026-09-11T18:22:57Z | `c-20260911182256-078f` | no |
| `image.license-bundle` | auto | PASS | 2026-09-11T17:29:26Z | `c-20260911172921-9426` | yes |
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-11T18:22:58Z | `c-20260911182256-078f` | no |
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-11T18:23:19Z | `c-20260911182256-078f` | no |
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-11T18:23:29Z | `c-20260911182256-078f` | no |
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-11T18:23:48Z | `c-20260911182256-078f` | no |
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-11T18:24:32Z | `c-20260911182256-078f` | no |
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-11T17:31:23Z | `c-20260911172921-9426` | yes |
| `kernel.pic-soc-load` | auto | PASS | 2026-09-11T17:31:26Z | `c-20260911172921-9426` | yes |
| `forgectrl.auth` | auto | PASS | 2026-09-11T17:31:27Z | `c-20260911172921-9426` | yes |
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-11T17:31:27Z | `c-20260911172921-9426` | yes |
| `forgectrl.panel-serves` | auto | PASS | 2026-09-11T17:29:23Z | `c-20260911172921-9426` | yes |
| `commission.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-11T17:31:46Z | `c-20260911172921-9426` | yes |
| `commission.override-until-reboot` | auto | PASS | 2026-09-11T17:31:46Z | `c-20260911172921-9426` | yes |
| `commission.advisories-rehash` | auto, takeover | PASS | 2026-09-11T17:31:55Z | `c-20260911172921-9426` | yes |
| `commission.account-login` | auto, takeover | PASS | 2026-09-11T17:32:44Z | `c-20260911172921-9426` | yes |
| `commission.https-only-writes` | auto | PASS | 2026-09-11T17:32:45Z | `c-20260911172921-9426` | yes |
| `commission.ssh-until-reboot` | auto | PASS | 2026-09-11T17:32:46Z | `c-20260911172921-9426` | yes |
| `commission.cloud-disabled-surface` | auto | PASS | 2026-09-11T17:32:47Z | `c-20260911172921-9426` | yes |
| `commission.factory-return` | auto | PASS | 2026-09-11T17:32:49Z | `c-20260911172921-9426` | yes |
| `commission.machine-name` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
| `commission.first-run-flow` | operator, takeover | PASS | 2026-09-11T17:54:32Z | `c-20260911172921-9426` | yes |
| `commission.first-run-page` | operator, takeover | PASS | 2026-09-11T17:55:49Z | `c-20260911172921-9426` | yes |
| `commission.cert-page` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
| `commission.what-changed` | auto, takeover | PASS | 2026-09-11T17:32:59Z | `c-20260911172921-9426` | yes |
| `commission.record-export` | auto | PASS | 2026-09-11T17:34:22Z | `c-20260911172921-9426` | yes |
| `commission.mirror` | auto, takeover | PASS | 2026-09-11T17:34:54Z | `c-20260911172921-9426` | yes |
| `commission.check-switches` | operator | PASS | 2026-09-11T17:56:07Z | `c-20260911172921-9426` | yes |
| `commission.check-sensors` | auto | PASS | 2026-09-11T17:34:34Z | `c-20260911172921-9426` | yes |
| `commission.check-airflow` | auto, takeover | PASS | 2026-09-11T17:36:26Z | `c-20260911172921-9426` | yes |
| `commission.check-cameras` | operator | PASS | 2026-09-11T17:56:22Z | `c-20260911172921-9426` | yes |
| `commission.check-motion` | auto, takeover | PASS | 2026-09-11T17:37:06Z | `c-20260911172921-9426` | yes |
| `commission.check-flow-verify` | auto, takeover | PASS | 2026-09-11T17:44:42Z | `c-20260911172921-9426` | yes |
| `commission.cloud-header-capture` | operator, takeover | PASS | 2026-09-11T17:59:41Z | `c-20260911172921-9426` | yes |
| `commission.sheet` | live, takeover | PASS | 2026-09-11T18:17:29Z | `c-20260911172921-9426` | yes |
| `logs.tree-tail-export` | auto | PASS | 2026-09-11T17:33:40Z | `c-20260911172921-9426` | yes |
| `logs.routing` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
| `logs.level-settings` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
| `motion.pacing` | auto | PASS | 2026-09-11T17:36:41Z | `c-20260911172921-9426` | yes |
| `motion.jog-roundtrip` | auto | PASS | 2026-09-11T17:45:00Z | `c-20260911172921-9426` | yes |
| `motion.microstep-modes` | auto | PASS | 2026-09-11T17:45:25Z | `c-20260911172921-9426` | yes |
| `motion.liveness-probe` | auto | PASS | 2026-09-11T17:45:33Z | `c-20260911172921-9426` | yes |
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-11T18:24:48Z | `c-20260911182256-078f` | no |
| `motion.cancel-abort` | auto | PASS | 2026-09-11T17:45:40Z | `c-20260911172921-9426` | yes |
| `motion.deadman` | auto | PASS | 2026-09-11T17:46:09Z | `c-20260911172921-9426` | yes |
| `motion.button-hold-resume` | operator | PASS | 2026-09-11T18:24:59Z | `c-20260911182256-078f` | no |
| `motion.lid-cancel-home` | operator | PASS | 2026-09-11T18:25:15Z | `c-20260911182256-078f` | no |
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-11T18:25:24Z | `c-20260911182256-078f` | no |
| `motion.lid-policy-hold` | operator | PASS | 2026-09-11T18:25:37Z | `c-20260911182256-078f` | no |
| `motion.step-timing-under-load` | auto | PASS | 2026-09-11T17:46:40Z | `c-20260911172921-9426` | yes |
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-11T17:38:38Z | `c-20260911172921-9426` | yes |
| `cooling.flow-verify` | auto | PASS | 2026-09-11T17:41:29Z | `c-20260911172921-9426` | yes |
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-11T17:47:35Z | `c-20260911172921-9426` | yes |
| `cooling.gate-off` | auto | PASS | 2026-09-11T17:35:02Z | `c-20260911172921-9426` | yes |
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-11T17:49:02Z | `c-20260911172921-9426` | yes |
| `cooling.tec-drive` | auto | PASS | 2026-09-11T17:49:10Z | `c-20260911172921-9426` | yes |
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-11T17:49:25Z | `c-20260911172921-9426` | yes |
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-11T17:49:32Z | `c-20260911172921-9426` | yes |
| `cooling.critical-tier` | auto | PASS | 2026-09-11T17:49:41Z | `c-20260911172921-9426` | yes |
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-11T17:35:45Z | `c-20260911172921-9426` | yes |
| `cooling.flow-under-load` | live | PASS | 2026-09-11T18:41:47Z | `c-20260911182256-078f` | no |
| `laser.power-floor` | auto | PASS | 2026-09-11T17:49:42Z | `c-20260911172921-9426` | yes |
| `laser.emission-witness` | live, core | PASS | 2026-09-11T18:40:15Z | `c-20260911182256-078f` | no |
| `laser.m5-rapid-dark` | live | PASS | 2026-09-11T18:42:25Z | `c-20260911182256-078f` | no |
| `laser.disarm-in-hold` | live | PASS | 2026-09-11T18:43:51Z | `c-20260911182256-078f` | no |
| `laser.armed-kill` | live | PASS | 2026-09-11T18:44:43Z | `c-20260911182256-078f` | no |
| `laser.arm-wait-lid` | operator | PASS | 2026-09-11T18:25:57Z | `c-20260911182256-078f` | no |
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-11T18:45:12Z | `c-20260911182256-078f` | no |
| `camera.snapshot` | auto | PASS | 2026-09-11T17:49:57Z | `c-20260911172921-9426` | yes |
| `camera.sensor-profile` | auto | PASS | 2026-09-11T17:50:10Z | `c-20260911172921-9426` | yes |
| `camera.h264-stream` | auto | PASS | 2026-09-11T17:50:23Z | `c-20260911172921-9426` | yes |
| `camera.frame-health` | auto | PASS | 2026-09-11T17:50:36Z | `c-20260911172921-9426` | yes |
| `camera.lid-privacy` | operator | PASS | 2026-09-11T18:26:12Z | `c-20260911182256-078f` | no |
| `camera.key-read` | auto | PASS | 2026-09-11T17:50:37Z | `c-20260911172921-9426` | yes |
| `update.slots-and-signature` | auto | PASS | 2026-09-11T17:32:49Z | `c-20260911172921-9426` | yes |
| `cloud.mode-switch` | operator | PASS | 2026-09-11T17:58:28Z | `c-20260911172921-9426` | yes |
| `cloud.service-protocol` | operator | PASS | 2026-09-11T18:46:13Z | `c-20260911182256-078f` | no |
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-11T18:47:21Z | `c-20260911182256-078f` | no |
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-11T18:26:51Z | `c-20260911182256-078f` | no |
| `cloud.verdict-hold` | operator | PASS | 2026-09-11T18:38:58Z | `c-20260911182256-078f` | no |
| `cloud.pause-resume` | live | PASS | 2026-09-11T18:49:33Z | `c-20260911182256-078f` | no |
| `cloud.oversize-stream` | live | PASS | 2026-09-11T18:52:19Z | `c-20260911182256-078f` | no |
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-11T18:52:48Z | `c-20260911182256-078f` | no |
Artifact sha256: `d7bb5d900f9237711a17ae207df90025605340c2eb2eaf34f9ed508cc3a899a6`
+98 -2
View File
@@ -25,6 +25,19 @@ no root) and drives it over TCP:
does not free Z either does not free Z either
6. X and Y are still free after both writes 6. X and Y are still free after both writes
A referenced lens opens the envelope to the window the shared settings
hold, the same keys the daemon's wizards build their Z from. forgectrl
leaves the lens on its hall edge and a marker in the state directory
before the controller starts; the driver takes it as it loads its
settings and places the edge on its step grid:
7. the marker with the edge alone: Z sits at the edge's grid step, the
fallback window (10 below, 12 above) runs to its ends, and two
half-steps past either end alarms
8. the marker with the focus card's stops (14 below, 20 above): the
wider window runs to its ends, two half-steps past either alarms
9. a stop count out of range (41) falls back on its side alone
The binary keeps its settings in EEPROM.DAT in the working directory, so The binary keeps its settings in EEPROM.DAT in the working directory, so
each run starts from defaults in a temporary directory and leaves each run starts from defaults in a temporary directory and leaves
nothing behind. nothing behind.
@@ -53,13 +66,24 @@ def fail(msg):
class Session: class Session:
"""One null-sink controller process with its own settings store.""" """One null-sink controller process with its own settings store.
`conf` (key: value) is the shared config the driver reads; with
`referenced` the lens marker forgectrl leaves is in the state
directory, so the driver takes the lens reference at its start."""
def __init__(self): def __init__(self, conf=None, referenced=False):
self.workdir = tempfile.mkdtemp(prefix="z-envelope-") self.workdir = tempfile.mkdtemp(prefix="z-envelope-")
env = dict(os.environ, GF_STATE_DIR=self.workdir, FFLOG_STDERR="1") env = dict(os.environ, GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
for key in ("GFSINK", "GF_SWITCH_FILE", "GF_VERDICT_FILE"): for key in ("GFSINK", "GF_SWITCH_FILE", "GF_VERDICT_FILE"):
env.pop(key, None) env.pop(key, None)
conf_path = os.path.join(self.workdir, "forgefirm.conf")
with open(conf_path, "w") as f:
for k, v in (conf or {}).items():
f.write("%s = %s\n" % (k, v))
env["GFHOME_CONF"] = conf_path
if referenced:
with open(os.path.join(self.workdir, "lens.home"), "w") as f:
f.write("edge 0 3 3\n")
self.proc = subprocess.Popen([BIN, "-p", str(PORT)], self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
cwd=self.workdir, env=env, cwd=self.workdir, env=env,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
@@ -113,6 +137,23 @@ class Session:
m = re.search(r"<(\w+)", self.send("?")) m = re.search(r"<(\w+)", self.send("?"))
return m.group(1) if m else "?" return m.group(1) if m else "?"
def mpos_z(self):
"""The reported machine Z (no work offset is set in a session)."""
m = re.search(r"[MW]Pos:(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)", self.send("?"))
if not m:
fail("no position in the status report")
return float(m.group(3))
def wait_idle(self, timeout=15.0):
"""A move that ran plays out before the next line is judged: the
soft limit on a moving machine holds first and alarms after."""
end = time.time() + timeout
while time.time() < end:
if self.state() == "Idle":
return
time.sleep(0.1)
fail("the controller did not return to Idle")
def move(self, gcode): def move(self, gcode):
"""Run one move. Returns True when the soft limit blocked it. """Run one move. Returns True when the soft limit blocked it.
An alarm needs a reset and an unlock before the next move.""" An alarm needs a reset and an unlock before the next move."""
@@ -160,6 +201,60 @@ def free_in_xy(s, when):
check(ran, "X and Y free %s" % when, "an X or Y move was blocked %s" % when) check(ran, "X and Y free %s" % when, "an X or Y move was blocked %s" % when)
def window_holds(s, steps, below, above, what):
"""The envelope of a referenced lens whose edge sits on grid step
`steps`: the ends of the reach (`below` and `above` half-steps from
the edge) run, two half-steps past either end alarms. The controller
keeps a half-step of slack beyond the reach, so one past is not
judged here; two is outside on every head."""
spm = float(s.setting("$102"))
top, bottom = (steps + above) / spm, (steps - below) / spm
s.wait_idle()
check(not s.move("G0 Z%.3f" % top), "%s: the top of the reach, Z %.3f, runs" % (what, top),
"%s: Z %.3f, the top of the reach, was blocked" % (what, top))
s.wait_idle()
check(not s.move("G0 Z%.3f" % bottom), "%s: the bottom of the reach, Z %.3f, runs" % (what, bottom),
"%s: Z %.3f, the bottom of the reach, was blocked" % (what, bottom))
over, under = (steps + above + 2) / spm, (steps - below - 2) / spm
s.wait_idle()
check(s.move("G0 Z%.3f" % over), "%s: two half-steps over the top, Z %.3f, alarms" % (what, over),
"%s: Z %.3f, two half-steps over the top, ran" % (what, over))
s.wait_idle()
check(s.move("G0 Z%.3f" % under), "%s: two half-steps under the bottom, Z %.3f, alarms" % (what, under),
"%s: Z %.3f, two half-steps under the bottom, ran" % (what, under))
def referenced_cases():
"""The lens referenced by forgectrl's marker: the envelope is the
window the shared settings hold, as the daemon's wizards read it."""
edge = 3.35
conf = {"lens_hall_edge_z_mm": "%.2f" % edge}
s = Session(conf=conf, referenced=True)
try:
spm = float(s.setting("$102"))
steps = int(round(edge * spm))
z = s.mpos_z()
check(abs(z - steps / spm) < 0.002,
"referenced: Z sits at the edge's grid step, %.3f" % z,
"referenced: Z reads %.3f, not the edge's grid step %.3f" % (z, steps / spm))
window_holds(s, steps, 10, 12, "the fallback window")
free_in_xy(s, "with the lens referenced")
finally:
s.close()
s = Session(conf=dict(conf, lens_stop_below_steps="14", lens_stop_above_steps="20"), referenced=True)
try:
window_holds(s, steps, 14, 20, "the focus card's window")
finally:
s.close()
s = Session(conf=dict(conf, lens_stop_below_steps="41", lens_stop_above_steps="20"), referenced=True)
try:
window_holds(s, steps, 10, 20, "a below count out of range")
finally:
s.close()
def main(): def main():
if not os.path.isfile(BIN): if not os.path.isfile(BIN):
fail("no controller binary at %s" % BIN) fail("no controller binary at %s" % BIN)
@@ -189,6 +284,7 @@ def main():
free_in_xy(s, "after the settings writes") free_in_xy(s, "after the settings writes")
finally: finally:
s.close() s.close()
referenced_cases()
print("PASS z_envelope_test") print("PASS z_envelope_test")
+5 -2
View File
@@ -52,8 +52,11 @@ CONTENT_LAYERS = {"meta-forgefirm": ("forgefirm", "meta-forgefirm"),
"meta-glowforge-bsp": ("meta-openglow", "meta-glowforge-bsp"), "meta-glowforge-bsp": ("meta-openglow", "meta-glowforge-bsp"),
"meta-openglow-core": ("meta-openglow", "meta-openglow-core")} "meta-openglow-core": ("meta-openglow", "meta-openglow-core")}
# Left out of a layer's content, as in forgefirm-image-manifest.bbclass # Left out of a layer's content, as in forgefirm-image-manifest.bbclass
# (FORGEFIRM_MANIFEST_PIN_SUFFIX): documentation and the component pin files. # (FORGEFIRM_MANIFEST_PIN_SUFFIX, FORGEFIRM_MANIFEST_VERSION_SUFFIX):
LAYER_SKIP_SUFFIXES = (".md", "-pin.inc") # documentation, the component pin files, and the release version file.
# All three are metadata; hashing them would turn a pin bump or a version
# bump into a platform change and invalidate every acceptance result.
LAYER_SKIP_SUFFIXES = (".md", "-pin.inc", "forgefirm-release.inc")
def git(args, cwd=None, input=None): def git(args, cwd=None, input=None):
+7 -4
View File
@@ -45,7 +45,7 @@
# forgefirm-source-v<version>.tar.gz, and refuses to pack a bundle in which # forgefirm-source-v<version>.tar.gz, and refuses to pack a bundle in which
# a recipe that needs source has none. # a recipe that needs source has none.
# #
# Version contract: <version> == FORGEFIRM_RELEASE in forgefirm-image.bb # Version contract: <version> == FORGEFIRM_RELEASE in forgefirm-release.inc
# == /etc/forgefirm-version ("v<version>") in the built rootfs == .fw # == /etc/forgefirm-version ("v<version>") in the built rootfs == .fw
# meta-version ("v<version>") == release tag ("v<version>"). # meta-version ("v<version>") == release tag ("v<version>").
@@ -55,6 +55,9 @@ REPO="$(cd "$(dirname "$0")/.." && pwd)"
DEPLOY_ROOT="$REPO/build/tmp/deploy" DEPLOY_ROOT="$REPO/build/tmp/deploy"
DEPLOY="$DEPLOY_ROOT/images/glowforge" DEPLOY="$DEPLOY_ROOT/images/glowforge"
IMAGE_BB="$REPO/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb" IMAGE_BB="$REPO/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb"
# The release version, in its own file so a bump is not a platform change
# (the manifest leaves it out of the layer content hash).
RELEASE_INC="$REPO/meta-forgefirm/recipes-forgefirm/images/forgefirm-release.inc"
INSTALLER="$REPO/scripts/install-forgefirm.sh" INSTALLER="$REPO/scripts/install-forgefirm.sh"
WARN_BYTES=$((170 * 1024 * 1024)) WARN_BYTES=$((170 * 1024 * 1024))
FAIL_BYTES=$((195 * 1024 * 1024)) FAIL_BYTES=$((195 * 1024 * 1024))
@@ -134,7 +137,7 @@ if [ "$MODE" = "dev" ]; then
EXT4="${EXT4/forgefirm-image-glowforge/forgefirm-image-dev-glowforge}" EXT4="${EXT4/forgefirm-image-glowforge/forgefirm-image-dev-glowforge}"
[ -f "$EXT4" ] || die "dev rootfs not found: $EXT4" [ -f "$EXT4" ] || die "dev rootfs not found: $EXT4"
check_size check_size
REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$IMAGE_BB") REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$RELEASE_INC")
DEVVER="v${REL}-dev-$(date +%Y%m%d%H%M%S)" DEVVER="v${REL}-dev-$(date +%Y%m%d%H%M%S)"
OUT="$DEPLOY/forgefirm-dev.fw" OUT="$DEPLOY/forgefirm-dev.fw"
"$REPO/scripts/mkfw.sh" "$EXT4" "$DEVVER" "$OUT" "$KEY" "$REPO/scripts/mkfw.sh" "$EXT4" "$DEVVER" "$OUT" "$KEY"
@@ -163,9 +166,9 @@ else
fi fi
# Version single-source check. # Version single-source check.
BB_REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$IMAGE_BB") BB_REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$RELEASE_INC")
[ "$BB_REL" = "$VERSION" ] \ [ "$BB_REL" = "$VERSION" ] \
|| die "FORGEFIRM_RELEASE in forgefirm-image.bb is '$BB_REL', not '$VERSION'" || die "FORGEFIRM_RELEASE in forgefirm-release.inc is '$BB_REL', not '$VERSION'"
# The beta rule: every release below 0.1.0 is a beta, and 0.1.0 is the # The beta rule: every release below 0.1.0 is a beta, and 0.1.0 is the
# first release that is not. While the README carries the beta banner, a # first release that is not. While the README carries the beta banner, a