mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
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:
+28
-3
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
firmware and back requires no reinstall.
|
||||
|
||||
To reinstall or update ForgeFIRM before the built-in updater ships:
|
||||
switch to factory firmware and run the installer again — it skips
|
||||
archives it already has and simply rewrites the ForgeFIRM slot.
|
||||
**Routine updates do not use the installer.** Update from the web
|
||||
control panel's System page, which downloads (or accepts an upload of)
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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.**
|
||||
|
||||
+60
-13
@@ -1,7 +1,42 @@
|
||||
# ForgeFIRM bring-up status & cold-start runbook
|
||||
|
||||
Last updated: **2026-08-13** — **shared machine services complete and
|
||||
closed out.** forgectrl is the one machine-services daemon behind both
|
||||
Last updated: **2026-08-14** — **audit remediation Phases 0 + 1 landed**
|
||||
(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
|
||||
hardware), controller-mode supervision, the pulse-device broker, and
|
||||
the motion-liveness gate. Both controllers are cooling-engine clients
|
||||
@@ -121,9 +156,8 @@ item 8.
|
||||
|
||||
## The bench
|
||||
|
||||
- **Board**: SSH `root@172.16.1.97` (fixed DHCP lease since 2026-08-02;
|
||||
was .130), empty password
|
||||
(`ssh -o PreferredAuthentications=none` logs straight in). The bench
|
||||
- **Board**: SSH `root@<machine-ip>` (dev images permit passwordless
|
||||
root login). The bench
|
||||
machine is a **Basic/Plus** (the control board is common to
|
||||
Basic/Plus/Pro). Dev image
|
||||
(`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;
|
||||
modern fwup verifies+applies the factory .fw — signer key
|
||||
2017-05-001.pub). The production signing-key ceremony
|
||||
(UPDATE-SYSTEM.md gate 8) was executed 2026-08-08.
|
||||
**The production release key lives at
|
||||
`~/forgefirm-release-key/fwup-key.priv`** (0600; `fwup-key.pub` +
|
||||
`fwup-key-raw.pub` beside it; offline backups held by the operator) —
|
||||
the installer embeds its pubkey, so releases sign with THIS key only.
|
||||
(UPDATE-SYSTEM.md gate 8) was executed 2026-08-08. **The production
|
||||
release key is held offline by the operator** — the installer embeds
|
||||
its public key, so releases sign with that key only.
|
||||
Pack releases with `scripts/mkfw.sh`; the full pipeline is
|
||||
`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
|
||||
@@ -235,7 +267,7 @@ hardware I/O — host testing).
|
||||
since the last run, `$RST=$` once (stored settings win). Each motion
|
||||
run logs a producer-stats line to stderr (callbacks, µs/call,
|
||||
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`
|
||||
(controlled decel) and raises an alarm; TCP disconnects never kill the
|
||||
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
|
||||
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`).
|
||||
Remaining Phase 2 nicety: the installer's embedded pubkey is the
|
||||
DEV key until the production ceremony.
|
||||
The installer's embedded pubkey is the **production release key**
|
||||
(ceremony executed 2026-08-08; `release.sh` enforces the match).
|
||||
**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
|
||||
/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
|
||||
states are documented in `kernel-module-glowforge/UAPI.md`; no
|
||||
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
@@ -1,5 +1,28 @@
|
||||
# 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:
|
||||
|
||||
- **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
|
||||
|
||||
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
|
||||
scans serial ports).
|
||||
2. Device type: **grblHAL** if your LightBurn version lists it,
|
||||
otherwise **GRBL** — both speak the right protocol.
|
||||
3. Connection: **Ethernet/TCP**. IP address: **172.16.1.97** (LightBurn
|
||||
uses TCP port 23 for GRBL devices, which is exactly where the
|
||||
controller listens).
|
||||
3. Connection: **Ethernet/TCP**. IP address: **`<machine-ip>`**
|
||||
(LightBurn uses TCP port 23 for GRBL devices, which is exactly where
|
||||
the controller listens).
|
||||
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
|
||||
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
|
||||
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
|
||||
instead of creating a new one.
|
||||
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
|
||||
dot). The job then runs into the bed from wherever the head currently
|
||||
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
|
||||
home corner when the controller started; after any Stop/alarm the
|
||||
absolute frame is stale until the controller is restarted with the head
|
||||
re-parked.)
|
||||
(`Absolute Coords` also works after a successful `$H`, or if the head
|
||||
was parked at the home corner when the controller started. After any
|
||||
Stop/alarm the absolute frame is stale until you re-home with `$H` or
|
||||
restart the controller with the head re-parked.)
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
First a dry run, then a light cut on scrap:
|
||||
|
||||
1. Draw a rectangle (~100 × 60 mm) with a circle inside.
|
||||
2. Double-click the layer color bar (bottom): mode **Line**, 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
|
||||
it at home), **Frame**, watch the perimeter trace, then **Start**.
|
||||
|
||||
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 +
|
||||
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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""Milestone-2 motion-quality bench: factory-true rates/accels over TCP.
|
||||
|
||||
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
|
||||
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,
|
||||
then a G1 move with a feed-hold/resume in the middle.
|
||||
"""
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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):
|
||||
r = subprocess.run(['wsl', '-d', 'forge-yocto', '--', 'ssh',
|
||||
|
||||
@@ -15,11 +15,14 @@ driver only writes the heater on M8/M9 transitions, so an idle driver
|
||||
leaves this alone.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
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).
|
||||
F = 1024.0 * 1.3
|
||||
|
||||
@@ -35,7 +35,9 @@ import subprocess
|
||||
import sys
|
||||
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__))
|
||||
RESULTS = os.path.join(HERE, os.environ.get('FM_RESULTS', 'flow_matrix_results.json'))
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
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
|
||||
RD, BETA = 10000.0, 3380.0
|
||||
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
|
||||
|
||||
@@ -9,13 +9,16 @@ cut-profile fans, logging bulk coolant temperature and every verdict.
|
||||
Usage: flow_sustained.py [minutes] (default 30)
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
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
|
||||
RD, BETA = 10000.0, 3380.0
|
||||
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
|
||||
|
||||
@@ -20,7 +20,9 @@ import subprocess
|
||||
import sys
|
||||
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__))
|
||||
RESULTS = os.path.join(HERE, 'flow_warm_results.json')
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"""Host-side verification of the laser pulse-stream emission.
|
||||
|
||||
Runs the native grblHAL_glowforge binary in null-sink mode with
|
||||
GFSINK_DUMP capturing the shipped byte stream, drives a small M4 laser
|
||||
job over TCP, then checks the dump against the kernel feeder contract:
|
||||
GFSINK_DUMP capturing the shipped byte stream, drives small laser jobs
|
||||
over TCP, then checks the dumps against the kernel feeder contract:
|
||||
|
||||
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)
|
||||
@@ -13,6 +13,15 @@ job over TCP, then checks the dump against the kernel feeder contract:
|
||||
the G0 return, none at the tail
|
||||
6. step accounting survives the insertions: X returns to net zero and
|
||||
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)
|
||||
"""
|
||||
@@ -24,13 +33,24 @@ import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build-native/grblHAL_glowforge")
|
||||
PORT = 2399
|
||||
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",
|
||||
"G1 X5 F600 S500",
|
||||
"G1 X10 S1000",
|
||||
@@ -38,6 +58,29 @@ JOB = [
|
||||
"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):
|
||||
print("FAIL: %s" % msg)
|
||||
@@ -76,12 +119,43 @@ def read_avail(sock, log, timeout, until=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-")
|
||||
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)
|
||||
|
||||
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,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
try:
|
||||
@@ -93,31 +167,27 @@ def main():
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
if sock is None:
|
||||
fail("cannot connect to the controller")
|
||||
fail("[%s] cannot connect to the controller" % name)
|
||||
|
||||
log = []
|
||||
read_avail(sock, log, 0.5) # banner / hello
|
||||
|
||||
for line in JOB:
|
||||
send_line(sock, line, log)
|
||||
for step in steps:
|
||||
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
|
||||
# is wall-paced), then for the Idle report.
|
||||
idle = False
|
||||
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")
|
||||
wait_idle(sock, log)
|
||||
time.sleep(1.0) # let the shipper drain the tail
|
||||
|
||||
text = "".join(log)
|
||||
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()
|
||||
finally:
|
||||
@@ -126,12 +196,51 @@ def main():
|
||||
proc.wait(5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
stop.set()
|
||||
pub.join(2)
|
||||
|
||||
data = open(dump, "rb").read()
|
||||
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:
|
||||
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]
|
||||
tail_steps = 0
|
||||
tick = 0
|
||||
prev_power = False
|
||||
for b in data:
|
||||
if b & 0x80:
|
||||
prev_power = True
|
||||
continue
|
||||
if tick > last_fire and b & 0x01:
|
||||
tail_steps += 1
|
||||
@@ -195,11 +302,43 @@ def main():
|
||||
if tail_steps < 400:
|
||||
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, "
|
||||
"X peak %d steps net 0, %d dark return steps"
|
||||
return fire_ticks, powers, x_max, tail_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),
|
||||
powers, x_max, tail_steps))
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
powers, x_max, tail_steps, gap_a))
|
||||
|
||||
# --- 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__":
|
||||
|
||||
@@ -25,7 +25,9 @@ import subprocess
|
||||
import sys
|
||||
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')
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user