BRINGUP describes the present: the 54-test catalog and its seven-test always core, the tier counts, the shipped low-temperature gates, the density floor ($35 = 10), the two local core commits, the ffboot env write, the aa-offset route, the current bench image, and the bench measurements the audit asks for (pooled into the next session). The workstation shell notes and every em dash are gone. forgetest: the takeover waits for the cloud client too (found by its command line); the unauthenticated /boot probe names the endpoint's parameter; the UI prose is American English. Recipes: forgetest fetches its package directory and init script only and drops __pycache__ at unpack; the dev image no longer re-adds forgectrl; the release image's remove list drops the gfui-client the BSP no longer has; the platform identity strips the kernel's local-version hash from the modules directory name, so a re-patched kernel keeps its fingerprints. grblhal restart is stop then start. release.sh --dev packs the dev image. fixture.sh refuses a readable env file. Bench tools: the live-fire drills measure the lid-IR baseline before every run and point at the fire-watch thresholds the engine reads; one thermistor conversion (gfbench.degc) serves every drill; the six dated measurement records leave the tool directory; feeder.c names the two sysfs writes its caller makes. Host tests: forgetest 258 pass; the coverage lint reports no uncovered path across 54 tests. Acceptance: forgectrl.auth covers the /boot probe; update.* cover ffboot and the manifest identity; the runbook and bench-tool changes have no catalog consequence.
forgefixture: the bench actuator
The acceptance campaign asks a person for about eighty small things,
and most of them are the same three: open the lid, pull the interlock,
press the button. This is the box that does those on request, so the
tool can run an operator test with nobody in the room. An ESP32-S3
DevKitC-1 on the bench network drives three relays wired into the
machine's own connectors; the acceptance tool (forgetest, on the
machine) asks it over HTTP and then watches the machine for the result,
the way it watches an operator's hand today.
Nothing here touches the laser's safety chain. Two of the contacts are normally closed and sit in series with loops the chain already reads; the third is normally open across the button input and is only ever pulsed. With the fixture unpowered, unplugged, rebooting or crashed, the machine is a stock machine.
Hardware
| Part | What |
|---|---|
| ESP32-S3-DevKitC-1 | any flash size; powered from its USB port by a USB wall adapter (see grounds) |
| 3 × 1-channel 3.3 V optocoupler relay modules, high-level trigger | VCC, GND, IN; coil ~100 mA each |
| a 2-pin header and jumper | the button channel's enable |
| the interposer harness at the machine | bench-local; described in the project's hardware facts bank, not here |
Pins on the DevKit (all plain GPIOs, no strapping, USB, flash or PSRAM role):
| Signal | GPIO | Relay contact | Where in the machine |
|---|---|---|---|
| lid | 4 | NC, in series with the lid-switch loop | energized = the loop opens = lid open |
| interlock | 5 | NC, in series with the interlock loop | energized = the loop opens = interlock pulled |
| button | 6 | NO, across the button input | energized = pressed; pulsed only, 20 to 500 ms |
| button enable | 7 | input, internal pull-up | a jumper to GND enables the button channel; no jumper, no presses |
Relay coils from the machine's 3.3 V (the modules' VCC and GND), the three IN pins from the GPIOs above, the DevKit's GND to the modules' GND. The modules' opto inputs share GND with their coils, so the ESP32 and the machine share a ground: power the DevKit from a wall adapter, not from a PC, unless you want that PC's ground on the machine.
Build and flash
The only input is fixture.env: the wifi network, the API key, the
hostname. Everything else is pinned (ESP-IDF v5.5, the mDNS component in
dependencies.lock).
./fixture.sh env # fixture.env from the example, with a fresh key; fill in the wifi
./fixture.sh build # idf.py if installed, else the espressif/idf container (docker or podman)
./fixture.sh flash COM5 # or /dev/ttyUSB0; esptool from pip talks to the board directly
./fixture.sh monitor COM5 # the log
Either USB port of the DevKit flashes; the one marked UART shows the log
too. fixture.env is git-ignored and never leaves the bench. A build in
the container works anywhere docker or podman runs (Git Bash on Windows
included); pip install esptool pyserial is the whole host-side
requirement for flashing and watching.
What it does on the network
It joins the wifi as forgefixture (the HOSTNAME in fixture.env),
sends that name in its DHCP request, and announces forgefixture.local
over mDNS with a _forgefixture._tcp service. It reconnects forever and
never sleeps the radio. The API is HTTP on port 80, JSON, LAN only, no
OTA (the flash happens over USB and nowhere else).
Every request carries the key in X-Fixture-Key; without it, or with
a wrong one, every path answers 401 and the attempt is logged with the
caller's address.
| Request | Does |
|---|---|
GET / or /state |
identity, firmware version, uptime, the three channels' states, whether the button is enabled, the wifi link |
POST /lid {"state":"open"} |
energizes the lid channel (the loop opens); "close" releases it |
POST /interlock {"state":"open"} |
the same for the interlock loop |
POST /button {"ms":200} |
one press, ms clamped into 20 to 500 (200 when absent); 409 while the enable jumper is out or a press is still in progress |
POST /release |
every channel released |
An action answers with the state as GET / shows it. The fixture does
not read the machine's switches; the tool verifies every action through
the machine's own readings, which is the point.
curl -s -H "X-Fixture-Key: $KEY" http://forgefixture.local/
curl -s -H "X-Fixture-Key: $KEY" -d '{"state":"open"}' http://forgefixture.local/lid
The tool's side
forgetest looks for /data/forgetest/fixture.json on the machine:
{"hostname": "forgefixture", "key": "<the API_KEY>", "ip": null,
"channels": ["lid", "interlock", "button"], "arm_press": false}
ip overrides the mDNS lookup (the tool resolves <hostname>.local
itself; the image has no mDNS resolver). channels names what is
wired. arm_press stays false unless the fixture may press the button
to arm the laser for a live test; by default that press is a person's.
The file is mode 0600 and bench-local. With the fixture up, an
operator test whose actions it covers runs in the unattended queue;
live tests keep their kind and their acknowledgment, and every action
records who performed it. Presses are spaced by the tool: the next one
waits for the last pulse to end plus a 300 ms release, so the machine
sees two presses and not one long one.
What keeps it safe
- Every line is driven low first thing at boot, before the radio or the server exist, and after any reset.
- The button is a pulse whose end is armed before the line rises; a hung task trips the task watchdog, which panics and reboots to all-low.
- The button channel needs the jumper; a LAN key leak cannot press the button on a fixture whose jumper is out.
- NC contacts in the loops: a fixture fault can only add an open, never mask a real lid open or a pulled interlock.
- No OTA, no configuration over the network, nothing persisted beyond the wifi driver's own calibration.
Layout
main/ the firmware: main.c, wifi.c, api.c, relays.c, policy.c (the pure decisions)
test/ the host test of policy.c (./fixture.sh test)
fixture.env.example, sdkconfig.defaults, dependencies.lock