23 Commits
Author SHA1 Message Date
ScottW514 08b29fee01 Release v0.0.6 2026-09-19 18:35:29 -04:00
ScottW514 f306c9983f forgetest: the hand-back reads an engine hold again past the engine's next tick
The cooling engine publishes its state once a tick (1 Hz). A /cool/status
read inside the tick after a run ended still shows the run: while a
diagnostic owns the hardware every tick publishes phase "diag" with the
hold set, and a fail tier's hold stands until the tick that ends its
session. The baseline took one read, and by its rule an arm or a hold is
the run's doing, so a test that finished inside that second failed its
hand-back on a hold the engine's next tick cleared.

Seen on the bench reference twice. cooling.aa-offset-calibrate in campaign
c-20260919215024-c402: the diagnostic reported done at 22:07:14 with its
offset measured (15.7 counts, spread 0.7), and the hand-back at 22:07:15
read "cool=diag/armed=False/hold=True ... -> waited" and failed the run;
the test had passed on five images before, the last one earlier the same
day. cooling.fail-tier-stop in c-20260919202934-3d3a, the same way on the
crash fault's hold (0ceb4ab made that test wait for its own fault; this is
the general case).

The cooling check moves into Baseline._cool_side. An arm or a hold is read
again for up to COOL_PUBLISH_S (2.5 s: two ticks and a margin) before it is
called the run's doing. One the next tick cleared is logged as the engine's
last word on the run and judged on what the engine reads then: idle is
clean, a cooldown phase is waited out as the engine's own post-job work. A
hold a run did leave stands for a job that is never coming back, so it is
still there after the tick and is recorded, stood down and failed exactly
as before; a hold that clears only later is still a leftover ("waited").

Proven: tests/test_baseline.py CoolPublishTests - a diagnostic's hold the
next tick clears, a fail tier's hold clearing into the smoke phase, a hold
that outlives the tick (recorded, machine stood down), a hold that clears
only later (recorded as waited), an idle engine and a silent daemon; the
baseline, queue, operator, mode, responsiveness and server host tests
pass under Linux. baseline.py is not a suite module, so no test's
fingerprint moves with it.
2026-09-19 18:16:04 -04:00
ScottW514 dc7170d0d1 forgetest: cooling.flow-under-load finds the verdict by offset, not by a tail count
The test counted "heater rise" in the last 300 lines of the forgectrl log
before the job, then waited for the count to grow. A tail of fixed length
cannot show that: the new verdict line comes in at the bottom as an old one
leaves at the top, the count does not move, and a check that verified reads
as one that never judged.

Seen on the bench reference in campaign c-20260919210959-8780: the engine
logged "coolant flow verified (heater rise 11.5 C, dT 9.5 C; laser 1.6 off
13.1)" at 21:37:46, 70 s into the job and inside the wait, and the test
failed at 21:39:21 with "the engine published no flow verdict within 120 s".
The 21:17:27 verdict of an earlier test sat near the top of the 300-line
tail when the test began. Replayed against that log, the old method reads 4
before the job and 4 with the new verdict in the tail; the search from the
byte offset finds the 21:37:46 line.

The test now takes the log's byte offset before the job (_log_offset, as
the fail-tier and liveness tests do) and searches what the file gained
since with the verdict expression, stopping only on a line that matches.
_log_since reads a file that is now shorter than the offset from its start:
a rotation under the test leaves only newer lines. The /logs/tail helper
and its line count are gone with their one user.

Proven: tests/test_cooling_suite.py FlowVerdictLogTests - the text after the
offset alone, the real verdict line through the expression, an old verdict
before the offset not taken for the new one, a rotated log read whole, a
missing log read as nothing; the cooling host tests pass under Linux, 24
tests. No component source changed, so no pin moves.
2026-09-19 17:45:27 -04:00
ScottW514 0ceb4abc4a forgetest: cooling.fail-tier-stop waits for the crash fault to end with its session
The test raised a crash fault and handed the machine back with it still
standing. The engine trips the crash tier on one 1 Hz tick and ends the run
session on its next tick, which is when the fault and its hold clear ("head
crash fault cleared with the run session"). The supervisor has the new
controller up inside that second, and the test returned as soon as it saw
the new pid and the two log lines, so the runner's hand-back check could
read /cool/status before the clearing tick: phase run, hold true. A hold is
the run's doing by the baseline's rule, so the check recorded a leftover
and failed the test, though the wait that followed ended on its own.

Seen on the bench reference in campaign c-20260919202934-3d3a, the only
failure among 65 results: the trip at 20:54:44.871, the new controller at
20:54:45.094, the test's PASS line at 20:54:45, the session end at
20:54:45.863, and "cool=run/armed=False/hold=True ... -> waited" from the
hand-back check. The engine opened no session after that one; the 16 s the
check waited were the smoke phase that follows an armed session.

The test now asserts what the engine documents: after the restart checks it
waits up to 10 s for the verdict to leave CRASH, records how long that took
(fault_cleared_s) with the phase, verdict, hold and armed flags it found,
and fails when the fault does not end with the session. The machine is then
handed back in the engine's own post-job phase, which the baseline already
treats as the engine's work and not a leftover.

Proven: the cooling, artifact, campaign and queue host tests pass and the
catalog loads with the new source. No component source changed, so no pin
moves; the test's own source hash does, so its result comes from this
revision.
2026-09-19 17:04:49 -04:00
ScottW514 aa628d684c kas lock: meta-openglow onto the head that drops the DHCPv6 client
meta-openglow   31c52050bc70 -> 71fad52e8ff5

The layer head removes the wlan0 inet6 stanza and the busybox bbappend that
built udhcpc6, the client that held a field machine's boot in ifup. The
acceptance test image.network-boot fails on any image that still carries
that client, so the lock moves with it. poky, meta-openembedded,
meta-freescale and meta-freescale-distro keep their commits.

Proven: kas checks layers/meta-openglow out at 71fad52, the layer holds no
busybox recipe directory and its interfaces file names no udhcpc6, and
bitbake -c fetch busybox init-ifupdown succeeds against it.
2026-09-19 16:22:43 -04:00
ScottW514 29b00cc056 Pin forgectrl 0.1.27 (the export carries the installer's log)
forgectrl   7a9de005ede5 -> 0dae758cb93e  (PV 0.1.26 -> 0.1.27)

forgectrl 0dae758 stages logs/install/ into the log export, which is what
logs.tree-tail-export now requires of the bundle. The PV moves with the
SRCREV so the hash-derived package version stays monotonic.

Proven: bitbake -c fetch forgectrl resolves the pin (forgectrl-1_0.1.27-r0
do_fetch succeeded).
2026-09-19 16:22:43 -04:00
ScottW514 d482e76402 installer: a download that resumes and retries, and an install log
A field install failed on "firmware download failed", and worked after a
reboot. The download was one bare curl -fL: no retry, no resume, no bound
on a stalled transfer, and nothing on the machine recorded what had gone
wrong.

The download. download_fw makes up to five tries, 5, 15, 30 and 60 seconds
apart. Each try resumes the partial file (curl -C -) and is bounded: 20 s
to connect, and a transfer below 1 KB/s for 30 s ends the try. The file is
written as forgefirm.fw.part and takes its name only when curl finished;
the signature check that follows is what vouches for its content. A full
disk (curl 23) and a release that is not there (HTTP 404) end the tries at
once, because waiting cannot fix them. A partial file the server will not
resume (curl 33 or 36, HTTP 416) starts over. The loop is the installer's
own rather than curl --retry: the factory curl on the bench reference is
7.69.1, whose --retry does not count a resolver failure or a dropped
transfer as retryable, and older factory builds carry older curls. The
owner sees the reason in words with each retry, and the final failure says
that a re-run goes straight to the download, because the archives are kept.

The log. Every run appends to /data/log/forgefirm/install/install.log, in
the log tree's own line format (UTC, program "install"): the installer's
md5 (which revision ran), the factory version and the slots, the owner's
answers, each archive, each download try with curl's exit code, the HTTP
code and the reason, the machine's clock at each try (a wrong clock breaks
TLS), and after a failed try the address, the default route, the resolver
and whether github.com resolves; then the signature and identity checks,
the write, the boot selection, and the reason for any failure through
die(). The log is appended across runs, so the run that failed is still
there after the run that worked. Logging never fails the install.
forgectrl's log export carries the directory (forgectrl 0dae758).

Proven: tests/test_installer.py runs the installer's own functions under
sh against a scripted curl - a clean download, a resolver failure and a
dropped transfer that resume to the full file, the tries running out, 404
and a full disk ending them at once, a stale partial file starting over,
the TLS reason naming the clock, every log line in the tree format, die()
leaving its reason, and an unwritable log not failing the run. The whole
host suite, 409 tests, passes under Linux and the coverage lint is clean.
Bench: the same functions under the factory firmware's own shell (busybox
1.31.1 ash, the factory slot of the bench reference in a chroot) resumed,
retried, ran out of tries and logged exactly as under sh.

Acceptance: logs.tree-tail-export now plants a probe file in the install
directory and requires it back in the export bundle, its line intact and
its MAC and IPv4 address redacted, and requires an install log in the
bundle when the machine has one. The installer itself is not on the image:
the install page fetches it from master, so it is live with this push.
2026-09-19 16:20:32 -04:00
ScottW514 8232c8c9fe forgetest: image.network-boot - the boot does not wait on the network
A client that waits in ifup's foreground on a server's answer holds the
whole machine, because init starts the rest of the boot - sshd, forgectrl,
the console login - only after S01networking returns. A field machine sat
there forever on a router that refused DHCPv6 (the record is in the
meta-openglow commit that drops the DHCPv6 client). Nothing in the catalog
looked at the network boot path; this test does.

It asserts: wlan0 is in ifupdown's state file and no ifup is running; the
console getty is up; udhcpc runs with -b (it leaves ifup after three
unanswered discovers) and has been reparented to init; no DHCPv6 client is
named in /etc/network/interfaces, running, or on the image, and its hook
script is gone; IPv6 on wlan0 is the kernel's own - enabled, router
advertisements accepted, a link-local address up. A global address is
evidence only: a network whose router advertisement offers no SLAAC prefix
gives none. What a hostile server does to a client is a bench drill, not a
test.

covers is empty by design, as with setup.machine-name: the interfaces file
is layer content, in the platform identity of every fingerprint, so a
change there already makes every test necessary again.

Proven: the host tests for the parsers and the registration
(tests/test_image.py), and the whole host suite, 398 tests, under Linux.
The test's logic, run read-only on the bench reference against an image
that carries the client, fails exactly the four DHCPv6 checks and passes
every other one. The test fails on any image built from a meta-openglow
that still carries the client, so the kas lock moves to the layer head
that drops it.
2026-09-19 15:57:42 -04:00
ScottW514 260f466400 release.sh: no slot size gate on the dev path, which is SD-only
check_size guards the 200 MiB eMMC slot. The dev path called it against the
release rootfs until 88ec984 switched that path to the dev image, and the
call was left in place: the dev rootfs is about 368 MiB - forgetest and the
bench tools - so release.sh --dev has died on "too close to the 200 MiB
slot" ever since, which is why no forgefirm-dev.fw exists anywhere in the
tree.

The gate is not the real limit. A 368 MiB rootfs cannot be raw_written into
a 200 MiB slot at all, so the dev image is never installed into one: it runs
from an SD card, which the boot selector already carries as a first-class
location and which the bench has always used. The comment says so, and the
gate now applies to the release rootfs alone, which is what it was written
for.

The dev rootfs has been this size in every archived build back to
20260911172215 (386,465,792 bytes then, 386,727,936 today), so nothing here
is a regression - the gate simply outlived the image it was guarding.
2026-09-18 18:40:19 -04:00
ScottW514 cf7b51fb32 kas lock: meta-openglow onto the pinned head with the bumped component pins
The lock held meta-openglow at dd8f74c, three commits behind the pushed
scarthgap head, so a build from this repository resolved the old BSP and
none of the component pins just bumped there. Moves that one entry to
31c5205.

poky, meta-openembedded, meta-freescale and meta-freescale-distro keep their
commits: the platform is deliberately unchanged, so the build exercises the
pins and nothing else.

