Add laser-safety and regulatory documentation; scrub bench identity

- LIGHTBURN.md: mandatory "Before you cut" safety section; the
  walkthrough now reflects the firing machine (dry runs need the
  layer output off or M5; live first-cut instructions); the homing
  entry documents homing_mode and the gfcloud method; the machine
  address is a placeholder.
- README.md: condensed safety section linking the full text and the
  regulatory notes.
- INSTALL.md: "Regulatory and legal" section ahead of the install
  steps; routine updates route through the panel updater rather than
  the installer.
- BRINGUP.md: the release signing key is described as held offline
  (no on-disk path); bench address and credential notes removed;
  Next-work item 7 corrected (the installer embeds the production
  release key); status entry for audit remediation Phases 0-1; the
  GATE A kernel drills join the pending image-flash checklist.
- bench scripts: the target host comes from GF_HOST (or argv) instead
  of a hardcoded address.
- laser_stream_test.py: per-session controller runs with a hermetic
  cooling-verdict publisher; new assertions that every stream
  terminates with FIRE clear (including M3 held to stream end) and
  that no FIRE bit rides a zero-step gap; a cycle-churn session
  exercises the stop/start seams.

Audit findings D-1, D-2, D-3, D-5, D-10, D-12, B-10, and the harness
half of D-4/G-1.
This commit is contained in:
ScottW514
2026-08-14 15:38:24 -04:00
parent 5dddea12ee
commit cc927aca5f
13 changed files with 361 additions and 63 deletions
+28 -3
View File
@@ -30,6 +30,28 @@ software could seriously maim or kill you or others, and voids your
warranty. It is not affiliated with or endorsed by Glowforge. Use it at warranty. It is not affiliated with or endorsed by Glowforge. Use it at
your own risk.** your own risk.**
## Regulatory and legal
Installing ForgeFIRM replaces the firmware of a certified laser product.
This is disclosure, not legal advice — but understand the categories
before you install:
- **Laser product certification (US).** The factory machine is
certified under FDA/CDRH 21 CFR 1040.10 and 1040.11. Modifying it
makes **you** the manufacturer of a modified laser product for
regulatory purposes; the original accession no longer describes the
article you operate.
- **CE/UKCA (EU/UK).** The manufacturer's declaration of conformity no
longer covers the modified machine.
- **Safety listing.** Any UL/ETL or equivalent listing applies to the
product as shipped, not as modified.
- **Insurance.** Property and liability policies commonly exclude fire
loss involving modified equipment. Check yours before running jobs.
The hardware safety interlocks (lid switches, interlock, power-fault
chain) remain active under ForgeFIRM — but the regulatory status of the
machine is yours to own once you flash it.
## Install ## Install
Log in at the factory console and run: Log in at the factory console and run:
@@ -75,9 +97,12 @@ Switch targets are probed first — `ffboot` refuses to select a slot
that does not look bootable (`-f` overrides). Reverting to factory that does not look bootable (`-f` overrides). Reverting to factory
firmware and back requires no reinstall. firmware and back requires no reinstall.
To reinstall or update ForgeFIRM before the built-in updater ships: **Routine updates do not use the installer.** Update from the web
switch to factory firmware and run the installer again — it skips control panel's System page, which downloads (or accepts an upload of)
archives it already has and simply rewrites the ForgeFIRM slot. a signed `forgefirm.fw` release, verifies it, and applies it to the
inactive slot. Rerunning the installer is only for recovering a broken
ForgeFIRM install: switch to factory firmware and run it again — it
skips archives it already has and simply rewrites the ForgeFIRM slot.
## Upgrading from a legacy dual-partition install ## Upgrading from a legacy dual-partition install
+11
View File
@@ -55,6 +55,17 @@ The control board is common to Glowforge Basic, Plus, and Pro. The 5 MP
* Cloud mode: stream jobs into the motion ring during the run, lifting the * Cloud mode: stream jobs into the motion ring during the run, lifting the
job-length cap that buffering the whole job imposes. job-length cap that buffering the whole job imposes.
## Safety
**This machine contains a Class 4 CO₂ laser: it burns, blinds, and starts
fires.** Never defeat the lid switches or interlock, always vent the exhaust
outdoors, and never cut PVC or other chlorinated plastics. Never leave a
running job unattended — keep a fire extinguisher within reach. Read
[Before you cut — safety](docs/LIGHTBURN.md#before-you-cut--safety-read-this-first)
before your first job, and the
[Regulatory and legal](INSTALL.md#regulatory-and-legal) section before
installing.
**A very important warning: this is experimental software. Use of this software **A very important warning: this is experimental software. Use of this software
could seriously maim or kill you or others, and voids your warranty. It is not could seriously maim or kill you or others, and voids your warranty. It is not
affiliated with or endorsed by Glowforge. Use it at your own risk.** affiliated with or endorsed by Glowforge. Use it at your own risk.**
+60 -13
View File
@@ -1,7 +1,42 @@
# ForgeFIRM bring-up status & cold-start runbook # ForgeFIRM bring-up status & cold-start runbook
Last updated: **2026-08-13** — **shared machine services complete and Last updated: **2026-08-14** — **audit remediation Phases 0 + 1 landed**
closed out.** forgectrl is the one machine-services daemon behind both (from an independent whole-tree audit dated 2026-08-13; the remediation
is sequenced behind two gates — GATE A, uncommanded energy, before any
further live-fire; GATE B, control surface + release, before any
published release). Phase 0: user-facing laser-safety and
regulatory text is in place (LIGHTBURN.md "Before you cut", README,
INSTALL.md "Regulatory and legal" + updater-first update path, a
persistent panel safety banner), the walkthrough no longer claims the
laser cannot fire, bench-machine identity and the signing-key location
are scrubbed from tracked files (bench scripts take `GF_HOST`), and
every repo has a commit-msg hook enforcing commit attribution. Phase 1
(GATE A, uncommanded energy) is **code-complete and host-verified**:
the stream engine records the cycle-end laser-off so idle-gap pads ship
dark and every stream terminates FIRE-clear (G-1), latch writes are
serialized against the shipper's relight (G-5) with the arm-state and
verdict caches made properly atomic (G-19/G-20/G-21), the cooling
report path moved to a bounded-connect reporter thread off the protocol
thread (A-3/G-7), and gf.lock is priority-inheriting with PIC-SPI and
rail-settle work moved outside it (G-8). Kernel fixes K-1 (saturating
decel ramp + EPIT divisor clamp), K-2 (resume-waypoint latch guard) and
K-3 (latch writes under status_lock; FIRE drive never restored mid-run
or mid-ramp) are code-complete and **ride the pending full-image
flash** with the platform-hygiene batch. `scripts/bench/
laser_stream_test.py` now asserts the termination and zero-step-gap
rules across M4, M3-to-stream-end, and cycle-churn sessions (with a
hermetic cooling-verdict publisher): all PASS on the fixed controller
(the M4 session reproduces the recorded baseline byte-for-byte:
28 354 fire ticks, X peak 533 net 0, 534 dark return steps), and a
build with only the G-1 hunks reverted FAILS on the M3 termination
rule — the harness catches the defect class. **GATE A stays open — no
live-fire — until the flashed image passes the bench drills**
(controlled stop decelerates at the default cloud tick, resume with the
latch locked stays laser-less, mid-ramp latch writes do not re-arm
FIRE) and the harness is wired into CI.
Previously — **shared machine services complete and
closed out (2026-08-13).** forgectrl is the one machine-services daemon behind both
controller modes: the cooling engine (single owner of the thermal controller modes: the cooling engine (single owner of the thermal
hardware), controller-mode supervision, the pulse-device broker, and hardware), controller-mode supervision, the pulse-device broker, and
the motion-liveness gate. Both controllers are cooling-engine clients the motion-liveness gate. Both controllers are cooling-engine clients
@@ -121,9 +156,8 @@ item 8.
## The bench ## The bench
- **Board**: SSH `root@172.16.1.97` (fixed DHCP lease since 2026-08-02; - **Board**: SSH `root@<machine-ip>` (dev images permit passwordless
was .130), empty password root login). The bench
(`ssh -o PreferredAuthentications=none` logs straight in). The bench
machine is a **Basic/Plus** (the control board is common to machine is a **Basic/Plus** (the control board is common to
Basic/Plus/Pro). Dev image Basic/Plus/Pro). Dev image
(`forgefirm-image-dev`) on SD; BusyBox userland + python3 + gdb/strace. (`forgefirm-image-dev`) on SD; BusyBox userland + python3 + gdb/strace.
@@ -167,11 +201,9 @@ item 8.
proven both ways (modern-packed signed archives apply with 0.14.2; proven both ways (modern-packed signed archives apply with 0.14.2;
modern fwup verifies+applies the factory .fw — signer key modern fwup verifies+applies the factory .fw — signer key
2017-05-001.pub). The production signing-key ceremony 2017-05-001.pub). The production signing-key ceremony
(UPDATE-SYSTEM.md gate 8) was executed 2026-08-08. (UPDATE-SYSTEM.md gate 8) was executed 2026-08-08. **The production
**The production release key lives at release key is held offline by the operator** — the installer embeds
`~/forgefirm-release-key/fwup-key.priv`** (0600; `fwup-key.pub` + its public key, so releases sign with that key only.
`fwup-key-raw.pub` beside it; offline backups held by the operator) —
the installer embeds its pubkey, so releases sign with THIS key only.
Pack releases with `scripts/mkfw.sh`; the full pipeline is Pack releases with `scripts/mkfw.sh`; the full pipeline is
`scripts/release.sh`, invoked on this host as: `scripts/release.sh`, invoked on this host as:
`FWUP=~/fwup-lab/bin/fwup-v1.16.0 FWUP_COMPAT=~/fwup-lab/bin/fwup-0.14.2 `FWUP=~/fwup-lab/bin/fwup-v1.16.0 FWUP_COMPAT=~/fwup-lab/bin/fwup-0.14.2
@@ -235,7 +267,7 @@ hardware I/O — host testing).
since the last run, `$RST=$` once (stored settings win). Each motion since the last run, `$RST=$` once (stored settings win). Each motion
run logs a producer-stats line to stderr (callbacks, µs/call, run logs a producer-stats line to stderr (callbacks, µs/call,
max-behind, clamped) — clamped should stay 0. max-behind, clamped) — clamped should stay 0.
4. Connect LightBurn/UGS to `172.16.1.97:23`, or jog raw: 4. Connect LightBurn/UGS to `<machine-ip>:23`, or jog raw:
`$J=G91X40F1200`. `^X` mid-motion aborts via kernel `cnc/stop` `$J=G91X40F1200`. `^X` mid-motion aborts via kernel `cnc/stop`
(controlled decel) and raises an alarm; TCP disconnects never kill the (controlled decel) and raises an alarm; TCP disconnects never kill the
process (the deadman fd stays held). process (the deadman fd stays held).
@@ -1412,8 +1444,8 @@ accordingly ("Automatic — AP country, else World").
needs `-f` from factory). The **bench board now runs ForgeFIRM needs `-f` from factory). The **bench board now runs ForgeFIRM
v0.1.0 from eMMC slot 2** (factory 2024 in slot 1, archives in v0.1.0 from eMMC slot 2** (factory 2024 in slot 1, archives in
/data/forgefirm/archive, dev image still on SD via `ffboot -s`). /data/forgefirm/archive, dev image still on SD via `ffboot -s`).
Remaining Phase 2 nicety: the installer's embedded pubkey is the The installer's embedded pubkey is the **production release key**
DEV key until the production ceremony. (ceremony executed 2026-08-08; `release.sh` enforces the match).
**Post-test: the bench rests on the SD dev image again** (`ffboot **Post-test: the bench rests on the SD dev image again** (`ffboot
-s`; slot 1 = factory 2024, slot 2 = ForgeFIRM v0.1.0, archives in -s`; slot 1 = factory 2024, slot 2 = ForgeFIRM v0.1.0, archives in
/data/forgefirm/archive). Platform fact pinned by experiment while /data/forgefirm/archive). Platform fact pinned by experiment while
@@ -1513,3 +1545,18 @@ accordingly ("Automatic — AP country, else World").
- The uniprocessor locking assumption and the panic/dead-man safe - The uniprocessor locking assumption and the panic/dead-man safe
states are documented in `kernel-module-glowforge/UAPI.md`; no states are documented in `kernel-module-glowforge/UAPI.md`; no
bench item. bench item.
- **GATE A kernel fixes added to the same flash (2026-08-14):**
the controlled-deceleration ramp now floors at the minimum step
frequency with a saturating decrement, and `epit_hz_to_divisor()`
can no longer return the degenerate divisor 0 (a 0 Hz request maps
to the slowest achievable tick); the resume waypoint re-enables
the FIRE drive only when the laser latch is unlocked; and
`laser_latch` writes run under `status_lock`, restoring the FIRE
output drive only when no run or ramp is in flight.
**Bench (GATE A stays open — no live-fire — until these pass):**
a controlled-stop drill at the default cloud tick (10 kHz, ramp
125000) shows a decelerating tail rather than a max-rate burst;
feed-hold, jog-cancel and `^X` each land in a controlled stop with
position preserved; a resume waypoint with the latch locked stays
laser-less; `laser_latch=0` written mid-ramp does not re-arm FIRE
(probe the PSU-connector LASER_ON line as in `fire_test.py`).
+66 -11
View File
@@ -1,5 +1,28 @@
# LightBurn setup & operation (ForgeFIRM) # LightBurn setup & operation (ForgeFIRM)
## Before you cut — safety (read this first)
ForgeFIRM replaces the factory software, **not** the factory safety rules. The
machine contains a Class 4 CO₂ laser emitting invisible 10.6 µm infrared at
roughly 45 W. **Jobs sent from LightBurn fire the laser.**
- **Eyes.** The enclosure and lid glass are the eye-safety barrier. Never
defeat the lid switches or the Pro's remote-interlock plug, and never
operate with any cover removed. Direct or reflected 10.6 µm radiation
blinds and burns.
- **Fumes.** Vent the exhaust to the outdoors, always. Laser-cutting fumes
are toxic and flammable.
- **Materials.** Never cut PVC, vinyl, or any chlorinated plastic — they
release chlorine gas that corrodes the machine and injures your lungs.
Know what your material is before you cut it.
- **Fire.** Never leave a running job unattended. Small flare-ups are normal
with some materials; sustained flame is not. Keep a fire extinguisher
(CO₂ preferred) within reach and know how you will open the lid and
smother a fire before you start.
- **Stop means stop.** The big button, LightBurn's Stop, and opening the
lid each halt the job. If anything looks wrong, stop first and diagnose
second.
The laser fires only inside an operator-armed window: The laser fires only inside an operator-armed window:
- **Starting a job that fires: press the button.** At the first - **Starting a job that fires: press the button.** At the first
@@ -26,21 +49,30 @@ The laser fires only inside an operator-armed window:
## One-time device setup ## One-time device setup
Prerequisite: the controller is running on the board (see BRINGUP.md; Prerequisite: the controller is running on the board (see BRINGUP.md;
`grblHAL_glowforge` on TCP port 23 at 172.16.1.97). `grblHAL_glowforge` on TCP port 23 at your machine's IP address, shown
below as `<machine-ip>`).
1. **Laser window → Devices → Create Manually** (skip auto-find; it 1. **Laser window → Devices → Create Manually** (skip auto-find; it
scans serial ports). scans serial ports).
2. Device type: **grblHAL** if your LightBurn version lists it, 2. Device type: **grblHAL** if your LightBurn version lists it,
otherwise **GRBL** — both speak the right protocol. otherwise **GRBL** — both speak the right protocol.
3. Connection: **Ethernet/TCP**. IP address: **172.16.1.97** (LightBurn 3. Connection: **Ethernet/TCP**. IP address: **`<machine-ip>`**
uses TCP port 23 for GRBL devices, which is exactly where the (LightBurn uses TCP port 23 for GRBL devices, which is exactly where
controller listens). the controller listens).
4. Name: e.g. `Glowforge ForgeFIRM`. Work area: **X 495 mm, Y 279 mm**. 4. Name: e.g. `Glowforge ForgeFIRM`. Work area: **X 495 mm, Y 279 mm**.
5. **Origin**: pick the corner where the head sits after parking at 5. **Origin**: pick the corner where the head sits after parking at
home — **rear-left as you face the machine** (the top-left dot in home — **rear-left as you face the machine** (the top-left dot in
the selector). This is what keeps jobs un-mirrored: machine +X runs the selector). This is what keeps jobs un-mirrored: machine +X runs
right, +Y runs from the rear rail toward you. right, +Y runs from the rear rail toward you.
6. **Auto-home on startup: NO.** Homing is not wired yet; `$H` errors. 6. **Auto-home on startup: NO** — LightBurn would issue `$H` at every
connect, and the working homing method runs a multi-minute session.
`$H` itself works and is selected by the `homing_mode` setting in the
machine's web control panel: `gfcloud` (Glowforge web-service vision
homing — the working method; X/Y home to the factory corner, Z to the
hall sensor; requires a signed-in Glowforge session), `switches`
(physical limit switches, once installed), or `none` (`$H` is
rejected). Run `$H` deliberately from the Console tab when you want a
true machine origin.
7. Finish. If a stale device profile already exists, edit its IP 7. Finish. If a stale device profile already exists, edit its IP
instead of creating a new one. instead of creating a new one.
8. Device Settings (wrench icon): **S-Value Max = 1000** (matches $30). 8. Device Settings (wrench icon): **S-Value Max = 1000** (matches $30).
@@ -54,12 +86,12 @@ In the Laser window set **Start From: Current Position**, and set the
**Job Origin** dot to the same corner as the machine origin (top-left **Job Origin** dot to the same corner as the machine origin (top-left
dot). The job then runs into the bed from wherever the head currently dot). The job then runs into the bed from wherever the head currently
sits — absolute machine zero never matters, which is the forgiving mode sits — absolute machine zero never matters, which is the forgiving mode
while the machine has no homing switches. when you have not homed.
(`Absolute Coords` also works, but only if the head was parked at the (`Absolute Coords` also works after a successful `$H`, or if the head
home corner when the controller started; after any Stop/alarm the was parked at the home corner when the controller started. After any
absolute frame is stale until the controller is restarted with the head Stop/alarm the absolute frame is stale until you re-home with `$H` or
re-parked.) restart the controller with the head re-parked.)
## Operating basics ## Operating basics
@@ -90,15 +122,38 @@ full, exhaust and intake fans to factory run speeds, then a ~15 s
cooldown after the layer before returning to idle. Leave it ON for cooldown after the layer before returning to idle. Leave it ON for
anything that will eventually involve the beam; expect real fan noise. anything that will eventually involve the beam; expect real fan noise.
## Dry runs (motion only, no fire)
**Setting a low power value does NOT make a job inert — any laser layer
prompts for the arm button and then fires.** The motion-only modes are:
- **Frame** and jogging — never fire.
- A job whose layers emit no laser-on command: turn the layer's
**Output** off in the cut settings, or send gcode that stays in `M5`.
- A job run with the laser latch left locked (never press the arm
button): the job pauses at the white-button prompt and aborts after
`laser_button_timeout_s` — useful only to confirm the prompt itself.
If the white arm prompt appears and you did not intend to fire, press
**Stop** in LightBurn.
## A good first job ## A good first job
First a dry run, then a light cut on scrap:
1. Draw a rectangle (~100 × 60 mm) with a circle inside. 1. Draw a rectangle (~100 × 60 mm) with a circle inside.
2. Double-click the layer color bar (bottom): mode **Line**, speed 2. Double-click the layer color bar (bottom): mode **Line**, speed
**50 mm/s** (= 3000 mm/min; check Edit → Settings for your speed **50 mm/s** (= 3000 mm/min; check Edit → Settings for your speed
units), power anything (ignored — nothing fires). units). For the dry run turn the layer's **Output** off.
3. Park the head where the job's rear-left corner should be (or leave 3. Park the head where the job's rear-left corner should be (or leave
it at home), **Frame**, watch the perimeter trace, then **Start**. it at home), **Frame**, watch the perimeter trace, then **Start**.
Expected behavior: darting travels at up to 200 mm/s, smooth 50 mm/s Expected behavior: darting travels at up to 200 mm/s, smooth 50 mm/s
tracing of the shapes, silky and near-silent motion (factory currents + tracing of the shapes, silky and near-silent motion (factory currents +
decay mode), and the head finishing per the job's return setting. decay mode), and the head finishing per the job's return setting.
4. For the live pass: put scrap material on the bed (never an empty
honeycomb over the fan grill), re-enable the layer's **Output**, set
power to **30 %** or more (below ~30 % the tube barely marks), turn
the layer's **Air Assist** on, close the lid, **Frame**, **Start**,
and press the white button when it lights. Watch the whole job.
+5 -2
View File
@@ -2,7 +2,7 @@
"""Milestone-2 motion-quality bench: factory-true rates/accels over TCP. """Milestone-2 motion-quality bench: factory-true rates/accels over TCP.
Runs a bounded, return-to-start jog sequence against grblHAL on the board Runs a bounded, return-to-start jog sequence against grblHAL on the board
(default 172.16.1.97:23) and reports peak feed reached, state transitions, (argv[1] or GF_HOST, port 23) and reports peak feed reached, state transitions,
and final position drift. Every move is relative and round-trip, so the and final position drift. Every move is relative and round-trip, so the
head ends where it started; the laser stays latched (motion-only backend). head ends where it started; the laser stays latched (motion-only backend).
@@ -10,11 +10,14 @@ Sequence: sanity jogs (X, Y, 40 mm out/back at 2400 mm/min), max-rate X
out/back (60 mm at F12000 - peaks ~200 mm/s mid-move), diagonal out/back, out/back (60 mm at F12000 - peaks ~200 mm/s mid-move), diagonal out/back,
then a G1 move with a feed-hold/resume in the middle. then a G1 move with a feed-hold/resume in the middle.
""" """
import os
import socket import socket
import sys import sys
import time import time
HOST = sys.argv[1] if len(sys.argv) > 1 else '172.16.1.97' HOST = sys.argv[1] if len(sys.argv) > 1 else os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('pass the machine IP as argv[1] or set GF_HOST')
PORT = 23 PORT = 23
+4 -1
View File
@@ -1,6 +1,9 @@
import socket, subprocess, time import socket, subprocess, time
import os
HOST = '172.16.1.97' HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
def board(cmd): def board(cmd):
r = subprocess.run(['wsl', '-d', 'forge-yocto', '--', 'ssh', r = subprocess.run(['wsl', '-d', 'forge-yocto', '--', 'ssh',
+4 -1
View File
@@ -15,11 +15,14 @@ driver only writes the heater on M8/M9 transitions, so an idle driver
leaves this alone. leaves this alone.
""" """
import math import math
import os
import subprocess import subprocess
import sys import sys
import time import time
HOST = '172.16.1.97' HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
# Factory B-equation conversion (see kernel-module-glowforge/UAPI.md). # Factory B-equation conversion (see kernel-module-glowforge/UAPI.md).
F = 1024.0 * 1.3 F = 1024.0 * 1.3
+3 -1
View File
@@ -35,7 +35,9 @@ import subprocess
import sys import sys
import time import time
HOST = '172.16.1.97' HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
RESULTS = os.path.join(HERE, os.environ.get('FM_RESULTS', 'flow_matrix_results.json')) RESULTS = os.path.join(HERE, os.environ.get('FM_RESULTS', 'flow_matrix_results.json'))
+4 -1
View File
@@ -37,11 +37,14 @@ Runs both flow and no-flow cases from a comparable loop state and
prints the differential separation. Aborts if downstream passes 45 C. prints the differential separation. Aborts if downstream passes 45 C.
""" """
import math import math
import os
import subprocess import subprocess
import sys import sys
import time import time
HOST = '172.16.1.97' HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
F = 1024.0 * 1.3 F = 1024.0 * 1.3
RD, BETA = 10000.0, 3380.0 RD, BETA = 10000.0, 3380.0
RINF = 10000.0 * math.exp(-3380.0 / 298.15) RINF = 10000.0 * math.exp(-3380.0 / 298.15)
+4 -1
View File
@@ -9,13 +9,16 @@ cut-profile fans, logging bulk coolant temperature and every verdict.
Usage: flow_sustained.py [minutes] (default 30) Usage: flow_sustained.py [minutes] (default 30)
""" """
import math import math
import os
import re import re
import socket import socket
import subprocess import subprocess
import sys import sys
import time import time
HOST = '172.16.1.97' HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
F = 1024.0 * 1.3 F = 1024.0 * 1.3
RD, BETA = 10000.0, 3380.0 RD, BETA = 10000.0, 3380.0
RINF = 10000.0 * math.exp(-3380.0 / 298.15) RINF = 10000.0 * math.exp(-3380.0 / 298.15)
+3 -1
View File
@@ -20,7 +20,9 @@ import subprocess
import sys import sys
import time import time
HOST = '172.16.1.97' HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
RESULTS = os.path.join(HERE, 'flow_warm_results.json') RESULTS = os.path.join(HERE, 'flow_warm_results.json')
+166 -27
View File
@@ -2,8 +2,8 @@
"""Host-side verification of the laser pulse-stream emission. """Host-side verification of the laser pulse-stream emission.
Runs the native grblHAL_glowforge binary in null-sink mode with Runs the native grblHAL_glowforge binary in null-sink mode with
GFSINK_DUMP capturing the shipped byte stream, drives a small M4 laser GFSINK_DUMP capturing the shipped byte stream, drives small laser jobs
job over TCP, then checks the dump against the kernel feeder contract: over TCP, then checks the dumps against the kernel feeder contract:
1. a power byte (bit 7) leads the stream, before any tick byte 1. a power byte (bit 7) leads the stream, before any tick byte
2. no two consecutive power bytes (the SDMA script drops the second) 2. no two consecutive power bytes (the SDMA script drops the second)
@@ -13,6 +13,15 @@ job over TCP, then checks the dump against the kernel feeder contract:
the G0 return, none at the tail the G0 return, none at the tail
6. step accounting survives the insertions: X returns to net zero and 6. step accounting survives the insertions: X returns to net zero and
peaks at the programmed 10 mm peaks at the programmed 10 mm
7. termination: every stream ends with FIRE clear, including an M3
(constant-power) job whose core never issues a laser-off update -
the stream must never lean on the kernel's end-of-data backstop
8. no FIRE bit ever rides a zero-step gap: a stepless run of stream
bytes carrying FIRE longer than any legitimate between-step
interval is a stationary dwell burn
9. rules 7-8 hold across rapid cycle stop/start churn (planner-starve
shaped jobs), where the FIRE state of the previous cycle must not
leak into the idle-gap pad bytes
Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge) Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge)
""" """
@@ -24,13 +33,24 @@ import socket
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import threading
import time import time
BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build-native/grblHAL_glowforge") BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build-native/grblHAL_glowforge")
PORT = 2399 PORT = 2399
STEPS_PER_MM = 53.333 STEPS_PER_MM = 53.333
JOB = [ # Longest stepless run allowed to carry FIRE, in machine ticks. The
# slowest legitimate between-step interval in these jobs is the first
# step of an accel-from-rest: sqrt(2 * (1/53.333 mm) / 700 mm/s^2)
# = 7.3 ms = ~206 ticks at 28160 Hz. 500 gives >2x margin while staying
# far below any idle-gap pad run.
FIRE_GAP_LIMIT_TICKS = 500
WAIT_IDLE = ("wait_idle",)
# Session A: the original M4 dynamic-power job (rules 1-6).
JOB_M4 = [
"M4 S0", "M4 S0",
"G1 X5 F600 S500", "G1 X5 F600 S500",
"G1 X10 S1000", "G1 X10 S1000",
@@ -38,6 +58,29 @@ JOB = [
"M5", "M5",
] ]
# Session B: M3 constant power to the end of the stream. The core never
# issues a laser-off update for M3, so the stream engine itself must
# terminate the cycle dark (rule 7).
JOB_M3_TERM = [
"M3 S1000",
"G1 X5 F600",
WAIT_IDLE,
("sleep", 1.0),
"M5",
]
# Session C: rapid cycle churn - many tiny laser moves sent one at a
# time with small gaps, so cycles stop and restart the way a planner
# starve produces them (rules 8-9).
JOB_CHURN = []
for _ in range(30):
JOB_CHURN.append("G1 X0.2 F600 S800")
JOB_CHURN.append(("sleep", 0.02))
JOB_CHURN.append("G1 X0 S800")
JOB_CHURN.append(("sleep", 0.02))
JOB_CHURN.insert(0, "M4 S0")
JOB_CHURN.append("M5")
def fail(msg): def fail(msg):
print("FAIL: %s" % msg) print("FAIL: %s" % msg)
@@ -76,12 +119,43 @@ def read_avail(sock, log, timeout, until=None):
return None return None
def main(): def wait_idle(sock, log):
for _ in range(100):
sock.sendall(b"?")
read_avail(sock, log, 0.3)
if re.search(r"<Idle", "".join(log[-3:])):
return
time.sleep(0.2)
fail("controller never returned to Idle")
def publish_verdicts(path, stop):
"""Publish a fresh, clean cooling verdict every 0.5 s (the arm flow
refuses without one; freshness window is 2 s). Same-host monotonic
clock, atomic rename so the reader never sees a torn file."""
while not stop.is_set():
body = ('{"ts_mono":%.3f,"fire_ok":true,"hold":false,'
'"resume_ok":true,"reason":""}'
% time.clock_gettime(time.CLOCK_MONOTONIC))
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.write(body)
os.replace(tmp, path)
stop.wait(0.5)
def run_session(name, steps):
"""Launch the controller, run the job steps, return the dump bytes."""
workdir = tempfile.mkdtemp(prefix="laser-test-") workdir = tempfile.mkdtemp(prefix="laser-test-")
dump = os.path.join(workdir, "stream.bin") dump = os.path.join(workdir, "stream.bin")
env = dict(os.environ, GFSINK_DUMP=dump) verdict = os.path.join(workdir, "cooling.state")
env = dict(os.environ, GFSINK_DUMP=dump, GF_VERDICT_FILE=verdict)
env.pop("GFSINK", None) env.pop("GFSINK", None)
stop = threading.Event()
pub = threading.Thread(target=publish_verdicts, args=(verdict, stop), daemon=True)
pub.start()
proc = subprocess.Popen([BIN, "-p", str(PORT)], cwd=workdir, env=env, proc = subprocess.Popen([BIN, "-p", str(PORT)], cwd=workdir, env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
try: try:
@@ -93,31 +167,27 @@ def main():
except OSError: except OSError:
time.sleep(0.1) time.sleep(0.1)
if sock is None: if sock is None:
fail("cannot connect to the controller") fail("[%s] cannot connect to the controller" % name)
log = [] log = []
read_avail(sock, log, 0.5) # banner / hello read_avail(sock, log, 0.5) # banner / hello
for line in JOB: for step in steps:
send_line(sock, line, log) if step == WAIT_IDLE:
wait_idle(sock, log)
elif isinstance(step, tuple) and step[0] == "sleep":
time.sleep(step[1])
else:
send_line(sock, step, log)
# Wait for the motion to play out on the wall clock (the shipper # Wait for the motion to play out on the wall clock (the shipper
# is wall-paced), then for the Idle report. # is wall-paced), then for the Idle report.
idle = False wait_idle(sock, log)
for _ in range(100):
sock.sendall(b"?")
read_avail(sock, log, 0.3)
if re.search(r"<Idle", "".join(log[-3:])):
idle = True
break
time.sleep(0.2)
if not idle:
fail("controller never returned to Idle")
time.sleep(1.0) # let the shipper drain the tail time.sleep(1.0) # let the shipper drain the tail
text = "".join(log) text = "".join(log)
if "laser armed" not in text: if "laser armed" not in text:
fail("no 'laser armed' message (arming flow did not run)") fail("[%s] no 'laser armed' message (arming flow did not run)" % name)
sock.close() sock.close()
finally: finally:
@@ -126,12 +196,51 @@ def main():
proc.wait(5) proc.wait(5)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
proc.kill() proc.kill()
stop.set()
pub.join(2)
data = open(dump, "rb").read() data = open(dump, "rb").read()
if not data: if not data:
fail("empty stream dump") fail("[%s] empty stream dump" % name)
shutil.rmtree(workdir, ignore_errors=True)
return data
# --- contract checks -------------------------------------------------
def tick_bytes(data):
"""The stream with power bytes stripped (tick bytes only)."""
return bytes(b for b in data if not b & 0x80)
def check_fire_gaps(name, data):
"""Rule 8: no stepless run carrying FIRE longer than the limit."""
run = 0
worst = 0
for tick, b in enumerate(tick_bytes(data)):
if b & 0x10 and not b & 0x25: # FIRE, no X/Y/Z step
run += 1
worst = max(worst, run)
if run >= FIRE_GAP_LIMIT_TICKS:
fail("[%s] FIRE carried across a %d-tick zero-step gap "
"ending at tick %d (stationary dwell burn)"
% (name, run, tick))
else:
run = 0
return worst
def check_termination(name, data):
"""Rule 7: the stream's final tick byte must carry FIRE clear."""
ticks = tick_bytes(data)
if not ticks:
fail("[%s] no tick bytes in the stream" % name)
if ticks[-1] & 0x10:
fail("[%s] stream ends with FIRE set (0x%02x) - termination "
"rule violated, relies on the end-of-data backstop"
% (name, ticks[-1]))
def check_m4_job(data):
"""Rules 1-6 on the original M4 job."""
if not data[0] & 0x80: if not data[0] & 0x80:
fail("stream does not lead with a power byte (first byte 0x%02x)" % data[0]) fail("stream does not lead with a power byte (first byte 0x%02x)" % data[0])
@@ -184,10 +293,8 @@ def main():
last_fire = fire_ticks[-1][0] last_fire = fire_ticks[-1][0]
tail_steps = 0 tail_steps = 0
tick = 0 tick = 0
prev_power = False
for b in data: for b in data:
if b & 0x80: if b & 0x80:
prev_power = True
continue continue
if tick > last_fire and b & 0x01: if tick > last_fire and b & 0x01:
tail_steps += 1 tail_steps += 1
@@ -195,11 +302,43 @@ def main():
if tail_steps < 400: if tail_steps < 400:
fail("only %d fire-free steps after the last FIRE bit - G0 return not dark" % tail_steps) fail("only %d fire-free steps after the last FIRE bit - G0 return not dark" % tail_steps)
print("PASS: %d bytes, %d power bytes, %d fire ticks, powers %s, " return fire_ticks, powers, x_max, tail_steps
"X peak %d steps net 0, %d dark return steps"
def count_fire(data):
return sum(1 for b in tick_bytes(data) if b & 0x10)
def main():
# --- session A: M4 dynamic power, rules 1-6 + 7-8 -------------------
data = run_session("m4", JOB_M4)
fire_ticks, powers, x_max, tail_steps = check_m4_job(data)
check_termination("m4", data)
gap_a = check_fire_gaps("m4", data)
print("PASS [m4]: %d bytes, %d power bytes, %d fire ticks, powers %s, "
"X peak %d steps net 0, %d dark return steps, max fire gap %d"
% (len(data), sum(1 for b in data if b & 0x80), len(fire_ticks), % (len(data), sum(1 for b in data if b & 0x80), len(fire_ticks),
powers, x_max, tail_steps)) powers, x_max, tail_steps, gap_a))
shutil.rmtree(workdir, ignore_errors=True)
# --- session B: M3 constant power to stream end, rule 7 -------------
data = run_session("m3-term", JOB_M3_TERM)
if not count_fire(data):
fail("[m3-term] no FIRE bits in the stream")
check_termination("m3-term", data)
gap_b = check_fire_gaps("m3-term", data)
print("PASS [m3-term]: %d bytes, %d fire ticks end dark, max fire gap %d"
% (len(data), count_fire(data), gap_b))
# --- session C: cycle churn, rules 8-9 ------------------------------
data = run_session("churn", JOB_CHURN)
if not count_fire(data):
fail("[churn] no FIRE bits in the stream")
check_termination("churn", data)
gap_c = check_fire_gaps("churn", data)
print("PASS [churn]: %d bytes, %d fire ticks, max fire gap %d"
% (len(data), count_fire(data), gap_c))
print("PASS: all stream emission rules hold")
if __name__ == "__main__": if __name__ == "__main__":
+3 -1
View File
@@ -25,7 +25,9 @@ import subprocess
import sys import sys
import time import time
HOST = '172.16.1.97' HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
STORE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp_calibration.json') STORE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp_calibration.json')