Proven: bitbake -c fetch and -c populate_lic over forgectrl,
grblhal-glowforge, gfcloud, gfhome, python3-ffmachine, python3-gfhardware,
python3-gfutilities and kernel-module-glowforge all succeed - the four
LIC_FILES_CHKSUM values written ahead of their pins now match the fetched
license text. A full bitbake forgefirm-image forgefirm-image-dev run
completed, 5841 tasks, no errors, and the release rootfs manifest carries
forgectrl 0.1.26, grblhal-glowforge 0.1.18, kernel-module-glowforge 0.0.6,
python3-gfutilities 0.9.19+git0+0a93ebf30b and
gfcloud/gfhome/python3-ffmachine 0.1.30+git0+6cc4f45d31. Release rootfs
125 MiB, inside the 195 MiB gate.
2026-09-18 18:27:25 -04:00
ScottW514 e2be3ca4c5 Pin forgectrl, grblHAL-glowforge and forgefirm-app on the pushed heads
forgectrl          da3eddcc0f43 -> 7a9de005ede5  (PV 0.1.25 -> 0.1.26)
  grblhal-glowforge  ecebe9c8eb8d -> f93aca89821a  (PV 0.1.17 -> 0.1.18)
  forgefirm-app      5ca279a1f600 -> 6cc4f45d311a  (PV 0.1.29 -> 0.1.30)

forgectrl brings the jobstream_test SIGPIPE fix and the x32 setting comment;
grblHAL-glowforge brings the x32 xy_microsteps default and the rewritten
COPYING this layer's LIC_FILES_CHKSUM already expects, which no longer fails
the fetch now that the pin resolves to it. forgefirm-app tracks the same
python3-gfhardware revision meta-openglow just pinned.

Each PV moves with its SRCREV so the hash-derived package version stays
monotonic.
2026-09-18 17:49:37 -04:00
ScottW514 cd4c176a87 Finish the x32 xy_microsteps default in the baseline test and the stream harness
The x32 default landed in forgetest/baseline.py and the driver, but two
callers still judged the machine at x8 and both failed on the host.

forgetest/tests/test_baseline.py: setUp seeded the fake machine from the x8
FIXED_SYSFS literals while enforce() compares against fixed_sysfs() of the
resolved mode, so x_mode, y_mode, step_freq and ramp_rate read as deviations
on a clean machine - 23 failures across BaselineTests and
TransientNotLeftoverTests. It seeds from fixed_sysfs() now, and the tick
expectations come from it (DEFAULT_TICK) rather than a typed 28160. The
xy_mode_of and ref_xy_mode unset/invalid cases expect 32, with an explicit
"8" case added that had no coverage. Two reference_preconfig dumps taken on
an x8 machine carry xy_microsteps = 8, because the markers are read at the
reference's own mode. wait_configured wrote the static CONFIGURED_MARKERS
where the function watches configured_markers() of the mode in force, and
the held-controller jog typed 221 steps for "4.144 mm", which is 1.036 mm at
x32; both derive from the mode now. 69 tests, all pass.

scripts/bench/laser_stream_test.py: STEPS_PER_MM was the x8 53.333, so the
X-peak check failed at 2133 steps against an expected 533. The whole harness
now derives from XY_MICROSTEPS_BASE/DEFAULT the way glowforge.h and
baseline.py do, which uncovered five more x8-only expectations behind the
first: the machine tick, the fire-gap limit (it grows as sqrt(k), not k - a
finer mode shortens the accel interval by sqrt(k) while speeding the tick by
k), the rung split in fire_spans, the density period and minimum burst
(laser_pulse_ticks is in x8 ticks and the stream scales it, so the config
keeps the x8 numbers and the measured lengths scale), and the decel/hold
budgets. Run against the null-sink build: all stream emission rules hold.

xy_mode_test.py's docstring still described the no-key case as x8 while its
own assertions had moved to x32.

No behavior change and no acceptance-catalog consequence: these are test
expectations and a bench harness, not image component sources. The coverage
lint is unchanged at 0 uncovered paths.
2026-09-18 17:47:53 -04:00
ScottW514 8850adcc5e Update release 2026-09-18 15:59:00 -04:00
ScottW514 f9f4de31c1 Fixed artifact exporter 2026-09-18 14:25:45 -04:00
ScottW514 9ddab969e0 Attribution 2026-09-18 14:22:14 -04:00
ScottW514 12e7d50746 Update contributing 2026-09-18 12:50:11 -04:00
ScottW514 b3f0cd54d9 Attribution 2026-09-18 12:30:39 -04:00
ScottW514 fbac17a485 Updated release to guard against publishing without source bundle 2026-09-18 12:26:12 -04:00
ScottW514 0eb764bf75 Added SPDX 2026-09-18 12:14:22 -04:00
ScottW514 07158e5d7f Update key prov 2026-09-18 11:21:44 -04:00
ScottW514 091702c26f Updated grblhal-glowforge license checksum 2026-09-18 10:52:53 -04:00
ScottW514 9a288b175b Track the x32 xy_microsteps default in forgetest and the bench tools
Acceptance baseline: XY_MODE_DEFAULT 8 -> 32, split out XY_MODE_BASE (8) for
the tick/ramp scaling, matching the driver. The mode-aware comparisons
(fixed_sysfs of the resolved mode) judge an unset machine at x32.

Host CI: xy_mode_test.py default and invalid cases expect x32/213.333/112640.

Bench tools: raster_dry.py and xy_pattern_accel.py set_mode() cleared the key
for mode 8, which relied on clear == the x8 default; clearing now yields x32,
so they set the mode explicitly and restore to x32. live_fire_drills
STREAM_RATE_HZ is the 28160 Hz laser reference tick, the same at every mode.
2026-09-16 15:39:20 -04:00
ScottW514 147393322f CI: run the push workflows on master only, not on tag pushes
on: push had a paths filter but no branch restriction, so a tag push
(a release's v<version> tag) re-ran forgetest-ci and fixture-ci on the
commit master had already tested - a tag push bypasses the paths
filter. branches: [master] scopes the push trigger to the branch;
pull_request and workflow_dispatch are unchanged.
2026-09-15 20:21:44 -04:00
174 changed files with 25562 additions and 148 deletions
+1
View File
@@ -11,6 +11,7 @@ name: fixture-ci
on: on:
push: push:
branches: [master]
paths: paths:
- 'fixture/**' - 'fixture/**'
- '.github/workflows/fixture-ci.yml' - '.github/workflows/fixture-ci.yml'
+1
View File
@@ -20,6 +20,7 @@ name: forgetest-ci
on: on:
push: push:
branches: [master]
paths: paths:
- 'forgetest/**' - 'forgetest/**'
- 'scripts/acceptance-gate.py' - 'scripts/acceptance-gate.py'
+5 -1
View File
@@ -325,7 +325,11 @@ docstrings, log and console text, commit messages, issue text, and replies.
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
`Written by Scott Wiederhold`. Keep the year, or the year range, that `Written by Scott Wiederhold`. Keep the year, or the year range, that
the file already has. The copyright notices of other holders stay the file already has. The copyright notices of other holders stay
unchanged. unchanged. Those two lines are for files this project authors: an
outside contributor who writes a new file puts their own name there, or
leaves the lines out, and keeps their copyright in what they wrote.
Editing an existing file changes no header - a contribution is recorded
by its `Signed-off-by` line and by git, not by a copyright line.
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line - **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
under the license of its repository. under the license of its repository.
+54
View File
@@ -0,0 +1,54 @@
# Contributing
Patches are welcome. How the project works, what proof a change needs, and the
writing rules are on the documentation site:
<https://docs.forgefirm.org/developers/contributing/>.
## Sign your work
Every commit carries a `Signed-off-by` line:
Signed-off-by: Your Name <your.email@example.com>
`git commit -s` adds it, using your `user.name` and `user.email`. Use your real
name; an anonymous or pseudonymous sign-off certifies nothing.
The line certifies the Developer Certificate of Origin, version 1.1, below. It
is the same statement the Linux kernel and grblHAL use. It is not a copyright
assignment: you keep the copyright in what you write, and your contribution
comes in under the license of the repository you are contributing to.
You do not add a copyright line for a patch. A change to an existing file
leaves its header alone. If you write a whole new file, put your own name in
its copyright line, or leave the line out; either way the file carries an
`SPDX-License-Identifier` for the repository's license.
## Developer Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
This DCO text is licensed under the terms of the Creative Commons
Attribution-ShareAlike 3.0 Unported license.
+9
View File
@@ -5,5 +5,14 @@ the associated recipe (.bb file) unless otherwise stated.
See: See:
COPYING.MIT (MIT) COPYING.MIT (MIT)
The code in this repository that is not layer metadata is MIT licensed:
forgetest/ the release acceptance tool (see forgetest/LICENSE)
scripts/ the release, install and bench scripts
fixture/ the bench actuator firmware, excluding
fixture/managed_components/, which carries its own licenses
Every file in those directories states MIT in its own header.
License information for any other files is either explicitly stated License information for any other files is either explicitly stated
or defaults to GPL version 2. or defaults to GPL version 2.
+2 -1
View File
@@ -83,4 +83,5 @@ before you install.
**This is experimental software. Use of it could seriously maim or kill you or **This is experimental software. Use of it could seriously maim or kill you or
others, and it may void your warranty. Use it at your own risk.** others, and it may void your warranty. Use it at your own risk.**
This project is not affiliated with or endorsed by Glowforge. Glowforge is a trademark of Glowforge, Inc. This project is not affiliated
with, authorized by, or endorsed by Glowforge, Inc.
+4
View File
@@ -1,4 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
# forgefixture: build, flash and watch the bench actuator's firmware. # forgefixture: build, flash and watch the bench actuator's firmware.
# #
# ./fixture.sh env fixture.env from the example, with a fresh API key # ./fixture.sh env fixture.env from the example, with a fresh API key
+5
View File
@@ -1,3 +1,8 @@
/*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
/* forgefixture firmware version, reported by GET /. */ /* forgefixture firmware version, reported by GET /. */
#pragma once #pragma once
+4
View File
@@ -1,4 +1,8 @@
#!/bin/sh #!/bin/sh
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
# The host test of the fixture's policy: needs only a C compiler. # The host test of the fixture's policy: needs only a C compiler.
set -e set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
+23
View File
@@ -0,0 +1,23 @@
ForgeFIRM release acceptance tool (forgetest/) and the bench scripts it
drives (scripts/bench/).
Copyright 2026 514 LLC d/b/a OpenGlow
Written by Scott Wiederhold
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+4
View File
@@ -1,4 +1,8 @@
#!/bin/sh #!/bin/sh
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
### BEGIN INIT INFO ### BEGIN INIT INFO
# Provides: forgetest # Provides: forgetest
# Required-Start: $network forgectrl # Required-Start: $network forgectrl
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""forgetest - the ForgeFIRM release acceptance tool. """forgetest - the ForgeFIRM release acceptance tool.
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""forgetest daemon entry point. """forgetest daemon entry point.
python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH] python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH]
+12 -1
View File
@@ -1,5 +1,15 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The release artifact (acceptance.json / acceptance.md) and its verification. """The release artifact (acceptance.json / acceptance.md) and its verification.
Every test record is redacted before it enters the artifact (redact.py), so
the committed file carries no identity of the machine that ran the campaign.
It happens inside build(), ahead of the self-hash, because verify() rejects a
file edited after the fact. The gate reads none of the redacted fields.
The exporter serializes the campaign state with, for every catalog test, The exporter serializes the campaign state with, for every catalog test,
the winning result record (same-campaign PASS or inherited PASS, with its the winning result record (same-campaign PASS or inherited PASS, with its
origin) and the fingerprint it was recorded under. The gate origin) and the fingerprint it was recorded under. The gate
@@ -15,6 +25,7 @@ from . import VERSION
from . import campaign as _campaign from . import campaign as _campaign
from . import manifest as _manifest from . import manifest as _manifest
from .log import now_ts from .log import now_ts
from .redact import scrub
FORMAT = 1 FORMAT = 1
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image", _RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
@@ -47,7 +58,7 @@ def build(state, tests, manifest, records, catalog_hash):
"fingerprint": st["fingerprint"], "fingerprint": st["fingerprint"],
"satisfied": st["satisfied"], "satisfied": st["satisfied"],
"inherited": st["status"] == "inherited", "inherited": st["status"] == "inherited",
"record": _record(rec) if rec else None, "record": scrub(_record(rec)) if rec else None,
}) })
tests_out.append(entry) tests_out.append(entry)
+39 -8
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The fresh-boot idle state: what every test starts from and leaves behind. """The fresh-boot idle state: what every test starts from and leaves behind.
The runner checks the machine against this baseline before a run and The runner checks the machine against this baseline before a run and
@@ -48,7 +53,7 @@ from .log import now_ts
# in the pulse path (a job's Z moves the lens; the driver's Z soft limit # in the pulse path (a job's Z moves the lens; the driver's Z soft limit
# guards it), step_freq its machine tick and ramp_rate the kernel stop # guards it), step_freq its machine tick and ramp_rate the kernel stop
# ramp, both derived from the XY microstep mode (the values here are the # ramp, both derived from the XY microstep mode (the values here are the
# x8 default's; fixed_sysfs() gives the mode's); streaming is only ever 1 # x8 base; fixed_sysfs() gives the mode's); streaming is only ever 1
# inside a live job; the head white LED is a camera lamp, off at idle; the # inside a live job; the head white LED is a camera lamp, off at idle; the
# loop heater and TEC are the diagnostics' tools, off at idle. # loop heater and TEC are the diagnostics' tools, off at idle.
FIXED_SYSFS = [ FIXED_SYSFS = [
@@ -133,6 +138,7 @@ BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon a
SETTLE_S = 150 # the supervisor's probe + rail-off ladder SETTLE_S = 150 # the supervisor's probe + rail-off ladder
CAM_IDLE_S = 20 # camera engine idle stop is 10 s CAM_IDLE_S = 20 # camera engine idle stop is 10 s
COOL_IDLE_S = 120 # cooldown after motion COOL_IDLE_S = 120 # cooldown after motion
COOL_PUBLISH_S = 2.5 # the engine publishes once a tick (1 Hz): two ticks and a margin
IDLE_S = 30 # cnc/state back to idle after a job IDLE_S = 30 # cnc/state back to idle after a job
GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running
@@ -147,11 +153,12 @@ RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most th
POSITION_DEADBAND_MM = 0.1 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, # 32). The GRBL controller reads it at its start and derives its scale,
# its machine tick and the kernel stop ramp from it (boards/glowforge.h): # its machine tick and the kernel stop ramp from it (boards/glowforge.h):
# the fixed values of x/y_mode, step_freq and ramp_rate are the mode's. # the fixed values of x/y_mode, step_freq and ramp_rate are the mode's.
XY_MODES = (8, 16, 32) XY_MODES = (8, 16, 32)
XY_MODE_DEFAULT = 8 XY_MODE_BASE = 8 # the factory x8 reference the tick and ramp scale from
XY_MODE_DEFAULT = 32 # the mode an unset or invalid setting reads as
XY_STEPS_PER_MM_OF = {8: 53.333, 16: 106.667, 32: 213.333} # $100/$101 per mode XY_STEPS_PER_MM_OF = {8: 53.333, 16: 106.667, 32: 213.333} # $100/$101 per mode
XY_TICK_X8_HZ = 28160 # the x8 machine tick XY_TICK_X8_HZ = 28160 # the x8 machine tick
XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it
@@ -175,7 +182,7 @@ def fixed_sysfs(mode=XY_MODE_DEFAULT):
"""FIXED_SYSFS with the mode's own x/y_mode, step_freq and ramp_rate: """FIXED_SYSFS with the mode's own x/y_mode, step_freq and ramp_rate:
the tick and the ramp scale with the mode (x16 doubles both, x32 the tick and the ramp scale with the mode (x16 doubles both, x32
quadruples them).""" quadruples them)."""
k = mode // XY_MODE_DEFAULT k = mode // XY_MODE_BASE
own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode), own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode),
"cnc/step_freq": str(XY_TICK_X8_HZ * k), "cnc/step_freq": str(XY_TICK_X8_HZ * k),
"cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)} "cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)}
@@ -607,13 +614,37 @@ class Baseline:
except OSError as e: except OSError as e:
act = "failed: %s" % e act = "failed: %s" % e
left.append(Leftover("laser_locked", False, True, act)) left.append(Leftover("laser_locked", False, True, act))
# cooling engine idle, unarmed self._cool_side(left)
def _cool_side(self, left):
"""The cooling engine idle, unarmed, holding nothing."""
st, c = self.fc_get("/cool/status") st, c = self.fc_get("/cool/status")
if st == 200 and isinstance(c, dict): if st != 200 or not isinstance(c, dict):
if c.get("phase") != "idle" or c.get("armed") or c.get("hold"): return
if c.get("phase") == "idle" and not c.get("armed") and not c.get("hold"):
return
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"))
idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731 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 {})
# The engine publishes once a tick (1 Hz). A read inside the tick
# after a run ended still shows the run: a diagnostic's hold until
# the engine has the hardware back, a fail tier's hold until the
# session ends with the controller. What the engine's next tick
# clears was never left behind, so an arm or a hold is read again
# past that tick before it is called the run's doing. One that a
# run did leave stands for a job that is never coming back, and it
# is still there.
if c.get("armed") or c.get("hold"):
free = lambda: (lambda x: bool(x) and not x.get("armed") # noqa: E731
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
if self._wait("cool publish", free, COOL_PUBLISH_S) is not None:
st, c2 = self.fc_get("/cool/status")
c = c2 if st == 200 and isinstance(c2, dict) else {}
self.log("cool: %s was the engine's last word on the run; its next tick reads "
"%s/armed=%s/hold=%s" % (found, c.get("phase"), c.get("armed"), c.get("hold")))
if c.get("phase") == "idle":
return
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
# Armed, or holding, is the run's doing. A phase alone is # Armed, or holding, is the run's doing. A phase alone is
# not: the engine clears smoke at run duty after an armed # not: the engine clears smoke at run duty after an armed
# session and cools down after a hot one, both timed and # session and cools down after a hot one, both timed and
@@ -625,7 +656,7 @@ class Baseline:
if w is not None and not dirt: 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 " self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
"work, not a leftover" % (found, w)) "work, not a leftover" % (found, w))
else: return
# The engine holds for a job. A run that ended without # The engine holds for a job. A run that ended without
# ending its job leaves one alive, and no amount of # ending its job leaves one alive, and no amount of
# waiting ends it: stand the machine down and let the # waiting ends it: stand the machine down and let the
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The bench diagnostics page: registry of the bench tools and the """The bench diagnostics page: registry of the bench tools and the
subprocess runner behind the #bench tab. subprocess runner behind the #bench tab.
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Campaign rules: which results apply, what is required, and whether a """Campaign rules: which results apply, what is required, and whether a
release is authorized. release is authorized.
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The acceptance catalog: test definitions and the registry. """The acceptance catalog: test definitions and the registry.
A test is a function decorated with @test(...). The decorator records A test is a function decorated with @test(...). The decorator records
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Coverage lint: every source path in the manifest must be selected by """Coverage lint: every source path in the manifest must be selected by
some test's coverage globs, except the allowlisted non-behavioral paths, some test's coverage globs, except the allowlisted non-behavioral paths,
and every coverage glob must select at least one path of its component. and every coverage glob must select at least one path of its component.
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The bench actuator (forgefixture): the box on the bench network that """The bench actuator (forgefixture): the box on the bench network that
opens the lid loop, pulls the interlock loop and presses the button on opens the lid loop, pulls the interlock loop and presses the button on
request, so an operator test can run with nobody in the room. request, so an operator test can run with nobody in the room.
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Hardware and service access for the suite: forgectrl's HTTP API, the """Hardware and service access for the suite: forgectrl's HTTP API, the
kernel module's sysfs, the init scripts, and the Grbl TCP port. kernel module's sysfs, the init scripts, and the Grbl TCP port.
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The append-only result log (JSONL) and the export helpers. """The append-only result log (JSONL) and the export helpers.
One JSON object per line under the data directory (default One JSON object per line under the data directory (default
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The image manifest and the domain fingerprint. """The image manifest and the domain fingerprint.
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass) /etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The single page: acceptance tab + bench tab, assembled from ui/ into """The single page: acceptance tab + bench tab, assembled from ui/ into
one self-contained response (no external assets). Bootstrap and the one self-contained response (no external assets). Bootstrap and the
OpenGlow theme are inlined; theme.css and the vendor/ files are the same OpenGlow theme are inlined; theme.css and the vendor/ files are the same
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Pulse files for the offline cloud service: a job the machine will run """Pulse files for the offline cloud service: a job the machine will run
without the service having cut it. without the service having cut it.
+135
View File
@@ -0,0 +1,135 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Redaction for the release artifact.
The artifact is committed to a public repository, so it carries no
identity of the machine that ran the campaign. This applies the same two
layers forgectrl's log export applies (forgectrl/src/sanitize.c), in the
same order, to every value the exporter writes:
1. Known keys: a value held under a key that names identifying data -
the serial, the machine id, the hostname, the wireless credentials,
a token - is replaced whole. Naming the key is far more reliable
than guessing the shape of what is under it.
2. Pattern classes over every remaining string: MAC addresses, IPv4 and
IPv6 addresses (loopback and unspecified kept), e-mail addresses,
bearer and basic credentials, JWTs, and long hex blobs. Each distinct
value in a class keeps a stable number for the life of one export, so
"<IP-2>" is the same host wherever it appears and the artifact stays
readable as evidence.
Integrity values are never touched: the self-hash, the manifest and
catalog hashes, each test's source hash and fingerprint. Those are what
the gate recomputes, and a long hex blob is exactly what they look like.
Over-redaction is accepted where the alternative is a leak.
"""
import re
# Layer 1: the value under one of these keys is replaced whole.
REDACT_KEYS = frozenset((
"gf_serial", "machine_id", "serial", "serial_number",
"hostname", "hostname_file", "host",
"ssid", "psk", "passphrase", "password", "passwd",
"token", "panel_token", "secret", "api_key", "key",
"mac", "macaddr", "mac_suffix", "wlan0", "eth0",
"gf_username", "gf_password", "username", "user", "email",
))
# Never touched, by key: the gate recomputes these and they look like
# exactly what layer 2 hunts for.
KEEP_KEYS = frozenset((
"sha256", "manifest_sha", "identity_sha", "catalog_hash",
"source_sha", "fingerprint", "content_sha", "srcrev",
))
_KEEP_IP = frozenset(("0.0.0.0", "255.255.255.255"))
_CLASSES = (
("SECRET", re.compile(r"(?i)\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}")),
("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
("EMAIL", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
("MAC", re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b")),
# Three or more colons, or a "::" run: two colons is a clock time, and
# tagging every timestamp would make the evidence unreadable.
("IP6", re.compile(
r"(?:[0-9A-Fa-f]{1,4}:){3,7}[0-9A-Fa-f]{1,4}"
r"|[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{1,4})*::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?")),
("IP", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")),
("HEX", re.compile(r"\b[0-9A-Fa-f]{32,}\b")),
)
class Redactor:
"""Stable per-export numbering, one instance per artifact."""
# A known value shorter than this is too generic to replace blind.
MIN_KNOWN = 4
def __init__(self):
self._n = {}
self._known = {}
def learn(self, obj, key=None):
"""Collect the values held under the layer-1 keys, so they are
replaced wherever else they appear - a hostname in a log line is
the same leak as a hostname in its own field."""
if key is not None and key in KEEP_KEYS:
return
if isinstance(obj, dict):
for k, v in obj.items():
self.learn(v, k)
elif isinstance(obj, list):
for v in obj:
self.learn(v)
elif key in REDACT_KEYS and isinstance(obj, str) and len(obj) >= self.MIN_KNOWN:
if obj not in self._known:
self._known[obj] = self._tag("REDACTED", "%s=%s" % (key, obj))
def _tag(self, cls, value):
seen = self._n.setdefault(cls, {})
if value not in seen:
seen[value] = len(seen) + 1
return "<%s-%d>" % (cls, seen[value])
def text(self, s):
for known in sorted(self._known, key=len, reverse=True):
if known in s:
s = s.replace(known, self._known[known])
for cls, rx in _CLASSES:
def sub(m, cls=cls):
v = m.group(0)
if cls == "IP" and (v.startswith("127.") or v in _KEEP_IP):
return v
if cls == "IP6" and v in ("::", "::1"):
return v
return self._tag(cls, v)
s = rx.sub(sub, s)
return s
def value(self, v, key=None):
if key is not None and key in KEEP_KEYS:
return v
if key is not None and key in REDACT_KEYS and not isinstance(v, (dict, list)):
if v is None:
return None
if isinstance(v, str) and v in self._known:
return self._known[v]
return self._tag("REDACTED", "%s=%s" % (key, v))
if isinstance(v, dict):
return {k: self.value(x, k) for k, x in v.items()}
if isinstance(v, list):
return [self.value(x) for x in v]
if isinstance(v, str):
return self.text(v)
return v
def scrub(obj):
"""Redact a value tree with one export's numbering."""
r = Redactor()
r.learn(obj)
return r.value(obj)
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The runner: executes one acceptance test or one bench tool at a time. """The runner: executes one acceptance test or one bench tool at a time.
Acceptance tests run in a worker thread with a Context: log lines, an Acceptance tests run in a worker thread with a Context: log lines, an
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""HTTP: the page, the JSON API, and the access rules. """HTTP: the page, the JSON API, and the access rules.
Access follows forgectrl's panel (auth.c): the Host header must be an Access follows forgectrl's panel (auth.c): the Host header must be an
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The acceptance suite: one module per subsystem, imported in display """The acceptance suite: one module per subsystem, imported in display
order. Each module registers its tests with @catalog.test.""" order. Each module registers its tests with @catalog.test."""
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""camera.* - the lid camera pipeline through forgectrl.""" """camera.* - the lid camera pipeline through forgectrl."""
import json import json
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cloud.* - the controller mode switch and the optional Glowforge web-service """cloud.* - the controller mode switch and the optional Glowforge web-service
mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the
grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and
+37 -10
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cooling.* - the cooling engine: flow verification through forgectrl's """cooling.* - the cooling engine: flow verification through forgectrl's
diagnostics runner (the same check the fire gate runs), the fan profile diagnostics runner (the same check the fire gate runs), the fan profile
returning to idle after motion, the gate settings (a value inside the returning to idle after motion, the gate settings (a value inside the
@@ -5,6 +10,7 @@ legal range trips the gate, the far end of the range turns it off by
value, and both are said out loud: the settings reply, /status, the value, and both are said out loud: the settings reply, /status, the
engine's run-start log line), and the airflow gates (a fan under its engine's run-start log line), and the airflow gates (a fan under its
floor past the spin-up grace is a fault for the rest of the run).""" floor past the spin-up grace is a fault for the rest of the run)."""
import os
import time import time
from ..catalog import test from ..catalog import test
@@ -1241,7 +1247,6 @@ LOAD_LINES = 27 # two 30 x 4 mm fills back to back, about 35
LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window
LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit
LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s
LOAD_LOG_LINES = "300"
_LOAD_LINE_RX = None _LOAD_LINE_RX = None
@@ -1265,9 +1270,19 @@ def _load_fill():
return lines return lines
def _forgectrl_tail(fc): def _log_since(path, offset):
st, body = fc.get("/logs/tail", params={"name": "forgectrl", "lines": LOAD_LOG_LINES}) """The text a log gained since byte `offset`. A count of lines in a
return body.get("text", "") if st == 200 and isinstance(body, dict) else "" tail of fixed length cannot tell a new line from an old one: the new
line comes in at the bottom as an old one leaves at the top. A file now
shorter than the offset was rotated under the test, and all of it is
newer."""
try:
with open(path, "rb") as f:
if os.fstat(f.fileno()).st_size >= offset:
f.seek(offset)
return f.read().decode("utf-8", "replace")
except OSError:
return ""
@test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window", @test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window",
@@ -1285,6 +1300,7 @@ def _forgectrl_tail(fc):
"the tube's share off and read its baseline as a mean.") "the tube's share off and read its baseline as a mean.")
def flow_under_load(ctx): def flow_under_load(ctx):
from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm
from .motion import FORGECTRL_LOG, _log_offset
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
state, check_s = _gate_state(fc, "cool_flow_check_s") state, check_s = _gate_state(fc, "cool_flow_check_s")
@@ -1292,7 +1308,7 @@ def flow_under_load(ctx):
ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s) ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s)
ctx.check(limit, "cool_flow_rise unreadable") ctx.check(limit, "cool_flow_rise unreadable")
ev.update({"check_s": check_s, "limit_c": limit}) ev.update({"check_s": check_s, "limit_c": limit})
before = _forgectrl_tail(fc).count("heater rise") off = _log_offset(FORGECTRL_LOG) # the verdict is a line written after this
# The fill ends one line length out and (LOAD_LINES - 1) pitches up; # The fill ends one line length out and (LOAD_LINES - 1) pitches up;
# the job brings the head back so the baseline finds it where it began. # the job brings the head back so the baseline finds it where it began.
back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1)) back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1))
@@ -1327,12 +1343,11 @@ def flow_under_load(ctx):
ctx.checkpoint() ctx.checkpoint()
if hw.sysfs_int("thermal/heater_pwm", 0) > 0: if hw.sysfs_int("thermal/heater_pwm", 0) > 0:
heater_seen = True heater_seen = True
text = _forgectrl_tail(fc)
if text.count("heater rise") > before:
m = None m = None
for m in _load_verdict_rx().finditer(text): for m in _load_verdict_rx().finditer(_log_since(FORGECTRL_LOG, off)):
pass pass
line = m.group(0) if m else None if m:
line = m.group(0)
break break
ctx.sleep(1) ctx.sleep(1)
ev["heater_seen"] = heater_seen ev["heater_seen"] = heater_seen
@@ -1437,7 +1452,9 @@ def fan_duty_readback(ctx):
"watch's thresholds at their lowest (1) make the head's own move trip the abort " "watch's thresholds at their lowest (1) make the head's own move trip the abort "
"generator inside an armed, dark (S0) job. Expected: the engine logs the crash " "generator inside an armed, dark (S0) job. Expected: the engine logs the crash "
"signal, the supervisor logs the stop and starts a new controller (new pid), the " "signal, the supervisor logs the stop and starts a new controller (new pid), the "
"latch is locked, the kernel idle, no emission, and the thresholds are put back.") "latch is locked, the kernel idle, no emission, the crash fault ends with the run "
"session (the engine's next tick after the controller is gone), and the "
"thresholds are put back.")
def fail_tier_stop(ctx): def fail_tier_stop(ctx):
from .laser import prepare, stream, sample from .laser import prepare, stream, sample
from .motion import FORGECTRL_LOG, _log_lines, _log_offset from .motion import FORGECTRL_LOG, _log_lines, _log_offset
@@ -1496,6 +1513,16 @@ def fail_tier_stop(ctx):
ctx.check(ev["result"]["latch_locked"], "latch not locked after the crash tier") ctx.check(ev["result"]["latch_locked"], "latch not locked after the crash tier")
ctx.check(not (smp and smp["emission"]), "emission during a dark job: %s", smp and smp["emission"]) ctx.check(not (smp and smp["emission"]), "emission during a dark job: %s", smp and smp["emission"])
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the restart") ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the restart")
# The crash fault is the run session's: the engine ends the session
# on its next 1 Hz tick after the controller is gone, and the fault
# and its hold go with it. The supervisor has the new controller up
# inside that second, so a test that returned here would hand the
# machine back with the fault it raised still standing.
cleared = ctx.wait_for(lambda: _cool(fc).get("verdict") != "CRASH", 10)
ev["result"]["fault_cleared_s"] = cleared
ctx.log("the crash fault ended with the run session after %s s: %s", cleared,
{k: _cool(fc).get(k) for k in ("phase", "verdict", "hold", "armed")})
ctx.check(cleared is not None, "the crash fault did not end with the run session: %s", _cool(fc))
finally: finally:
fc.wait_idle(30, abort=ctx.aborted) fc.wait_idle(30, abort=ctx.aborted)
st, body = fc.post("/settings", params=orig) st, body = fc.post("/settings", params=orig)
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""forgectrl.* - the machine-services daemon's API, access control, and panel.""" """forgectrl.* - the machine-services daemon's API, access control, and panel."""
import json import json
import os import os
+143
View File
@@ -1,8 +1,14 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""image.* - the post-flash health of the running image (always-required).""" """image.* - the post-flash health of the running image (always-required)."""
import glob import glob
import gzip import gzip
import mmap import mmap
import os import os
import shutil
import struct import struct
import re import re
import stat import stat
@@ -395,3 +401,140 @@ def license_bundle(ctx):
text = bytes(body).decode("utf-8", "replace") if body else "" text = bytes(body).decode("utf-8", "replace") if body else ""
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text, ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
"the Licenses page lacks the download link or the manifest") "the Licenses page lacks the download link or the manifest")
# ------------------------------------------------------- the network boot
IFSTATE = "/var/run/ifstate" # busybox ifupdown's state file
INTERFACES = "/etc/network/interfaces"
DHCP6_CLIENTS = ("udhcpc6", "dhcp6c", "odhcp6c")
def ifstate_names(text):
"""The interfaces ifup finished configuring: one name=name line each
in busybox's state file, written when ifup returns for the name."""
return {line.split("=", 1)[0].strip() for line in (text or "").splitlines() if "=" in line}
def dhcp6_in_interfaces(text):
"""The lines of an interfaces file that start a DHCPv6 client: an
inet6 stanza with a method that runs one, or a command that names
one. A comment starts nothing."""
bad = []
for raw in (text or "").splitlines():
words = raw.split("#", 1)[0].split()
if not words:
continue
if words[0] == "iface":
if len(words) >= 4 and words[2] == "inet6" and words[3] in ("dhcp", "auto"):
bad.append(" ".join(words))
elif any(w.split("/")[-1] in DHCP6_CLIENTS for w in words[1:]):
bad.append(" ".join(words))
return bad
def parse_stat(text):
"""(pid, comm, ppid) from a /proc/<pid>/stat line, or None. The comm
sits in parentheses and may itself hold spaces and parentheses."""
try:
head, tail = text.split("(", 1)
comm, rest = tail.rsplit(")", 1)
return int(head), comm, int(rest.split()[1])
except (ValueError, IndexError):
return None
def _procs():
"""(pid, ppid, comm, command line) of every process."""
out = []
for name in os.listdir("/proc"):
if not name.isdigit():
continue
st = parse_stat(_read("/proc/%s/stat" % name, ""))
if st is None:
continue
cmd = (_read("/proc/%s/cmdline" % name, "") or "").replace("\0", " ").strip()
out.append((st[0], st[2], st[1], cmd))
return out
def link_addrs(text, iface):
"""(link-local, global) IPv6 addresses of one interface, as the hex
strings of /proc/net/if_inet6 (address, index, prefix length, scope,
flags, name; scope 20 is the link, 00 the world)."""
local, world = [], []
for line in (text or "").splitlines():
f = line.split()
if len(f) == 6 and f[5] == iface:
if f[3] == "20":
local.append(f[0])
elif f[3] == "00":
world.append(f[0])
return local, world
@test("image.network-boot", title="The boot does not wait on the network",
subsystem="image", kind="auto", est_min=1,
description="The network comes up without the boot waiting on a server. init starts the "
"rest of the boot, the console login last, only after the networking script "
"returns, so a client that waits in ifup's foreground on a server's answer "
"holds the whole machine: no login, no SSH, no control panel. ifup has "
"returned for wlan0 (it is in the state file and no ifup is running) and the "
"console login is up; the DHCP client is the bounded one (-b, which leaves "
"ifup after three unanswered discovers) and has left ifup; no DHCPv6 client "
"is named in the interfaces file, running, or on the image; and IPv6 is the "
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
"address up. A global address is evidence only, because a network whose "
"router advertisement offers no SLAAC prefix gives none. What a hostile "
"server does to a client is a bench drill, not a test. No component covers "
"this: the interfaces file is layer content, in the platform identity of "
"every fingerprint.")
def network_boot(ctx):
ev = ctx.evidence
procs = _procs()
# 1. ifup came back, and the boot went on to the console login
state = ifstate_names(_read(IFSTATE, ""))
ev["ifstate"] = sorted(state)
ctx.check("wlan0" in state, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(state))
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
ev["ifup_running"] = [p[3] for p in stuck]
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
logins = [p[3] for p in procs if p[2] == "getty"]
ev["getty"] = logins
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
# 2. the DHCP client is the bounded one, and it has left ifup
v4 = [p for p in procs if p[2] == "udhcpc"]
ev["udhcpc"] = [p[3] for p in v4]
ctx.check(v4, "no DHCP client is running")
for pid, ppid, _comm, cmd in v4:
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
# 3. no DHCPv6 client: not started, not running, not on the image
text = _read(INTERFACES)
ctx.check(text is not None, "%s is missing", INTERFACES)
named = dhcp6_in_interfaces(text)
ev["interfaces_dhcp6"] = named
ctx.check(not named, "%s starts a DHCPv6 client: %s", INTERFACES, named)
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
ev["dhcp6_running"] = alive
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
for name in DHCP6_CLIENTS:
ctx.check(shutil.which(name) is None, "%s is on the image", name)
ctx.check(not os.path.exists("/usr/share/udhcpc/default6.script"),
"the DHCPv6 hook script is on the image")
# 4. IPv6 is the kernel's own
conf = "/proc/sys/net/ipv6/conf/wlan0/"
ev["disable_ipv6"] = (_read(conf + "disable_ipv6", "") or "").strip()
ev["accept_ra"] = (_read(conf + "accept_ra", "") or "").strip()
ctx.check(ev["disable_ipv6"] == "0", "IPv6 is off on wlan0 (disable_ipv6=%r)", ev["disable_ipv6"])
ctx.check(ev["accept_ra"] not in ("", "0"),
"wlan0 does not accept router advertisements (accept_ra=%r)", ev["accept_ra"])
local, world = link_addrs(_read("/proc/net/if_inet6", ""), "wlan0")
ev["link_local"] = local
ev["global"] = world
ctx.check(local, "wlan0 has no link-local IPv6 address")
ctx.log("wlan0: %d link-local, %d global IPv6 address(es)", len(local), len(world))
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills. """kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py` The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`. """laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
Every test here can emit, except `laser.power-floor`, which only reads Every test here can emit, except `laser.power-floor`, which only reads
+54 -3
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""logs.* - unified logging: the log tree, tail, and the sanitized export; """logs.* - unified logging: the log tree, tail, and the sanitized export;
the routing path (emitters, relays, rendered rules); the level settings.""" the routing path (emitters, relays, rendered rules); the level settings."""
import gzip import gzip
@@ -22,14 +27,46 @@ def _read(path, default=None):
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"), _LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
("forgectrl", "src/main.c")] ("forgectrl", "src/main.c")]
LOGS_ROOT = "/data/log/forgefirm"
# What the export test plants in the installer's directory: a line to find
# again, and two addresses (documentation ranges) the sanitizer must take.
_PROBE_MARK = "forgetest export probe"
_PROBE_MAC = "02:00:5e:10:20:30"
_PROBE_IP = "192.0.2.77"
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs", @test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
kind="auto", est_min=1, kind="auto", est_min=1,
covers=_LOG_COVERS, requires=["forgectrl.auth"], covers=_LOG_COVERS, requires=["forgectrl.auth"],
description="/logs lists the loggers with their levels and files, /logs/tail returns the " description="/logs lists the loggers with their levels and files, /logs/tail returns the "
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz " "forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
"bundle that contains neither the panel token nor the camera key.") "bundle that contains neither the panel token nor the camera key. The bundle "
"also carries the installer's directory of the tree (logs/install/), which no "
"logger feeds: a probe file planted there for the export comes back in the "
"bundle with its addresses redacted, and an install log that the installer "
"left is in the bundle too.")
def tree_tail_export(ctx): def tree_tail_export(ctx):
install_dir = os.path.join(LOGS_ROOT, "install")
probe = os.path.join(install_dir, "forgetest-probe.txt")
made_dir = not os.path.isdir(install_dir)
try:
os.makedirs(install_dir, exist_ok=True)
with open(probe, "w") as f:
f.write("%s from %s at %s\n" % (_PROBE_MARK, _PROBE_MAC, _PROBE_IP))
_tree_tail_export(ctx, probe)
finally:
try:
os.unlink(probe)
except OSError:
pass
if made_dir:
try:
os.rmdir(install_dir)
except OSError:
pass
def _tree_tail_export(ctx, probe):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
st, body = fc.get("/logs") st, body = fc.get("/logs")
@@ -82,6 +119,21 @@ def tree_tail_export(ctx):
ev[what.replace(" ", "_") + "_leaks"] = leaked ev[what.replace(" ", "_") + "_leaks"] = leaked
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked) ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
# the installer's directory: no logger feeds it, the export carries it
by_name = dict(contents)
want = "logs/install/" + os.path.basename(probe)
got = [name for name in by_name if name.endswith(want)]
ev["install_members"] = sorted(name for name in by_name if "/logs/install/" in name)
ctx.check(got, "the bundle lacks %s: %s", want, ev["install_members"])
if got:
text = by_name[got[0]].decode("utf-8", "replace")
ctx.check(_PROBE_MARK in text, "the probe file came back without its line: %r", text)
for what, value in (("MAC address", _PROBE_MAC), ("IPv4 address", _PROBE_IP)):
ctx.check(value not in text, "the install directory left the sanitizer with its %s", what)
if os.path.isfile(os.path.join(os.path.dirname(probe), "install.log")):
ctx.check(any(name.endswith("logs/install/install.log") for name in by_name),
"the machine has an install log and the bundle does not")
# The routing test proves the whole path every logger takes: emitter (or # The routing test proves the whole path every logger takes: emitter (or
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the # relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
@@ -95,8 +147,7 @@ _ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"), ("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
("forgefirm-app", "forgefirm-app/gfhome.py")] ("forgefirm-app", "forgefirm-app/gfhome.py")]
LOGS_ROOT = "/data/log/forgefirm" LOGGERS =("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
LOGGERS = ("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] " _LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$") r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7} _SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""motion.* - the motion controller under grblHAL: dry motion, no emission. """motion.* - the motion controller under grblHAL: dry motion, no emission.
Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.* - the first-run setup: the record and the """setup.* - the first-run setup: 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 machine's name, and the factory boundary, SSH, the cloud switch, the machine's name, and the factory
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.check-* - the setup's checks (the dark wizards), driven the """setup.check-* - the setup's checks (the dark wizards), driven the
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled, way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
the prompts answered from here (the bench fixture works the lid and the the prompts answered from here (the bench fixture works the lid and the
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.sheet - the setup's sheet (the live wizards) in one run, """setup.sheet - the setup's sheet (the live wizards) in one run,
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
polled, the prompts answered from here, each burn armed by a press, the polled, the prompts answered from here, each burn armed by a press, the
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""update.* - the A/B slot inventory and the firmware verification path.""" """update.* - the A/B slot inventory and the firmware verification path."""
import os import os
import re import re
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Shared fixtures for the forgetest unit tests: synthetic manifests and """Shared fixtures for the forgetest unit tests: synthetic manifests and
catalog entries that never touch hardware.""" catalog entries that never touch hardware."""
import hashlib import hashlib
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
import copy import copy
import json import json
import unittest import unittest
+114 -17
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The baseline: fixed resting values are restored, preserved values are """The baseline: fixed resting values are restored, preserved values are
handed back, every deviation is a recorded leftover. Runs against a fake handed back, every deviation is a recorded leftover. Runs against a fake
sysfs tree; forgectrl is unreachable (service-side checks skip).""" sysfs tree; forgectrl is unreachable (service-side checks skip)."""
@@ -10,6 +15,11 @@ import unittest
from forgetest import baseline from forgetest import baseline
# The fixed machine tick at the mode an unset xy_microsteps reads as.
# Taken from baseline rather than typed, so a change to the default mode
# moves the expectations with it.
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
class BaselineTests(unittest.TestCase): class BaselineTests(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -21,8 +31,11 @@ class BaselineTests(unittest.TestCase):
for name in baseline.BUTTON_LEDS + ("lid_led",): for name in baseline.BUTTON_LEDS + ("lid_led",):
os.makedirs(self.leds + name) os.makedirs(self.leds + name)
self._led(name, "0") self._led(name, "0")
# a clean machine # A clean machine at the mode an unset xy_microsteps reads as:
for attr, val in baseline.FIXED_SYSFS + baseline.IDLE_READBACKS: # x/y_mode, step_freq and ramp_rate are the mode's, not the x8
# literals in FIXED_SYSFS, and that is what enforce() compares
# against.
for attr, val in baseline.fixed_sysfs() + baseline.IDLE_READBACKS:
self._attr(attr, val) self._attr(attr, val)
self._attr("cnc/interlock_circuit", "45") self._attr("cnc/interlock_circuit", "45")
self._attr("pic/lid_led", "0") self._attr("pic/lid_led", "0")
@@ -90,7 +103,7 @@ class BaselineTests(unittest.TestCase):
for x in left: for x in left:
self.assertEqual(x.action, "restored", str(x)) self.assertEqual(x.action, "restored", str(x))
self.assertEqual(self._read("cnc/motor_lock"), "0") self.assertEqual(self._read("cnc/motor_lock"), "0")
self.assertEqual(self._read("cnc/step_freq"), "28160") self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
self.assertEqual(self._read("cnc/streaming"), "0") self.assertEqual(self._read("cnc/streaming"), "0")
self.assertEqual(items["cnc/motor_lock"].found, "15") self.assertEqual(items["cnc/motor_lock"].found, "15")
self.assertEqual(items["cnc/motor_lock"].expected, "0") self.assertEqual(items["cnc/motor_lock"].expected, "0")
@@ -153,14 +166,16 @@ class BaselineTests(unittest.TestCase):
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
b = self.bl() b = self.bl()
cap = b.capture() cap = b.capture()
self._pos(221, 0, 0) # 4.144 mm into the held move # 4.144 mm into the held move, in the steps of the mode in force
held_mm = 4.144
self._pos(round(held_mm * baseline.xy_steps_per_mm(baseline.XY_MODE_DEFAULT)), 0, 0)
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.assertTrue(items["position"].action.startswith("restored"), items["position"].action) self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
self.assertEqual(dev.sent[0], "^X") self.assertEqual(dev.sent[0], "^X")
jogs = [l for l in dev.sent if l.startswith("$J=")] jogs = [l for l in dev.sent if l.startswith("$J=")]
self.assertEqual(len(jogs), 1) self.assertEqual(len(jogs), 1)
self.assertIn("X-4.144", jogs[0]) self.assertIn("X-%.3f" % held_mm, jogs[0])
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines) self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
finally: finally:
dev.stop() dev.stop()
@@ -245,7 +260,7 @@ class BaselineTests(unittest.TestCase):
def test_fixed_constants_checked_against_a_dump(self): def test_fixed_constants_checked_against_a_dump(self):
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}} ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
diffs = baseline.check_fixed_against(ref, self.lines.append) diffs = baseline.check_fixed_against(ref, self.lines.append)
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=28160"]) self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=%s" % DEFAULT_TICK])
# -- the reference is taken after the controller applied its config ----- # -- the reference is taken after the controller applied its config -----
@@ -264,7 +279,8 @@ class BaselineTests(unittest.TestCase):
def sleep(_s): def sleep(_s):
calls["n"] += 1 calls["n"] += 1
if calls["n"] == 3: # the controller's init writes land if calls["n"] == 3: # the controller's init writes land
for attr, val in baseline.CONFIGURED_MARKERS: # at the mode wait_controller_configured watches by default
for attr, val in baseline.configured_markers():
self._attr(attr, val) self._attr(attr, val)
ok = baseline.wait_controller_configured( ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"}, self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
@@ -302,11 +318,15 @@ class BaselineTests(unittest.TestCase):
def test_preconfig_reference_is_recognized(self): def test_preconfig_reference_is_recognized(self):
self.assertTrue(baseline.reference_preconfig( self.assertTrue(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}})) {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
# The markers are read at the reference's own mode, so a dump taken
# on an x8 machine carries the setting that says so.
self.assertFalse(baseline.reference_preconfig( self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"}})) {"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"},
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
# a genuinely different single constant is a machine fact, not pre-config # a genuinely different single constant is a machine fact, not pre-config
self.assertFalse(baseline.reference_preconfig( self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"}})) {"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"},
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
self.assertFalse(baseline.reference_preconfig({"sysfs": {}})) self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
# -- the XY microstep mode drives the fixed values ------------------------ # -- the XY microstep mode drives the fixed values ------------------------
@@ -325,16 +345,18 @@ class BaselineTests(unittest.TestCase):
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"]) self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
def test_the_mode_is_read_from_the_settings_with_a_default(self): def test_the_mode_is_read_from_the_settings_with_a_default(self):
# An unset or unusable key reads as the driver's default, x32.
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16) self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32) self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 8) self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "8"}), 8)
self.assertEqual(baseline.xy_mode_of({}), 8) self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 32)
self.assertEqual(baseline.xy_mode_of(None), 8) self.assertEqual(baseline.xy_mode_of({}), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 8) self.assertEqual(baseline.xy_mode_of(None), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 8) self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 32)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16) self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 8) self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 32)
self.assertEqual(baseline.ref_xy_mode({}), 8) self.assertEqual(baseline.ref_xy_mode({}), 32)
def test_fixed_constants_are_checked_at_the_reference_mode(self): def test_fixed_constants_are_checked_at_the_reference_mode(self):
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"}, ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
@@ -415,6 +437,81 @@ class TransientNotLeftoverTests(BaselineTests):
self.assertEqual(items[0].found, "1014") self.assertEqual(items[0].found, "1014")
class CoolPublishTests(unittest.TestCase):
"""The cooling engine publishes once a tick. A status read inside the
tick after a run ended is the engine's last word on the run, not the
machine as the run left it. Found on the bench reference: a diagnostic
that finished clean (cooling.aa-offset-calibrate) and a fail tier that
did its work (cooling.fail-tier-stop) both failed their hand-back on a
hold the engine's next tick cleared."""
IDLE = {"phase": "idle", "armed": False, "hold": False}
def setUp(self):
self.lines = []
def bl(self, statuses):
"""A baseline whose /cool/status answers play in order (the last
one repeats) and whose waits count reads, not seconds: three for
the engine's next tick, as the real wait makes, and enough for the
cooldown to let a slow script run out."""
b = baseline.Baseline(self.lines.append)
seq = list(statuses)
b.fc_get = lambda path: (200, dict(seq.pop(0) if len(seq) > 1 else seq[0]))
def wait(what, pred, timeout):
for i in range(3 if what == "cool publish" else 50):
if pred():
return float(i)
return None
b._wait = wait
b.stood_down = []
b.stand_down = b.stood_down.append
return b
def test_a_diagnostic_hold_the_next_tick_clears_is_not_a_leftover(self):
left = []
b = self.bl([{"phase": "diag", "armed": False, "hold": True}, self.IDLE])
b._cool_side(left)
self.assertEqual(left, [])
self.assertTrue(any("last word on the run" in ln for ln in self.lines), self.lines)
def test_a_fail_tier_hold_clears_into_the_engines_own_post_job_phase(self):
left = []
b = self.bl([{"phase": "run", "armed": False, "hold": True},
{"phase": "smoke", "armed": False, "hold": False},
{"phase": "smoke", "armed": False, "hold": False}, self.IDLE])
b._cool_side(left)
self.assertEqual(left, [])
self.assertTrue(any("not a leftover" in ln for ln in self.lines), self.lines)
def test_a_hold_that_outlives_the_tick_is_the_runs_doing(self):
left = []
b = self.bl([{"phase": "run", "armed": True, "hold": True}])
b._cool_side(left)
self.assertEqual([x.item for x in left], ["cool"])
self.assertEqual(left[0].found, "run/armed=True/hold=True")
self.assertTrue(left[0].action.startswith("failed: still"), left[0].action)
self.assertEqual(len(b.stood_down), 1)
def test_a_hold_that_clears_only_later_is_still_recorded(self):
left = []
held = {"phase": "run", "armed": False, "hold": True}
b = self.bl([held] * 10 + [self.IDLE])
b._cool_side(left)
self.assertEqual([x.item for x in left], ["cool"])
self.assertEqual(left[0].action, "waited")
self.assertEqual(b.stood_down, [])
def test_an_idle_engine_and_a_silent_daemon_leave_nothing(self):
left = []
self.bl([self.IDLE])._cool_side(left)
b = baseline.Baseline(self.lines.append)
b.fc_get = lambda path: (None, None)
b._cool_side(left)
self.assertEqual(left, [])
class CloudOwnedSysfsTests(unittest.TestCase): class CloudOwnedSysfsTests(unittest.TestCase):
"""In cloud mode the cloud client configures the machine from the """In cloud mode the cloud client configures the machine from the
pulse header it is playing, the lens included: z_mode comes from pulse header it is playing, the lens included: z_mode comes from
@@ -571,7 +668,7 @@ class BaselineModeTests(BaselineTests):
self._attr("pic/lid_led", "77") self._attr("pic/lid_led", "77")
left = self.bl().enforce("pre", captured=None) left = self.bl().enforce("pre", captured=None)
self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"]) self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"])
self.assertEqual(self._read("cnc/step_freq"), "28160") self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
self.assertEqual(self._read("pic/lid_led"), "236") self.assertEqual(self._read("pic/lid_led"), "236")
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self): def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The bench registry against scripts/bench: every python tool is """The bench registry against scripts/bench: every python tool is
registered (or named as not-a-tool), every registered script exists, registered (or named as not-a-tool), every registered script exists,
every argument spec builds a command line, every script compiles, and every argument spec builds a command line, every script compiles, and
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
import unittest import unittest
import helpers import helpers
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Every phrase the cloud suite greps for in the gfcloud log must be a """Every phrase the cloud suite greps for in the gfcloud log must be a
phrase the pinned cloud app can actually log. phrase the pinned cloud app can actually log.
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The cloud.* job-behavior tests against a fake forgectrl and a replayed """The cloud.* job-behavior tests against a fake forgectrl and a replayed
gfcloud log: the real test functions run under the real runner Context, gfcloud log: the real test functions run under the real runner Context,
the operator prompts answered - and the standing notices of the machine the operator prompts answered - and the standing notices of the machine
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cooling.aa-offset-calibrate is registered before the heater tools, so """cooling.aa-offset-calibrate is registered before the heater tools, so
a queue runs it first: a heater trial leaves a warm slug circulating past a queue runs it first: a heater trial leaves a warm slug circulating past
the coolant sensors for minutes, and the tool's edges read it as the coolant sensors for minutes, and the tool's edges read it as
+53
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side """cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
under the real runner Context against a scripted machine: a fake under the real runner Context against a scripted machine: a fake
forgectrl (/status fans, /cool/status phase and verdict, /settings with forgectrl (/status fans, /cool/status phase and verdict, /settings with
@@ -729,3 +734,51 @@ class FanGateTests(unittest.TestCase):
self.run_test() self.run_test()
self.assertIn("lacks exhaust", str(cm.exception)) self.assertIn("lacks exhaust", str(cm.exception))
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "") self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
class FlowVerdictLogTests(unittest.TestCase):
"""cooling.flow-under-load finds the engine's verdict in what the log
gained since the job began, not in a count over a tail of fixed length:
there a new verdict line comes in at the bottom as an old one leaves at
the top, the count does not move, and a check that verified reads as
one that never judged."""
VERDICT = ("2026-09-19T21:37:46.869674+00:00 forgectrl[2796] INFO cool: coolant flow verified "
"(heater rise 11.5 C, dT 9.5 C; laser 1.6 off 13.1)\n")
OLD = ("2026-09-19T21:17:27.200840+00:00 forgectrl[965] INFO cool: coolant flow verified "
"(heater rise 9.5 C, dT 8.7 C)\n")
def setUp(self):
self.dir = tempfile.mkdtemp(prefix="flowlog.")
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.path = os.path.join(self.dir, "forgectrl.log")
def write(self, text, mode="w"):
with open(self.path, mode, newline="\n") as f:
f.write(text)
def test_only_the_text_after_the_offset_comes_back(self):
self.write(self.OLD + "filler\n" * 400)
off = os.path.getsize(self.path)
self.assertEqual(cooling._log_since(self.path, off), "")
self.write(self.VERDICT, mode="a")
text = cooling._log_since(self.path, off)
self.assertEqual(text, self.VERDICT)
m = cooling._load_verdict_rx().search(text)
self.assertEqual(m.group(1), "coolant flow verified")
self.assertEqual((m.group(2), m.group(5)), ("11.5", "1.6"))
def test_an_old_verdict_before_the_offset_is_not_the_new_one(self):
self.write(self.OLD)
off = os.path.getsize(self.path)
self.write("2026-09-19T21:36:40+00:00 forgectrl[2796] INFO cool: crash watch armed\n", mode="a")
self.assertIsNone(cooling._load_verdict_rx().search(cooling._log_since(self.path, off)))
def test_a_log_rotated_under_the_test_is_read_whole(self):
self.write(self.OLD + "filler\n" * 400)
off = os.path.getsize(self.path)
self.write(self.VERDICT) # the rotation: a new, shorter file
self.assertEqual(cooling._log_since(self.path, off), self.VERDICT)
def test_a_missing_log_reads_as_nothing(self):
self.assertEqual(cooling._log_since(os.path.join(self.dir, "none.log"), 0), "")
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The bench actuator as the tool sees it. """The bench actuator as the tool sees it.
The fixture is a box on the network that opens the lid loop, pulls the The fixture is a box on the network that opens the lid loop, pulls the
+62
View File
@@ -1,5 +1,11 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
import unittest import unittest
from forgetest import catalog
from forgetest.suite import image from forgetest.suite import image
@@ -20,5 +26,61 @@ class KernelIdentTests(unittest.TestCase):
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"])) self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
class NetworkBootTests(unittest.TestCase):
STANZA = ("auto wlan0\n"
"iface wlan0 inet dhcp\n"
"\twpa-driver nl80211\n"
"\tudhcpc_opts -x hostname:$(hostname)\n")
def test_the_test_is_registered_and_covers_nothing_by_design(self):
t = catalog.load_suite()["image.network-boot"]
self.assertEqual(t.kind, "auto")
self.assertEqual(t.covers, ()) # the interfaces file is layer content
def test_ifstate_names_the_interfaces_ifup_finished(self):
self.assertEqual(image.ifstate_names("lo=lo\nwlan0=wlan0\n"), {"lo", "wlan0"})
self.assertEqual(image.ifstate_names("lo=lo\n"), {"lo"})
self.assertEqual(image.ifstate_names(""), set())
self.assertEqual(image.ifstate_names(None), set())
def test_an_interfaces_file_without_a_dhcpv6_client_is_clean(self):
self.assertEqual(image.dhcp6_in_interfaces(self.STANZA), [])
self.assertEqual(image.dhcp6_in_interfaces(None), [])
def test_a_command_that_starts_a_dhcpv6_client_is_named(self):
text = self.STANZA + ("iface wlan0 inet6 manual\n"
"\tup udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid\n")
self.assertEqual(image.dhcp6_in_interfaces(text),
["up udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid"])
detached = "\tup start-stop-daemon -S -b -x /usr/bin/udhcpc6 -- -f -i wlan0\n"
self.assertEqual(len(image.dhcp6_in_interfaces(self.STANZA + detached)), 1)
def test_an_inet6_method_that_runs_a_client_is_named(self):
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 dhcp\n"),
["iface wlan0 inet6 dhcp"])
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 static\n"), [])
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet dhcp\n"), [])
def test_a_comment_starts_nothing(self):
text = self.STANZA + "# No DHCPv6 client runs; udhcpc6 is not on the image.\n"
self.assertEqual(image.dhcp6_in_interfaces(text), [])
def test_parse_stat_reads_the_parent_past_an_awkward_comm(self):
self.assertEqual(image.parse_stat("371 (udhcpc) S 1 371 371 0 -1 4194624"),
(371, "udhcpc", 1))
self.assertEqual(image.parse_stat("9 (a (b) c) S 7 9 9 0"), (9, "a (b) c", 7))
self.assertIsNone(image.parse_stat(""))
self.assertIsNone(image.parse_stat("12 (x) S"))
def test_link_addrs_sorts_one_interface_by_scope(self):
text = ("fe800000000000002e6b7dfffe0db00a 02 40 20 80 wlan0\n"
"26001700000000000000000000001234 02 80 00 00 wlan0\n"
"00000000000000000000000000000001 01 80 10 80 lo\n")
local, world = image.link_addrs(text, "wlan0")
self.assertEqual(local, ["fe800000000000002e6b7dfffe0db00a"])
self.assertEqual(world, ["26001700000000000000000000001234"])
self.assertEqual(image.link_addrs(text, "eth0"), ([], []))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+194
View File
@@ -0,0 +1,194 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""scripts/install-forgefirm.sh - the download that retries, and the log.
The installer runs once per machine, on factory firmware, over whatever
network the owner has. These tests run its own functions under sh with a
scripted curl in front of them: a transfer that fails resumes and is
tried again, the failures that waiting cannot fix end the tries at once,
and every step leaves a line in the install log in the log tree's own
format."""
import os
import re
import shutil
import stat
import subprocess
import tempfile
import unittest
import helpers # noqa: F401 (sys.path)
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
SCRIPT = os.path.join(REPO, "scripts", "install-forgefirm.sh")
MARK = "# The install log, before anything can fail."
# The log tree's line format (forgetest/suite/logs.py keeps the same one).
LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d install\[\d+\] "
r"(EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) .+$")
# A curl that plays a script: each call takes the next "rc http bytes"
# line of $CURL_PLAN, appends that many bytes to the --output file the way
# a resumed transfer does, prints the HTTP code as -w '%{http_code}'
# would, records its arguments, and exits rc.
FAKE_CURL = r"""#!/bin/sh
echo "$*" >> "$CURL_CALLS"
OUT=""
while [ $# -gt 0 ]; do
[ "$1" = "--output" ] && OUT="$2"
shift
done
LINE=$(sed -n 1p "$CURL_PLAN")
sed -i 1d "$CURL_PLAN"
set -- $LINE
[ "${3:-0}" -gt 0 ] && dd if=/dev/zero bs=1 count="$3" 2>/dev/null | tr '\0' 'x' >> "$OUT"
printf '%s' "$2"
exit "$1"
"""
def functions():
"""The installer up to its first action: constants and functions."""
with open(SCRIPT, encoding="utf-8") as f:
text = f.read()
head, mark, _ = text.partition(MARK)
assert mark, "the installer lost the line the tests cut it at"
return head
@unittest.skipUnless(shutil.which("sh") and os.name == "posix", "needs a POSIX sh")
class InstallerTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="ffinstall-test.")
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
self.bin = os.path.join(self.tmp, "bin")
os.mkdir(self.bin)
for name, body in (("curl", FAKE_CURL), ("nslookup", "#!/bin/sh\nexit 1\n"),
("ip", "#!/bin/sh\nexit 0\n"), ("sleep", "#!/bin/sh\nexit 0\n")):
path = os.path.join(self.bin, name)
with open(path, "w", newline="\n") as f:
f.write(body)
os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR)
self.fw = os.path.join(self.tmp, "forgefirm.fw")
self.log = os.path.join(self.tmp, "log", "install.log")
self.calls = os.path.join(self.tmp, "curl.calls")
self.plan = os.path.join(self.tmp, "curl.plan")
def run_sh(self, body, plan=()):
with open(self.plan, "w", newline="\n") as f:
f.write("".join("%s\n" % line for line in plan))
open(self.calls, "w").close()
script = "%s\nFW_FILE='%s'\nLOG_DIR='%s'\nLOG_FILE='%s'\nmkdir -p \"$LOG_DIR\"\nLOG_OK=yes\n%s\n" % (
functions(), self.fw, os.path.dirname(self.log), self.log, body)
env = dict(os.environ, PATH=self.bin + os.pathsep + os.environ["PATH"],
CURL_PLAN=self.plan, CURL_CALLS=self.calls)
return subprocess.run(["sh", "-c", script], env=env, capture_output=True, text=True, timeout=60)
def download(self, plan):
r = self.run_sh('download_fw; echo "rc=$? why=$DL_WHY"', plan)
with open(self.calls) as f:
calls = f.read().splitlines()
return r.stdout, calls, self.log_lines()
def log_lines(self):
try:
with open(self.log) as f:
return f.read().splitlines()
except OSError:
return []
def test_a_clean_download_takes_one_try_and_its_name(self):
out, calls, log = self.download(["0 200 5000"])
self.assertIn("rc=0", out)
self.assertEqual(len(calls), 1)
self.assertEqual(os.path.getsize(self.fw), 5000)
self.assertFalse(os.path.exists(self.fw + ".part"))
self.assertTrue(any("complete on try 1, 5000 bytes" in line for line in log), log)
def test_a_failed_transfer_resumes_and_is_tried_again(self):
out, calls, log = self.download(["6 000 0", "56 200 3000", "0 206 2000"])
self.assertIn("rc=0", out)
self.assertEqual(len(calls), 3)
for c in calls: # every try resumes, bounded in time
self.assertIn("-C -", c)
self.assertIn("--connect-timeout", c)
self.assertIn("--speed-time", c)
self.assertEqual(os.path.getsize(self.fw), 5000) # 3000 kept, 2000 resumed
warns = [line for line in log if " WARNING download: try " in line]
self.assertEqual(len(warns), 2, log)
self.assertIn("did not resolve", warns[0])
self.assertIn("dropped mid-transfer (3000 bytes so far)", warns[1])
self.assertIn("Trying again in 5s (try 2 of 5)", out)
self.assertIn("Trying again in 15s (try 3 of 5)", out)
def test_the_tries_run_out(self):
out, calls, log = self.download(["28 000 0"] * 5)
self.assertIn("rc=1", out)
self.assertIn("timed out or stalled", out)
self.assertEqual(len(calls), 5)
self.assertFalse(os.path.exists(self.fw))
self.assertFalse(os.path.exists(self.fw + ".part"))
# each failed try leaves what the network looked like
self.assertEqual(sum("github.com does not resolve" in line for line in log), 5, log)
def test_a_missing_release_and_a_full_disk_end_the_tries_at_once(self):
out, calls, _ = self.download(["22 404 0"])
self.assertIn("rc=1", out)
self.assertIn("HTTP 404", out)
self.assertEqual(len(calls), 1)
out, calls, _ = self.download(["23 200 100"])
self.assertIn("rc=1", out)
self.assertIn("could not be written", out)
self.assertEqual(len(calls), 1)
def test_a_partial_file_the_server_will_not_resume_starts_over(self):
out, calls, _ = self.download(["56 200 4000", "22 416 0", "0 200 5000"])
self.assertIn("rc=0", out)
self.assertEqual(len(calls), 3)
self.assertEqual(os.path.getsize(self.fw), 5000) # not 9000: the stale part went
def test_a_tls_failure_names_the_clock(self):
out, _, _ = self.download(["60 000 0"] * 5)
self.assertRegex(out, r"TLS handshake failed \(a wrong clock does this: \d{4}-\d\d-\d\d")
def test_every_log_line_is_in_the_tree_format(self):
self.download(["7 000 0", "0 200 10"])
self.run_sh('log NOTICE "operator declined to continue; no changes made"')
lines = self.log_lines()
self.assertGreater(len(lines), 5)
for line in lines:
self.assertRegex(line, LINE_RE)
def test_die_leaves_the_reason_in_the_log(self):
r = self.run_sh('die "archiving slot 1 failed"; echo not-reached')
self.assertEqual(r.returncode, 1)
self.assertNotIn("not-reached", r.stdout)
self.assertTrue(any(" ERR install failed: archiving slot 1 failed" in line
for line in self.log_lines()))
def test_no_writable_log_never_fails_the_install(self):
r = self.run_sh('LOG_OK=""; log INFO "x"; LOG_OK=yes; LOG_FILE=/nonexistent/dir/x.log; '
'log INFO "y"; echo "still-running rc=$?"')
self.assertIn("still-running rc=0", r.stdout)
class InstallerShapeTests(unittest.TestCase):
"""What holds on any host, with no shell to run."""
def test_the_log_lives_in_the_tree_the_export_carries(self):
head = functions()
self.assertIn('LOG_DIR="/data/log/forgefirm/install"', head)
self.assertIn('LOG_FILE="$LOG_DIR/install.log"', head)
def test_the_download_is_the_retrying_one(self):
with open(SCRIPT, encoding="utf-8") as f:
text = f.read()
body = text.partition(MARK)[2]
self.assertIn("download_fw", body)
self.assertNotRegex(body, r"(?m)^\s*curl ") # no bare curl past the functions
if __name__ == "__main__":
unittest.main()
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""wait_hv_off against a scripted charge-pump readback: a chain that """wait_hv_off against a scripted charge-pump readback: a chain that
releases inside the window passes, a chain held past it is the refusal, releases inside the window passes, a chain held past it is the refusal,
and a chain already off costs no wait.""" and a chain already off costs no wait."""
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The dwell-gap verdict of laser.emission-witness over synthetic trails: """The dwell-gap verdict of laser.emission-witness over synthetic trails:
the button latch judged from the first emission through every sample the button latch judged from the first emission through every sample
whose readback word shows the laser latch unlocked, both bits from that whose readback word shows the laser latch unlocked, both bits from that
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
import os import os
import unittest import unittest
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The controller mode a test declares: the runner puts the machine there """The controller mode a test declares: the runner puts the machine there
before the test and the baseline keeps it there afterward. before the test and the baseline keeps it there afterward.
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The operator channel: notices, `ready`, `act`, and the precheck. """The operator channel: notices, `ready`, `act`, and the precheck.
A test asks for the operator's part in a way the page can show before A test asks for the operator's part in a way the page can show before
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The page assembly: the ui/ files inlined in order, the gzipped install """The page assembly: the ui/ files inlined in order, the gzipped install
(what the dev image carries) assembling to the same bytes as a checkout, (what the dev image carries) assembling to the same bytes as a checkout,
the token placeholder carried exactly once, and a missing marker refused the token placeholder carried exactly once, and a missing marker refused
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The two queues: what they select, the order they run in, and where """The two queues: what they select, the order they run in, and where
they stop. they stop.
+64
View File
@@ -0,0 +1,64 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The release artifact carries no machine identity (redact.py).
The artifact is committed to a public repository. These hold the exporter
to that, and to not redacting what the gate recomputes.
"""
import json
import unittest
from forgetest.redact import scrub
class RedactTests(unittest.TestCase):
def test_keyed_identity_is_replaced(self):
out = scrub({"gf_serial": "15232020", "machine_id": "JTY-876",
"hostname": "forgefirm-b00a"})
self.assertNotIn("15232020", json.dumps(out))
self.assertNotIn("JTY-876", json.dumps(out))
self.assertNotIn("forgefirm-b00a", json.dumps(out))
def test_a_known_value_is_replaced_in_free_text_too(self):
# A hostname in a log line is the same leak as one in its own field.
out = scrub({"evidence": {"hostname": "forgefirm-b00a"},
"log": ["set hostname 'forgefirm-b00a' ok"]})
self.assertNotIn("forgefirm-b00a", json.dumps(out))
def test_addresses_and_macs_go(self):
out = scrub(["ping 172.16.1.97", "wlan0 2C:6B:7D:0D:B0:0A up"])
t = json.dumps(out)
self.assertNotIn("172.16.1.97", t)
self.assertNotIn("2C:6B:7D:0D:B0:0A", t)
def test_loopback_is_kept(self):
self.assertIn("127.0.0.1", json.dumps(scrub(["curl http://127.0.0.1:8090/"])))
def test_numbering_is_stable_within_an_export(self):
out = scrub(["a 10.0.0.5", "b 10.0.0.6", "c 10.0.0.5"])
self.assertEqual(out[0].split()[1], out[2].split()[1])
self.assertNotEqual(out[0].split()[1], out[1].split()[1])
def test_integrity_values_survive(self):
# The gate recomputes these; a long hex blob is what they look like.
rec = {"fingerprint": "a" * 64, "manifest_sha": "b" * 64,
"source_sha": "c" * 64, "sha256": "d" * 64,
"catalog_hash": "e" * 64, "identity_sha": "f" * 64}
self.assertEqual(scrub(rec), rec)
def test_a_clock_time_is_not_an_address(self):
out = scrub(["2026-09-18T18:50:11Z baseline: pre: clean"])
self.assertIn("18:50:11", out[0])
def test_result_fields_the_gate_reads_are_untouched(self):
rec = {"result": "PASS", "ts": "2026-09-18T18:50:11Z",
"campaign": "c-1", "fingerprint": "a" * 64}
self.assertEqual(scrub(rec), rec)
if __name__ == "__main__":
unittest.main()
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""What keeps the page answering the operator instead of the timer. """What keeps the page answering the operator instead of the timer.
Two things went wrong on the bench and are pinned here: Two things went wrong on the bench and are pinned here:
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Runner + HTTP API end to end on localhost with a fake catalog and a """Runner + HTTP API end to end on localhost with a fake catalog and a
fake bench tool. No hardware, no forgectrl.""" fake bench tool. No hardware, no forgectrl."""
import json import json
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The setup's checks in the catalog: registered as automatic tests with """The setup's checks in the catalog: registered as automatic tests with
no precheck, the takeover ones marked so, every covers map naming the no precheck, the takeover ones marked so, every covers map naming the
check runner; and the check driver itself against the fake daemon: a check runner; and the check driver itself against the fake daemon: a
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The setup's sheet in the catalog: one live test on the button with a """The setup's sheet in the catalog: one live test on the button with a
piece of wood at hand, its covers map naming the live runner, the piece of wood at hand, its covers map naming the live runner, the
renderer, the sender, the page, and the driver's laser module; and the renderer, the sender, the page, and the driver's laser module; and the
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The setup.* suite on the host: the registration (ids, kinds, the """The setup.* suite on the host: the registration (ids, kinds, the
takeover tests, the operator tests' hands), the record builders, the takeover tests, the operator tests' hands), the record builders, the
machine's name, 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
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""scripts/source-bundle.py - the source that a release publishes. """scripts/source-bundle.py - the source that a release publishes.
The bundle must hold the source of every recipe of the image whose license The bundle must hold the source of every recipe of the image whose license
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""scripts/manifest-from-tree.py - the workstation/CI mirror of the image """scripts/manifest-from-tree.py - the workstation/CI mirror of the image
manifest: recipe pins are read through their pin files, and a layer's manifest: recipe pins are read through their pin files, and a layer's
content hash leaves the pin files out (a component bump is not a platform content hash leaves the pin files out (a component bump is not a platform
+1 -1
View File
@@ -11,4 +11,4 @@ overrides:
meta-freescale-distro: meta-freescale-distro:
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345 commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
meta-openglow: meta-openglow:
commit: dd8f74c2d58e998913e77e28450e8dca5e164a48 commit: 71fad52e8ff5695bce3b9c6bc8530a1468426819
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# forgefirm-image-manifest.bbclass - assemble /etc/forgefirm-manifest.json # forgefirm-image-manifest.bbclass - assemble /etc/forgefirm-manifest.json
# #
# Inherited by the ForgeFIRM images. At rootfs postprocess it collects the # Inherited by the ForgeFIRM images. At rootfs postprocess it collects the
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# forgefirm-manifest.bbclass - source fingerprint of one ForgeFIRM component # forgefirm-manifest.bbclass - source fingerprint of one ForgeFIRM component
# #
# A component recipe that inherits this class records what source it was # A component recipe that inherits this class records what source it was
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
require conf/distro/include/fslc-base.inc require conf/distro/include/fslc-base.inc
DISTRO = "forgefirm" DISTRO = "forgefirm"
+2
View File
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# We have a conf and classes directory, add to BBPATH # We have a conf and classes directory, add to BBPATH
BBPATH .= ":${LAYERDIR}" BBPATH .= ":${LAYERDIR}"
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# NXP distributes the i.MX VPU/EPDC firmware blobs under its firmware EULA # NXP distributes the i.MX VPU/EPDC firmware blobs under its firmware EULA
# (accepted with ACCEPT_FSL_EULA), so an image carrying those blobs has to # (accepted with ACCEPT_FSL_EULA), so an image carrying those blobs has to
# carry the license text with them. # carry the license text with them.
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# ForgeFIRM SSH policy, set in the installed files so the release image # ForgeFIRM SSH policy, set in the installed files so the release image
# carries it as built: # carries it as built:
# PermitRootLogin no root logs in at the serial console only # PermitRootLogin no root logs in at the serial console only
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# ForgeFIRM image manifest: source fingerprint of the hardware library # ForgeFIRM image manifest: source fingerprint of the hardware library
# (forgefirm-manifest.bbclass in this layer). # (forgefirm-manifest.bbclass in this layer).
inherit forgefirm-manifest inherit forgefirm-manifest
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# ForgeFIRM image manifest: source fingerprint of the cloud-mode service # ForgeFIRM image manifest: source fingerprint of the cloud-mode service
# layer (forgefirm-manifest.bbclass in this layer). # layer (forgefirm-manifest.bbclass in this layer).
inherit forgefirm-manifest inherit forgefirm-manifest
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# urllib3's optional pyOpenSSL/cryptography backend is not used here: the cloud # urllib3's optional pyOpenSSL/cryptography backend is not used here: the cloud
# client and the machine glue talk TLS through the standard library's ssl # client and the machine glue talk TLS through the standard library's ssl
# module (nothing imports OpenSSL or cryptography). Dropping the two runtime # module (nothing imports OpenSSL or cryptography). Dropping the two runtime
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
SUMMARY = "Configurable embedded Linux firmware update creator and runner" SUMMARY = "Configurable embedded Linux firmware update creator and runner"
DESCRIPTION = "Applies and creates signed .fw firmware archives. ForgeFIRM \ DESCRIPTION = "Applies and creates signed .fw firmware archives. ForgeFIRM \
uses the factory's own update format: fwup applies ForgeFIRM upgrades and \ uses the factory's own update format: fwup applies ForgeFIRM upgrades and \
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
DESCRIPTION = "Potluck with different functions for C programs (babelouest base library)" DESCRIPTION = "Potluck with different functions for C programs (babelouest base library)"
HOMEPAGE = "https://github.com/babelouest/orcania" HOMEPAGE = "https://github.com/babelouest/orcania"
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# /etc/rsyslog.conf - ForgeFIRM # /etc/rsyslog.conf - ForgeFIRM
# #
# rsyslog is the only log writer on the machine. Every ForgeFIRM process # rsyslog is the only log writer on the machine. Every ForgeFIRM process
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# rsyslog is the ForgeFIRM system logger: the only log writer, one # rsyslog is the ForgeFIRM system logger: the only log writer, one
# directory per logger under /data/log/forgefirm. This appends the # directory per logger under /data/log/forgefirm. This appends the
# ForgeFIRM /etc/rsyslog.conf (inputs, line format, and the include of # ForgeFIRM /etc/rsyslog.conf (inputs, line format, and the include of
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
DESCRIPTION = "Web Framework to build REST APIs, Webservices or any HTTP endpoint in C" DESCRIPTION = "Web Framework to build REST APIs, Webservices or any HTTP endpoint in C"
HOMEPAGE = "https://github.com/babelouest/ulfius" HOMEPAGE = "https://github.com/babelouest/ulfius"
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
DESCRIPTION = "Logging library for C applications (babelouest)" DESCRIPTION = "Logging library for C applications (babelouest)"
HOMEPAGE = "https://github.com/babelouest/yder" HOMEPAGE = "https://github.com/babelouest/yder"
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
SUMMARY = "Boot-slot selection and inventory for Glowforge factory hardware" SUMMARY = "Boot-slot selection and inventory for Glowforge factory hardware"
DESCRIPTION = "Inventories the bootable partitions (ffboot -l) and switches \ DESCRIPTION = "Inventories the bootable partitions (ffboot -l) and switches \
the boot target by rewriting the saved U-Boot environment with read-back \ the boot target by rewriting the saved U-Boot environment with read-back \
@@ -1,6 +1,8 @@
# SPDX-License-Identifier: MIT
# forgectrl pin. Bump deliberately after pushing forgectrl changes; keep # forgectrl pin. Bump deliberately after pushing forgectrl changes; keep
# 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 = "da3eddcc0f4324dc6ec8a0042ab7db0f1b16fd4f" SRCREV = "0dae758cb93e1461034aaa58f4b9fc22edc68367"
PV = "0.1.25" PV = "0.1.27"
@@ -1,8 +1,10 @@
# SPDX-License-Identifier: MIT
DESCRIPTION = "System Control Daemon for ForgeFIRM powered Glowforge" DESCRIPTION = "System Control Daemon for ForgeFIRM powered Glowforge"
HOMEPAGE = "https://github.com/openglow-org/forgectrl" HOMEPAGE = "https://github.com/openglow-org/forgectrl"
LICENSE = "MIT" LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://LICENSE;md5=fe9dfc6caad62544b4b1d0fa55c4b92f" LIC_FILES_CHKSUM = "file://LICENSE;md5=785c97b59b518e7ad943cd9e8b15fc97"
PE = "1" PE = "1"
@@ -1,12 +1,14 @@
# SPDX-License-Identifier: MIT
# forgefirm-app pin (python3-gfhardware repository, forgefirm-app/ tree). # forgefirm-app pin (python3-gfhardware repository, forgefirm-app/ tree).
# Bump deliberately (AUTOREV is not reproducible); keep only SRCREV and PV # Bump deliberately (AUTOREV is not reproducible); keep only SRCREV and PV
# here - the image manifest leaves *-pin.inc out of the layer content hash # here - the image manifest leaves *-pin.inc out of the layer content hash
# because the component entry already identifies the pinned source # because the component entry already identifies the pinned source
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in # (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
# meta-glowforge-bsp pins the same repository; move both together. # meta-glowforge-bsp pins the same repository; move both together.
SRCREV = "5ca279a1f6008ddf6e19a17c6d6375972d27ed7c" SRCREV = "6cc4f45d311ab451c7d72dee009103e55a749675"
# Bump PV with every SRCREV move: the hash-derived package version is not # Bump PV with every SRCREV move: the hash-derived package version is not
# monotonic on its own and buildhistory QA fails the build when it sorts # monotonic on its own and buildhistory QA fails the build when it sorts
# backwards. # backwards.
PV = "0.1.29+git" PV = "0.1.30+git"
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# Shared fetch for the ForgeFIRM web-service apps (gfhome, python3-ffmachine, # Shared fetch for the ForgeFIRM web-service apps (gfhome, python3-ffmachine,
# gfcloud). Their sources live in the forgefirm-app/ directory of the # gfcloud). Their sources live in the forgefirm-app/ directory of the
# python3-gfhardware repository. # python3-gfhardware repository.
@@ -7,7 +9,7 @@ HOMEPAGE = "https://github.com/openglow-org/python3-gfhardware"
# repository's LGPL component (the Bayer decoder compiled into # repository's LGPL component (the Bayer decoder compiled into
# gfhardware._cam) is not part of them; python3-gfhardware declares it. # gfhardware._cam) is not part of them; python3-gfhardware declares it.
LICENSE = "MIT" LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://LICENSE;md5=7029670f29302d9352647c6c89614bc2" LIC_FILES_CHKSUM = "file://LICENSE;md5=314d21c23b626bdadbb4801c47b16266"
SRC_URI = "git://github.com/openglow-org/python3-gfhardware.git;protocol=https;branch=master" SRC_URI = "git://github.com/openglow-org/python3-gfhardware.git;protocol=https;branch=master"
# SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass). # SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass).
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
DESCRIPTION = "Full Glowforge web-service controller daemon for ForgeFIRM (cloud mode)" DESCRIPTION = "Full Glowforge web-service controller daemon for ForgeFIRM (cloud mode)"
require forgefirm-app.inc require forgefirm-app.inc
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
DESCRIPTION = "One-shot Glowforge web-service homing for ForgeFIRM" DESCRIPTION = "One-shot Glowforge web-service homing for ForgeFIRM"
require forgefirm-app.inc require forgefirm-app.inc
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
DESCRIPTION = "Shared ForgeFIRM web-service hardware-machine glue (gfhome + gfcloud)" DESCRIPTION = "Shared ForgeFIRM web-service hardware-machine glue (gfhome + gfcloud)"
require forgefirm-app.inc require forgefirm-app.inc
@@ -1,4 +1,8 @@
#!/bin/sh #!/bin/sh
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
### BEGIN INIT INFO ### BEGIN INIT INFO
# Provides: forgefirm-banner # Provides: forgefirm-banner
# Required-Start: $network # Required-Start: $network
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
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): one control panel URL per global address of wlan0 and eth0. \ (/etc/issue): one control panel URL per global address of wlan0 and eth0. \
@@ -1,4 +1,8 @@
#!/bin/sh #!/bin/sh
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
### BEGIN INIT INFO ### BEGIN INIT INFO
# Provides: forgefirm-hostname # Provides: forgefirm-hostname
# Required-Start: $local_fs # Required-Start: $local_fs
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
SUMMARY = "ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address" SUMMARY = "ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address"
DESCRIPTION = "Names the machine forgefirm-<xxxx>, where xxxx is the last \ 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). \ four hex digits of the wlan0 MAC address (eth0 on a machine with no WiFi). \
@@ -0,0 +1,27 @@
Glowforge factory keyring
=========================
Eleven Ed25519 *public* keys, 32 raw bytes each, recovered from Glowforge
factory firmware. They are the factory trust anchors: ForgeFIRM ships them so
the machine can verify the signature on a factory `.fw` archive during a
factory restore. They verify archives and nothing else.
Public key material only. No private or secret key belongs in this directory,
and nothing here can be used to sign anything. ForgeFIRM's own release-signing
public key is `../forgefirm-release.pub`; its private half is held offline by
the operator and is not in this repository, in CI, or in any cloud-synced
store.
Provenance and rights
---------------------
These keys are Glowforge, Inc.'s. They are bare cryptographic public keys --
algorithm output, carrying no authorship -- so no copyright subsists in them
and none is claimed. 514 LLC d/b/a OpenGlow asserts no rights over them and
grants none; the recipe's `LICENSE = "MIT"` covers the recipe, not this key
material. Glowforge has not published these keys itself, so there is no
upstream license to point to and none is implied.
For how the trust anchors are used, see "The update manager" and "Decisions"
on the documentation site:
https://docs.forgefirm.org/technical/forgefirm/install-and-update/
@@ -1,8 +1,14 @@
# SPDX-License-Identifier: MIT
SUMMARY = "Firmware verification public keys" SUMMARY = "Firmware verification public keys"
DESCRIPTION = "Trust anchors for firmware archive verification: the \ DESCRIPTION = "Trust anchors for firmware archive verification: the \
ForgeFIRM release-signing public key (verifies release downloads and \ ForgeFIRM release-signing public key (verifies release downloads and \
uploads) and the Glowforge factory keyring (verifies factory .fw \ uploads) and the Glowforge factory keyring (verifies factory .fw \
archives for cloud restore). Public keys only." archives for cloud restore). Public keys only."
# LICENSE covers this recipe, not the key material. The Glowforge factory
# keyring in files/gf/ is Glowforge, Inc.'s: bare Ed25519 public keys, in which
# no copyright subsists and over which OpenGlow claims nothing and grants
# nothing. files/gf/README records that and the public-key-only boundary.
LICENSE = "MIT" LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302" LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
@@ -1,4 +1,8 @@
#!/bin/sh #!/bin/sh
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
### BEGIN INIT INFO ### BEGIN INIT INFO
# Provides: forgefirm-logging # Provides: forgefirm-logging
# Required-Start: $local_fs # Required-Start: $local_fs

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